Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
695613b1e7
|
||
|
|
20797772f8 | ||
|
|
72c312810a
|
||
|
|
b61f050e78
|
||
|
|
2a77ba9dd4 |
@@ -274,7 +274,8 @@ jobs:
|
||||
config.example.jsonc \
|
||||
plugin/subminer \
|
||||
plugin/subminer.conf \
|
||||
assets/themes/subminer.rasi
|
||||
assets/themes/subminer.rasi \
|
||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
|
||||
@@ -273,7 +273,8 @@ jobs:
|
||||
config.example.jsonc \
|
||||
plugin/subminer \
|
||||
plugin/subminer.conf \
|
||||
assets/themes/subminer.rasi
|
||||
assets/themes/subminer.rasi \
|
||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
APP_NAME := subminer
|
||||
THEME_SOURCE := assets/themes/subminer.rasi
|
||||
THUMBNAILER_SOURCE := assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
|
||||
LAUNCHER_OUT := dist/launcher/$(APP_NAME)
|
||||
THEME_FILE := subminer.rasi
|
||||
THUMBNAILER_FILE := subminer-ffmpegthumbnailer.thumbnailer
|
||||
|
||||
# Default install prefix for the wrapper script.
|
||||
PREFIX ?= $(HOME)/.local
|
||||
@@ -221,11 +223,13 @@ docs-dev: ensure-bun
|
||||
|
||||
|
||||
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 -m 0755 "$(LAUNCHER_OUT)" "$(BINDIR)/$(APP_NAME)"
|
||||
@install -d "$(LINUX_DATA_DIR)/themes"
|
||||
@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"
|
||||
@cp -R ./plugin/subminer/. "$(LINUX_DATA_DIR)/plugin/subminer/"
|
||||
@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' " Build one with: make build"; \
|
||||
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
|
||||
@printf '%s\n' "[INFO] Installing macOS wrapper/theme/app artifacts"
|
||||
@@ -275,8 +279,9 @@ uninstall:
|
||||
uninstall-linux:
|
||||
@rm -f "$(BINDIR)/subminer" "$(BINDIR)/SubMiner.AppImage"
|
||||
@rm -f "$(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
||||
@rm -f "$(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
||||
@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:
|
||||
@rm -f "$(BINDIR)/subminer"
|
||||
|
||||
@@ -15,7 +15,7 @@ Integrates Yomitan and mpv - on-screen lookups, mine to Anki, and track immersio
|
||||
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||
[](https://www.typescriptlang.org)
|
||||
|
||||
[](https://github.com/user-attachments/assets/89e61895-e2b7-4b47-8d50-a35afe4132b2)
|
||||
[](https://github.com/user-attachments/assets/7abab8a9-4e4e-4f06-9f3c-9783e15a3807)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 23 MiB |
|
Before Width: | Height: | Size: 303 KiB |
|
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;
|
||||
@@ -20,7 +20,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "43.4.1",
|
||||
"electron": "42.6.0",
|
||||
"electron-builder": "26.15.3",
|
||||
"esbuild": "^0.25.12",
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,4 +0,0 @@
|
||||
type: docs
|
||||
area: documentation
|
||||
|
||||
- Hid the unfinished feature demos page from the documentation sidebar while keeping its direct URL available.
|
||||
@@ -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.
|
||||
@@ -5,7 +5,7 @@ Short recordings of SubMiner's key features and integrations from real playback
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress';
|
||||
|
||||
const v = '20260301-1';
|
||||
const v = '20260819-1';
|
||||
</script>
|
||||
|
||||
## Anki Card Mining & Enrichment
|
||||
|
||||
@@ -64,10 +64,6 @@ make dev-watch # watch TS + renderer and launch E
|
||||
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
|
||||
dev binary path with `mpv.subminerBinaryPath` in your SubMiner config. The launcher injects it into
|
||||
the mpv plugin at runtime:
|
||||
|
||||
@@ -88,7 +88,7 @@ features:
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress';
|
||||
|
||||
const demoAssetVersion = '20260223-2';
|
||||
const demoAssetVersion = '20260819-1';
|
||||
</script>
|
||||
|
||||
<div class="landing-shell">
|
||||
|
||||
@@ -392,7 +392,7 @@ subminer -u
|
||||
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.
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -456,18 +456,20 @@ sudo chmod +x /usr/local/bin/subminer
|
||||
|
||||
### 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
|
||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||
mkdir -p ~/.local/share/SubMiner/themes
|
||||
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
|
||||
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`.
|
||||
|
||||
|
||||
@@ -34,18 +34,22 @@ subminer -R -r -d ~/Anime # rofi picker, recursive
|
||||
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
|
||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer-assets.tar.gz -O /tmp/subminer-assets.tar.gz
|
||||
tar -xzf /tmp/subminer-assets.tar.gz -C /tmp
|
||||
mkdir -p ~/.local/share/SubMiner/themes
|
||||
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
|
||||
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):
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 23 MiB |
|
Before Width: | Height: | Size: 303 KiB |
|
Before Width: | Height: | Size: 523 KiB |
|
Before Width: | Height: | Size: 3.0 MiB After Width: | Height: | Size: 3.8 MiB |
@@ -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.
|
||||
|
||||
**"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"**
|
||||
|
||||
SubMiner bundles Yomitan and searches for it in these locations (in order):
|
||||
|
||||
@@ -155,7 +155,6 @@ The tray menu also includes `View Changelog`, which opens the in-app changelog m
|
||||
|
||||
- `--log-level` controls logger verbosity.
|
||||
- `--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` 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`).
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type HistorySeriesEntry,
|
||||
} from '../history.js';
|
||||
import type { Args } from '../types.js';
|
||||
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
export type HistorySessionAction = 'previous' | 'replay' | 'next' | 'browse' | 'quit';
|
||||
@@ -333,6 +334,13 @@ export async function runHistoryCommand(
|
||||
const { args, scriptPath } = context;
|
||||
|
||||
checkPickerDependencies(args);
|
||||
if (args.useRofi) {
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
appPath: context.appPath ?? undefined,
|
||||
scriptPath,
|
||||
logLevel: args.logLevel,
|
||||
});
|
||||
}
|
||||
const themePath = args.useRofi ? findRofiTheme(scriptPath) : null;
|
||||
|
||||
const dbPath = resolveImmersionDbPath();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { fail } from '../log.js';
|
||||
import { runAppCommandWithInherit } from '../mpv.js';
|
||||
import { commandExists } from '../util.js';
|
||||
import { runJellyfinPlayMenu } from '../jellyfin.js';
|
||||
import { ensureLinuxRuntimePluginAvailable } from '../runtime-plugin-preflight.js';
|
||||
import { shouldForwardLogLevel } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
@@ -64,6 +65,13 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
||||
if (args.useRofi && !commandExists('rofi')) {
|
||||
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);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -496,3 +496,39 @@ test('playback command ensures Linux runtime plugin before mpv launch', async ()
|
||||
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -157,6 +157,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
});
|
||||
},
|
||||
chooseTarget,
|
||||
checkPickerDependencies,
|
||||
checkDependencies,
|
||||
registerCleanup,
|
||||
startMpv,
|
||||
@@ -177,6 +178,7 @@ type PlaybackCommandDeps = {
|
||||
args: Args,
|
||||
scriptPath: string,
|
||||
) => Promise<{ target: string; kind: 'file' | 'url' } | null>;
|
||||
checkPickerDependencies?: (args: Args) => void;
|
||||
checkDependencies: (args: Args) => void;
|
||||
registerCleanup: (context: LauncherCommandContext) => void;
|
||||
startMpv: typeof startMpv;
|
||||
@@ -201,7 +203,18 @@ export async function runPlaybackCommandWithDeps(
|
||||
await deps.ensurePlaybackSetupReady(context);
|
||||
|
||||
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);
|
||||
@@ -266,7 +279,7 @@ export async function runPlaybackCommandWithDeps(
|
||||
);
|
||||
}
|
||||
|
||||
await deps.ensureRuntimePluginReady(context);
|
||||
await ensureRuntimeAssetsReady();
|
||||
|
||||
await deps.startMpv(
|
||||
selectedTarget.target,
|
||||
|
||||
@@ -36,6 +36,11 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
||||
launcher: { status: 'updated' },
|
||||
supportAssets: [
|
||||
{ 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.' },
|
||||
],
|
||||
};
|
||||
@@ -52,6 +57,7 @@ test('runUpdateCommand updates directly on Linux without launching Electron', as
|
||||
'info:AppImage update: updated',
|
||||
'info:Launcher update: updated',
|
||||
'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.',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
parseSha256Sums,
|
||||
type FetchLike,
|
||||
} 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 = {
|
||||
ok: boolean;
|
||||
@@ -36,15 +39,14 @@ type DirectReleaseUpdateRequest = {
|
||||
channel: UpdateChannel;
|
||||
};
|
||||
|
||||
type DirectSupportAssetsUpdateResult = Omit<SupportAssetsUpdateResult, 'status'> & {
|
||||
status: string;
|
||||
};
|
||||
|
||||
type DirectReleaseUpdateResult = {
|
||||
appImage: { status: string; command?: string; message?: string };
|
||||
launcher: { status: string; command?: string; message?: string };
|
||||
supportAssets: Array<{
|
||||
status: string;
|
||||
component?: 'theme' | 'plugin';
|
||||
command?: string;
|
||||
message?: string;
|
||||
}>;
|
||||
supportAssets: DirectSupportAssetsUpdateResult[];
|
||||
};
|
||||
|
||||
type UpdateCommandDeps = {
|
||||
@@ -129,12 +131,7 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
|
||||
|
||||
function logUpdateResult(
|
||||
label: string,
|
||||
result: {
|
||||
status: string;
|
||||
component?: 'theme' | 'plugin';
|
||||
command?: string;
|
||||
message?: string;
|
||||
},
|
||||
result: DirectSupportAssetsUpdateResult,
|
||||
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
|
||||
deps: Pick<UpdateCommandDeps, 'log'>,
|
||||
): void {
|
||||
|
||||
@@ -73,20 +73,21 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
|
||||
};
|
||||
}
|
||||
|
||||
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which —
|
||||
// when the runtime plugin/theme are missing — spawns the app with
|
||||
// `--ensure-linux-runtime-plugin-assets` and polls up to 30s
|
||||
// On Linux the playback path runs `ensureLinuxRuntimePluginAvailable`, which
|
||||
// spawns the app with `--ensure-linux-runtime-plugin-assets` when managed
|
||||
// support assets are missing and polls up to 30s
|
||||
// (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
|
||||
// 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.
|
||||
const RUNTIME_PLUGIN_PREFLIGHT_SH = `if [ "$1" = "--ensure-linux-runtime-plugin-assets" ]; then
|
||||
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=true\\n' > "$data/plugin/subminer.conf"
|
||||
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
|
||||
mkdir -p "$(dirname "$3")"
|
||||
printf '{"ok":true,"status":"installed","path":"%s"}' "$data/plugin/subminer/main.lua" > "$3"
|
||||
|
||||
@@ -3,7 +3,12 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { findRofiTheme, formatRofiPrompt } from './picker';
|
||||
import {
|
||||
findRofiTheme,
|
||||
findRofiThumbnailerDataRoot,
|
||||
formatRofiPrompt,
|
||||
prependXdgDataDir,
|
||||
} from './picker';
|
||||
|
||||
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
|
||||
|
||||
@@ -23,6 +28,7 @@ test('formatRofiPrompt leaves an empty prompt empty', () => {
|
||||
// ── findRofiTheme: Linux packaged path discovery ──────────────────────────────
|
||||
|
||||
const ROFI_THEME_FILE = 'subminer.rasi';
|
||||
const ROFI_THUMBNAILER_FILE = 'subminer-ffmpegthumbnailer.thumbnailer';
|
||||
|
||||
function makeFile(filePath: string): void {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
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`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -159,6 +159,9 @@ interface RofiIconEntry {
|
||||
iconPath?: string;
|
||||
}
|
||||
|
||||
const ROFI_THUMBNAILER_FILE = 'subminer-ffmpegthumbnailer.thumbnailer';
|
||||
const DEFAULT_XDG_DATA_DIRS = ['/usr/local/share', '/usr/share'];
|
||||
|
||||
function showRofiIconMenu(
|
||||
entries: RofiIconEntry[],
|
||||
prompt: string,
|
||||
@@ -389,6 +392,47 @@ export function findRofiTheme(scriptPath: string): string | 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(
|
||||
videos: string[],
|
||||
dir: string,
|
||||
@@ -420,6 +464,7 @@ export function showRofiMenu(
|
||||
const result = spawnSync('rofi', args, {
|
||||
input: buildRofiMenu(videos, dir, recursive),
|
||||
encoding: 'utf8',
|
||||
env: buildRofiThumbnailEnvironment(scriptPath),
|
||||
stdio: ['pipe', 'pipe', 'ignore'],
|
||||
});
|
||||
if (result.error) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
ensureLinuxRuntimePluginAvailable,
|
||||
installManagedPluginAssetsViaApp,
|
||||
@@ -31,7 +33,7 @@ test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', asyn
|
||||
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[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
@@ -52,13 +54,17 @@ test('ensureLinuxRuntimePluginAvailable skips install when installed global plug
|
||||
calls.push('theme');
|
||||
return true;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
},
|
||||
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[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
@@ -80,14 +86,19 @@ test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path
|
||||
calls.push('theme');
|
||||
return true;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
},
|
||||
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 () => {
|
||||
const calls: string[] = [];
|
||||
let themeAvailable = false;
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
@@ -102,10 +113,15 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
|
||||
},
|
||||
isManagedThemeAvailable: () => {
|
||||
calls.push('theme');
|
||||
return false;
|
||||
return themeAvailable;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
themeAvailable = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: (level, _configured, message) => {
|
||||
@@ -117,13 +133,68 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
|
||||
'detect',
|
||||
'resolve',
|
||||
'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',
|
||||
'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',
|
||||
'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 () => {
|
||||
const calls: string[] = [];
|
||||
let resolveCount = 0;
|
||||
@@ -137,6 +208,8 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves
|
||||
calls.push(`resolve:${resolveCount}`);
|
||||
return resolveCount === 1 ? null : '/tmp/plugin/main.lua';
|
||||
},
|
||||
isManagedThemeAvailable: () => true,
|
||||
isManagedThumbnailerAvailable: () => true,
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
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, [
|
||||
'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',
|
||||
'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',
|
||||
]);
|
||||
});
|
||||
@@ -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 () => {
|
||||
let waited = false;
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ type EnsureLinuxRuntimePluginAvailableOptions = {
|
||||
detectInstalledPlugin?: () => boolean;
|
||||
resolveRuntimePluginPath?: () => string | null;
|
||||
isManagedThemeAvailable?: () => boolean;
|
||||
isManagedThumbnailerAvailable?: () => boolean;
|
||||
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
|
||||
log?: PreflightLog;
|
||||
};
|
||||
@@ -48,6 +49,14 @@ function resolveConfiguredLogLevel(
|
||||
return logLevel ?? 'warn';
|
||||
}
|
||||
|
||||
function isRegularFile(filePath: string): boolean {
|
||||
try {
|
||||
return fs.statSync(filePath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForInstallResponse(
|
||||
responsePath: string,
|
||||
): Promise<RuntimePluginPreflightResponse | null> {
|
||||
@@ -170,15 +179,17 @@ export async function ensureLinuxRuntimePluginAvailable(
|
||||
});
|
||||
const isManagedThemeAvailable =
|
||||
options.isManagedThemeAvailable ?? (() => fs.existsSync(managedPaths.themePath));
|
||||
const isManagedThumbnailerAvailable =
|
||||
options.isManagedThumbnailerAvailable ?? (() => isRegularFile(managedPaths.thumbnailerPath));
|
||||
const runtimePluginAvailable = installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
||||
if (runtimePluginAvailable && isManagedThemeAvailable()) {
|
||||
if (runtimePluginAvailable && isManagedThemeAvailable() && isManagedThumbnailerAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log(
|
||||
'info',
|
||||
configuredLogLevel,
|
||||
'Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||
'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||
);
|
||||
const installManagedPluginAssets =
|
||||
options.installManagedPluginAssets ??
|
||||
@@ -207,16 +218,21 @@ export async function ensureLinuxRuntimePluginAvailable(
|
||||
log(
|
||||
'info',
|
||||
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();
|
||||
if (runtimePluginPath) {
|
||||
const runtimePluginAvailableAfterInstall =
|
||||
installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
||||
if (
|
||||
runtimePluginAvailableAfterInstall &&
|
||||
isManagedThemeAvailable() &&
|
||||
isManagedThumbnailerAvailable()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
`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.';
|
||||
log('warn', configuredLogLevel, message);
|
||||
throw new Error(message);
|
||||
|
||||
@@ -165,11 +165,14 @@ if (entry.argv.includes('--ensure-linux-runtime-plugin-assets')) {
|
||||
const pluginDir = path.join(dataDir, 'plugin', 'subminer');
|
||||
const pluginConfigPath = path.join(dataDir, 'plugin', 'subminer.conf');
|
||||
const themePath = path.join(dataDir, 'themes', 'subminer.rasi');
|
||||
const thumbnailerPath = path.join(dataDir, 'thumbnailers', 'subminer-ffmpegthumbnailer.thumbnailer');
|
||||
fs.mkdirSync(pluginDir, { 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(pluginConfigPath, 'smoke=true\\n');
|
||||
fs.writeFileSync(themePath, '/* smoke theme */\\n');
|
||||
fs.writeFileSync(thumbnailerPath, '[Thumbnailer Entry]\\n');
|
||||
if (responsePath) {
|
||||
fs.mkdirSync(path.dirname(responsePath), { recursive: true });
|
||||
fs.writeFileSync(responsePath, JSON.stringify({ ok: true, status: 'installed', path: path.join(pluginDir, 'main.lua') }));
|
||||
@@ -620,11 +623,22 @@ test(
|
||||
);
|
||||
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
|
||||
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(
|
||||
fs.existsSync(path.join(smokeCase.xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
smokeCase.xdgDataHome,
|
||||
'SubMiner',
|
||||
'thumbnailers',
|
||||
'subminer-ffmpegthumbnailer.thumbnailer',
|
||||
),
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"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",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -125,7 +125,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "43.4.1",
|
||||
"electron": "42.6.0",
|
||||
"electron-builder": "26.15.3",
|
||||
"undici": "7.29.0",
|
||||
"esbuild": "^0.25.12",
|
||||
|
||||
@@ -58,6 +58,8 @@ package() {
|
||||
"${pkgdir}/usr/share/SubMiner/plugin/subminer.conf"
|
||||
install -Dm644 "${srcdir}/assets/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"
|
||||
cp -a "${srcdir}/plugin/subminer/." "${pkgdir}/usr/share/SubMiner/plugin/subminer/"
|
||||
|
||||
@@ -4,37 +4,43 @@
|
||||
|
||||
## Highlights
|
||||
### 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
- Library Merge & Reassignment
|
||||
- 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.
|
||||
- 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
|
||||
- **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
|
||||
- **Feature Demos Page**
|
||||
- Hidden the unfinished feature demos page from the documentation sidebar; it's still reachable by direct URL.
|
||||
- Subtitle Duplication from Karaoke & Animated Signs
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -47,6 +53,13 @@
|
||||
- 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): 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
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ Options:
|
||||
-w, --webp Generate animated WebP preview
|
||||
|
||||
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
|
||||
- WebM: AV1/VP9 + Opus at 30 fps
|
||||
USAGE
|
||||
@@ -148,7 +149,8 @@ pick_webp_encoder() {
|
||||
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"
|
||||
|
||||
echo "Generating MP4: $mp4_out"
|
||||
|
||||
@@ -40,7 +40,7 @@ function toBashPath(filePath: string): string {
|
||||
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) => {
|
||||
const binDir = path.join(root, 'bin');
|
||||
const inputPath = path.join(root, 'sample.mkv');
|
||||
@@ -104,5 +104,9 @@ touch "$output"
|
||||
|
||||
const ffmpegLog = fs.readFileSync(ffmpegLogPath, 'utf8');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ test('update-aur-package updates PKGBUILD and .SRCINFO without makepkg', () => {
|
||||
pkgbuild,
|
||||
/^\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, /^\tprovides = subminer=0\.6\.3$/m);
|
||||
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 db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
@@ -103,15 +103,9 @@ async function backfillMissingCoverBlob(): Promise<void> {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'fetchIfMissing backfills a missing blob from an existing cover URL',
|
||||
{ timeout: 15_000 },
|
||||
backfillMissingCoverBlob,
|
||||
);
|
||||
|
||||
async function reuseCachedAnimeCoverArt(): Promise<void> {
|
||||
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
@@ -185,13 +179,7 @@ async function reuseCachedAnimeCoverArt(): Promise<void> {
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
}
|
||||
|
||||
test(
|
||||
'fetchIfMissing reuses cached cover art from another video in the same anime',
|
||||
{ timeout: 15_000 },
|
||||
reuseCachedAnimeCoverArt,
|
||||
);
|
||||
});
|
||||
|
||||
function createJsonResponse(payload: unknown): Response {
|
||||
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.roundedCorners, false);
|
||||
});
|
||||
|
||||
test('Linux visible overlay window allows compositor resize for mpv-sized placement', () => {
|
||||
|
||||
@@ -37,7 +37,6 @@ export function buildOverlayWindowOptions(
|
||||
paintWhenInitiallyHidden: true,
|
||||
backgroundColor: '#00000000',
|
||||
frame: false,
|
||||
...(platform === 'linux' ? { roundedCorners: false } : {}),
|
||||
alwaysOnTop: shouldStartAlwaysOnTop,
|
||||
skipTaskbar: true,
|
||||
resizable: shouldAllowCompositorResize,
|
||||
|
||||
@@ -67,7 +67,6 @@ export function buildStatsWindowOptions(options: {
|
||||
width: options.bounds?.width ?? DEFAULT_STATS_WINDOW_WIDTH,
|
||||
height: options.bounds?.height ?? DEFAULT_STATS_WINDOW_HEIGHT,
|
||||
frame: false,
|
||||
...(platform === 'linux' ? { roundedCorners: false } : {}),
|
||||
transparent: false,
|
||||
alwaysOnTop: true,
|
||||
resizable: false,
|
||||
|
||||
@@ -57,7 +57,6 @@ test('buildStatsWindowOptions remains a regular window off macOS', () => {
|
||||
});
|
||||
|
||||
assert.equal(options.type, undefined);
|
||||
assert.equal(options.roundedCorners, false);
|
||||
});
|
||||
|
||||
test('stats panels present after document load on macOS', () => {
|
||||
|
||||
@@ -606,93 +606,3 @@ test('configureEarlyAppPaths pins userData to canonical SubMiner config dir', ()
|
||||
assert.equal(userDataPath, '/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']);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
||||
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 MAX_TRANSPORTED_APP_ARGS = 256;
|
||||
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 MACOS_APP_CONTROL_HANDOFF_TIMEOUT_MS = 3000;
|
||||
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
|
||||
@@ -56,8 +53,6 @@ type EarlyAppPathOptions = {
|
||||
xdgConfigHome?: string;
|
||||
homeDir?: string;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
argv?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
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 {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const env = options?.env ?? process.env;
|
||||
const configDir = resolveConfigDir({
|
||||
platform,
|
||||
appDataDir: options?.appDataDir ?? env.APPDATA,
|
||||
xdgConfigHome: options?.xdgConfigHome ?? env.XDG_CONFIG_HOME,
|
||||
const userDataPath = resolveConfigDir({
|
||||
platform: options?.platform ?? process.platform,
|
||||
appDataDir: options?.appDataDir ?? process.env.APPDATA,
|
||||
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
|
||||
homeDir: options?.homeDir ?? os.homedir(),
|
||||
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.setPath('userData', userDataPath);
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
spawnDetachedApp,
|
||||
} from './main-entry-runtime';
|
||||
import {
|
||||
requestSingleInstanceLockEarly,
|
||||
shouldBypassSingleInstanceLockForArgv,
|
||||
} from './main/early-single-instance';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
||||
import {
|
||||
@@ -38,7 +35,6 @@ import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/win
|
||||
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
|
||||
import { handleSyncCliAtEntry } from './main/sync-cli';
|
||||
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
|
||||
import { enforceElectronRuntimeGuard } from './main/electron-runtime-guard';
|
||||
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
|
||||
import {
|
||||
applyLogFileTogglesToEnv,
|
||||
@@ -196,25 +192,11 @@ registerFatalErrorHandlers({
|
||||
});
|
||||
|
||||
function startMainProcess(): void {
|
||||
// Normal launches serialize the runtime guard with the profile-scoped lock. Stats daemon
|
||||
// commands keep their existing lock bypass when Electron runs in Node mode.
|
||||
const gotSingleInstanceLock =
|
||||
shouldBypassSingleInstanceLockForArgv(process.argv) || requestSingleInstanceLockEarly(app);
|
||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.exit(0);
|
||||
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 {
|
||||
require('./main.js');
|
||||
} catch (error) {
|
||||
|
||||
@@ -36,10 +36,6 @@ import { openPlaylistBrowser as openPlaylistBrowserRuntime } from './main/runtim
|
||||
import { createAniSkipRuntime } from './main/runtime/aniskip-runtime';
|
||||
import { resolveAniSkipMetadataForFile } from './main/runtime/aniskip-metadata';
|
||||
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 { createEnsureBackgroundStatsServerHandler } from './main/runtime/background-stats-startup';
|
||||
import {
|
||||
@@ -768,7 +764,6 @@ type BootServices = MainBootServicesResult<
|
||||
const bootServices = createMainBootServices({
|
||||
platform: process.platform,
|
||||
argv: process.argv,
|
||||
configDir: app.getPath('userData'),
|
||||
appDataDir: process.env.APPDATA,
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
@@ -1437,10 +1432,6 @@ const firstRunSetupService = createFirstRunSetupService({
|
||||
error: (message, ...args) => logger.error(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;
|
||||
},
|
||||
isExternalYomitanConfigured: () =>
|
||||
@@ -2532,12 +2523,10 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
),
|
||||
getYomitanDictionaryInfo: async () => {
|
||||
await ensureYomitanExtensionLoaded();
|
||||
const dictionaries = await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||
error: (message, ...args) => logger.error(message, ...args),
|
||||
info: (message, ...args) => logger.info(message, ...args),
|
||||
});
|
||||
assertYomitanDictionaryMutationSafe(USER_DATA_PATH, dictionaries.length);
|
||||
return dictionaries;
|
||||
},
|
||||
importYomitanDictionary: async (zipPath) => {
|
||||
if (yomitanProfilePolicy.isExternalReadOnlyMode()) {
|
||||
@@ -2634,6 +2623,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
const characterDictionaryImageLookup = createCharacterDictionaryImageLookup({
|
||||
userDataPath: USER_DATA_PATH,
|
||||
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
|
||||
@@ -4058,7 +4053,7 @@ const recordTrackedCardsMined = (count: number, noteIds?: number[]): void => {
|
||||
ensureImmersionTrackerStarted();
|
||||
appState.immersionTracker?.recordCardsMined(count, noteIds);
|
||||
};
|
||||
const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
|
||||
function refreshCurrentSubtitleAnnotations(): void {
|
||||
const hasCurrentSubtitle = appState.currentSubText.trim().length > 0;
|
||||
if (hasCurrentSubtitle) {
|
||||
subtitlePrefetchService?.pause();
|
||||
@@ -4069,7 +4064,7 @@ const refreshCurrentSubtitleAfterKnownWordUpdate = (): void => {
|
||||
// Idle controller: no settle is coming to release the pause above.
|
||||
subtitlePrefetchService?.resume();
|
||||
}
|
||||
};
|
||||
}
|
||||
let hasAttemptedImmersionTrackerStartup = false;
|
||||
const ensureImmersionTrackerStarted = (): void => {
|
||||
if (hasAttemptedImmersionTrackerStartup || appState.immersionTracker) {
|
||||
@@ -5092,9 +5087,7 @@ function initializeOverlayRuntime(): void {
|
||||
overlayModalRuntime.primeModalWindow();
|
||||
}
|
||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||
);
|
||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
|
||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
@@ -5887,7 +5880,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
appState.ankiIntegration = integration;
|
||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(
|
||||
refreshCurrentSubtitleAfterKnownWordUpdate,
|
||||
refreshCurrentSubtitleAnnotations,
|
||||
);
|
||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(
|
||||
consumePendingSubtitleMiningContext,
|
||||
|
||||
@@ -138,56 +138,3 @@ test('createMainBootServices builds boot-phase service bundle', () => {
|
||||
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config', 'exit:7']);
|
||||
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' });
|
||||
});
|
||||
|
||||
@@ -31,7 +31,6 @@ export interface MainBootServicesParams<
|
||||
> {
|
||||
platform: NodeJS.Platform;
|
||||
argv: string[];
|
||||
configDir?: string;
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
@@ -175,15 +174,13 @@ export function createMainBootServices<
|
||||
TAppState,
|
||||
TAppLifecycleApp
|
||||
> {
|
||||
const configDir =
|
||||
params.configDir ??
|
||||
params.resolveConfigDir({
|
||||
platform: params.platform,
|
||||
appDataDir: params.appDataDir,
|
||||
xdgConfigHome: params.xdgConfigHome,
|
||||
homeDir: params.homeDir,
|
||||
existsSync: params.existsSync,
|
||||
});
|
||||
const configDir = params.resolveConfigDir({
|
||||
platform: params.platform,
|
||||
appDataDir: params.appDataDir,
|
||||
xdgConfigHome: params.xdgConfigHome,
|
||||
homeDir: params.homeDir,
|
||||
existsSync: params.existsSync,
|
||||
});
|
||||
const userDataPath = configDir;
|
||||
const defaultMpvLogPath = params.envMpvLog?.trim() || params.defaultMpvLogFile;
|
||||
const defaultImmersionDbPath = params.joinPath(userDataPath, 'immersion.sqlite');
|
||||
|
||||
@@ -450,7 +450,7 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
}
|
||||
|
||||
const nameSplitTokenizerAvailable = isNameSplitTokenizerAvailable();
|
||||
const resolvedNameSplits = nameSplitTokenizerAvailable
|
||||
const nameSplitResolution = nameSplitTokenizerAvailable
|
||||
? await resolveJapaneseNameSplits(
|
||||
characters,
|
||||
deps.tokenizeJapaneseName!,
|
||||
@@ -466,8 +466,8 @@ export function createCharacterDictionaryRuntimeService(deps: CharacterDictionar
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
const nameSplitSource =
|
||||
resolvedNameSplits && resolvedNameSplits.size > 0 ? 'mecab' : 'heuristic';
|
||||
const resolvedNameSplits = nameSplitResolution?.splits;
|
||||
const nameSplitSource = nameSplitResolution?.kind === 'complete' ? 'mecab' : 'heuristic';
|
||||
|
||||
progress?.onGenerateProgress?.({
|
||||
mediaId,
|
||||
|
||||
@@ -198,6 +198,66 @@ test('createCharacterDictionaryImageLookup can scope duplicate names to the curr
|
||||
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 () => {
|
||||
const outputDir = makeTempDir();
|
||||
const snapshot: CharacterDictionarySnapshot = {
|
||||
|
||||
@@ -218,6 +218,8 @@ export function createCharacterDictionaryImageLookup(deps: {
|
||||
userDataPath?: string;
|
||||
outputDir?: string;
|
||||
getCurrentMediaId?: () => number | null | undefined;
|
||||
onIndexReady?: () => void;
|
||||
onIndexReadyError?: (error: unknown) => void;
|
||||
}): {
|
||||
get: (term: string, mediaId?: number | null) => CharacterNameImage | null;
|
||||
invalidate: () => void;
|
||||
@@ -229,6 +231,24 @@ export function createCharacterDictionaryImageLookup(deps: {
|
||||
let index = new Map<string, CharacterNameImage>();
|
||||
let indexByMediaId = new Map<number, Map<string, CharacterNameImage>>();
|
||||
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
|
||||
// synchronously inside a lookup and froze the whole app right after a snapshot changed. Lookups
|
||||
@@ -241,6 +261,7 @@ export function createCharacterDictionaryImageLookup(deps: {
|
||||
signature = '';
|
||||
return;
|
||||
}
|
||||
deliverIndexReadyIfPending();
|
||||
const nextSignature = getSnapshotDirectorySignature(outputDir);
|
||||
if (nextSignature === signature || refreshInFlight) {
|
||||
return;
|
||||
@@ -262,6 +283,8 @@ export function createCharacterDictionaryImageLookup(deps: {
|
||||
index = nextIndex;
|
||||
indexByMediaId = nextIndexByMediaId;
|
||||
signature = nextSignature;
|
||||
indexReadyDeliveryPending = deps.onIndexReady !== undefined;
|
||||
deliverIndexReadyIfPending();
|
||||
} finally {
|
||||
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 () => {
|
||||
@@ -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 () => {
|
||||
@@ -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 () => {
|
||||
@@ -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 () => {
|
||||
@@ -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 () => {
|
||||
@@ -130,7 +135,8 @@ test('resolveJapaneseNameSplits survives tokenizer failures', async () => {
|
||||
(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.match(warnings[0]!, /mecab unavailable/);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
ResolvedNameSplit,
|
||||
} from './types';
|
||||
|
||||
export type JapaneseNameSplitResolution =
|
||||
| { kind: 'complete'; splits: Map<string, ResolvedNameSplit> }
|
||||
| { kind: 'incomplete'; splits: Map<string, ResolvedNameSplit> };
|
||||
|
||||
const NAME_SEPARATOR_PATTERN = /[\s ・・·•]/;
|
||||
|
||||
function joinSurfaces(tokens: NameSplitToken[]): string {
|
||||
@@ -87,8 +91,9 @@ export async function resolveJapaneseNameSplits(
|
||||
tokenize: NameSplitTokenizer,
|
||||
logWarn?: (message: string) => void,
|
||||
onCharacterResolved?: (completed: number, total: number) => void,
|
||||
): Promise<Map<string, ResolvedNameSplit>> {
|
||||
): Promise<JapaneseNameSplitResolution> {
|
||||
const splits = new Map<string, ResolvedNameSplit>();
|
||||
let tokenizerFailed = false;
|
||||
let resolvedCharacters = 0;
|
||||
for (const character of characters) {
|
||||
const familyHintReading = buildReadingFromHint(character.lastNameHint?.trim() || '');
|
||||
@@ -99,12 +104,17 @@ export async function resolveJapaneseNameSplits(
|
||||
try {
|
||||
tokens = await tokenize(name);
|
||||
} catch (err) {
|
||||
tokenizerFailed = true;
|
||||
logWarn?.(
|
||||
`[dictionary] name split tokenization failed for "${name}": ${(err as Error).message}`,
|
||||
);
|
||||
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 =
|
||||
splitIndexFromPersonNamePos(tokens) ??
|
||||
splitIndexFromHintReadings(tokens, familyHintReading, givenHintReading);
|
||||
@@ -118,5 +128,5 @@ export async function resolveJapaneseNameSplits(
|
||||
resolvedCharacters += 1;
|
||||
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 { getSnapshotPath, writeSnapshot } from './cache';
|
||||
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 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 outputDir = path.join(userDataPath, 'character-dictionaries');
|
||||
await writeSnapshot(getSnapshotPath(outputDir, 130298), {
|
||||
@@ -172,7 +177,6 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
let tokenizerCalls = 0;
|
||||
const runtime = createCharacterDictionaryRuntimeService({
|
||||
userDataPath,
|
||||
getCurrentMediaPath: () => '/tmp/eminence-s01e05.mkv',
|
||||
@@ -185,29 +189,54 @@ test('generateForCurrentMedia keeps failed MeCab name split refreshes retryable'
|
||||
source: 'fallback',
|
||||
}),
|
||||
getNameMatchImagesEnabled: () => false,
|
||||
tokenizeJapaneseName: async () => {
|
||||
tokenizerCalls += 1;
|
||||
return null;
|
||||
},
|
||||
tokenizeJapaneseName,
|
||||
getJapaneseNameTokenizerAvailable: () => true,
|
||||
now: () => 1_700_000_000_500,
|
||||
});
|
||||
|
||||
const result = await runtime.generateForCurrentMedia();
|
||||
const firstResult = await runtime.generateForCurrentMedia();
|
||||
const refreshedSnapshot = JSON.parse(
|
||||
fs.readFileSync(getSnapshotPath(outputDir, 130298), 'utf8'),
|
||||
) as CharacterDictionarySnapshot;
|
||||
const secondResult = await runtime.generateForCurrentMedia();
|
||||
|
||||
assert.equal(result.fromCache, false);
|
||||
assert.equal(refreshedSnapshot.nameSplitSource, 'heuristic');
|
||||
|
||||
const retriedResult = await runtime.generateForCurrentMedia();
|
||||
assert.equal(retriedResult.fromCache, false);
|
||||
assert.equal(characterPageRequests, 2);
|
||||
assert.equal(tokenizerCalls, 2);
|
||||
return {
|
||||
characterPageRequests,
|
||||
firstResultFromCache: firstResult.fromCache,
|
||||
refreshedNameSplitSource: refreshedSnapshot.nameSplitSource,
|
||||
secondResultFromCache: secondResult.fromCache,
|
||||
};
|
||||
} finally {
|
||||
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 () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -482,10 +482,10 @@ test('Linux visible overlay recreation avoids display fallback before tracked ge
|
||||
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 actionBlock = source.match(
|
||||
/const refreshCurrentSubtitleAfterKnownWordUpdate = \(\): void => \{(?<body>[\s\S]*?)\n\};/,
|
||||
/function refreshCurrentSubtitleAnnotations\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
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', () => {
|
||||
const source = readMainSource();
|
||||
const depsBlock = source.match(
|
||||
|
||||
@@ -8,6 +8,18 @@ import {
|
||||
resolveManagedLinuxRuntimePluginPaths,
|
||||
} 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> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-linux-plugin-assets-test-'));
|
||||
try {
|
||||
@@ -48,6 +60,7 @@ test('resolveManagedLinuxRuntimePluginPaths resolves XDG data target paths', ()
|
||||
pluginEntrypointPath: '/tmp/xdg-data/SubMiner/plugin/subminer/main.lua',
|
||||
pluginConfigPath: '/tmp/xdg-data/SubMiner/plugin/subminer.conf',
|
||||
themePath: '/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||
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) => {
|
||||
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
||||
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');
|
||||
fs.mkdirSync(path.join(sourceRoot, 'subminer'), { 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'),
|
||||
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||
themeSourcePath,
|
||||
thumbnailerSourcePath,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -117,6 +132,19 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
|
||||
),
|
||||
'/* 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) => {
|
||||
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
||||
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 targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||
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'),
|
||||
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||
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 () => {
|
||||
await withTempDir(async (tempDir) => {
|
||||
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 targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||
fs.mkdirSync(path.dirname(themeSourcePath), { recursive: true });
|
||||
@@ -183,6 +214,7 @@ test('ensureLinuxRuntimePluginAssets installs managed theme without resolving pl
|
||||
xdgDataHome,
|
||||
resolveBundledAssets: () => ({
|
||||
themeSourcePath,
|
||||
thumbnailerSourcePath,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -250,6 +282,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso
|
||||
path.join(xdgDataHome, 'SubMiner', 'themes', 'subminer.rasi'),
|
||||
'/* existing theme */\n',
|
||||
);
|
||||
const thumbnailerSourcePath = writeThumbnailer(path.join(tempDir, 'source', 'assets'));
|
||||
|
||||
const result = await ensureLinuxRuntimePluginAssets({
|
||||
platform: 'linux',
|
||||
@@ -258,6 +291,7 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso
|
||||
resolveBundledAssets: () => ({
|
||||
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||
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'),
|
||||
'/* theme */\n',
|
||||
);
|
||||
writeThumbnailer(path.join(xdgDataHome, 'SubMiner'));
|
||||
|
||||
const result = await ensureLinuxRuntimePluginAssets({
|
||||
platform: 'linux',
|
||||
@@ -369,6 +404,7 @@ test('ensureLinuxRuntimePluginAssets leaves no final target tree on failed insta
|
||||
await withTempDir(async (tempDir) => {
|
||||
const sourceRoot = path.join(tempDir, 'source', 'plugin');
|
||||
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 targetRoot = path.join(xdgDataHome, 'SubMiner', 'plugin');
|
||||
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'),
|
||||
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||
themeSourcePath,
|
||||
thumbnailerSourcePath,
|
||||
}),
|
||||
copyFile: async () => {
|
||||
throw new Error('copy failed');
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ManagedLinuxRuntimePluginPaths {
|
||||
pluginEntrypointPath: string;
|
||||
pluginConfigPath: string;
|
||||
themePath: string;
|
||||
thumbnailerPath: string;
|
||||
}
|
||||
|
||||
export interface EnsureLinuxRuntimePluginAssetsResult {
|
||||
@@ -23,6 +24,7 @@ interface RuntimePluginAssetSources {
|
||||
pluginDirSource?: string;
|
||||
pluginConfigSource?: string;
|
||||
themeSourcePath?: string;
|
||||
thumbnailerSourcePath?: string;
|
||||
}
|
||||
|
||||
interface RuntimePluginDirentLike {
|
||||
@@ -72,6 +74,11 @@ export function resolveManagedLinuxRuntimePluginPaths(options: {
|
||||
pluginEntrypointPath: pathModule.join(pluginDir, 'main.lua'),
|
||||
pluginConfigPath: pathModule.join(rootDir, 'subminer.conf'),
|
||||
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;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
existsSync: (candidate: string) => boolean;
|
||||
relativePath: string;
|
||||
}): string | null {
|
||||
const roots = [
|
||||
path.join(options.resourcesPath, 'assets'),
|
||||
@@ -111,7 +119,7 @@ function resolveBundledThemePath(options: {
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -129,16 +137,25 @@ function resolveBundledAssetsDefault(
|
||||
existsSync,
|
||||
});
|
||||
|
||||
const themeSourcePath = resolveBundledThemePath({
|
||||
const themeSourcePath = resolveBundledAssetPath({
|
||||
dirname: __dirname,
|
||||
appPath: process.execPath,
|
||||
resourcesPath,
|
||||
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 {
|
||||
...(pluginAssets ?? {}),
|
||||
...(themeSourcePath ? { themeSourcePath } : {}),
|
||||
...(thumbnailerSourcePath ? { thumbnailerSourcePath } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,7 +195,8 @@ export async function ensureLinuxRuntimePluginAssets(
|
||||
const pluginAssetsExist =
|
||||
existsSync(managedPaths.pluginEntrypointPath) && existsSync(managedPaths.pluginConfigPath);
|
||||
const themeExists = existsSync(managedPaths.themePath);
|
||||
if (pluginAssetsExist && themeExists) {
|
||||
const thumbnailerExists = existsSync(managedPaths.thumbnailerPath);
|
||||
if (pluginAssetsExist && themeExists && thumbnailerExists) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 'already-present',
|
||||
@@ -193,6 +211,7 @@ export async function ensureLinuxRuntimePluginAssets(
|
||||
|
||||
const shouldInstallPluginAssets = !pluginAssetsExist;
|
||||
const shouldInstallTheme = !themeExists;
|
||||
const shouldInstallThumbnailer = !thumbnailerExists;
|
||||
if (
|
||||
shouldInstallPluginAssets &&
|
||||
(!bundledAssets.pluginDirSource || !bundledAssets.pluginConfigSource)
|
||||
@@ -210,6 +229,13 @@ export async function ensureLinuxRuntimePluginAssets(
|
||||
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 stagedPluginDir = pathModule.join(managedPaths.rootDir, `.subminer-stage-${stagingSuffix}`);
|
||||
@@ -221,9 +247,14 @@ export async function ensureLinuxRuntimePluginAssets(
|
||||
pathModule.dirname(managedPaths.themePath),
|
||||
`.subminer.rasi-stage-${stagingSuffix}`,
|
||||
);
|
||||
const stagedThumbnailerPath = pathModule.join(
|
||||
pathModule.dirname(managedPaths.thumbnailerPath),
|
||||
`.subminer-ffmpegthumbnailer.thumbnailer-stage-${stagingSuffix}`,
|
||||
);
|
||||
let pluginDirInstalled = false;
|
||||
let pluginConfigInstalled = false;
|
||||
let themeInstalled = false;
|
||||
let thumbnailerInstalled = false;
|
||||
|
||||
try {
|
||||
if (shouldInstallPluginAssets) {
|
||||
@@ -249,6 +280,14 @@ export async function ensureLinuxRuntimePluginAssets(
|
||||
await mkdir(pathModule.dirname(managedPaths.themePath), { recursive: true });
|
||||
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) {
|
||||
await rm(managedPaths.pluginDir, { recursive: true, force: true });
|
||||
await rm(managedPaths.pluginConfigPath, { force: true });
|
||||
@@ -262,6 +301,11 @@ export async function ensureLinuxRuntimePluginAssets(
|
||||
await rename(stagedThemePath, managedPaths.themePath);
|
||||
themeInstalled = true;
|
||||
}
|
||||
if (shouldInstallThumbnailer) {
|
||||
await rm(managedPaths.thumbnailerPath, { force: true });
|
||||
await rename(stagedThumbnailerPath, managedPaths.thumbnailerPath);
|
||||
thumbnailerInstalled = true;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -278,9 +322,13 @@ export async function ensureLinuxRuntimePluginAssets(
|
||||
if (themeInstalled) {
|
||||
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(stagedPluginConfigPath, { force: true }).catch(() => {});
|
||||
await rm(stagedThemePath, { force: true }).catch(() => {});
|
||||
await rm(stagedThumbnailerPath, { force: true }).catch(() => {});
|
||||
return {
|
||||
ok: false,
|
||||
status: 'failed',
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './support-assets';
|
||||
|
||||
type SupportAssetsResultWithComponent = SupportAssetsUpdateResult & {
|
||||
component?: 'theme' | 'plugin';
|
||||
component?: 'theme' | 'thumbnailer' | 'plugin';
|
||||
};
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
@@ -22,18 +22,30 @@ function sha256(data: Buffer): string {
|
||||
|
||||
function makeSupportAssetsArchive(options?: {
|
||||
themeContent?: string;
|
||||
thumbnailerContent?: string;
|
||||
includeThumbnailer?: boolean;
|
||||
pluginVersion?: string | null;
|
||||
pluginMainContent?: string;
|
||||
extraPluginFiles?: Array<{ relativePath: string; content: string }>;
|
||||
}): { archive: Buffer; tempDir: string } {
|
||||
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 pluginMainContent = options?.pluginMainContent ?? 'new plugin\n';
|
||||
const extraPluginFiles = options?.extraPluginFiles ?? [];
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-support-assets-test-'));
|
||||
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.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);
|
||||
if (pluginVersion !== null) {
|
||||
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(
|
||||
"https://example.test/subminer assets.tar.gz?sig='abc'",
|
||||
'ABCDEF1234',
|
||||
@@ -110,11 +122,28 @@ test('buildProtectedSupportAssetsCommand installs both theme and plugin assets',
|
||||
command,
|
||||
/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 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 rm -rf .*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,
|
||||
message: 'Updated theme.',
|
||||
},
|
||||
{
|
||||
status: 'updated',
|
||||
component: 'thumbnailer',
|
||||
path: dataDir,
|
||||
message: 'Installed rofi thumbnailer.',
|
||||
},
|
||||
{
|
||||
status: 'updated',
|
||||
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'),
|
||||
'new theme\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
|
||||
'utf8',
|
||||
),
|
||||
'[Thumbnailer Entry]\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
||||
'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 dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
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.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/version.lua'),
|
||||
@@ -371,6 +418,12 @@ test('updateSupportAssetsFromRelease skips identical theme and up-to-date plugin
|
||||
path: dataDir,
|
||||
message: 'Theme already up to date.',
|
||||
},
|
||||
{
|
||||
status: 'skipped',
|
||||
component: 'thumbnailer',
|
||||
path: dataDir,
|
||||
message: 'Rofi thumbnailer already up to date.',
|
||||
},
|
||||
{
|
||||
status: 'skipped',
|
||||
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 dataDir = path.posix.join(xdgDataHome, 'SubMiner');
|
||||
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.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/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');
|
||||
const { archive, tempDir } = makeSupportAssetsArchive({
|
||||
themeContent: 'new theme\n',
|
||||
thumbnailerContent: '[Thumbnailer Entry]\n',
|
||||
pluginVersion: '0.12.0',
|
||||
pluginMainContent: 'new plugin main\n',
|
||||
extraPluginFiles: [{ relativePath: 'fresh.lua', content: 'fresh\n' }],
|
||||
@@ -424,6 +483,12 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w
|
||||
path: dataDir,
|
||||
message: 'Updated theme.',
|
||||
},
|
||||
{
|
||||
status: 'updated',
|
||||
component: 'thumbnailer',
|
||||
path: dataDir,
|
||||
message: 'Updated rofi thumbnailer.',
|
||||
},
|
||||
{
|
||||
status: 'updated',
|
||||
component: 'plugin',
|
||||
@@ -435,6 +500,13 @@ test('updateSupportAssetsFromRelease updates changed theme and outdated plugin w
|
||||
fs.readFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'utf8'),
|
||||
'new theme\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(dataDir, 'thumbnailers/subminer-ffmpegthumbnailer.thumbnailer'),
|
||||
'utf8',
|
||||
),
|
||||
'[Thumbnailer Entry]\n',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dataDir, 'plugin/subminer/main.lua'), 'utf8'),
|
||||
'new plugin main\n',
|
||||
@@ -479,6 +551,12 @@ test('updateSupportAssetsFromRelease returns protected commands for managed root
|
||||
path: dataDir,
|
||||
command: true,
|
||||
},
|
||||
{
|
||||
status: 'protected',
|
||||
component: 'thumbnailer',
|
||||
path: dataDir,
|
||||
command: true,
|
||||
},
|
||||
{
|
||||
status: 'protected',
|
||||
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 ?? '',
|
||||
/thumbnailers\/subminer-ffmpegthumbnailer\.thumbnailer/,
|
||||
);
|
||||
assert.match(results[0]?.command ?? '', /plugin\/subminer/);
|
||||
} finally {
|
||||
fs.chmodSync(dataDir, originalMode);
|
||||
@@ -522,3 +604,25 @@ test('updateSupportAssetsFromRelease returns missing-asset when release plugin v
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -9,13 +9,17 @@ import { compareSemverLike, findReleaseAsset } from './release-assets';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
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_VERSION_RELATIVE_PATH = path.join('plugin', 'subminer', 'version.lua');
|
||||
const PLUGIN_DIR_RELATIVE_PATH = path.join('plugin', 'subminer');
|
||||
|
||||
export interface SupportAssetsUpdateResult {
|
||||
status: 'updated' | 'skipped' | 'protected' | 'hash-mismatch' | 'missing-asset';
|
||||
component?: 'theme' | 'plugin';
|
||||
component?: 'theme' | 'thumbnailer' | 'plugin';
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
@@ -69,11 +73,12 @@ async function readInstalledPluginVersion(pluginDir: string): Promise<string | n
|
||||
async function detectManagedSupportAssetDataDirs(dataDirs: string[]): Promise<string[]> {
|
||||
const managedDataDirs: string[] = [];
|
||||
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, THUMBNAILER_RELATIVE_PATH)),
|
||||
pathExists(path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH)),
|
||||
]);
|
||||
if (hasTheme || hasPlugin) {
|
||||
if (hasTheme || hasThumbnailer || hasPlugin) {
|
||||
managedDataDirs.push(dataDir);
|
||||
}
|
||||
}
|
||||
@@ -163,8 +168,14 @@ export function buildProtectedSupportAssetsCommand(
|
||||
`curl -fSL ${shellQuote(assetUrl)} -o "$tmp/subminer-assets.tar.gz"`,
|
||||
`printf '%s %s\\n' ${quotedExpectedSha256} "$tmp/subminer-assets.tar.gz" | sha256sum -c -`,
|
||||
'tar -xzf "$tmp/subminer-assets.tar.gz" -C "$tmp"',
|
||||
'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 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 rm -rf ${quotedStagedPluginDir} ${quotedBackupPluginDir}`,
|
||||
`sudo cp -R "$tmp/plugin/subminer" ${quotedStagedPluginDir}`,
|
||||
@@ -216,6 +227,12 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
dataDir,
|
||||
'Support asset path is not a directory.',
|
||||
),
|
||||
makeSupportAssetResult(
|
||||
'skipped',
|
||||
'thumbnailer',
|
||||
dataDir,
|
||||
'Support asset path is not a directory.',
|
||||
),
|
||||
makeSupportAssetResult(
|
||||
'skipped',
|
||||
'plugin',
|
||||
@@ -244,6 +261,13 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
'Theme install requires a manual command.',
|
||||
command,
|
||||
),
|
||||
makeSupportAssetResult(
|
||||
'protected',
|
||||
'thumbnailer',
|
||||
dataDir,
|
||||
'Rofi thumbnailer install requires a manual command.',
|
||||
command,
|
||||
),
|
||||
makeSupportAssetResult(
|
||||
'protected',
|
||||
'plugin',
|
||||
@@ -284,6 +308,17 @@ export async function updateSupportAssetsFromRelease(options: {
|
||||
}
|
||||
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 sourcePluginEntrypoint = path.join(tempDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
|
||||
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 targetPluginEntrypoint = path.join(dataDir, PLUGIN_ENTRYPOINT_RELATIVE_PATH);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (fs.existsSync(dirPath)) return;
|
||||
@@ -13,48 +12,3 @@ export function ensureDirForFile(filePath: string): void {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||