Compare commits

..
Author SHA1 Message Date
sudacode 695613b1e7 chore(release): prepare v0.19.4-beta.2 2026-08-19 02:19:40 -07:00
sudacode 20797772f8 fix(character-dictionary): cache completed MeCab refreshes (#212) 2026-08-19 01:49:15 -07:00
sudacode 72c312810a docs: link README demo to GitHub video playback
- Use GitHub-hosted video playback for the README demo
- Document the animated fallback behavior
2026-08-19 01:02:05 -07:00
sudacode b61f050e78 docs: update mining demo and scale video crops
- Refresh mining demo assets and documentation references
- Scale mpv crop coordinates for resized OBS recordings
2026-08-19 00:55:31 -07:00
sudacode 2a77ba9dd4 fix(launcher): restore Matroska thumbnails in Linux rofi picker (#210) 2026-08-18 20:30:58 -07:00
76 changed files with 909 additions and 983 deletions
+2 -1
View File
@@ -274,7 +274,8 @@ jobs:
config.example.jsonc \ config.example.jsonc \
plugin/subminer \ plugin/subminer \
plugin/subminer.conf \ plugin/subminer.conf \
assets/themes/subminer.rasi assets/themes/subminer.rasi \
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
- name: Generate checksums - name: Generate checksums
run: | run: |
+2 -1
View File
@@ -273,7 +273,8 @@ jobs:
config.example.jsonc \ config.example.jsonc \
plugin/subminer \ plugin/subminer \
plugin/subminer.conf \ plugin/subminer.conf \
assets/themes/subminer.rasi assets/themes/subminer.rasi \
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
- name: Generate checksums - name: Generate checksums
run: | run: |
+8 -3
View File
@@ -2,8 +2,10 @@
APP_NAME := subminer APP_NAME := subminer
THEME_SOURCE := assets/themes/subminer.rasi THEME_SOURCE := assets/themes/subminer.rasi
THUMBNAILER_SOURCE := assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
LAUNCHER_OUT := dist/launcher/$(APP_NAME) LAUNCHER_OUT := dist/launcher/$(APP_NAME)
THEME_FILE := subminer.rasi THEME_FILE := subminer.rasi
THUMBNAILER_FILE := subminer-ffmpegthumbnailer.thumbnailer
# Default install prefix for the wrapper script. # Default install prefix for the wrapper script.
PREFIX ?= $(HOME)/.local PREFIX ?= $(HOME)/.local
@@ -221,11 +223,13 @@ docs-dev: ensure-bun
install-linux: build-launcher install-linux: build-launcher
@printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts" @printf '%s\n' "[INFO] Installing Linux wrapper/support artifacts"
@install -d "$(BINDIR)" @install -d "$(BINDIR)"
@install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)" @install -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
@install -d "$(LINUX_DATA_DIR)/themes" @install -d "$(LINUX_DATA_DIR)/themes"
@install -m 0644 "./$(THEME_SOURCE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)" @install -m 0644 "./$(THEME_SOURCE)" "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
@install -d "$(LINUX_DATA_DIR)/thumbnailers"
@install -m 0644 "./$(THUMBNAILER_SOURCE)" "$(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
@install -d "$(LINUX_DATA_DIR)/plugin/subminer" @install -d "$(LINUX_DATA_DIR)/plugin/subminer"
@cp -R ./plugin/subminer/. "$(LINUX_DATA_DIR)/plugin/subminer/" @cp -R ./plugin/subminer/. "$(LINUX_DATA_DIR)/plugin/subminer/"
@if [ -n "$(APPIMAGE_SRC)" ]; then \ @if [ -n "$(APPIMAGE_SRC)" ]; then \
@@ -234,7 +238,7 @@ install-linux: build-launcher
printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \ printf '%s\n' "[WARN] No release/SubMiner-*.AppImage found; skipping AppImage install"; \
printf '%s\n' " Build one with: make build"; \ printf '%s\n' " Build one with: make build"; \
fi fi
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" @printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
install-macos: build-launcher install-macos: build-launcher
@printf '%s\n' "[INFO] Installing macOS wrapper/theme/app artifacts" @printf '%s\n' "[INFO] Installing macOS wrapper/theme/app artifacts"
@@ -275,8 +279,9 @@ uninstall:
uninstall-linux: uninstall-linux:
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage" @rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)" @rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
@rm -f "$(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
@rm -rf "$(LINUX_DATA_DIR)/plugin/subminer" @rm -rf "$(LINUX_DATA_DIR)/plugin/subminer"
@printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/plugin/subminer" @printf '%s\n' "Removed:" " $(BINDIR)/subminer" " $(BINDIR)/SubMiner.AppImage" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)" " $(LINUX_DATA_DIR)/plugin/subminer"
uninstall-macos: uninstall-macos:
@rm -f "$(BINDIR)/subminer" @rm -f "$(BINDIR)/subminer"
+1 -1
View File
@@ -15,7 +15,7 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio
[![License](https://img.shields.io/github/license/ksyasuda/SubMiner?style=flat-square&color=1a1a2e)](https://www.gnu.org/licenses/gpl-3.0) [![License](https://img.shields.io/github/license/ksyasuda/SubMiner?style=flat-square&color=1a1a2e)](https://www.gnu.org/licenses/gpl-3.0)
[![TypeScript](https://img.shields.io/badge/TypeScript-1a1a2e?style=flat-square&logo=typescript&logoColor=3178c6)](https://www.typescriptlang.org) [![TypeScript](https://img.shields.io/badge/TypeScript-1a1a2e?style=flat-square&logo=typescript&logoColor=3178c6)](https://www.typescriptlang.org)
[![SubMiner demo](./assets/minecard.webp)](https://github.com/user-attachments/assets/89e61895-e2b7-4b47-8d50-a35afe4132b2) [![SubMiner demo](./assets/minecard.webp)](https://github.com/user-attachments/assets/7abab8a9-4e4e-4f06-9f3c-9783e15a3807)
</div> </div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.8 MiB

@@ -0,0 +1,4 @@
[Thumbnailer Entry]
TryExec=ffmpegthumbnailer
Exec=ffmpegthumbnailer -i %i -o %o -s %s -f
MimeType=video/matroska;video/matroska-3d;video/x-matroska;video/x-matroska-3d;
+2 -2
View File
@@ -20,7 +20,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "43.4.1", "electron": "42.6.0",
"electron-builder": "26.15.3", "electron-builder": "26.15.3",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
"eslint": "^10.8.0", "eslint": "^10.8.0",
@@ -346,7 +346,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@43.4.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA=="], "electron": ["electron@42.6.0", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-axGNgd+yCTg+vi1VEGrQqAj9WVWkePKwbICSAvMiT2eTaxhij9a/xhBHD6rXV8wrlW9ZfJzE5+xg752ImxrmTw=="],
"electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="], "electron-builder": ["electron-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA=="],
@@ -0,0 +1,5 @@
type: fixed
area: character dictionary
- Reuse character dictionaries after MeCab completes without finding any name splits instead of regenerating character data and portraits on every launch.
- Restore inline character portraits when a cached portrait index finishes loading after subtitles have already been tokenized.
@@ -1,6 +0,0 @@
type: fixed
area: dictionary
- Upgraded the desktop runtime to Electron 43.4.1 and added profile guards that block unsupported runtimes and Electron downgrades before Yomitan storage is loaded.
- Development launches now use a separate `SubMiner-dev` profile unless production-profile access is explicitly requested.
- Automatic character-dictionary changes now stop when a previously non-empty Yomitan profile suddenly reports zero dictionaries.
-4
View File
@@ -1,4 +0,0 @@
type: docs
area: documentation
- Hid the unfinished feature demos page from the documentation sidebar while keeping its direct URL available.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases.
+1 -1
View File
@@ -5,7 +5,7 @@ Short recordings of SubMiner's key features and integrations from real playback
<script setup> <script setup>
import { withBase } from 'vitepress'; import { withBase } from 'vitepress';
const v = '20260301-1'; const v = '20260819-1';
</script> </script>
## Anki Card Mining & Enrichment ## Anki Card Mining & Enrichment
-4
View File
@@ -64,10 +64,6 @@ make dev-watch # watch TS + renderer and launch E
make dev-watch-macos # same as dev-watch, forcing --backend macos make dev-watch-macos # same as dev-watch, forcing --backend macos
``` ```
Development and debug launches use a separate `SubMiner-dev` profile so runtime experiments cannot modify the installed app's configuration or Yomitan dictionaries. To intentionally use the production profile for a development launch, set `SUBMINER_USE_PRODUCTION_PROFILE=1`. Use that override only after backing up the profile.
Always launch source builds through `bun run dev` or `bun run electron`. SubMiner refuses to load its profile when the running Electron major differs from the version pinned by the repository.
For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time For mpv-plugin-driven testing without exporting `SUBMINER_BINARY_PATH` each run, set a one-time
dev binary path with `mpv.subminerBinaryPath` in your SubMiner config. The launcher injects it into dev binary path with `mpv.subminerBinaryPath` in your SubMiner config. The launcher injects it into
the mpv plugin at runtime: the mpv plugin at runtime:
+1 -1
View File
@@ -88,7 +88,7 @@ features:
<script setup> <script setup>
import { withBase } from 'vitepress'; import { withBase } from 'vitepress';
const demoAssetVersion = '20260223-2'; const demoAssetVersion = '20260819-1';
</script> </script>
<div class="landing-shell"> <div class="landing-shell">
+6 -4
View File
@@ -392,7 +392,7 @@ subminer -u
subminer --update subminer --update
``` ```
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. 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`, the rofi theme at `SubMiner/themes/subminer.rasi`, and the scoped Matroska thumbnailer registration under `SubMiner/thumbnailers`. 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` 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. 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.
@@ -404,7 +404,7 @@ 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. 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. The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher checks for its managed runtime plugin copy, rofi theme, and scoped thumbnailer registration before every mpv-managed launch and installs those support assets from the bundled app automatically if one is missing. For a rofi picker launch, this check runs before the picker opens. 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
@@ -456,18 +456,20 @@ sudo chmod +x /usr/local/bin/subminer
### Linux Support Assets ### Linux Support Assets
SubMiner ships the Linux rofi theme plus the launcher-managed runtime plugin copy in `subminer-assets.tar.gz`: SubMiner ships the Linux rofi theme, scoped Matroska thumbnailer registration, and 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/thumbnailers
cp /tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer ~/.local/share/SubMiner/thumbnailers/
mkdir -p ~/.local/share/SubMiner/plugin mkdir -p ~/.local/share/SubMiner/plugin
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer
``` ```
`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. `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 all three assets from the bundled app if one is missing, so manual extraction is mainly useful for pre-seeding or custom setups. Rofi receives the SubMiner data path through its process-local `XDG_DATA_DIRS`, so the thumbnailer registration does not change the desktop-wide configuration.
Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`. Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
+6 -2
View File
@@ -34,18 +34,22 @@ 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 the rofi theme plus the Linux launcher-managed runtime plugin copy in the release assets tarball: rofi shows a GUI menu with icon thumbnails when available. SubMiner ships the rofi theme, a scoped `ffmpegthumbnailer` MIME registration, and 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/thumbnailers
cp /tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer ~/.local/share/SubMiner/thumbnailers/
mkdir -p ~/.local/share/SubMiner/plugin mkdir -p ~/.local/share/SubMiner/plugin
cp -R /tmp/plugin/subminer ~/.local/share/SubMiner/plugin/subminer 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. Once the `SubMiner` data dir exists, `subminer -u` refreshes these assets automatically. Normal Linux launcher playback checks for all three assets and installs them from the bundled app when one is missing. For `subminer -R`, this repair runs before rofi opens.
When `ffmpegthumbnailer` is installed, SubMiner prepends its own data directory to `XDG_DATA_DIRS` for the rofi process only. This lets rofi recognize the canonical Matroska MIME types used by newer GLib versions without changing the desktop-wide MIME or thumbnailer configuration. An existing registration in your own `$XDG_DATA_HOME/thumbnailers` still takes priority.
The theme is auto-detected from these paths (first match wins): The theme is auto-detected from these paths (first match wins):
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.8 MiB

-8
View File
@@ -109,14 +109,6 @@ If the overlay position is slightly off, right-click and drag on subtitle text t
If you haven't set up dictionaries yet, see [Yomitan setup](/usage#yomitan-setup) first. If you haven't set up dictionaries yet, see [Yomitan setup](/usage#yomitan-setup) first.
**"Electron downgrade blocked" or "Unsupported Electron runtime"**
SubMiner refuses to load Yomitan storage when the current Electron major does not match the app build, or when the profile was previously opened by a newer Electron version. Launch the packaged app or use the repository's `bun run dev` command. Do not delete the runtime safety record merely to force an older Electron version to open the profile.
**"Yomitan reported zero dictionaries after previously reporting ..."**
SubMiner detected that a previously non-empty Yomitan profile suddenly appears empty. Automatic character-dictionary changes are blocked so they cannot normalize or overwrite the suspicious state. Close SubMiner, preserve the profile directory, and restore a known-good backup before importing or deleting dictionaries.
**"Yomitan extension not found in any search path"** **"Yomitan extension not found in any search path"**
SubMiner bundles Yomitan and searches for it in these locations (in order): SubMiner bundles Yomitan and searches for it in these locations (in order):
-1
View File
@@ -155,7 +155,6 @@ The tray menu also includes `View Changelog`, which opens the in-app changelog m
- `--log-level` controls logger verbosity. - `--log-level` controls logger verbosity.
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases. - `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
- `--dev` and `--debug` use a separate `SubMiner-dev` profile. They do not read or modify dictionaries and configuration from the installed app unless `SUBMINER_USE_PRODUCTION_PROFILE=1` is explicitly set.
- `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override. - `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows). - `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`). - Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
+8
View File
@@ -23,6 +23,7 @@ import {
type HistorySeriesEntry, type HistorySeriesEntry,
} from '../history.js'; } from '../history.js';
import type { Args } from '../types.js'; import type { Args } from '../types.js';
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit'; export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
@@ -333,6 +334,13 @@ export async function runHistoryCommand(
const { args, scriptPath } = context; const { args, scriptPath } = context;
checkPickerDependencies(args); checkPickerDependencies(args);
if (args.useRofi) {
await ensureLinuxRuntimePluginAvailable({
appPath: context.appPath ?? undefined,
scriptPath,
logLevel: args.logLevel,
});
}
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null; const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
const dbPath = resolveImmersionDbPath(); const dbPath = resolveImmersionDbPath();
+8
View File
@@ -2,6 +2,7 @@ import { fail } from '../log.js';
import { runAppCommandWithInherit } from '../mpv.js'; import { runAppCommandWithInherit } from '../mpv.js';
import { commandExists } from '../util.js'; import { commandExists } from '../util.js';
import { runJellyfinPlayMenu } from '../jellyfin.js'; import { runJellyfinPlayMenu } from '../jellyfin.js';
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
import { shouldForwardLogLevel } from '../types.js'; import { shouldForwardLogLevel } from '../types.js';
import type { LauncherCommandContext } from './context.js'; import type { LauncherCommandContext } from './context.js';
@@ -64,6 +65,13 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.useRofi && !commandExists('rofi')) { if (args.useRofi && !commandExists('rofi')) {
fail('rofi not found. Install rofi or omit -R for fzf.'); fail('rofi not found. Install rofi or omit -R for fzf.');
} }
if (args.useRofi) {
await ensureLinuxRuntimePluginAvailable({
appPath,
scriptPath,
logLevel: args.logLevel,
});
}
await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath); await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath);
return true; return true;
} }
@@ -496,3 +496,39 @@ test('playback command ensures Linux runtime plugin before mpv launch', async ()
assert.deepEqual(calls, ['plugin', 'startMpv']); assert.deepEqual(calls, ['plugin', 'startMpv']);
}); });
test('rofi playback repairs support assets before opening the picker', async () => {
const context = createContext();
context.args = {
...context.args,
target: '',
targetKind: '',
useRofi: true,
};
const calls: string[] = [];
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
ensureRuntimePluginReady: async () => {
calls.push('assets');
},
chooseTarget: async () => {
calls.push('picker');
return { target: '/tmp/movie.mkv', kind: 'file' };
},
checkPickerDependencies: () => {},
checkDependencies: () => {},
registerCleanup: () => {},
startMpv: async () => {
calls.push('startMpv');
},
waitForUnixSocketReady: async () => true,
startOverlay: async () => {},
launchAppCommandDetached: () => {},
log: () => {},
cleanupPlaybackSession: async () => {},
getMpvProc: () => null,
});
assert.deepEqual(calls, ['assets', 'picker', 'startMpv']);
});
+15 -2
View File
@@ -157,6 +157,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
}); });
}, },
chooseTarget, chooseTarget,
checkPickerDependencies,
checkDependencies, checkDependencies,
registerCleanup, registerCleanup,
startMpv, startMpv,
@@ -177,6 +178,7 @@ type PlaybackCommandDeps = {
args: Args, args: Args,
scriptPath: string, scriptPath: string,
) => Promise<{ target: string; kind: 'file' | 'url' } | null>; ) => Promise<{ target: string; kind: 'file' | 'url' } | null>;
checkPickerDependencies?: (args: Args) => void;
checkDependencies: (args: Args) => void; checkDependencies: (args: Args) => void;
registerCleanup: (context: LauncherCommandContext) => void; registerCleanup: (context: LauncherCommandContext) => void;
startMpv: typeof startMpv; startMpv: typeof startMpv;
@@ -201,7 +203,18 @@ export async function runPlaybackCommandWithDeps(
await deps.ensurePlaybackSetupReady(context); await deps.ensurePlaybackSetupReady(context);
if (!args.target) { if (!args.target) {
checkPickerDependencies(args); (deps.checkPickerDependencies ?? checkPickerDependencies)(args);
}
let runtimeAssetsReady = false;
const ensureRuntimeAssetsReady = async (): Promise<void> => {
if (runtimeAssetsReady) return;
await deps.ensureRuntimePluginReady(context);
runtimeAssetsReady = true;
};
if (!args.target && args.useRofi) {
await ensureRuntimeAssetsReady();
} }
const targetChoice = await deps.chooseTarget(args, scriptPath); const targetChoice = await deps.chooseTarget(args, scriptPath);
@@ -266,7 +279,7 @@ export async function runPlaybackCommandWithDeps(
); );
} }
await deps.ensureRuntimePluginReady(context); await ensureRuntimeAssetsReady();
await deps.startMpv( await deps.startMpv(
selectedTarget.target, selectedTarget.target,
+6
View File
@@ -36,6 +36,11 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
launcher: { status: 'updated' }, launcher: { status: 'updated' },
supportAssets: [ supportAssets: [
{ status: 'updated', component: 'theme', message: 'Installed theme.' }, { status: 'updated', component: 'theme', message: 'Installed theme.' },
{
status: 'updated',
component: 'thumbnailer',
message: 'Installed rofi thumbnailer.',
},
{ status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' }, { status: 'skipped', component: 'plugin', message: 'Plugin already up to date.' },
], ],
}; };
@@ -52,6 +57,7 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
'info:AppImage update: updated', 'info:AppImage update: updated',
'info:Launcher update: updated', 'info:Launcher update: updated',
'info:Support assets (theme) update: updated - Installed theme.', 'info:Support assets (theme) update: updated - Installed theme.',
'info:Support assets (thumbnailer) update: updated - Installed rofi thumbnailer.',
'info:Support assets (plugin) update: skipped - Plugin already up to date.', 'info:Support assets (plugin) update: skipped - Plugin already up to date.',
]); ]);
}); });
+10 -13
View File
@@ -21,7 +21,10 @@ import {
parseSha256Sums, parseSha256Sums,
type FetchLike, type FetchLike,
} from '../../src/main/runtime/update/release-assets.js'; } from '../../src/main/runtime/update/release-assets.js';
import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js'; import {
updateSupportAssetsFromRelease,
type SupportAssetsUpdateResult,
} from '../../src/main/runtime/update/support-assets.js';
type UpdateCommandResponse = { type UpdateCommandResponse = {
ok: boolean; ok: boolean;
@@ -36,15 +39,14 @@ type DirectReleaseUpdateRequest = {
channel: UpdateChannel; channel: UpdateChannel;
}; };
type DirectSupportAssetsUpdateResult = Omit<SupportAssetsUpdateResult, 'status'> & {
status: string;
};
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<{ supportAssets: DirectSupportAssetsUpdateResult[];
status: string;
component?: 'theme' | 'plugin';
command?: string;
message?: string;
}>;
}; };
type UpdateCommandDeps = { type UpdateCommandDeps = {
@@ -129,12 +131,7 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
function logUpdateResult( function logUpdateResult(
label: string, label: string,
result: { result: DirectSupportAssetsUpdateResult,
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 {
+6 -5
View File
@@ -73,20 +73,21 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
}; };
} }
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which // On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which
// when the runtime plugin/theme are missing — spawns the app with // spawns the app with `--ensure-linux-runtime-plugin-assets` when managed
// `--ensure-linux-runtime-plugin-assets` and polls up to 30s // support assets are missing and polls up to 30s
// (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits // (RESPONSE_TIMEOUT_MS) for an install response. A fake app that just exits
// never writes that response, so the launcher hangs and the test times out on // never writes that response, so the launcher hangs and the test times out on
// Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes // Linux CI (the preflight is a no-op on macOS/Windows). This shell prelude makes
// the fake app install the managed plugin/theme and write the response, matching // the fake app install the managed support assets and write the response, matching
// launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback. // launcher/smoke.e2e.test.ts. Prepend it to each fake app that reaches playback.
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner" data="\${XDG_DATA_HOME:-$HOME/.local/share}/SubMiner"
mkdir -p "$data/plugin/subminer" "$data/themes" mkdir -p "$data/plugin/subminer" "$data/themes" "$data/thumbnailers"
printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua" printf -- '-- test plugin\\n' > "$data/plugin/subminer/main.lua"
printf 'test=true\\n' > "$data/plugin/subminer.conf" printf 'test=true\\n' > "$data/plugin/subminer.conf"
printf '/* test theme */\\n' > "$data/themes/subminer.rasi" printf '/* test theme */\\n' > "$data/themes/subminer.rasi"
printf '[Thumbnailer Entry]\\n' > "$data/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"
if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then if [ "$2" = "--ensure-linux-runtime-plugin-assets-response-path" ] && [ -n "$3" ]; then
mkdir -p "$(dirname "$3")" mkdir -p "$(dirname "$3")"
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3" printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
+46 -1
View File
@@ -3,7 +3,12 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
import { findRofiTheme, formatRofiPrompt } from './picker'; import {
findRofiTheme,
findRofiThumbnailerDataRoot,
formatRofiPrompt,
prependXdgDataDir,
} from './picker';
// ── formatRofiPrompt: spacing between prompt and input field ────────────────── // ── formatRofiPrompt: spacing between prompt and input field ──────────────────
@@ -23,6 +28,7 @@ test('formatRofiPrompt leaves an empty prompt empty', () => {
// ── findRofiTheme: Linux packaged path discovery ────────────────────────────── // ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
const ROFI_THEME_FILE = 'subminer.rasi'; const ROFI_THEME_FILE = 'subminer.rasi';
const ROFI_THUMBNAILER_FILE = 'subminer-ffmpegthumbnailer.thumbnailer';
function makeFile(filePath: string): void { function makeFile(filePath: string): void {
fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -121,3 +127,42 @@ test('findRofiTheme resolves ~/.local/share/SubMiner/themes/subminer.rasi when X
fs.rmSync(baseDir, { recursive: true, force: true }); fs.rmSync(baseDir, { recursive: true, force: true });
} }
}); });
test('findRofiThumbnailerDataRoot resolves the managed XDG data root', () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
const originalXdgDataHome = process.env.XDG_DATA_HOME;
try {
process.env.XDG_DATA_HOME = xdgDataHome;
const dataRoot = path.join(xdgDataHome, 'SubMiner');
makeFile(path.join(dataRoot, 'thumbnailers', ROFI_THUMBNAILER_FILE));
const result = withPlatform('linux', () => findRofiThumbnailerDataRoot('/usr/bin/subminer'));
assert.equal(result, dataRoot);
} finally {
if (originalXdgDataHome === undefined) {
delete process.env.XDG_DATA_HOME;
} else {
process.env.XDG_DATA_HOME = originalXdgDataHome;
}
fs.rmSync(xdgDataHome, { recursive: true, force: true });
}
});
test('findRofiThumbnailerDataRoot is Linux-only', () => {
assert.equal(
withPlatform('darwin', () => findRofiThumbnailerDataRoot('/usr/bin/subminer')),
null,
);
});
test('prependXdgDataDir preserves existing roots and avoids duplicates', () => {
const root = '/tmp/subminer-data';
assert.equal(
prependXdgDataDir(root, `/opt/share${path.delimiter}${root}${path.delimiter}/usr/share`),
`${root}${path.delimiter}/opt/share${path.delimiter}/usr/share`,
);
assert.equal(
prependXdgDataDir(root),
`${root}${path.delimiter}/usr/local/share${path.delimiter}/usr/share`,
);
});
+45
View File
@@ -159,6 +159,9 @@ interface RofiIconEntry {
iconPath?: string; iconPath?: string;
} }
const ROFI_THUMBNAILER_FILE = 'subminer-ffmpegthumbnailer.thumbnailer';
const DEFAULT_XDG_DATA_DIRS = ['/usr/local/share', '/usr/share'];
function showRofiIconMenu( function showRofiIconMenu(
entries: RofiIconEntry[], entries: RofiIconEntry[],
prompt: string, prompt: string,
@@ -389,6 +392,47 @@ export function findRofiTheme(scriptPath: string): string | null {
return null; return null;
} }
export function findRofiThumbnailerDataRoot(scriptPath: string): string | null {
if (process.platform !== 'linux') return null;
const scriptDir = path.dirname(realpathMaybe(scriptPath));
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share');
const roots = [
path.join(xdgDataHome, 'SubMiner'),
path.posix.join('/usr/local/share/SubMiner'),
path.posix.join('/usr/share/SubMiner'),
path.join(scriptDir, 'assets'),
path.join(scriptDir, '..', 'assets'),
];
for (const root of roots) {
if (fs.existsSync(path.join(root, 'thumbnailers', ROFI_THUMBNAILER_FILE))) {
return root;
}
}
return null;
}
export function prependXdgDataDir(dataRoot: string, currentValue?: string): string {
const currentDirs = currentValue
? currentValue.split(path.delimiter).filter(Boolean)
: DEFAULT_XDG_DATA_DIRS;
return [dataRoot, ...currentDirs.filter((candidate) => candidate !== dataRoot)].join(
path.delimiter,
);
}
function buildRofiThumbnailEnvironment(scriptPath: string): NodeJS.ProcessEnv {
if (!commandExists('ffmpegthumbnailer')) return process.env;
const dataRoot = findRofiThumbnailerDataRoot(scriptPath);
if (!dataRoot) return process.env;
return {
...process.env,
XDG_DATA_DIRS: prependXdgDataDir(dataRoot, process.env.XDG_DATA_DIRS),
};
}
export function showRofiMenu( export function showRofiMenu(
videos: string[], videos: string[],
dir: string, dir: string,
@@ -420,6 +464,7 @@ export function showRofiMenu(
const result = spawnSync('rofi', args, { const result = spawnSync('rofi', args, {
input: buildRofiMenu(videos, dir, recursive), input: buildRofiMenu(videos, dir, recursive),
encoding: 'utf8', encoding: 'utf8',
env: buildRofiThumbnailEnvironment(scriptPath),
stdio: ['pipe', 'pipe', 'ignore'], stdio: ['pipe', 'pipe', 'ignore'],
}); });
if (result.error) { if (result.error) {
+136 -9
View File
@@ -1,6 +1,8 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { import {
ensureLinuxRuntimePluginAvailable, ensureLinuxRuntimePluginAvailable,
installManagedPluginAssetsViaApp, installManagedPluginAssetsViaApp,
@@ -31,7 +33,7 @@ test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', asyn
assert.deepEqual(calls, []); assert.deepEqual(calls, []);
}); });
test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => { test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => {
const calls: string[] = []; const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({ await ensureLinuxRuntimePluginAvailable({
@@ -52,13 +54,17 @@ test('ensureLinuxRuntimePluginAvailable skips install when installed global plug
calls.push('theme'); calls.push('theme');
return true; return true;
}, },
isManagedThumbnailerAvailable: () => {
calls.push('thumbnailer');
return true;
},
log: () => {}, log: () => {},
}); });
assert.deepEqual(calls, ['detect', 'theme']); assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']);
}); });
test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => { test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => {
const calls: string[] = []; const calls: string[] = [];
await ensureLinuxRuntimePluginAvailable({ await ensureLinuxRuntimePluginAvailable({
@@ -80,14 +86,19 @@ test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path
calls.push('theme'); calls.push('theme');
return true; return true;
}, },
isManagedThumbnailerAvailable: () => {
calls.push('thumbnailer');
return true;
},
log: () => {}, log: () => {},
}); });
assert.deepEqual(calls, ['detect', 'resolve', 'theme']); assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']);
}); });
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => { test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
const calls: string[] = []; const calls: string[] = [];
let themeAvailable = false;
await ensureLinuxRuntimePluginAvailable({ await ensureLinuxRuntimePluginAvailable({
platform: 'linux', platform: 'linux',
@@ -102,10 +113,15 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
}, },
isManagedThemeAvailable: () => { isManagedThemeAvailable: () => {
calls.push('theme'); calls.push('theme');
return false; return themeAvailable;
},
isManagedThumbnailerAvailable: () => {
calls.push('thumbnailer');
return true;
}, },
installManagedPluginAssets: async () => { installManagedPluginAssets: async () => {
calls.push('install'); calls.push('install');
themeAvailable = true;
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
}, },
log: (level, _configured, message) => { log: (level, _configured, message) => {
@@ -117,13 +133,68 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
'detect', 'detect',
'resolve', 'resolve',
'theme', 'theme',
'info:Linux runtime support assets missing; installing managed plugin/theme assets.', 'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
'install', 'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi', 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
'resolve', 'resolve',
'theme',
'thumbnailer',
]); ]);
}); });
test('ensureLinuxRuntimePluginAvailable installs managed assets when thumbnailer is missing', async () => {
const calls: string[] = [];
let thumbnailerAvailable = false;
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => true,
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
isManagedThemeAvailable: () => true,
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
installManagedPluginAssets: async () => {
calls.push('install');
thumbnailerAvailable = true;
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: (_level, _configured, message) => {
calls.push(message);
},
});
assert.deepEqual(calls, [
'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
'install',
'Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
]);
});
test('ensureLinuxRuntimePluginAvailable retains an installed plugin after installing support assets', async () => {
const calls: string[] = [];
let thumbnailerAvailable = false;
await ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => true,
resolveRuntimePluginPath: () => {
calls.push('resolve');
return null;
},
isManagedThemeAvailable: () => true,
isManagedThumbnailerAvailable: () => thumbnailerAvailable,
installManagedPluginAssets: async () => {
calls.push('install');
thumbnailerAvailable = true;
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: () => {},
});
assert.deepEqual(calls, ['install']);
});
test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => { test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves plugin path', async () => {
const calls: string[] = []; const calls: string[] = [];
let resolveCount = 0; let resolveCount = 0;
@@ -137,6 +208,8 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves
calls.push(`resolve:${resolveCount}`); calls.push(`resolve:${resolveCount}`);
return resolveCount === 1 ? null : '/tmp/plugin/main.lua'; return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
}, },
isManagedThemeAvailable: () => true,
isManagedThumbnailerAvailable: () => true,
installManagedPluginAssets: async () => { installManagedPluginAssets: async () => {
calls.push('install'); calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' }; return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
@@ -148,9 +221,9 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'resolve:1', 'resolve:1',
'info:Linux runtime support assets missing; installing managed plugin/theme assets.', 'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
'install', 'install',
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi', 'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
'resolve:2', 'resolve:2',
]); ]);
}); });
@@ -191,6 +264,60 @@ test('ensureLinuxRuntimePluginAvailable fails when runtime path remains unresolv
); );
}); });
test('ensureLinuxRuntimePluginAvailable fails when thumbnailer remains missing after install', async () => {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome: '/tmp/xdg-data',
detectInstalledPlugin: () => true,
resolveRuntimePluginPath: () => '/tmp/plugin/main.lua',
isManagedThemeAvailable: () => true,
isManagedThumbnailerAvailable: () => false,
installManagedPluginAssets: async () => ({
ok: true,
status: 'installed',
path: '/tmp/plugin/main.lua',
}),
log: () => {},
}),
/thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
);
});
test('ensureLinuxRuntimePluginAvailable rejects a thumbnailer directory before and after install', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-thumbnailer-directory-'));
const thumbnailerPath = path.join(
xdgDataHome,
'SubMiner',
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
);
fs.mkdirSync(thumbnailerPath, { recursive: true });
const calls: string[] = [];
try {
await assert.rejects(
() =>
ensureLinuxRuntimePluginAvailable({
platform: 'linux',
xdgDataHome,
detectInstalledPlugin: () => true,
isManagedThemeAvailable: () => true,
installManagedPluginAssets: async () => {
calls.push('install');
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
},
log: () => {},
}),
/thumbnailer=.*subminer-ffmpegthumbnailer\.thumbnailer/i,
);
assert.deepEqual(calls, ['install']);
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
}
});
test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => { test('installManagedPluginAssetsViaApp returns launch errors without waiting for a response file', async () => {
let waited = false; let waited = false;
+22 -6
View File
@@ -31,6 +31,7 @@ type EnsureLinuxRuntimePluginAvailableOptions = {
detectInstalledPlugin?: () => boolean; detectInstalledPlugin?: () => boolean;
resolveRuntimePluginPath?: () => string | null; resolveRuntimePluginPath?: () => string | null;
isManagedThemeAvailable?: () => boolean; isManagedThemeAvailable?: () => boolean;
isManagedThumbnailerAvailable?: () => boolean;
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>; installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
log?: PreflightLog; log?: PreflightLog;
}; };
@@ -48,6 +49,14 @@ function resolveConfiguredLogLevel(
return logLevel ?? 'warn'; return logLevel ?? 'warn';
} }
function isRegularFile(filePath: string): boolean {
try {
return fs.statSync(filePath).isFile();
} catch {
return false;
}
}
async function waitForInstallResponse( async function waitForInstallResponse(
responsePath: string, responsePath: string,
): Promise<RuntimePluginPreflightResponse | null> { ): Promise<RuntimePluginPreflightResponse | null> {
@@ -170,15 +179,17 @@ export async function ensureLinuxRuntimePluginAvailable(
}); });
const isManagedThemeAvailable = const isManagedThemeAvailable =
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath)); options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
const isManagedThumbnailerAvailable =
options.isManagedThumbnailerAvailable ?? (() => isRegularFile(managedPaths.thumbnailerPath));
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath()); const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
if (runtimePluginAvailable && isManagedThemeAvailable()) { if (runtimePluginAvailable && isManagedThemeAvailable() && isManagedThumbnailerAvailable()) {
return; return;
} }
log( log(
'info', 'info',
configuredLogLevel, configuredLogLevel,
'Linux runtime support assets missing; installing managed plugin/theme assets.', 'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
); );
const installManagedPluginAssets = const installManagedPluginAssets =
options.installManagedPluginAssets ?? options.installManagedPluginAssets ??
@@ -207,16 +218,21 @@ export async function ensureLinuxRuntimePluginAvailable(
log( log(
'info', 'info',
configuredLogLevel, configuredLogLevel,
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`, `Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}`,
); );
const runtimePluginPath = resolveRuntimePluginPath(); const runtimePluginAvailableAfterInstall =
if (runtimePluginPath) { installedPluginAvailable || Boolean(resolveRuntimePluginPath());
if (
runtimePluginAvailableAfterInstall &&
isManagedThemeAvailable() &&
isManagedThumbnailerAvailable()
) {
return; return;
} }
const message = const message =
`Linux managed runtime plugin assets could not be installed. ` + `Linux managed runtime plugin assets could not be installed. ` +
`Checked path: ${managedPaths.pluginEntrypointPath}. ` + `Checked paths: plugin=${managedPaths.pluginEntrypointPath} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}. ` +
'Launch aborted before starting mpv.'; 'Launch aborted before starting mpv.';
log('warn', configuredLogLevel, message); log('warn', configuredLogLevel, message);
throw new Error(message); throw new Error(message);
+15 -1
View File
@@ -165,11 +165,14 @@ if (entry.argv.includes('--ensure-linux-runtime-plugin-assets')) {
const pluginDir = path.join(dataDir, 'plugin', 'subminer'); const pluginDir = path.join(dataDir, 'plugin', 'subminer');
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf'); const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
const themePath = path.join(dataDir, 'themes', 'subminer.rasi'); const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
const thumbnailerPath = path.join(dataDir, 'thumbnailers', 'subminer-ffmpegthumbnailer.thumbnailer');
fs.mkdirSync(pluginDir, { recursive: true }); fs.mkdirSync(pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(themePath), { recursive: true }); fs.mkdirSync(path.dirname(themePath), { recursive: true });
fs.mkdirSync(path.dirname(thumbnailerPath), { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n'); fs.writeFileSync(path.join(pluginDir, 'main.lua'), '-- smoke plugin\\n');
fs.writeFileSync(pluginConfigPath, 'smoke=true\\n'); fs.writeFileSync(pluginConfigPath, 'smoke=true\\n');
fs.writeFileSync(themePath, '/* smoke theme */\\n'); fs.writeFileSync(themePath, '/* smoke theme */\\n');
fs.writeFileSync(thumbnailerPath, '[Thumbnailer Entry]\\n');
if (responsePath) { if (responsePath) {
fs.mkdirSync(path.dirname(responsePath), { recursive: true }); fs.mkdirSync(path.dirname(responsePath), { recursive: true });
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') })); fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
@@ -620,11 +623,22 @@ test(
); );
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);
if (process.platform === 'linux') { if (process.platform === 'linux') {
assert.match(result.stdout, /managed plugin\/theme assets/i); assert.match(result.stdout, /managed plugin\/theme\/thumbnailer assets/i);
assert.equal( assert.equal(
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')), fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
true, true,
); );
assert.equal(
fs.existsSync(
path.join(
smokeCase.xdgDataHome,
'SubMiner',
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
),
),
true,
);
} }
}); });
}, },
+2 -2
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.19.4-beta.1", "version": "0.19.4-beta.2",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "main": "dist/main-entry.js",
@@ -125,7 +125,7 @@
"devDependencies": { "devDependencies": {
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"electron": "43.4.1", "electron": "42.6.0",
"electron-builder": "26.15.3", "electron-builder": "26.15.3",
"undici": "7.29.0", "undici": "7.29.0",
"esbuild": "^0.25.12", "esbuild": "^0.25.12",
+2
View File
@@ -58,6 +58,8 @@ package() {
"${pkgdir}/usr/share/SubMiner/plugin/subminer.conf" "${pkgdir}/usr/share/SubMiner/plugin/subminer.conf"
install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \ install -Dm644 "${srcdir}/assets/themes/subminer.rasi" \
"${pkgdir}/usr/share/SubMiner/themes/subminer.rasi" "${pkgdir}/usr/share/SubMiner/themes/subminer.rasi"
install -Dm644 "${srcdir}/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer" \
"${pkgdir}/usr/share/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"
install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer" install -dm755 "${pkgdir}/usr/share/SubMiner/plugin/subminer"
cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/" cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/"
+41 -28
View File
@@ -4,37 +4,43 @@
## Highlights ## Highlights
### Added ### Added
- **Library Merge and Move**
- Duplicate library cards for the same show can now be combined: select cards in the library grid, choose "Merge Selected," and pick which entry to keep. Sessions, mined cards, and watch time move over, and future episodes stay matched to the merged card. - Library Merge & Reassignment
- Episodes can be reassigned to a different library entry with a "→" button on the episode row, fixing cases where a file lands under the wrong title. Manual assignments survive later filename parsing, Jellyfin refreshes, and season repair. - Duplicate library entries for the same show can now be merged: pick entries in "Select" mode and use "Merge Selected" to combine sessions, mined cards, and watch time onto one card.
- Exact AniList matches with compatible seasons now merge automatically, while fuzzy matches show up as a dismissible "Possible duplicate" prompt instead of merging without confirmation. - Episodes can be moved to a different library entry with a per-episode "→" button, fixing cases where a stray filename split off its own entry; manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair.
- Exact AniList matches with compatible seasons now merge automatically, and likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging silently.
- Duplicate Line Cleanup
- The Vocabulary tab's new **Duplicates** button scans a chosen time window for the repeated-line bursts described under Fixed below and collapses each burst to a single line once you confirm it; a matching `subminer stats cleanup --duplicate-lines` command (with `--dry-run` and `--lookback-days <n>`) is available from the terminal.
- Only the affected subtitle lines and the vocabulary counts they inflated are touched; watch time and lines-seen totals are left as recorded.
### Fixed ### Fixed
- **Anki Audio Generation on Network Drives**
- Fixed sentence-audio generation timing out on slow network-mounted video files with many subtitle and font-attachment streams.
- Extraction now uses bounded probing and a two-minute budget, and failures show a clear error instead of a cryptic one.
- **Duplicate Subtitle Line Stats**
- Fixed karaoke openings and animated signs (which record one subtitle event per animation frame) inflating word and kanji counts and skewing "Top Repeated Words." Ordinary repeated dialogue and rewatches are unaffected.
- Already-inflated stats can be cleaned up with the new "Duplicates" button in the Vocabulary tab, or `subminer stats cleanup --duplicate-lines` (supports `--dry-run` and `--lookback-days`). Only the affected subtitle lines and vocabulary counts are touched; watch time and lines-seen totals are untouched.
- **Overlay Modals on macOS and Windows**
- Fixed overlay modals and the stats window opening on the wrong macOS Space, or forcing a Space switch, when mpv is fullscreen. They now open above fullscreen mpv on its current Space.
- Modals are now prewarmed on macOS and Windows so shortcuts open them promptly, and Windows keeps the hidden modal responsive between sessions.
- **Wayland File Drag-and-Drop**
- Fixed dragging subtitle and video files from file managers like Thunar onto the overlay on native Wayland; dropped files are now resolved and sent to mpv.
- **Windows Mouse Lag**
- Fixed system-wide mouse lag while SubMiner is running on Windows, caused by a global mouse hook and blocking window lookups during click-through tracking.
- **Mining Clip Accuracy**
- Fixed mined audio and animated image clips sometimes capturing the wrong subtitle line when audio extraction was slow. The clip range is now locked in at the moment of lookup, so audio and image clips always match.
- **Linux Notifications**
- Character dictionary progress notifications on Linux now update in place instead of flickering off and back on with every status change.
- **Stats Delete Performance**
- Fixed stats deletes freezing the dashboard; deletes now reliably run off the main thread, with automatic retry if the delete worker crashes.
- Deletes, library merges/moves, and AniList reassignments are now much faster because totals are updated incrementally instead of rebuilt from scratch, and no longer erase lifetime totals older than the recent session history.
- Session deletes on large libraries dropped from minutes to milliseconds.
### Docs - Subtitle Duplication from Karaoke & Animated Signs
- **Feature Demos Page** - Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mined cards, or stats with repeated glyph fragments or per-frame duplicates; the complete authored line is recovered instead, without merging genuinely repeated dialogue or separately positioned signs.
- Hidden the unfinished feature demos page from the documentation sidebar; it's still reachable by direct URL. - The secondary overlay now shares the same deduplication logic as the primary overlay, so layered animation text no longer appears multiple times there or in what gets mined.
- Vocabulary stats no longer count every animation frame of a karaoke opening as a separate line, which previously could push an OP lyric to the top of "Top Repeated Words."
- Anki Media Generation
- Sentence-audio generation no longer times out on slow network-mounted video files with many subtitle and font streams, and a failed extraction now reports a clear error instead of a raw `ENOENT`.
- Mined audio and animated AVIF clips now capture the subtitle line you actually mined, instead of whatever line happened to be on screen once slow audio extraction finished.
- Character Dictionary Performance & Notifications
- Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries, and cached results are reused across launches instead of regenerating character data and portraits every time.
- Desktop progress notifications, including on Linux AppImage installs, now update in place instead of flickering closed and reopening.
- Overlay Reliability
- Overlay modals (settings, stats, etc.) now open promptly on the first shortcut press and appear above fullscreen mpv on macOS instead of switching Spaces or opening off-screen.
- The overlay no longer gets stuck on "Overlay loading" indefinitely if mpv's connection stalls; it now retries and shows an actionable error after 30 seconds.
- Fixed native Wayland drag-and-drop from file managers like Thunar, so subtitle and video files dropped on the overlay reach mpv.
- Fixed system-wide mouse lag on Windows caused by the overlay's click-through handling and repeated mpv window lookups.
- Stats Dashboard
- Deletes, library merges, video moves, and AniList reassignments no longer freeze the stats dashboard or rebuild lifetime totals from scratch; large deletes that used to take minutes now finish in milliseconds.
- Vocabulary totals and charts now count all tracked vocabulary instead of just the first page, new-word history uses corrected daily rollups, calendar labels respect time zones west of UTC, and vocabulary cards refresh automatically after editing the word exclusion list.
- Linux Launcher Thumbnails
- Fixed missing MKV thumbnails in the Linux rofi picker when the system thumbnailer only registers legacy Matroska MIME aliases.
## What's Changed ## What's Changed
@@ -47,6 +53,13 @@
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199 - fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200 - fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201 - fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
- fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202
- fix(mpv): recover from stalled IPC connects by @ksyasuda in #204
- fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205
- fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207
- fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208
- fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210
- fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212
## Installation ## Installation
+4 -2
View File
@@ -19,7 +19,8 @@ Options:
-w, --webp Generate animated WebP preview -w, --webp Generate animated WebP preview
Encoding profile: Encoding profile:
- Crop: 1920x1080 at x=760 y=200 - Crop: mpv region at 1920x1080, x=760 y=205 on a 3440x1440 canvas
- Output size: 1920x1080
- MP4: H.264 + AAC - MP4: H.264 + AAC
- WebM: AV1/VP9 + Opus at 30 fps - WebM: AV1/VP9 + Opus at 30 fps
USAGE USAGE
@@ -148,7 +149,8 @@ pick_webp_encoder() {
return 1 return 1
} }
crop_vf="crop=1920:1080:760:205" # OBS may resize the 3440x1440 canvas, so scale the mpv bounds with the input.
crop_vf="crop=1920*iw/3440:1080*ih/1440:760*iw/3440:205*ih/1440,scale=1920:1080:flags=lanczos"
webm_vf="${crop_vf},fps=30" webm_vf="${crop_vf},fps=30"
echo "Generating MP4: $mp4_out" echo "Generating MP4: $mp4_out"
+5 -1
View File
@@ -40,7 +40,7 @@ function toBashPath(filePath: string): string {
return `${drive.toUpperCase()}:/${rest}`; return `${drive.toUpperCase()}:/${rest}`;
} }
test('mkv-to-readme-video accepts libwebp_anim when libwebp is unavailable', () => { test('mkv-to-readme-video builds every output with the scaled mpv crop', () => {
withTempDir((root) => { withTempDir((root) => {
const binDir = path.join(root, 'bin'); const binDir = path.join(root, 'bin');
const inputPath = path.join(root, 'sample.mkv'); const inputPath = path.join(root, 'sample.mkv');
@@ -104,5 +104,9 @@ touch "$output"
const ffmpegLog = fs.readFileSync(ffmpegLogPath, 'utf8'); const ffmpegLog = fs.readFileSync(ffmpegLogPath, 'utf8');
assert.match(ffmpegLog, /-c:v libwebp_anim/); assert.match(ffmpegLog, /-c:v libwebp_anim/);
const scaledCropUses = ffmpegLog.match(
/-vf crop=1920\*iw\/3440:1080\*ih\/1440:760\*iw\/3440:205\*ih\/1440,scale=1920:1080:flags=lanczos/g,
);
assert.equal(scaledCropUses?.length, 4);
}); });
}); });
+1
View File
@@ -82,6 +82,7 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
pkgbuild, pkgbuild,
/^\s*install -Dm755 "\$\{srcdir\}\/subminer-\$\{pkgver\}" "\$\{pkgdir\}\/usr\/bin\/subminer"$/m, /^\s*install -Dm755 "\$\{srcdir\}\/subminer-\$\{pkgver\}" "\$\{pkgdir\}\/usr\/bin\/subminer"$/m,
); );
assert.match(pkgbuild, /assets\/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/);
assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m); assert.match(srcinfo, /^\tpkgver = 0\.6\.3$/m);
assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m); assert.match(srcinfo, /^\tprovides = subminer=0\.6\.3$/m);
assert.match( assert.match(
@@ -47,7 +47,7 @@ test('stripFilenameTags normalizes common media-title formats', () => {
); );
}); });
async function backfillMissingCoverBlob(): Promise<void> { test('fetchIfMissing backfills a missing blob from an existing cover URL', async () => {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
ensureSchema(db); ensureSchema(db);
@@ -103,15 +103,9 @@ async function backfillMissingCoverBlob(): Promise<void> {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
} }
} });
test( test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
'fetchIfMissing backfills a missing blob from an existing cover URL',
{ timeout: 15_000 },
backfillMissingCoverBlob,
);
async function reuseCachedAnimeCoverArt(): Promise<void> {
const dbPath = makeDbPath(); const dbPath = makeDbPath();
const db = new Database(dbPath); const db = new Database(dbPath);
ensureSchema(db); ensureSchema(db);
@@ -185,13 +179,7 @@ async function reuseCachedAnimeCoverArt(): Promise<void> {
db.close(); db.close();
cleanupDbPath(dbPath); cleanupDbPath(dbPath);
} }
} });
test(
'fetchIfMissing reuses cached cover art from another video in the same anime',
{ timeout: 15_000 },
reuseCachedAnimeCoverArt,
);
function createJsonResponse(payload: unknown): Response { function createJsonResponse(payload: unknown): Response {
return new Response(JSON.stringify(payload), { return new Response(JSON.stringify(payload), {
@@ -39,7 +39,6 @@ test('non-macOS modal overlay remains a regular window', () => {
}); });
assert.equal(options.type, undefined); assert.equal(options.type, undefined);
assert.equal(options.roundedCorners, false);
}); });
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => { test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
@@ -37,7 +37,6 @@ export function buildOverlayWindowOptions(
paintWhenInitiallyHidden: true, paintWhenInitiallyHidden: true,
backgroundColor: '#00000000', backgroundColor: '#00000000',
frame: false, frame: false,
...(platform === 'linux' ? { roundedCorners: false } : {}),
alwaysOnTop: shouldStartAlwaysOnTop, alwaysOnTop: shouldStartAlwaysOnTop,
skipTaskbar: true, skipTaskbar: true,
resizable: shouldAllowCompositorResize, resizable: shouldAllowCompositorResize,
@@ -67,7 +67,6 @@ export function buildStatsWindowOptions(options: {
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH, width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT, height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
frame: false, frame: false,
...(platform === 'linux' ? { roundedCorners: false } : {}),
transparent: false, transparent: false,
alwaysOnTop: true, alwaysOnTop: true,
resizable: false, resizable: false,
-1
View File
@@ -57,7 +57,6 @@ test('buildStatsWindowOptions remains a regular window off macOS', () => {
}); });
assert.equal(options.type, undefined); assert.equal(options.type, undefined);
assert.equal(options.roundedCorners, false);
}); });
test('stats panels present after document load on macOS', () => { test('stats panels present after document load on macOS', () => {
-90
View File
@@ -606,93 +606,3 @@ test('configureEarlyAppPaths pins userData to canonical SubMiner config dir', ()
assert.equal(userDataPath, '/tmp/xdg/SubMiner'); assert.equal(userDataPath, '/tmp/xdg/SubMiner');
assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner']); assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner']);
}); });
test('configureEarlyAppPaths isolates development runs from the production profile', () => {
const calls: string[] = [];
const userDataPath = configureEarlyAppPaths(
{
setName: (name) => calls.push(`name:${name}`),
setPath: (key, value) => calls.push(`path:${key}:${value}`),
},
{
platform: 'linux',
homeDir: '/home/tester',
xdgConfigHome: '/tmp/xdg',
existsSync: () => false,
argv: ['electron', '.', '--start', '--dev'],
env: {},
},
);
assert.equal(userDataPath, '/tmp/xdg/SubMiner-dev');
assert.deepEqual(calls, ['name:SubMiner', 'path:userData:/tmp/xdg/SubMiner-dev']);
});
test('configureEarlyAppPaths ignores development flags forwarded to mpv', () => {
for (const forwardedFlag of ['--dev', '--debug']) {
let selectedPath = '';
const userDataPath = configureEarlyAppPaths(
{
setName: () => {},
setPath: (_key, value) => {
selectedPath = value;
},
},
{
platform: 'linux',
homeDir: '/home/tester',
xdgConfigHome: '/tmp/xdg',
existsSync: () => false,
argv: ['electron', '.', '--launch-mpv', forwardedFlag],
env: {},
},
);
assert.equal(userDataPath, '/tmp/xdg/SubMiner');
assert.equal(selectedPath, '/tmp/xdg/SubMiner');
}
});
test('configureEarlyAppPaths uses the supplied environment for config discovery', () => {
const paths: string[] = [];
const userDataPath = configureEarlyAppPaths(
{
setName: () => {},
setPath: (_key, value) => paths.push(value),
},
{
platform: 'linux',
homeDir: '/home/tester',
existsSync: () => false,
argv: ['electron', '.', '--start'],
env: { XDG_CONFIG_HOME: '/tmp/injected-xdg' },
},
);
assert.equal(userDataPath, '/tmp/injected-xdg/SubMiner');
assert.deepEqual(paths, ['/tmp/injected-xdg/SubMiner']);
});
test('configureEarlyAppPaths allows an explicit production-profile development run', () => {
const paths: string[] = [];
const userDataPath = configureEarlyAppPaths(
{
setName: () => {},
setPath: (_key, value) => paths.push(value),
},
{
platform: 'win32',
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
homeDir: 'C:\\Users\\tester',
existsSync: () => false,
argv: ['electron.exe', '.', '--debug'],
env: { SUBMINER_USE_PRODUCTION_PROFILE: '1' },
},
);
assert.equal(userDataPath, 'C:\\Users\\tester\\AppData\\Roaming\\SubMiner');
assert.deepEqual(paths, ['C:\\Users\\tester\\AppData\\Roaming\\SubMiner']);
});
+4 -21
View File
@@ -1,6 +1,5 @@
import fs from 'node:fs'; import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args'; import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
import { resolveConfigDir } from './config/path-resolution'; import { resolveConfigDir } from './config/path-resolution';
@@ -15,8 +14,6 @@ const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_'; const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
const MAX_TRANSPORTED_APP_ARGS = 256; const MAX_TRANSPORTED_APP_ARGS = 256;
const APP_NAME = 'SubMiner'; const APP_NAME = 'SubMiner';
const DEVELOPMENT_APP_NAME = 'SubMiner-dev';
export const USE_PRODUCTION_PROFILE_ENV = 'SUBMINER_USE_PRODUCTION_PROFILE';
const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500; const DEFAULT_APP_CONTROL_HANDOFF_TIMEOUT_MS = 500;
const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000; const MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([ const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
@@ -56,8 +53,6 @@ type EarlyAppPathOptions = {
xdgConfigHome?: string; xdgConfigHome?: string;
homeDir?: string; homeDir?: string;
existsSync?: (candidate: string) => boolean; existsSync?: (candidate: string) => boolean;
argv?: string[];
env?: NodeJS.ProcessEnv;
}; };
function removeLsfgLayer(env: NodeJS.ProcessEnv): void { function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
@@ -257,25 +252,13 @@ export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): st
} }
export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPathOptions): string { export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPathOptions): string {
const platform = options?.platform ?? process.platform; const userDataPath = resolveConfigDir({
const env = options?.env ?? process.env; platform: options?.platform ?? process.platform,
const configDir = resolveConfigDir({ appDataDir: options?.appDataDir ?? process.env.APPDATA,
platform, xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
appDataDir: options?.appDataDir ?? env.APPDATA,
xdgConfigHome: options?.xdgConfigHome ?? env.XDG_CONFIG_HOME,
homeDir: options?.homeDir ?? os.homedir(), homeDir: options?.homeDir ?? os.homedir(),
existsSync: options?.existsSync ?? fs.existsSync, existsSync: options?.existsSync ?? fs.existsSync,
}); });
const argv = options?.argv ?? process.argv;
const launchMpvIndex = argv.indexOf('--launch-mpv');
const appArgv = launchMpvIndex === -1 ? argv : argv.slice(0, launchMpvIndex);
const useDevelopmentProfile =
(appArgv.includes('--dev') || appArgv.includes('--debug')) &&
env[USE_PRODUCTION_PROFILE_ENV] !== '1';
const platformPath = platform === 'win32' ? path.win32 : path.posix;
const userDataPath = useDevelopmentProfile
? platformPath.join(platformPath.dirname(configDir), DEVELOPMENT_APP_NAME)
: configDir;
app.setName(APP_NAME); app.setName(APP_NAME);
app.setPath('userData', userDataPath); app.setPath('userData', userDataPath);
+2 -20
View File
@@ -22,10 +22,7 @@ import {
shouldHandleStatsDaemonCommandAtEntry, shouldHandleStatsDaemonCommandAtEntry,
spawnDetachedApp, spawnDetachedApp,
} from './main-entry-runtime'; } from './main-entry-runtime';
import { import { requestSingleInstanceLockEarly } from './main/early-single-instance';
requestSingleInstanceLockEarly,
shouldBypassSingleInstanceLockForArgv,
} from './main/early-single-instance';
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config'; import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client'; import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
import { import {
@@ -38,7 +35,6 @@ import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/win
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry'; import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
import { handleSyncCliAtEntry } from './main/sync-cli'; import { handleSyncCliAtEntry } from './main/sync-cli';
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error'; import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
import { enforceElectronRuntimeGuard } from './main/electron-runtime-guard';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args'; import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import { import {
applyLogFileTogglesToEnv, applyLogFileTogglesToEnv,
@@ -196,25 +192,11 @@ registerFatalErrorHandlers({
}); });
function startMainProcess(): void { function startMainProcess(): void {
// Normal launches serialize the runtime guard with the profile-scoped lock. Stats daemon const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
// commands keep their existing lock bypass when Electron runs in Node mode.
const gotSingleInstanceLock =
shouldBypassSingleInstanceLockForArgv(process.argv) || requestSingleInstanceLockEarly(app);
if (!gotSingleInstanceLock) { if (!gotSingleInstanceLock) {
app.exit(0); app.exit(0);
return; return;
} }
const runtimeGuard = enforceElectronRuntimeGuard({
electronVersion: process.versions.electron ?? '',
userDataPath,
});
if (!runtimeGuard.ok) {
console.error(runtimeGuard.details);
dialog.showErrorBox(runtimeGuard.title, runtimeGuard.details);
app.exit(1);
return;
}
try { try {
require('./main.js'); require('./main.js');
} catch (error) { } catch (error) {
+11 -18
View File
@@ -36,10 +36,6 @@ import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtim
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime'; import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata'; import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js'; import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
import {
assertYomitanDictionaryMutationSafe,
observeYomitanDictionaryCount,
} from './main/runtime/yomitan-dictionary-integrity';
import { startAppControlServer } from './main/runtime/app-control-server'; import { startAppControlServer } from './main/runtime/app-control-server';
import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup'; import { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
import { import {
@@ -768,7 +764,6 @@ type BootServices = MainBootServicesResult<
const bootServices = createMainBootServices({ const bootServices = createMainBootServices({
platform: process.platform, platform: process.platform,
argv: process.argv, argv: process.argv,
configDir: app.getPath('userData'),
appDataDir: process.env.APPDATA, appDataDir: process.env.APPDATA,
xdgConfigHome: process.env.XDG_CONFIG_HOME, xdgConfigHome: process.env.XDG_CONFIG_HOME,
homeDir: os.homedir(), homeDir: os.homedir(),
@@ -1437,10 +1432,6 @@ const firstRunSetupService = createFirstRunSetupService({
error: (message, ...args) => logger.error(message, ...args), error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args), info: (message, ...args) => logger.info(message, ...args),
}); });
const integrity = observeYomitanDictionaryCount(USER_DATA_PATH, dictionaries.length);
if (!integrity.safe) {
logger.error(`[dictionary:integrity] ${integrity.message}`);
}
return dictionaries.length; return dictionaries.length;
}, },
isExternalYomitanConfigured: () => isExternalYomitanConfigured: () =>
@@ -2532,12 +2523,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
), ),
getYomitanDictionaryInfo: async () => { getYomitanDictionaryInfo: async () => {
await ensureYomitanExtensionLoaded(); await ensureYomitanExtensionLoaded();
const dictionaries = await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), { return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
error: (message, ...args) => logger.error(message, ...args), error: (message, ...args) => logger.error(message, ...args),
info: (message, ...args) => logger.info(message, ...args), info: (message, ...args) => logger.info(message, ...args),
}); });
assertYomitanDictionaryMutationSafe(USER_DATA_PATH, dictionaries.length);
return dictionaries;
}, },
importYomitanDictionary: async (zipPath) => { importYomitanDictionary: async (zipPath) => {
if (yomitanProfilePolicy.isExternalReadOnlyMode()) { if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
@@ -2634,6 +2623,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({ const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
userDataPath: USER_DATA_PATH, userDataPath: USER_DATA_PATH,
getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(), getCurrentMediaId: () => characterDictionaryAutoSyncRuntime.getCurrentMediaId(),
onIndexReady: () => refreshCurrentSubtitleAnnotations(),
onIndexReadyError: (error) =>
logger.warn(
'Failed to refresh subtitle annotations after character portrait index became ready.',
error,
),
}); });
// Lets the Yomitan scan runtime skip name lookups at positions where no // Lets the Yomitan scan runtime skip name lookups at positions where no
@@ -4058,7 +4053,7 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
appState.immersionTracker?.recordCardsMined(count, noteIds); appState.immersionTracker?.recordCardsMined(count, noteIds);
}; };
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => { function refreshCurrentSubtitleAnnotations(): void {
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0; const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
if (hasCurrentSubtitle) { if (hasCurrentSubtitle) {
subtitlePrefetchService?.pause(); subtitlePrefetchService?.pause();
@@ -4069,7 +4064,7 @@ const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
// Idle controller: no settle is coming to release the pause above. // Idle controller: no settle is coming to release the pause above.
subtitlePrefetchService?.resume(); subtitlePrefetchService?.resume();
} }
}; }
let hasAttemptedImmersionTrackerStartup = false; let hasAttemptedImmersionTrackerStartup = false;
const ensureImmersionTrackerStarted = (): void => { const ensureImmersionTrackerStarted = (): void => {
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) { if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
@@ -5092,9 +5087,7 @@ function initializeOverlayRuntime(): void {
overlayModalRuntime.primeModalWindow(); overlayModalRuntime.primeModalWindow();
} }
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
refreshCurrentSubtitleAfterKnownWordUpdate,
);
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext); appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
syncOverlayMpvSubtitleSuppression(); syncOverlayMpvSubtitleSuppression();
} }
@@ -5887,7 +5880,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
appState.ankiIntegration = integration; appState.ankiIntegration = integration;
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined); appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback( appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
refreshCurrentSubtitleAfterKnownWordUpdate, refreshCurrentSubtitleAnnotations,
); );
appState.ankiIntegration?.setSubtitleMiningContextConsumer( appState.ankiIntegration?.setSubtitleMiningContextConsumer(
consumePendingSubtitleMiningContext, consumePendingSubtitleMiningContext,
-53
View File
@@ -138,56 +138,3 @@ test('createMainBootServices builds boot-phase service bundle', () => {
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']); assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
assert.equal(setPathValue, '/tmp/subminer-config'); assert.equal(setPathValue, '/tmp/subminer-config');
}); });
test('createMainBootServices honors the profile selected by the early entrypoint', () => {
const services = createMainBootServices({
platform: 'linux',
argv: ['electron', '.', '--dev'],
configDir: '/tmp/SubMiner-dev',
appDataDir: undefined,
xdgConfigHome: undefined,
homeDir: '/home/tester',
defaultMpvLogFile: '/tmp/default.log',
envMpvLog: undefined,
defaultTexthookerPort: 5174,
getDefaultSocketPath: () => '/tmp/subminer.sock',
resolveConfigDir: () => {
throw new Error('early profile should be authoritative');
},
existsSync: () => false,
mkdirSync: () => {},
joinPath: (...parts) => parts.join('/'),
app: {
setPath: () => {},
quit: () => {},
exit: () => {},
on: () => ({}),
whenReady: async () => {},
},
shouldBypassSingleInstanceLock: () => false,
requestSingleInstanceLockEarly: () => true,
registerSecondInstanceHandlerEarly: () => {},
onConfigStartupParseError: () => {},
createConfigService: (configDir) => ({ configDir }),
createAnilistTokenStore: (targetPath) => ({ targetPath }),
createJellyfinTokenStore: (targetPath) => ({ targetPath }),
createAnilistUpdateQueue: (targetPath) => ({ targetPath }),
createSubtitleWebSocket: (payloadMode) => ({ payloadMode }),
createLogger: () => ({ warn: () => {}, info: () => {}, error: () => {} }),
createMainRuntimeRegistry: () => ({}),
createOverlayManager: () => ({ getMainWindow: () => null, getModalWindow: () => null }),
createOverlayModalInputState: () => ({
getModalInputExclusive: () => false,
handleModalInputStateChange: () => {},
}),
createOverlayContentMeasurementStore: () => ({}),
getSyncOverlayShortcutsForModal: () => () => {},
getSyncOverlayVisibilityForModal: () => () => {},
createOverlayModalRuntime: () => ({}),
createAppState: (input) => input,
});
assert.equal(services.configDir, '/tmp/SubMiner-dev');
assert.equal(services.userDataPath, '/tmp/SubMiner-dev');
assert.deepEqual(services.configService, { configDir: '/tmp/SubMiner-dev' });
});
+1 -4
View File
@@ -31,7 +31,6 @@ export interface MainBootServicesParams<
> { > {
platform: NodeJS.Platform; platform: NodeJS.Platform;
argv: string[]; argv: string[];
configDir?: string;
appDataDir: string | undefined; appDataDir: string | undefined;
xdgConfigHome: string | undefined; xdgConfigHome: string | undefined;
homeDir: string; homeDir: string;
@@ -175,9 +174,7 @@ export function createMainBootServices<
TAppState, TAppState,
TAppLifecycleApp TAppLifecycleApp
> { > {
const configDir = const configDir = params.resolveConfigDir({
params.configDir ??
params.resolveConfigDir({
platform: params.platform, platform: params.platform,
appDataDir: params.appDataDir, appDataDir: params.appDataDir,
xdgConfigHome: params.xdgConfigHome, xdgConfigHome: params.xdgConfigHome,
+3 -3
View File
@@ -450,7 +450,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
} }
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable(); const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
const resolvedNameSplits = nameSplitTokenizerAvailable const nameSplitResolution = nameSplitTokenizerAvailable
? await resolveJapaneseNameSplits( ? await resolveJapaneseNameSplits(
characters, characters,
deps.tokenizeJapaneseName!, deps.tokenizeJapaneseName!,
@@ -466,8 +466,8 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
}, },
) )
: undefined; : undefined;
const nameSplitSource = const resolvedNameSplits = nameSplitResolution?.splits;
resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic'; const nameSplitSource = nameSplitResolution?.kind === 'complete' ? 'mecab' : 'heuristic';
progress?.onGenerateProgress?.({ progress?.onGenerateProgress?.({
mediaId, mediaId,
@@ -198,6 +198,66 @@ test('createCharacterDictionaryImageLookup can scope duplicate names to the curr
assert.equal(scoped.alt, 'Kazuma'); assert.equal(scoped.alt, 'Kazuma');
}); });
test('createCharacterDictionaryImageLookup reports and retries a failed index-ready callback', async () => {
const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = {
formatVersion: CHARACTER_DICTIONARY_FORMAT_VERSION,
mediaId: 21858,
mediaTitle: 'Little Witch Academia',
entryCount: 1,
updatedAt: 1_700_000_000_000,
termEntries: [
[
'ダイアナ',
'だいあな',
'name primary',
'',
75,
[
{
type: 'structured-content',
content: {
tag: 'img',
path: 'img/m21858-c81709.png',
alt: 'ダイアナ・キャベンディッシュ',
},
},
],
0,
'',
],
],
images: [{ path: 'img/m21858-c81709.png', dataBase64: PNG_1X1_BASE64 }],
};
await writeSnapshot(getSnapshotPath(outputDir, snapshot.mediaId), snapshot);
const callbackError = new Error('annotation refresh failed');
const reportingError = new Error('error reporter failed');
let readyCount = 0;
const reportedErrors: unknown[] = [];
const lookup = createCharacterDictionaryImageLookup({
outputDir,
onIndexReady: () => {
readyCount += 1;
if (readyCount === 1) {
throw callbackError;
}
},
onIndexReadyError: (error) => {
reportedErrors.push(error);
throw reportingError;
},
});
assert.equal(lookup.get('ダイアナ', snapshot.mediaId), null);
await waitForRefresh(() => (reportedErrors.length === 1 ? true : null));
assert.ok(lookup.get('ダイアナ', snapshot.mediaId));
assert.equal(readyCount, 2);
assert.deepEqual(reportedErrors, [callbackError]);
lookup.get('ダイアナ', snapshot.mediaId);
assert.equal(readyCount, 2);
});
test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => { test('createCharacterDictionaryImageLookup does not fall back globally on scoped miss', async () => {
const outputDir = makeTempDir(); const outputDir = makeTempDir();
const snapshot: CharacterDictionarySnapshot = { const snapshot: CharacterDictionarySnapshot = {
@@ -218,6 +218,8 @@ export function createCharacterDictionaryImageLookup(deps: {
userDataPath?: string; userDataPath?: string;
outputDir?: string; outputDir?: string;
getCurrentMediaId?: () => number | null | undefined; getCurrentMediaId?: () => number | null | undefined;
onIndexReady?: () => void;
onIndexReadyError?: (error: unknown) => void;
}): { }): {
get: (term: string, mediaId?: number | null) => CharacterNameImage | null; get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
invalidate: () => void; invalidate: () => void;
@@ -229,6 +231,24 @@ export function createCharacterDictionaryImageLookup(deps: {
let index = new Map<string, CharacterNameImage>(); let index = new Map<string, CharacterNameImage>();
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>(); let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
let refreshInFlight = false; let refreshInFlight = false;
let indexReadyDeliveryPending = false;
function deliverIndexReadyIfPending(): void {
if (!indexReadyDeliveryPending || !deps.onIndexReady) {
return;
}
indexReadyDeliveryPending = false;
try {
deps.onIndexReady();
} catch (error) {
indexReadyDeliveryPending = true;
try {
deps.onIndexReadyError?.(error);
} catch {
// Error reporting must not reject the detached index refresh task.
}
}
}
// Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run // Rebuilding means re-reading every cached snapshot (potentially GBs of JSON), which used to run
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups // synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
@@ -241,6 +261,7 @@ export function createCharacterDictionaryImageLookup(deps: {
signature = ''; signature = '';
return; return;
} }
deliverIndexReadyIfPending();
const nextSignature = getSnapshotDirectorySignature(outputDir); const nextSignature = getSnapshotDirectorySignature(outputDir);
if (nextSignature === signature || refreshInFlight) { if (nextSignature === signature || refreshInFlight) {
return; return;
@@ -262,6 +283,8 @@ export function createCharacterDictionaryImageLookup(deps: {
index = nextIndex; index = nextIndex;
indexByMediaId = nextIndexByMediaId; indexByMediaId = nextIndexByMediaId;
signature = nextSignature; signature = nextSignature;
indexReadyDeliveryPending = deps.onIndexReady !== undefined;
deliverIndexReadyIfPending();
} finally { } finally {
refreshInFlight = false; refreshInFlight = false;
} }
@@ -43,7 +43,8 @@ test('resolveJapaneseNameSplits splits a single-kanji surname via person-name PO
}), }),
); );
assert.deepEqual(splits.get('東紫乃'), { family: '東', given: '紫乃' }); assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('東紫乃'), { family: '東', given: '紫乃' });
}); });
test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => { test('resolveJapaneseNameSplits corrects a hint-length-misleading surname boundary', async () => {
@@ -64,7 +65,8 @@ test('resolveJapaneseNameSplits corrects a hint-length-misleading surname bounda
}), }),
); );
assert.deepEqual(splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' }); assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('渡辺真奈美'), { family: '渡辺', given: '真奈美' });
}); });
test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => { test('resolveJapaneseNameSplits falls back to hint readings when POS tags are generic', async () => {
@@ -85,7 +87,8 @@ test('resolveJapaneseNameSplits falls back to hint readings when POS tags are ge
}), }),
); );
assert.deepEqual(splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' }); assert.equal(splits.kind, 'complete');
assert.deepEqual(splits.splits.get('鈴木みゆ'), { family: '鈴木', given: 'みゆ' });
}); });
test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => { test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the name', async () => {
@@ -96,7 +99,8 @@ test('resolveJapaneseNameSplits skips names whose tokens do not reconstruct the
}), }),
); );
assert.equal(splits.size, 0); assert.equal(splits.kind, 'complete');
assert.equal(splits.splits.size, 0);
}); });
test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => { test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', async () => {
@@ -117,7 +121,8 @@ test('resolveJapaneseNameSplits skips ambiguous or untagged segmentations', asyn
}), }),
); );
assert.equal(splits.size, 0); assert.equal(splits.kind, 'complete');
assert.equal(splits.splits.size, 0);
}); });
test('resolveJapaneseNameSplits survives tokenizer failures', async () => { test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
@@ -130,7 +135,8 @@ test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
(message) => warnings.push(message), (message) => warnings.push(message),
); );
assert.equal(splits.size, 0); assert.equal(splits.kind, 'incomplete');
assert.equal(splits.splits.size, 0);
assert.equal(warnings.length, 1); assert.equal(warnings.length, 1);
assert.match(warnings[0]!, /mecab unavailable/); assert.match(warnings[0]!, /mecab unavailable/);
}); });
@@ -7,6 +7,10 @@ import type {
ResolvedNameSplit, ResolvedNameSplit,
} from './types'; } from './types';
export type JapaneseNameSplitResolution =
| { kind: 'complete'; splits: Map<string, ResolvedNameSplit> }
| { kind: 'incomplete'; splits: Map<string, ResolvedNameSplit> };
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/; const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
function joinSurfaces(tokens: NameSplitToken[]): string { function joinSurfaces(tokens: NameSplitToken[]): string {
@@ -87,8 +91,9 @@ export async function resolveJapaneseNameSplits(
tokenize: NameSplitTokenizer, tokenize: NameSplitTokenizer,
logWarn?: (message: string) => void, logWarn?: (message: string) => void,
onCharacterResolved?: (completed: number, total: number) => void, onCharacterResolved?: (completed: number, total: number) => void,
): Promise<Map<string, ResolvedNameSplit>> { ): Promise<JapaneseNameSplitResolution> {
const splits = new Map<string, ResolvedNameSplit>(); const splits = new Map<string, ResolvedNameSplit>();
let tokenizerFailed = false;
let resolvedCharacters = 0; let resolvedCharacters = 0;
for (const character of characters) { for (const character of characters) {
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || ''); const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
@@ -99,12 +104,17 @@ export async function resolveJapaneseNameSplits(
try { try {
tokens = await tokenize(name); tokens = await tokenize(name);
} catch (err) { } catch (err) {
tokenizerFailed = true;
logWarn?.( logWarn?.(
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`, `[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
); );
continue; continue;
} }
if (!tokens || tokens.length < 2 || joinSurfaces(tokens) !== name) continue; if (!tokens) {
tokenizerFailed = true;
continue;
}
if (tokens.length < 2 || joinSurfaces(tokens) !== name) continue;
const splitIndex = const splitIndex =
splitIndexFromPersonNamePos(tokens) ?? splitIndexFromPersonNamePos(tokens) ??
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading); splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
@@ -118,5 +128,5 @@ export async function resolveJapaneseNameSplits(
resolvedCharacters += 1; resolvedCharacters += 1;
onCharacterResolved?.(resolvedCharacters, characters.length); onCharacterResolved?.(resolvedCharacters, characters.length);
} }
return splits; return tokenizerFailed ? { kind: 'incomplete', splits } : { kind: 'complete', splits };
} }
@@ -7,7 +7,7 @@ import test from 'node:test';
import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime'; import { createCharacterDictionaryRuntimeService } from '../character-dictionary-runtime';
import { getSnapshotPath, writeSnapshot } from './cache'; import { getSnapshotPath, writeSnapshot } from './cache';
import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants'; import { CHARACTER_DICTIONARY_FORMAT_VERSION } from './constants';
import type { CharacterDictionarySnapshot } from './types'; import type { CharacterDictionarySnapshot, NameSplitTokenizer } from './types';
const GRAPHQL_URL = 'https://graphql.anilist.co'; const GRAPHQL_URL = 'https://graphql.anilist.co';
const PNG_1X1 = Buffer.from( const PNG_1X1 = Buffer.from(
@@ -121,7 +121,12 @@ test('generateForCurrentMedia refreshes same-version snapshots missing images wh
} }
}); });
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => { async function runNameSplitRefreshScenario(tokenizeJapaneseName: NameSplitTokenizer): Promise<{
characterPageRequests: number;
firstResultFromCache: boolean;
refreshedNameSplitSource: CharacterDictionarySnapshot['nameSplitSource'];
secondResultFromCache: boolean;
}> {
const userDataPath = makeTempDir(); const userDataPath = makeTempDir();
const outputDir = path.join(userDataPath, 'character-dictionaries'); const outputDir = path.join(userDataPath, 'character-dictionaries');
await writeSnapshot(getSnapshotPath(outputDir, 130298), { await writeSnapshot(getSnapshotPath(outputDir, 130298), {
@@ -172,7 +177,6 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
}) as typeof globalThis.fetch; }) as typeof globalThis.fetch;
try { try {
let tokenizerCalls = 0;
const runtime = createCharacterDictionaryRuntimeService({ const runtime = createCharacterDictionaryRuntimeService({
userDataPath, userDataPath,
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv', getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
@@ -185,29 +189,54 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
source: 'fallback', source: 'fallback',
}), }),
getNameMatchImagesEnabled: () => false, getNameMatchImagesEnabled: () => false,
tokenizeJapaneseName: async () => { tokenizeJapaneseName,
tokenizerCalls += 1;
return null;
},
getJapaneseNameTokenizerAvailable: () => true, getJapaneseNameTokenizerAvailable: () => true,
now: () => 1_700_000_000_500, now: () => 1_700_000_000_500,
}); });
const result = await runtime.generateForCurrentMedia(); const firstResult = await runtime.generateForCurrentMedia();
const refreshedSnapshot = JSON.parse( const refreshedSnapshot = JSON.parse(
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'), fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
) as CharacterDictionarySnapshot; ) as CharacterDictionarySnapshot;
const secondResult = await runtime.generateForCurrentMedia();
assert.equal(result.fromCache, false); return {
assert.equal(refreshedSnapshot.nameSplitSource, 'heuristic'); characterPageRequests,
firstResultFromCache: firstResult.fromCache,
const retriedResult = await runtime.generateForCurrentMedia(); refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
assert.equal(retriedResult.fromCache, false); secondResultFromCache: secondResult.fromCache,
assert.equal(characterPageRequests, 2); };
assert.equal(tokenizerCalls, 2);
} finally { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
}
test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable', async () => {
let tokenizerCalls = 0;
const result = await runNameSplitRefreshScenario(async () => {
tokenizerCalls += 1;
return null;
});
assert.equal(result.firstResultFromCache, false);
assert.equal(result.refreshedNameSplitSource, 'heuristic');
assert.equal(result.secondResultFromCache, false);
assert.equal(result.characterPageRequests, 2);
assert.equal(tokenizerCalls, 2);
});
test('generateForCurrentMedia caches completed MeCab refreshes with no resolved splits', async () => {
let tokenizerCalls = 0;
const result = await runNameSplitRefreshScenario(async () => {
tokenizerCalls += 1;
return [];
});
assert.equal(result.firstResultFromCache, false);
assert.equal(result.refreshedNameSplitSource, 'mecab');
assert.equal(result.secondResultFromCache, true);
assert.equal(result.characterPageRequests, 1);
assert.equal(tokenizerCalls, 1);
}); });
test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => { test('generateForCurrentMedia keeps mecab-split snapshots when MeCab is available', async () => {
-142
View File
@@ -1,142 +0,0 @@
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 { enforceElectronRuntimeGuard, SUPPORTED_ELECTRON_MAJOR } from './electron-runtime-guard';
function withTempDir(run: (directory: string) => void): void {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-electron-guard-'));
try {
run(directory);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
test('runtime guard major matches the pinned Electron dependency', () => {
const packageJson = JSON.parse(
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'),
) as { devDependencies: { electron: string } };
assert.equal(Number.parseInt(packageJson.devDependencies.electron.split('.', 1)[0]!, 10), 43);
assert.equal(SUPPORTED_ELECTRON_MAJOR, 43);
});
test('runtime guard records the supported Electron major', () => {
withTempDir((userDataPath) => {
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, true);
assert.deepEqual(JSON.parse(fs.readFileSync(result.statePath, 'utf8')), {
highestElectronMajor: 43,
lastElectronVersion: '43.4.1',
});
});
});
test('runtime guard rejects a runtime outside the build major without writing state', () => {
withTempDir((userDataPath) => {
const result = enforceElectronRuntimeGuard({
electronVersion: '44.0.0',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'Unsupported Electron runtime');
assert.match(result.details, /requires Electron 43/);
assert.equal(fs.existsSync(result.statePath), false);
});
});
test('runtime guard rejects prerelease Electron versions without writing state', () => {
withTempDir((userDataPath) => {
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1-beta.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'SubMiner could not verify Electron');
assert.equal(fs.existsSync(result.statePath), false);
});
});
test('runtime guard blocks a profile downgrade before rewriting its safety record', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'electron-runtime.json');
fs.writeFileSync(
statePath,
JSON.stringify({ highestElectronMajor: 44, lastElectronVersion: '44.1.0' }),
'utf8',
);
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'Electron downgrade blocked');
assert.match(result.details, /destroy Yomitan dictionaries/);
assert.equal(result.details.includes(statePath), true);
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
highestElectronMajor: 44,
lastElectronVersion: '44.1.0',
});
});
});
test('runtime guard blocks a downgrade within the supported Electron major', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'electron-runtime.json');
fs.writeFileSync(
statePath,
JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.4.1' }),
'utf8',
);
const result = enforceElectronRuntimeGuard({
electronVersion: '43.3.0',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'Electron downgrade blocked');
assert.equal(result.details.includes(statePath), true);
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), {
highestElectronMajor: 43,
lastElectronVersion: '43.4.1',
});
});
});
test('runtime guard fails closed when its safety record is malformed', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'electron-runtime.json');
fs.writeFileSync(statePath, '{}', 'utf8');
const result = enforceElectronRuntimeGuard({
electronVersion: '43.4.1',
userDataPath,
supportedElectronMajor: 43,
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.title, 'SubMiner profile safety check failed');
assert.match(result.details, /invalid format/);
});
});
-171
View File
@@ -1,171 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { writeTextFileAtomicallyDurable } from '../shared/fs-utils';
export const SUPPORTED_ELECTRON_MAJOR = 43;
const RUNTIME_STATE_FILE_NAME = 'electron-runtime.json';
type ElectronRuntimeState = {
highestElectronMajor: number;
lastElectronVersion: string;
};
type ParsedElectronVersion = {
major: number;
minor: number;
patch: number;
};
type ValidatedElectronRuntimeState = {
state: ElectronRuntimeState;
version: ParsedElectronVersion;
};
export type ElectronRuntimeGuardResult =
| { ok: true; statePath: string }
| { ok: false; title: string; details: string; statePath: string };
function parseElectronVersion(version: string): ParsedElectronVersion | null {
const match = /^(\d+)\.(\d+)\.(\d+)(?:\+[0-9A-Za-z.-]+)?$/.exec(version.trim());
if (!match) return null;
const major = Number.parseInt(match[1]!, 10);
const minor = Number.parseInt(match[2]!, 10);
const patch = Number.parseInt(match[3]!, 10);
if (![major, minor, patch].every((part) => Number.isSafeInteger(part) && part >= 0)) {
return null;
}
if (major === 0) return null;
return { major, minor, patch };
}
function compareElectronVersions(
left: ParsedElectronVersion,
right: ParsedElectronVersion,
): number {
return left.major - right.major || left.minor - right.minor || left.patch - right.patch;
}
function readRuntimeState(statePath: string): ValidatedElectronRuntimeState | null {
if (!fs.existsSync(statePath)) return null;
const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as Partial<ElectronRuntimeState>;
const highestElectronMajor = parsed.highestElectronMajor;
const lastElectronVersion =
typeof parsed.lastElectronVersion === 'string'
? parseElectronVersion(parsed.lastElectronVersion)
: null;
if (
typeof highestElectronMajor !== 'number' ||
!Number.isSafeInteger(highestElectronMajor) ||
highestElectronMajor <= 0 ||
lastElectronVersion === null
) {
throw new Error('The runtime safety record has an invalid format.');
}
return {
state: {
highestElectronMajor,
lastElectronVersion: parsed.lastElectronVersion!,
},
version: lastElectronVersion,
};
}
function writeRuntimeState(statePath: string, state: ElectronRuntimeState): void {
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
}
export function enforceElectronRuntimeGuard(options: {
electronVersion: string;
userDataPath: string;
supportedElectronMajor?: number;
}): ElectronRuntimeGuardResult {
const supportedElectronMajor = options.supportedElectronMajor ?? SUPPORTED_ELECTRON_MAJOR;
const statePath = path.join(options.userDataPath, RUNTIME_STATE_FILE_NAME);
const currentVersion = parseElectronVersion(options.electronVersion);
if (currentVersion === null) {
return {
ok: false,
title: 'SubMiner could not verify Electron',
details: `Electron reported an invalid version: ${JSON.stringify(options.electronVersion)}. SubMiner did not load Yomitan storage.`,
statePath,
};
}
if (currentVersion.major !== supportedElectronMajor) {
return {
ok: false,
title: 'Unsupported Electron runtime',
details: [
`This SubMiner build requires Electron ${supportedElectronMajor}.`,
`The current runtime is Electron ${options.electronVersion}.`,
'',
'Launch SubMiner through its packaged application or the repository package scripts. Yomitan storage was not loaded.',
].join('\n'),
statePath,
};
}
let previousState: ValidatedElectronRuntimeState | null;
try {
previousState = readRuntimeState(statePath);
} catch (error) {
return {
ok: false,
title: 'SubMiner profile safety check failed',
details: [
`SubMiner could not read the runtime safety record at ${statePath}.`,
(error as Error).message,
'',
'Yomitan storage was not loaded. Repair or remove only this safety record after verifying the profile backup.',
].join('\n'),
statePath,
};
}
if (
previousState &&
(currentVersion.major < previousState.state.highestElectronMajor ||
compareElectronVersions(currentVersion, previousState.version) < 0)
) {
return {
ok: false,
title: 'Electron downgrade blocked',
details: [
`This profile was previously opened with Electron ${previousState.state.lastElectronVersion}.`,
`The current runtime is Electron ${options.electronVersion}.`,
`Runtime safety record: ${statePath}.`,
'',
'Opening Chromium storage with an older Electron version can destroy Yomitan dictionaries. Upgrade SubMiner before using this profile.',
].join('\n'),
statePath,
};
}
try {
writeRuntimeState(statePath, {
highestElectronMajor: Math.max(
currentVersion.major,
previousState?.state.highestElectronMajor ?? 0,
),
lastElectronVersion: options.electronVersion,
});
} catch (error) {
return {
ok: false,
title: 'SubMiner profile safety check failed',
details: [
`SubMiner could not update the runtime safety record at ${statePath}.`,
(error as Error).message,
'',
'Yomitan storage was not loaded.',
].join('\n'),
statePath,
};
}
return { ok: true, statePath };
}
+16 -2
View File
@@ -482,10 +482,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/); assert.doesNotMatch(actionBlock, /setOverlayWindowBounds\(getCurrentOverlayGeometry\(\)\)/);
}); });
test('known-word updates invalidate prefetched tokenizations before refreshing current subtitle', () => { test('subtitle annotation updates invalidate prefetched tokenizations before refreshing current subtitle', () => {
const source = readMainSource(); const source = readMainSource();
const actionBlock = source.match( const actionBlock = source.match(
/const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?<body>[\s\S]*?)\n\};/, /function refreshCurrentSubtitleAnnotations\(\): void \{(?<body>[\s\S]*?)\n\}/,
)?.groups?.body; )?.groups?.body;
assert.ok(actionBlock); assert.ok(actionBlock);
@@ -503,6 +503,20 @@ test('known-word updates invalidate prefetched tokenizations before refreshing c
); );
}); });
test('character portrait index readiness refreshes cached subtitle annotations', () => {
const source = readMainSource();
const lookupDeps = source.match(
/const characterDictionaryImageLookup = createCharacterDictionaryImageLookup\(\{(?<body>[\s\S]*?)\n\}\);/,
)?.groups?.body;
assert.ok(lookupDeps);
assert.match(lookupDeps, /onIndexReady: \(\) => refreshCurrentSubtitleAnnotations\(\),/);
assert.match(
lookupDeps,
/onIndexReadyError: \(error\) =>[\s\S]*?logger\.warn\([\s\S]*?character portrait index became ready\.[\s\S]*?error,/,
);
});
test('subtitle processing controller resumes prefetch on settle, not on its emits', () => { test('subtitle processing controller resumes prefetch on settle, not on its emits', () => {
const source = readMainSource(); const source = readMainSource();
const depsBlock = source.match( const depsBlock = source.match(
@@ -8,6 +8,18 @@ import {
resolveManagedLinuxRuntimePluginPaths, resolveManagedLinuxRuntimePluginPaths,
} from './linux-runtime-plugin-assets'; } from './linux-runtime-plugin-assets';
const THUMBNAILER_RELATIVE_PATH = path.join(
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
);
function writeThumbnailer(rootDir: string, content = '[Thumbnailer Entry]\n'): string {
const thumbnailerPath = path.join(rootDir, THUMBNAILER_RELATIVE_PATH);
fs.mkdirSync(path.dirname(thumbnailerPath), { recursive: true });
fs.writeFileSync(thumbnailerPath, content);
return thumbnailerPath;
}
async function withTempDir<T>(fn: (dir: string) => Promise<T> | T): Promise<T> { 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-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-linux-plugin-assets-test-'));
try { try {
@@ -48,6 +60,7 @@ test('resolveManagedLinuxRuntimePluginPaths resolves XDG data target paths', ()
pluginEntrypointPath: '/tmp/xdg-data/SubMiner/plugin/subminer/main.lua', pluginEntrypointPath: '/tmp/xdg-data/SubMiner/plugin/subminer/main.lua',
pluginConfigPath: '/tmp/xdg-data/SubMiner/plugin/subminer.conf', pluginConfigPath: '/tmp/xdg-data/SubMiner/plugin/subminer.conf',
themePath: '/tmp/xdg-data/SubMiner/themes/subminer.rasi', themePath: '/tmp/xdg-data/SubMiner/themes/subminer.rasi',
thumbnailerPath: '/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
}); });
}); });
@@ -79,6 +92,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
await withTempDir(async (tempDir) => { await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin'); const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi'); const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const targetRoot = path.join(tempDir, 'xdg-data', 'SubMiner', 'plugin'); const targetRoot = path.join(tempDir, 'xdg-data', 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true }); fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true }); fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
@@ -94,6 +108,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
pluginDirSource: path.join(sourceRoot, 'subminer'), pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'), pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath, themeSourcePath,
thumbnailerSourcePath,
}), }),
}); });
@@ -117,6 +132,19 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
), ),
'/* theme */\n', '/* theme */\n',
); );
assert.equal(
fs.readFileSync(
path.join(
tempDir,
'xdg-data',
'SubMiner',
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
),
'utf8',
),
'[Thumbnailer Entry]\n',
);
}); });
}); });
@@ -124,6 +152,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
await withTempDir(async (tempDir) => { await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin'); const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi'); const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const xdgDataHome = path.join(tempDir, 'xdg-data'); const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin'); const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true }); fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
@@ -143,6 +172,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
pluginDirSource: path.join(sourceRoot, 'subminer'), pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'), pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath, themeSourcePath,
thumbnailerSourcePath,
}), }),
}); });
@@ -169,6 +199,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
test('ensureLinuxRuntimePluginAssets installs managed theme without resolving plugin sources when plugin assets already exist', async () => { test('ensureLinuxRuntimePluginAssets installs managed theme without resolving plugin sources when plugin assets already exist', async () => {
await withTempDir(async (tempDir) => { await withTempDir(async (tempDir) => {
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi'); const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const xdgDataHome = path.join(tempDir, 'xdg-data'); const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin'); const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true }); fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
@@ -183,6 +214,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme without resolving pl
xdgDataHome, xdgDataHome,
resolveBundledAssets: () => ({ resolveBundledAssets: () => ({
themeSourcePath, themeSourcePath,
thumbnailerSourcePath,
}), }),
}); });
@@ -250,6 +282,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'), path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
'/* existing theme */\n', '/* existing theme */\n',
); );
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const result = await ensureLinuxRuntimePluginAssets({ const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux', platform: 'linux',
@@ -258,6 +291,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso
resolveBundledAssets: () => ({ resolveBundledAssets: () => ({
pluginDirSource: path.join(sourceRoot, 'subminer'), pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'), pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
thumbnailerSourcePath,
}), }),
}); });
@@ -332,6 +366,7 @@ test('ensureLinuxRuntimePluginAssets returns already-present when managed assets
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'), path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
'/* theme */\n', '/* theme */\n',
); );
writeThumbnailer(path.join(xdgDataHome, 'SubMiner'));
const result = await ensureLinuxRuntimePluginAssets({ const result = await ensureLinuxRuntimePluginAssets({
platform: 'linux', platform: 'linux',
@@ -369,6 +404,7 @@ test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed insta
await withTempDir(async (tempDir) => { await withTempDir(async (tempDir) => {
const sourceRoot = path.join(tempDir, 'source', 'plugin'); const sourceRoot = path.join(tempDir, 'source', 'plugin');
const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi'); const themeSourcePath = path.join(tempDir, 'source', 'assets', 'themes', 'subminer.rasi');
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
const xdgDataHome = path.join(tempDir, 'xdg-data'); const xdgDataHome = path.join(tempDir, 'xdg-data');
const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin'); const targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true }); fs.mkdirSync(path.join(sourceRoot, 'subminer'), { recursive: true });
@@ -385,6 +421,7 @@ test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed insta
pluginDirSource: path.join(sourceRoot, 'subminer'), pluginDirSource: path.join(sourceRoot, 'subminer'),
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'), pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
themeSourcePath, themeSourcePath,
thumbnailerSourcePath,
}), }),
copyFile: async () => { copyFile: async () => {
throw new Error('copy failed'); throw new Error('copy failed');
@@ -10,6 +10,7 @@ export interface ManagedLinuxRuntimePluginPaths {
pluginEntrypointPath: string; pluginEntrypointPath: string;
pluginConfigPath: string; pluginConfigPath: string;
themePath: string; themePath: string;
thumbnailerPath: string;
} }
export interface EnsureLinuxRuntimePluginAssetsResult { export interface EnsureLinuxRuntimePluginAssetsResult {
@@ -23,6 +24,7 @@ interface RuntimePluginAssetSources {
pluginDirSource?: string; pluginDirSource?: string;
pluginConfigSource?: string; pluginConfigSource?: string;
themeSourcePath?: string; themeSourcePath?: string;
thumbnailerSourcePath?: string;
} }
interface RuntimePluginDirentLike { interface RuntimePluginDirentLike {
@@ -72,6 +74,11 @@ export function resolveManagedLinuxRuntimePluginPaths(options: {
pluginEntrypointPath: pathModule.join(pluginDir, 'main.lua'), pluginEntrypointPath: pathModule.join(pluginDir, 'main.lua'),
pluginConfigPath: pathModule.join(rootDir, 'subminer.conf'), pluginConfigPath: pathModule.join(rootDir, 'subminer.conf'),
themePath: pathModule.join(dataDir, 'themes', 'subminer.rasi'), themePath: pathModule.join(dataDir, 'themes', 'subminer.rasi'),
thumbnailerPath: pathModule.join(
dataDir,
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
),
}; };
} }
@@ -95,11 +102,12 @@ async function copyDirectoryRecursive(
} }
} }
function resolveBundledThemePath(options: { function resolveBundledAssetPath(options: {
dirname: string; dirname: string;
appPath: string; appPath: string;
resourcesPath: string; resourcesPath: string;
existsSync: (candidate: string) => boolean; existsSync: (candidate: string) => boolean;
relativePath: string;
}): string | null { }): string | null {
const roots = [ const roots = [
path.join(options.resourcesPath, 'assets'), path.join(options.resourcesPath, 'assets'),
@@ -111,7 +119,7 @@ function resolveBundledThemePath(options: {
]; ];
for (const root of roots) { for (const root of roots) {
const candidate = path.join(root, 'themes', 'subminer.rasi'); const candidate = path.join(root, options.relativePath);
if (options.existsSync(candidate)) return candidate; if (options.existsSync(candidate)) return candidate;
} }
@@ -129,16 +137,25 @@ function resolveBundledAssetsDefault(
existsSync, existsSync,
}); });
const themeSourcePath = resolveBundledThemePath({ const themeSourcePath = resolveBundledAssetPath({
dirname: __dirname, dirname: __dirname,
appPath: process.execPath, appPath: process.execPath,
resourcesPath, resourcesPath,
existsSync, existsSync,
relativePath: path.join('themes', 'subminer.rasi'),
});
const thumbnailerSourcePath = resolveBundledAssetPath({
dirname: __dirname,
appPath: process.execPath,
resourcesPath,
existsSync,
relativePath: path.join('thumbnailers', 'subminer-ffmpegthumbnailer.thumbnailer'),
}); });
return { return {
...(pluginAssets ?? {}), ...(pluginAssets ?? {}),
...(themeSourcePath ? { themeSourcePath } : {}), ...(themeSourcePath ? { themeSourcePath } : {}),
...(thumbnailerSourcePath ? { thumbnailerSourcePath } : {}),
}; };
} }
@@ -178,7 +195,8 @@ export async function ensureLinuxRuntimePluginAssets(
const pluginAssetsExist = const pluginAssetsExist =
existsSync(managedPaths.pluginEntrypointPath) && existsSync(managedPaths.pluginConfigPath); existsSync(managedPaths.pluginEntrypointPath) && existsSync(managedPaths.pluginConfigPath);
const themeExists = existsSync(managedPaths.themePath); const themeExists = existsSync(managedPaths.themePath);
if (pluginAssetsExist && themeExists) { const thumbnailerExists = existsSync(managedPaths.thumbnailerPath);
if (pluginAssetsExist && themeExists && thumbnailerExists) {
return { return {
ok: true, ok: true,
status: 'already-present', status: 'already-present',
@@ -193,6 +211,7 @@ export async function ensureLinuxRuntimePluginAssets(
const shouldInstallPluginAssets = !pluginAssetsExist; const shouldInstallPluginAssets = !pluginAssetsExist;
const shouldInstallTheme = !themeExists; const shouldInstallTheme = !themeExists;
const shouldInstallThumbnailer = !thumbnailerExists;
if ( if (
shouldInstallPluginAssets && shouldInstallPluginAssets &&
(!bundledAssets.pluginDirSource || !bundledAssets.pluginConfigSource) (!bundledAssets.pluginDirSource || !bundledAssets.pluginConfigSource)
@@ -210,6 +229,13 @@ export async function ensureLinuxRuntimePluginAssets(
error: 'Bundled Linux runtime theme asset was not found.', error: 'Bundled Linux runtime theme asset was not found.',
}; };
} }
if (shouldInstallThumbnailer && !bundledAssets.thumbnailerSourcePath) {
return {
ok: false,
status: 'failed',
error: 'Bundled Linux rofi thumbnailer asset was not found.',
};
}
const stagingSuffix = `${process.pid}-${Date.now()}`; const stagingSuffix = `${process.pid}-${Date.now()}`;
const stagedPluginDir = pathModule.join(managedPaths.rootDir, `.subminer-stage-${stagingSuffix}`); const stagedPluginDir = pathModule.join(managedPaths.rootDir, `.subminer-stage-${stagingSuffix}`);
@@ -221,9 +247,14 @@ export async function ensureLinuxRuntimePluginAssets(
pathModule.dirname(managedPaths.themePath), pathModule.dirname(managedPaths.themePath),
`.subminer.rasi-stage-${stagingSuffix}`, `.subminer.rasi-stage-${stagingSuffix}`,
); );
const stagedThumbnailerPath = pathModule.join(
pathModule.dirname(managedPaths.thumbnailerPath),
`.subminer-ffmpegthumbnailer.thumbnailer-stage-${stagingSuffix}`,
);
let pluginDirInstalled = false; let pluginDirInstalled = false;
let pluginConfigInstalled = false; let pluginConfigInstalled = false;
let themeInstalled = false; let themeInstalled = false;
let thumbnailerInstalled = false;
try { try {
if (shouldInstallPluginAssets) { if (shouldInstallPluginAssets) {
@@ -249,6 +280,14 @@ export async function ensureLinuxRuntimePluginAssets(
await mkdir(pathModule.dirname(managedPaths.themePath), { recursive: true }); await mkdir(pathModule.dirname(managedPaths.themePath), { recursive: true });
await copyFile(themeSourcePath, stagedThemePath); await copyFile(themeSourcePath, stagedThemePath);
} }
if (shouldInstallThumbnailer) {
const thumbnailerSourcePath = bundledAssets.thumbnailerSourcePath;
if (!thumbnailerSourcePath) {
throw new Error('Bundled Linux rofi thumbnailer asset was not found.');
}
await mkdir(pathModule.dirname(managedPaths.thumbnailerPath), { recursive: true });
await copyFile(thumbnailerSourcePath, stagedThumbnailerPath);
}
if (shouldInstallPluginAssets) { if (shouldInstallPluginAssets) {
await rm(managedPaths.pluginDir, { recursive: true, force: true }); await rm(managedPaths.pluginDir, { recursive: true, force: true });
await rm(managedPaths.pluginConfigPath, { force: true }); await rm(managedPaths.pluginConfigPath, { force: true });
@@ -262,6 +301,11 @@ export async function ensureLinuxRuntimePluginAssets(
await rename(stagedThemePath, managedPaths.themePath); await rename(stagedThemePath, managedPaths.themePath);
themeInstalled = true; themeInstalled = true;
} }
if (shouldInstallThumbnailer) {
await rm(managedPaths.thumbnailerPath, { force: true });
await rename(stagedThumbnailerPath, managedPaths.thumbnailerPath);
thumbnailerInstalled = true;
}
return { return {
ok: true, ok: true,
@@ -278,9 +322,13 @@ export async function ensureLinuxRuntimePluginAssets(
if (themeInstalled) { if (themeInstalled) {
await rm(managedPaths.themePath, { force: true }).catch(() => {}); await rm(managedPaths.themePath, { force: true }).catch(() => {});
} }
if (thumbnailerInstalled) {
await rm(managedPaths.thumbnailerPath, { force: true }).catch(() => {});
}
await rm(stagedPluginDir, { recursive: true, force: true }).catch(() => {}); await rm(stagedPluginDir, { recursive: true, force: true }).catch(() => {});
await rm(stagedPluginConfigPath, { force: true }).catch(() => {}); await rm(stagedPluginConfigPath, { force: true }).catch(() => {});
await rm(stagedThemePath, { force: true }).catch(() => {}); await rm(stagedThemePath, { force: true }).catch(() => {});
await rm(stagedThumbnailerPath, { force: true }).catch(() => {});
return { return {
ok: false, ok: false,
status: 'failed', status: 'failed',
+106 -2
View File
@@ -13,7 +13,7 @@ import {
} from './support-assets'; } from './support-assets';
type SupportAssetsResultWithComponent = SupportAssetsUpdateResult & { type SupportAssetsResultWithComponent = SupportAssetsUpdateResult & {
component?: 'theme' | 'plugin'; component?: 'theme' | 'thumbnailer' | 'plugin';
}; };
function sha256(data: Buffer): string { function sha256(data: Buffer): string {
@@ -22,18 +22,30 @@ function sha256(data: Buffer): string {
function makeSupportAssetsArchive(options?: { function makeSupportAssetsArchive(options?: {
themeContent?: string; themeContent?: string;
thumbnailerContent?: string;
includeThumbnailer?: boolean;
pluginVersion?: string | null; pluginVersion?: string | null;
pluginMainContent?: string; pluginMainContent?: string;
extraPluginFiles?: Array<{ relativePath: string; content: string }>; extraPluginFiles?: Array<{ relativePath: string; content: string }>;
}): { archive: Buffer; tempDir: string } { }): { archive: Buffer; tempDir: string } {
const themeContent = options?.themeContent ?? 'new theme\n'; const themeContent = options?.themeContent ?? 'new theme\n';
const thumbnailerContent = options?.thumbnailerContent ?? '[Thumbnailer Entry]\n';
const pluginVersion = options && 'pluginVersion' in options ? options.pluginVersion : '0.12.0'; const pluginVersion = options && 'pluginVersion' in options ? options.pluginVersion : '0.12.0';
const pluginMainContent = options?.pluginMainContent ?? 'new plugin\n'; const pluginMainContent = options?.pluginMainContent ?? 'new plugin\n';
const extraPluginFiles = options?.extraPluginFiles ?? []; 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 });
if (options?.includeThumbnailer !== false) {
fs.mkdirSync(path.join(tempDir, 'assets/thumbnailers'), { 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'), themeContent); fs.writeFileSync(path.join(tempDir, 'assets/themes/subminer.rasi'), themeContent);
if (options?.includeThumbnailer !== false) {
fs.writeFileSync(
path.join(tempDir, 'assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
thumbnailerContent,
);
}
fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), pluginMainContent); fs.writeFileSync(path.join(tempDir, 'plugin/subminer/main.lua'), pluginMainContent);
if (pluginVersion !== null) { if (pluginVersion !== null) {
fs.writeFileSync( fs.writeFileSync(
@@ -93,7 +105,7 @@ test('detectSupportAssetDataDirs only returns Linux support-asset locations', ()
); );
}); });
test('buildProtectedSupportAssetsCommand installs both theme and plugin assets', () => { test('buildProtectedSupportAssetsCommand installs theme, thumbnailer, 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', 'ABCDEF1234',
@@ -110,11 +122,28 @@ test('buildProtectedSupportAssetsCommand installs both theme and plugin assets',
command, command,
/printf '%s %s\\n' 'abcdef1234' "\$tmp\/subminer-assets\.tar\.gz" \| sha256sum -c -/, /printf '%s %s\\n' 'abcdef1234' "\$tmp\/subminer-assets\.tar\.gz" \| sha256sum -c -/,
); );
const requiredAssetChecks = [
'test -f "$tmp/assets/themes/subminer.rasi"',
'test -f "$tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"',
'test -f "$tmp/plugin/subminer/main.lua"',
'test -f "$tmp/plugin/subminer/version.lua"',
];
const firstSudoIndex = command.indexOf('sudo ');
assert.notEqual(firstSudoIndex, -1);
for (const check of requiredAssetChecks) {
const checkIndex = command.indexOf(check);
assert.notEqual(checkIndex, -1);
assert.ok(checkIndex < firstSudoIndex);
}
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( assert.match(
command, command,
/sudo cp "\$tmp\/assets\/themes\/subminer\.rasi" '\/usr\/local\/share\/SubMiner'\\''s data'\/themes\/subminer\.rasi/, /sudo cp "\$tmp\/assets\/themes\/subminer\.rasi" '\/usr\/local\/share\/SubMiner'\\''s data'\/themes\/subminer\.rasi/,
); );
assert.match(
command,
/sudo cp "\$tmp\/assets\/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer" .*thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/,
);
assert.match(command, /sudo mkdir -p '\/usr\/local\/share\/SubMiner'\\''s data'\/plugin/); 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 rm -rf .*plugin\/subminer\.next/);
assert.match(command, /sudo cp -R "\$tmp\/plugin\/subminer" .*plugin\/subminer\.next/); assert.match(command, /sudo cp -R "\$tmp\/plugin\/subminer" .*plugin\/subminer\.next/);
@@ -209,6 +238,12 @@ test('updateSupportAssetsFromRelease installs missing plugin into a root with a
path: dataDir, path: dataDir,
message: 'Updated theme.', message: 'Updated theme.',
}, },
{
status: 'updated',
component: 'thumbnailer',
path: dataDir,
message: 'Installed rofi thumbnailer.',
},
{ {
status: 'updated', status: 'updated',
component: 'plugin', component: 'plugin',
@@ -220,6 +255,13 @@ test('updateSupportAssetsFromRelease installs missing plugin into a root with a
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(
fs.readFileSync(
path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
'utf8',
),
'[Thumbnailer Entry]\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'),
'new plugin\n', 'new plugin\n',
@@ -345,8 +387,13 @@ test('updateSupportAssetsFromRelease skips identical theme and up-to-date plugin
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'); const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true }); fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'thumbnailers'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { 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, 'themes/subminer.rasi'), 'same theme\n');
fs.writeFileSync(
path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
'[Thumbnailer Entry]\n',
);
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'same plugin\n'); fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'same plugin\n');
fs.writeFileSync( fs.writeFileSync(
path.join(dataDir, 'plugin/subminer/version.lua'), path.join(dataDir, 'plugin/subminer/version.lua'),
@@ -371,6 +418,12 @@ test('updateSupportAssetsFromRelease skips identical theme and up-to-date plugin
path: dataDir, path: dataDir,
message: 'Theme already up to date.', message: 'Theme already up to date.',
}, },
{
status: 'skipped',
component: 'thumbnailer',
path: dataDir,
message: 'Rofi thumbnailer already up to date.',
},
{ {
status: 'skipped', status: 'skipped',
component: 'plugin', component: 'plugin',
@@ -396,8 +449,13 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w
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'); const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true }); fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'thumbnailers'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { 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, 'themes/subminer.rasi'), 'old theme\n');
fs.writeFileSync(
path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
'[Old Thumbnailer]\n',
);
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n'); fs.writeFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'old plugin\n');
fs.writeFileSync( fs.writeFileSync(
path.join(dataDir, 'plugin/subminer/version.lua'), path.join(dataDir, 'plugin/subminer/version.lua'),
@@ -406,6 +464,7 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w
fs.writeFileSync(path.join(dataDir, 'plugin/subminer/stale.lua'), 'stale\n'); fs.writeFileSync(path.join(dataDir, 'plugin/subminer/stale.lua'), 'stale\n');
const { archive, tempDir } = makeSupportAssetsArchive({ const { archive, tempDir } = makeSupportAssetsArchive({
themeContent: 'new theme\n', themeContent: 'new theme\n',
thumbnailerContent: '[Thumbnailer Entry]\n',
pluginVersion: '0.12.0', pluginVersion: '0.12.0',
pluginMainContent: 'new plugin main\n', pluginMainContent: 'new plugin main\n',
extraPluginFiles: [{ relativePath: 'fresh.lua', content: 'fresh\n' }], extraPluginFiles: [{ relativePath: 'fresh.lua', content: 'fresh\n' }],
@@ -424,6 +483,12 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w
path: dataDir, path: dataDir,
message: 'Updated theme.', message: 'Updated theme.',
}, },
{
status: 'updated',
component: 'thumbnailer',
path: dataDir,
message: 'Updated rofi thumbnailer.',
},
{ {
status: 'updated', status: 'updated',
component: 'plugin', component: 'plugin',
@@ -435,6 +500,13 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w
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(
fs.readFileSync(
path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
'utf8',
),
'[Thumbnailer Entry]\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'),
'new plugin main\n', 'new plugin main\n',
@@ -479,6 +551,12 @@ test('updateSupportAssetsFromRelease returns protected commands for managed root
path: dataDir, path: dataDir,
command: true, command: true,
}, },
{
status: 'protected',
component: 'thumbnailer',
path: dataDir,
command: true,
},
{ {
status: 'protected', status: 'protected',
component: 'plugin', component: 'plugin',
@@ -488,6 +566,10 @@ test('updateSupportAssetsFromRelease returns protected commands for managed root
], ],
); );
assert.match(results[0]?.command ?? '', /themes\/subminer\.rasi/); assert.match(results[0]?.command ?? '', /themes\/subminer\.rasi/);
assert.match(
results[0]?.command ?? '',
/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/,
);
assert.match(results[0]?.command ?? '', /plugin\/subminer/); assert.match(results[0]?.command ?? '', /plugin\/subminer/);
} finally { } finally {
fs.chmodSync(dataDir, originalMode); fs.chmodSync(dataDir, originalMode);
@@ -522,3 +604,25 @@ test('updateSupportAssetsFromRelease returns missing-asset when release plugin v
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
} }
}); });
test('updateSupportAssetsFromRelease rejects archives without the rofi thumbnailer', 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({ includeThumbnailer: false });
try {
const results = await runLinuxSupportAssetUpdate({ archive, xdgDataHome });
assert.deepEqual(results, [
{
status: 'missing-asset',
message: 'Support asset archive is missing the rofi thumbnailer.',
},
]);
} finally {
fs.rmSync(xdgDataHome, { recursive: true, force: true });
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
+65 -3
View File
@@ -9,13 +9,17 @@ import { compareSemverLike, findReleaseAsset } from './release-assets';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const THEME_RELATIVE_PATH = path.join('themes', 'subminer.rasi'); const THEME_RELATIVE_PATH = path.join('themes', 'subminer.rasi');
const THUMBNAILER_RELATIVE_PATH = path.join(
'thumbnailers',
'subminer-ffmpegthumbnailer.thumbnailer',
);
const PLUGIN_ENTRYPOINT_RELATIVE_PATH = path.join('plugin', 'subminer', 'main.lua'); const PLUGIN_ENTRYPOINT_RELATIVE_PATH = path.join('plugin', 'subminer', 'main.lua');
const PLUGIN_VERSION_RELATIVE_PATH = path.join('plugin', 'subminer', 'version.lua'); const PLUGIN_VERSION_RELATIVE_PATH = path.join('plugin', 'subminer', 'version.lua');
const PLUGIN_DIR_RELATIVE_PATH = path.join('plugin', 'subminer'); 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'; component?: 'theme' | 'thumbnailer' | 'plugin';
path?: string; path?: string;
command?: string; command?: string;
message?: string; message?: string;
@@ -69,11 +73,12 @@ async function readInstalledPluginVersion(pluginDir: string): Promise<string | n
async function detectManagedSupportAssetDataDirs(dataDirs: string[]): Promise<string[]> { async function detectManagedSupportAssetDataDirs(dataDirs: string[]): Promise<string[]> {
const managedDataDirs: string[] = []; const managedDataDirs: string[] = [];
for (const dataDir of dataDirs) { for (const dataDir of dataDirs) {
const [hasTheme, hasPlugin] = await Promise.all([ const [hasTheme, hasThumbnailer, hasPlugin] = await Promise.all([
pathExists(path.join(dataDir, THEME_RELATIVE_PATH)), pathExists(path.join(dataDir, THEME_RELATIVE_PATH)),
pathExists(path.join(dataDir, THUMBNAILER_RELATIVE_PATH)),
pathExists(path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH)), pathExists(path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH)),
]); ]);
if (hasTheme || hasPlugin) { if (hasTheme || hasThumbnailer || hasPlugin) {
managedDataDirs.push(dataDir); managedDataDirs.push(dataDir);
} }
} }
@@ -163,8 +168,14 @@ export function buildProtectedSupportAssetsCommand(
`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 -`, `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"',
'test -f "$tmp/assets/themes/subminer.rasi"',
'test -f "$tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer"',
'test -f "$tmp/plugin/subminer/main.lua"',
'test -f "$tmp/plugin/subminer/version.lua"',
`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}/thumbnailers`,
`sudo cp "$tmp/assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer" ${quotedDir}/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer`,
`sudo mkdir -p ${quotedDir}/plugin`, `sudo mkdir -p ${quotedDir}/plugin`,
`sudo rm -rf ${quotedStagedPluginDir} ${quotedBackupPluginDir}`, `sudo rm -rf ${quotedStagedPluginDir} ${quotedBackupPluginDir}`,
`sudo cp -R "$tmp/plugin/subminer" ${quotedStagedPluginDir}`, `sudo cp -R "$tmp/plugin/subminer" ${quotedStagedPluginDir}`,
@@ -216,6 +227,12 @@ export async function updateSupportAssetsFromRelease(options: {
dataDir, dataDir,
'Support asset path is not a directory.', 'Support asset path is not a directory.',
), ),
makeSupportAssetResult(
'skipped',
'thumbnailer',
dataDir,
'Support asset path is not a directory.',
),
makeSupportAssetResult( makeSupportAssetResult(
'skipped', 'skipped',
'plugin', 'plugin',
@@ -244,6 +261,13 @@ export async function updateSupportAssetsFromRelease(options: {
'Theme install requires a manual command.', 'Theme install requires a manual command.',
command, command,
), ),
makeSupportAssetResult(
'protected',
'thumbnailer',
dataDir,
'Rofi thumbnailer install requires a manual command.',
command,
),
makeSupportAssetResult( makeSupportAssetResult(
'protected', 'protected',
'plugin', 'plugin',
@@ -284,6 +308,17 @@ export async function updateSupportAssetsFromRelease(options: {
} }
const themeBytes = await fs.promises.readFile(themeSourcePath); const themeBytes = await fs.promises.readFile(themeSourcePath);
const thumbnailerSourcePath = path.join(tempDir, 'assets', THUMBNAILER_RELATIVE_PATH);
if (!(await pathExists(thumbnailerSourcePath))) {
return [
{
status: 'missing-asset',
message: 'Support asset archive is missing the rofi thumbnailer.',
},
];
}
const thumbnailerBytes = await fs.promises.readFile(thumbnailerSourcePath);
const sourcePluginDir = path.join(tempDir, PLUGIN_DIR_RELATIVE_PATH); const sourcePluginDir = path.join(tempDir, PLUGIN_DIR_RELATIVE_PATH);
const sourcePluginEntrypoint = path.join(tempDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH); const sourcePluginEntrypoint = path.join(tempDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
if (!(await pathExists(sourcePluginEntrypoint))) { if (!(await pathExists(sourcePluginEntrypoint))) {
@@ -328,6 +363,33 @@ export async function updateSupportAssetsFromRelease(options: {
); );
} }
const targetThumbnailerPath = path.join(dataDir, THUMBNAILER_RELATIVE_PATH);
const existingThumbnailerBytes = await readFileIfExists(targetThumbnailerPath);
if (
existingThumbnailerBytes &&
Buffer.compare(existingThumbnailerBytes, thumbnailerBytes) === 0
) {
results.push(
makeSupportAssetResult(
'skipped',
'thumbnailer',
dataDir,
'Rofi thumbnailer already up to date.',
),
);
} else {
await fs.promises.mkdir(path.dirname(targetThumbnailerPath), { recursive: true });
await fs.promises.writeFile(targetThumbnailerPath, thumbnailerBytes);
results.push(
makeSupportAssetResult(
'updated',
'thumbnailer',
dataDir,
existingThumbnailerBytes ? 'Updated rofi thumbnailer.' : 'Installed rofi thumbnailer.',
),
);
}
const targetPluginDir = path.join(dataDir, PLUGIN_DIR_RELATIVE_PATH); const targetPluginDir = path.join(dataDir, PLUGIN_DIR_RELATIVE_PATH);
const targetPluginEntrypoint = path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH); const targetPluginEntrypoint = path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
const installedPluginVersion = await readInstalledPluginVersion(targetPluginDir); const installedPluginVersion = await readInstalledPluginVersion(targetPluginDir);
@@ -1,133 +0,0 @@
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 {
assertYomitanDictionaryMutationSafe,
observeYomitanDictionaryCount,
} from './yomitan-dictionary-integrity';
function withTempDir(run: (directory: string) => void): void {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-dictionary-integrity-'));
try {
run(directory);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
}
test('dictionary integrity observation establishes and updates a non-empty baseline', () => {
withTempDir((userDataPath) => {
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 5), {
safe: true,
previousCount: null,
});
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), {
safe: true,
previousCount: 5,
});
const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json');
const unchangedTimestamp = new Date('2000-01-01T00:00:00.000Z');
fs.utimesSync(statePath, unchangedTimestamp, unchangedTimestamp);
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 3), {
safe: true,
previousCount: 3,
});
assert.equal(fs.statSync(statePath).mtimeMs, unchangedTimestamp.getTime());
assert.deepEqual(JSON.parse(fs.readFileSync(statePath, 'utf8')), { lastKnownNonEmptyCount: 3 });
assert.deepEqual(fs.readdirSync(userDataPath), ['yomitan-dictionary-integrity.json']);
});
});
test('dictionary integrity permits an empty profile before dictionaries are installed', () => {
withTempDir((userDataPath) => {
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
safe: true,
previousCount: null,
});
});
});
test('dictionary integrity rejects invalid counts without creating a safety record', () => {
withTempDir((userDataPath) => {
for (const invalidCount of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, invalidCount), {
safe: false,
previousCount: null,
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
});
}
assert.equal(
fs.existsSync(path.join(userDataPath, 'yomitan-dictionary-integrity.json')),
false,
);
});
});
test('dictionary integrity migrates the previous count from first-run setup state', () => {
withTempDir((userDataPath) => {
fs.writeFileSync(
path.join(userDataPath, 'setup-state.json'),
JSON.stringify({
version: 4,
status: 'completed',
completedAt: '2026-08-18T00:00:00.000Z',
completionSource: 'user',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 4,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: {
startMenuEnabled: true,
desktopEnabled: false,
},
windowsMpvShortcutLastStatus: 'installed',
bunInstallStatus: 'installed',
launcherInstallStatus: 'installed',
launcherInstallPath: '/home/tester/.local/bin/subminer',
}),
'utf8',
);
assert.throws(
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
/reported zero dictionaries after previously reporting 4/,
);
});
});
test('dictionary integrity blocks automatic mutation after a non-empty profile becomes empty', () => {
withTempDir((userDataPath) => {
observeYomitanDictionaryCount(userDataPath, 6);
assert.throws(
() => assertYomitanDictionaryMutationSafe(userDataPath, 0),
/reported zero dictionaries after previously reporting 6/,
);
assert.deepEqual(observeYomitanDictionaryCount(userDataPath, 0), {
safe: false,
previousCount: 6,
message:
'Yomitan reported zero dictionaries after previously reporting 6. SubMiner blocked automatic dictionary changes because Chromium storage may have been reset. Close SubMiner and restore or inspect the profile before changing dictionaries.',
});
});
});
test('dictionary integrity fails closed when its state is malformed', () => {
withTempDir((userDataPath) => {
const statePath = path.join(userDataPath, 'yomitan-dictionary-integrity.json');
fs.writeFileSync(statePath, '{}', 'utf8');
assert.throws(
() => assertYomitanDictionaryMutationSafe(userDataPath, 2),
(error: unknown) => {
assert.ok(error instanceof Error);
assert.match(error.message, /could not verify Yomitan dictionary storage/);
assert.equal(error.message.includes(statePath), true);
return true;
},
);
});
});
@@ -1,112 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { writeTextFileAtomicallyDurable } from '../../shared/fs-utils';
import { getSetupStatePath, readSetupState } from '../../shared/setup-state';
const INTEGRITY_STATE_FILE_NAME = 'yomitan-dictionary-integrity.json';
type DictionaryIntegrityState = {
lastKnownNonEmptyCount: number;
};
export type DictionaryIntegrityObservation =
| { safe: true; previousCount: number | null }
| { safe: false; previousCount: number | null; message: string };
function getStatePath(userDataPath: string): string {
return path.join(userDataPath, INTEGRITY_STATE_FILE_NAME);
}
function readState(statePath: string): DictionaryIntegrityState | null {
if (!fs.existsSync(statePath)) return null;
const parsed = JSON.parse(
fs.readFileSync(statePath, 'utf8'),
) as Partial<DictionaryIntegrityState>;
const lastKnownNonEmptyCount = parsed.lastKnownNonEmptyCount;
if (
typeof lastKnownNonEmptyCount !== 'number' ||
!Number.isSafeInteger(lastKnownNonEmptyCount) ||
lastKnownNonEmptyCount <= 0
) {
throw new Error('The dictionary integrity record has an invalid format.');
}
return { lastKnownNonEmptyCount };
}
function writeState(statePath: string, state: DictionaryIntegrityState): void {
writeTextFileAtomicallyDurable(statePath, `${JSON.stringify(state, null, 2)}\n`);
}
function readLegacySetupCount(userDataPath: string): number | null {
const setupState = readSetupState(getSetupStatePath(userDataPath));
return setupState && setupState.lastSeenYomitanDictionaryCount > 0
? setupState.lastSeenYomitanDictionaryCount
: null;
}
export function observeYomitanDictionaryCount(
userDataPath: string,
dictionaryCount: number,
): DictionaryIntegrityObservation {
if (!Number.isSafeInteger(dictionaryCount) || dictionaryCount < 0) {
return {
safe: false,
previousCount: null,
message: 'SubMiner could not verify Yomitan dictionary storage: invalid dictionary count.',
};
}
const normalizedCount = dictionaryCount;
const statePath = getStatePath(userDataPath);
let state: DictionaryIntegrityState | null;
try {
state = readState(statePath);
if (state === null) {
const legacyCount = readLegacySetupCount(userDataPath);
state = legacyCount === null ? null : { lastKnownNonEmptyCount: legacyCount };
}
} catch (error) {
return {
safe: false,
previousCount: null,
message: `SubMiner could not verify Yomitan dictionary storage at ${statePath}: ${(error as Error).message}`,
};
}
if (normalizedCount === 0 && state !== null) {
return {
safe: false,
previousCount: state.lastKnownNonEmptyCount,
message: [
`Yomitan reported zero dictionaries after previously reporting ${state.lastKnownNonEmptyCount}.`,
'SubMiner blocked automatic dictionary changes because Chromium storage may have been reset.',
'Close SubMiner and restore or inspect the profile before changing dictionaries.',
].join(' '),
};
}
if (normalizedCount > 0 && normalizedCount !== state?.lastKnownNonEmptyCount) {
try {
writeState(statePath, { lastKnownNonEmptyCount: normalizedCount });
} catch (error) {
return {
safe: false,
previousCount: state?.lastKnownNonEmptyCount ?? null,
message: `SubMiner could not update the Yomitan dictionary integrity record: ${(error as Error).message}`,
};
}
}
return { safe: true, previousCount: state?.lastKnownNonEmptyCount ?? null };
}
export function assertYomitanDictionaryMutationSafe(
userDataPath: string,
dictionaryCount: number,
): void {
const observation = observeYomitanDictionaryCount(userDataPath, dictionaryCount);
if (!observation.safe) {
throw new Error(observation.message);
}
}
-46
View File
@@ -1,6 +1,5 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { randomUUID } from 'node:crypto';
export function ensureDir(dirPath: string): void { export function ensureDir(dirPath: string): void {
if (fs.existsSync(dirPath)) return; if (fs.existsSync(dirPath)) return;
@@ -13,48 +12,3 @@ export function ensureDirForFile(filePath: string): void {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
} }
} }
function syncDirectory(directoryPath: string): void {
// Node cannot open Windows directory handles for fsync. The atomic rename still prevents
// torn files there, while supported platforms also flush the directory entry below.
if (process.platform === 'win32') return;
const directory = fs.openSync(directoryPath, 'r');
try {
fs.fsyncSync(directory);
} finally {
fs.closeSync(directory);
}
}
/** Writes and flushes a sibling temporary file before atomically replacing the target. */
export function writeTextFileAtomicallyDurable(filePath: string, content: string): void {
const directoryPath = path.dirname(filePath);
ensureDir(directoryPath);
const temporaryPath = path.join(directoryPath, `.${path.basename(filePath)}.${randomUUID()}.tmp`);
let temporaryFile: number | undefined;
try {
temporaryFile = fs.openSync(temporaryPath, 'wx', 0o600);
fs.writeFileSync(temporaryFile, content, 'utf8');
fs.fsyncSync(temporaryFile);
fs.closeSync(temporaryFile);
temporaryFile = undefined;
fs.renameSync(temporaryPath, filePath);
syncDirectory(directoryPath);
} catch (error) {
if (temporaryFile !== undefined) {
try {
fs.closeSync(temporaryFile);
} catch {
// Best effort cleanup preserves the original write failure.
}
}
try {
fs.rmSync(temporaryPath, { force: true });
} catch {
// The temporary file is disposable; the existing target stays untouched before rename.
}
throw error;
}
}