mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-12 17:16:20 -07:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80ffa26400
|
||
|
|
06b21a68fa
|
@@ -274,8 +274,7 @@ jobs:
|
||||
config.example.jsonc \
|
||||
plugin/subminer \
|
||||
plugin/subminer.conf \
|
||||
assets/themes/subminer.rasi \
|
||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
|
||||
assets/themes/subminer.rasi
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
|
||||
@@ -273,8 +273,7 @@ jobs:
|
||||
config.example.jsonc \
|
||||
plugin/subminer \
|
||||
plugin/subminer.conf \
|
||||
assets/themes/subminer.rasi \
|
||||
assets/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer
|
||||
assets/themes/subminer.rasi
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
|
||||
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
|
||||
@@ -223,13 +221,11 @@ docs-dev: ensure-bun
|
||||
|
||||
|
||||
install-linux: build-launcher
|
||||
@printf '%s\n' "[INFO] Installing Linux wrapper/support artifacts"
|
||||
@printf '%s\n' "[INFO] Installing Linux wrapper/theme 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 \
|
||||
@@ -238,7 +234,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)" " $(LINUX_DATA_DIR)/thumbnailers/$(THUMBNAILER_FILE)"
|
||||
@printf '%s\n' "Installed to:" " $(BINDIR)/subminer" " $(LINUX_DATA_DIR)/themes/$(THEME_FILE)"
|
||||
|
||||
install-macos: build-launcher
|
||||
@printf '%s\n' "[INFO] Installing macOS wrapper/theme/app artifacts"
|
||||
@@ -279,9 +275,8 @@ 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)/thumbnailers/$(THUMBNAILER_FILE)" " $(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"
|
||||
|
||||
uninstall-macos:
|
||||
@rm -f "$(BINDIR)/subminer"
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
[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": "42.6.0",
|
||||
"electron": "43.4.1",
|
||||
"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@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": ["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-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,6 @@
|
||||
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: fixed
|
||||
area: launcher
|
||||
|
||||
- Fixed missing MKV thumbnails in the Linux rofi picker when system thumbnailer registrations only advertise legacy Matroska MIME aliases.
|
||||
@@ -64,6 +64,10 @@ 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:
|
||||
|
||||
@@ -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`, 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.
|
||||
SubMiner verifies AppImage, launcher, and Linux support-asset downloads against `SHA256SUMS.txt`. On Linux those support assets include the launcher-managed runtime plugin copy under `SubMiner/plugin/subminer` plus the rofi theme at `SubMiner/themes/subminer.rasi`. If the binary is in a protected path, SubMiner shows the exact command to run rather than elevating itself.
|
||||
|
||||
The tray "Check for Updates" entry installs the new app automatically on Linux, macOS, and Windows. On Linux it replaces the running `.AppImage` in place via `electron-updater` 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 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.
|
||||
The bundled mpv plugin is injected at runtime automatically - you don't need to install it separately. On Linux, the `subminer` launcher now checks for its managed runtime plugin copy and rofi theme before every mpv-managed launch and installs those support assets from the bundled app automatically if either one is missing. It provides in-player keybindings (the `y` chord) for controlling the overlay from within mpv. See [MPV Plugin](/mpv-plugin) for the full keybinding and configuration reference.
|
||||
|
||||
## Platform Notes
|
||||
|
||||
@@ -456,20 +456,18 @@ sudo chmod +x /usr/local/bin/subminer
|
||||
|
||||
### Linux Support Assets
|
||||
|
||||
SubMiner ships the Linux rofi theme, scoped Matroska thumbnailer registration, and launcher-managed runtime plugin copy in `subminer-assets.tar.gz`:
|
||||
SubMiner ships the Linux rofi theme plus the 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 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.
|
||||
`subminer -u` and the tray updater keep those Linux support assets in sync automatically once the `SubMiner` data dir exists. Normal Linux launcher playback also auto-installs the managed runtime plugin copy and rofi theme from the bundled app if either support asset is missing, so manual extraction is mainly useful for pre-seeding or custom setups.
|
||||
|
||||
Override the theme path with `SUBMINER_ROFI_THEME=/absolute/path/to/theme.rasi`.
|
||||
|
||||
|
||||
@@ -34,22 +34,18 @@ 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, a scoped `ffmpegthumbnailer` MIME registration, and 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 plus 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 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.
|
||||
Once the `SubMiner` data dir exists, `subminer -u` refreshes both assets automatically. Normal Linux launcher playback also checks for the managed runtime plugin copy and rofi theme before mpv launch and installs them from the bundled app automatically if either one is missing.
|
||||
|
||||
The theme is auto-detected from these paths (first match wins):
|
||||
|
||||
|
||||
@@ -109,6 +109,14 @@ 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,6 +155,7 @@ 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,7 +23,6 @@ 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';
|
||||
@@ -334,13 +333,6 @@ 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,7 +2,6 @@ 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';
|
||||
|
||||
@@ -65,13 +64,6 @@ 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,39 +496,3 @@ 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,7 +157,6 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
|
||||
});
|
||||
},
|
||||
chooseTarget,
|
||||
checkPickerDependencies,
|
||||
checkDependencies,
|
||||
registerCleanup,
|
||||
startMpv,
|
||||
@@ -178,7 +177,6 @@ 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;
|
||||
@@ -203,18 +201,7 @@ export async function runPlaybackCommandWithDeps(
|
||||
await deps.ensurePlaybackSetupReady(context);
|
||||
|
||||
if (!args.target) {
|
||||
(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();
|
||||
checkPickerDependencies(args);
|
||||
}
|
||||
|
||||
const targetChoice = await deps.chooseTarget(args, scriptPath);
|
||||
@@ -279,7 +266,7 @@ export async function runPlaybackCommandWithDeps(
|
||||
);
|
||||
}
|
||||
|
||||
await ensureRuntimeAssetsReady();
|
||||
await deps.ensureRuntimePluginReady(context);
|
||||
|
||||
await deps.startMpv(
|
||||
selectedTarget.target,
|
||||
|
||||
@@ -36,11 +36,6 @@ 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.' },
|
||||
],
|
||||
};
|
||||
@@ -57,7 +52,6 @@ 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,10 +21,7 @@ import {
|
||||
parseSha256Sums,
|
||||
type FetchLike,
|
||||
} from '../../src/main/runtime/update/release-assets.js';
|
||||
import {
|
||||
updateSupportAssetsFromRelease,
|
||||
type SupportAssetsUpdateResult,
|
||||
} from '../../src/main/runtime/update/support-assets.js';
|
||||
import { updateSupportAssetsFromRelease } from '../../src/main/runtime/update/support-assets.js';
|
||||
|
||||
type UpdateCommandResponse = {
|
||||
ok: boolean;
|
||||
@@ -39,14 +36,15 @@ 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: DirectSupportAssetsUpdateResult[];
|
||||
supportAssets: Array<{
|
||||
status: string;
|
||||
component?: 'theme' | 'plugin';
|
||||
command?: string;
|
||||
message?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type UpdateCommandDeps = {
|
||||
@@ -131,7 +129,12 @@ function readUpdateChannel(root: Record<string, unknown> | null): UpdateChannel
|
||||
|
||||
function logUpdateResult(
|
||||
label: string,
|
||||
result: DirectSupportAssetsUpdateResult,
|
||||
result: {
|
||||
status: string;
|
||||
component?: 'theme' | 'plugin';
|
||||
command?: string;
|
||||
message?: string;
|
||||
},
|
||||
configuredLogLevel: NonNullable<LauncherCommandContext['args']['logLevel']>,
|
||||
deps: Pick<UpdateCommandDeps, 'log'>,
|
||||
): void {
|
||||
|
||||
@@ -73,21 +73,20 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// (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 support assets and write the response, matching
|
||||
// the fake app install the managed plugin/theme 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" "$data/thumbnailers"
|
||||
mkdir -p "$data/plugin/subminer" "$data/themes"
|
||||
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"
|
||||
|
||||
+1
-46
@@ -3,12 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
findRofiTheme,
|
||||
findRofiThumbnailerDataRoot,
|
||||
formatRofiPrompt,
|
||||
prependXdgDataDir,
|
||||
} from './picker';
|
||||
import { findRofiTheme, formatRofiPrompt } from './picker';
|
||||
|
||||
// ── formatRofiPrompt: spacing between prompt and input field ──────────────────
|
||||
|
||||
@@ -28,7 +23,6 @@ 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 });
|
||||
@@ -127,42 +121,3 @@ 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,9 +159,6 @@ 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,
|
||||
@@ -392,47 +389,6 @@ 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,
|
||||
@@ -464,7 +420,6 @@ 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,8 +1,6 @@
|
||||
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,
|
||||
@@ -33,7 +31,7 @@ test('ensureLinuxRuntimePluginAvailable is a no-op on non-Linux platforms', asyn
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and thumbnailer exist', async () => {
|
||||
test('ensureLinuxRuntimePluginAvailable skips install when installed global plugin and managed theme exist', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
@@ -54,17 +52,13 @@ test('ensureLinuxRuntimePluginAvailable skips install when plugin, theme, and th
|
||||
calls.push('theme');
|
||||
return true;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['detect', 'theme', 'thumbnailer']);
|
||||
assert.deepEqual(calls, ['detect', 'theme']);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable skips install when all managed assets resolve', async () => {
|
||||
test('ensureLinuxRuntimePluginAvailable skips install when managed runtime path and theme already resolve', async () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
@@ -86,19 +80,14 @@ test('ensureLinuxRuntimePluginAvailable skips install when all managed assets re
|
||||
calls.push('theme');
|
||||
return true;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ['detect', 'resolve', 'theme', 'thumbnailer']);
|
||||
assert.deepEqual(calls, ['detect', 'resolve', 'theme']);
|
||||
});
|
||||
|
||||
test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme is missing', async () => {
|
||||
const calls: string[] = [];
|
||||
let themeAvailable = false;
|
||||
|
||||
await ensureLinuxRuntimePluginAvailable({
|
||||
platform: 'linux',
|
||||
@@ -113,15 +102,10 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
|
||||
},
|
||||
isManagedThemeAvailable: () => {
|
||||
calls.push('theme');
|
||||
return themeAvailable;
|
||||
},
|
||||
isManagedThumbnailerAvailable: () => {
|
||||
calls.push('thumbnailer');
|
||||
return true;
|
||||
return false;
|
||||
},
|
||||
installManagedPluginAssets: async () => {
|
||||
calls.push('install');
|
||||
themeAvailable = true;
|
||||
return { ok: true, status: 'installed', path: '/tmp/plugin/main.lua' };
|
||||
},
|
||||
log: (level, _configured, message) => {
|
||||
@@ -133,68 +117,13 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets when rofi theme
|
||||
'detect',
|
||||
'resolve',
|
||||
'theme',
|
||||
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||
'install',
|
||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||
'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;
|
||||
@@ -208,8 +137,6 @@ 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' };
|
||||
@@ -221,9 +148,9 @@ test('ensureLinuxRuntimePluginAvailable installs managed assets and re-resolves
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'resolve:1',
|
||||
'info:Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||
'info:Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||
'install',
|
||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi thumbnailer=/tmp/xdg-data/SubMiner/thumbnailers/subminer-ffmpegthumbnailer.thumbnailer',
|
||||
'info:Managed Linux runtime support assets installed: plugin=/tmp/plugin/main.lua theme=/tmp/xdg-data/SubMiner/themes/subminer.rasi',
|
||||
'resolve:2',
|
||||
]);
|
||||
});
|
||||
@@ -264,60 +191,6 @@ 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,7 +31,6 @@ type EnsureLinuxRuntimePluginAvailableOptions = {
|
||||
detectInstalledPlugin?: () => boolean;
|
||||
resolveRuntimePluginPath?: () => string | null;
|
||||
isManagedThemeAvailable?: () => boolean;
|
||||
isManagedThumbnailerAvailable?: () => boolean;
|
||||
installManagedPluginAssets?: () => Promise<EnsureLinuxRuntimePluginAssetsResult>;
|
||||
log?: PreflightLog;
|
||||
};
|
||||
@@ -49,14 +48,6 @@ 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> {
|
||||
@@ -179,17 +170,15 @@ 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() && isManagedThumbnailerAvailable()) {
|
||||
if (runtimePluginAvailable && isManagedThemeAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log(
|
||||
'info',
|
||||
configuredLogLevel,
|
||||
'Linux runtime support assets missing; installing managed plugin/theme/thumbnailer assets.',
|
||||
'Linux runtime support assets missing; installing managed plugin/theme assets.',
|
||||
);
|
||||
const installManagedPluginAssets =
|
||||
options.installManagedPluginAssets ??
|
||||
@@ -218,21 +207,16 @@ export async function ensureLinuxRuntimePluginAvailable(
|
||||
log(
|
||||
'info',
|
||||
configuredLogLevel,
|
||||
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}`,
|
||||
`Managed Linux runtime support assets installed: plugin=${installResult.path ?? 'unknown path'} theme=${managedPaths.themePath}`,
|
||||
);
|
||||
const runtimePluginAvailableAfterInstall =
|
||||
installedPluginAvailable || Boolean(resolveRuntimePluginPath());
|
||||
if (
|
||||
runtimePluginAvailableAfterInstall &&
|
||||
isManagedThemeAvailable() &&
|
||||
isManagedThumbnailerAvailable()
|
||||
) {
|
||||
const runtimePluginPath = resolveRuntimePluginPath();
|
||||
if (runtimePluginPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const message =
|
||||
`Linux managed runtime plugin assets could not be installed. ` +
|
||||
`Checked paths: plugin=${managedPaths.pluginEntrypointPath} theme=${managedPaths.themePath} thumbnailer=${managedPaths.thumbnailerPath}. ` +
|
||||
`Checked path: ${managedPaths.pluginEntrypointPath}. ` +
|
||||
'Launch aborted before starting mpv.';
|
||||
log('warn', configuredLogLevel, message);
|
||||
throw new Error(message);
|
||||
|
||||
@@ -165,14 +165,11 @@ 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') }));
|
||||
@@ -623,22 +620,11 @@ 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\/thumbnailer assets/i);
|
||||
assert.match(result.stdout, /managed plugin\/theme 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,
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.6.0",
|
||||
"electron": "43.4.1",
|
||||
"electron-builder": "26.15.3",
|
||||
"undici": "7.29.0",
|
||||
"esbuild": "^0.25.12",
|
||||
|
||||
@@ -58,8 +58,6 @@ 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/"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-base-version: 0.19.4 -->
|
||||
|
||||
## 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(stats): add library entry merge and episode move by @ksyasuda in #190
|
||||
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
|
||||
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
|
||||
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
|
||||
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197
|
||||
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
|
||||
- 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
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -82,7 +82,6 @@ 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', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchIfMissing backfills a missing blob from an existing cover URL', async () => {
|
||||
async function backfillMissingCoverBlob(): Promise<void> {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
@@ -103,9 +103,15 @@ test('fetchIfMissing backfills a missing blob from an existing cover URL', async
|
||||
db.close();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('fetchIfMissing reuses cached cover art from another video in the same anime', async () => {
|
||||
test(
|
||||
'fetchIfMissing backfills a missing blob from an existing cover URL',
|
||||
{ timeout: 15_000 },
|
||||
backfillMissingCoverBlob,
|
||||
);
|
||||
|
||||
async function reuseCachedAnimeCoverArt(): Promise<void> {
|
||||
const dbPath = makeDbPath();
|
||||
const db = new Database(dbPath);
|
||||
ensureSchema(db);
|
||||
@@ -179,7 +185,13 @@ test('fetchIfMissing reuses cached cover art from another video in the same anim
|
||||
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,6 +39,7 @@ 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,6 +37,7 @@ export function buildOverlayWindowOptions(
|
||||
paintWhenInitiallyHidden: true,
|
||||
backgroundColor: '#00000000',
|
||||
frame: false,
|
||||
...(platform === 'linux' ? { roundedCorners: false } : {}),
|
||||
alwaysOnTop: shouldStartAlwaysOnTop,
|
||||
skipTaskbar: true,
|
||||
resizable: shouldAllowCompositorResize,
|
||||
|
||||
@@ -67,6 +67,7 @@ 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,6 +57,7 @@ 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,3 +606,93 @@ 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,5 +1,6 @@
|
||||
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';
|
||||
@@ -14,6 +15,8 @@ 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([
|
||||
@@ -53,6 +56,8 @@ type EarlyAppPathOptions = {
|
||||
xdgConfigHome?: string;
|
||||
homeDir?: string;
|
||||
existsSync?: (candidate: string) => boolean;
|
||||
argv?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
function removeLsfgLayer(env: NodeJS.ProcessEnv): void {
|
||||
@@ -252,13 +257,25 @@ export function normalizeStartupArgv(argv: string[], env: NodeJS.ProcessEnv): st
|
||||
}
|
||||
|
||||
export function configureEarlyAppPaths(app: EarlyAppLike, options?: EarlyAppPathOptions): string {
|
||||
const userDataPath = resolveConfigDir({
|
||||
platform: options?.platform ?? process.platform,
|
||||
appDataDir: options?.appDataDir ?? process.env.APPDATA,
|
||||
xdgConfigHome: options?.xdgConfigHome ?? process.env.XDG_CONFIG_HOME,
|
||||
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,
|
||||
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);
|
||||
|
||||
+20
-2
@@ -22,7 +22,10 @@ import {
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
spawnDetachedApp,
|
||||
} from './main-entry-runtime';
|
||||
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
|
||||
import {
|
||||
requestSingleInstanceLockEarly,
|
||||
shouldBypassSingleInstanceLockForArgv,
|
||||
} from './main/early-single-instance';
|
||||
import { readConfiguredWindowsMpvLaunch } from './main-entry-launch-config';
|
||||
import { isAppControlServerAvailable, sendAppControlCommand } from './shared/app-control-client';
|
||||
import {
|
||||
@@ -35,6 +38,7 @@ 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,
|
||||
@@ -192,11 +196,25 @@ registerFatalErrorHandlers({
|
||||
});
|
||||
|
||||
function startMainProcess(): void {
|
||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
||||
// 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);
|
||||
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) {
|
||||
|
||||
+12
-1
@@ -36,6 +36,10 @@ 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 {
|
||||
@@ -764,6 +768,7 @@ 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(),
|
||||
@@ -1432,6 +1437,10 @@ 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: () =>
|
||||
@@ -2523,10 +2532,12 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
|
||||
),
|
||||
getYomitanDictionaryInfo: async () => {
|
||||
await ensureYomitanExtensionLoaded();
|
||||
return await getYomitanDictionaryInfo(getYomitanParserRuntimeDeps(), {
|
||||
const dictionaries = 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()) {
|
||||
|
||||
@@ -138,3 +138,56 @@ 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,6 +31,7 @@ export interface MainBootServicesParams<
|
||||
> {
|
||||
platform: NodeJS.Platform;
|
||||
argv: string[];
|
||||
configDir?: string;
|
||||
appDataDir: string | undefined;
|
||||
xdgConfigHome: string | undefined;
|
||||
homeDir: string;
|
||||
@@ -174,13 +175,15 @@ export function createMainBootServices<
|
||||
TAppState,
|
||||
TAppLifecycleApp
|
||||
> {
|
||||
const configDir = params.resolveConfigDir({
|
||||
platform: params.platform,
|
||||
appDataDir: params.appDataDir,
|
||||
xdgConfigHome: params.xdgConfigHome,
|
||||
homeDir: params.homeDir,
|
||||
existsSync: params.existsSync,
|
||||
});
|
||||
const configDir =
|
||||
params.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');
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
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 };
|
||||
}
|
||||
@@ -8,18 +8,6 @@ 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 {
|
||||
@@ -60,7 +48,6 @@ 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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,7 +79,6 @@ 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 });
|
||||
@@ -108,7 +94,6 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin dir, config, and ro
|
||||
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||
themeSourcePath,
|
||||
thumbnailerSourcePath,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -132,19 +117,6 @@ 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,7 +124,6 @@ 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 });
|
||||
@@ -172,7 +143,6 @@ test('ensureLinuxRuntimePluginAssets installs managed theme when plugin assets a
|
||||
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||
themeSourcePath,
|
||||
thumbnailerSourcePath,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -199,7 +169,6 @@ 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 });
|
||||
@@ -214,7 +183,6 @@ test('ensureLinuxRuntimePluginAssets installs managed theme without resolving pl
|
||||
xdgDataHome,
|
||||
resolveBundledAssets: () => ({
|
||||
themeSourcePath,
|
||||
thumbnailerSourcePath,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -282,7 +250,6 @@ 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',
|
||||
@@ -291,7 +258,6 @@ test('ensureLinuxRuntimePluginAssets installs managed plugin assets without reso
|
||||
resolveBundledAssets: () => ({
|
||||
pluginDirSource: path.join(sourceRoot, 'subminer'),
|
||||
pluginConfigSource: path.join(sourceRoot, 'subminer.conf'),
|
||||
thumbnailerSourcePath,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -366,7 +332,6 @@ 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',
|
||||
@@ -404,7 +369,6 @@ 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 });
|
||||
@@ -421,7 +385,6 @@ 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,7 +10,6 @@ export interface ManagedLinuxRuntimePluginPaths {
|
||||
pluginEntrypointPath: string;
|
||||
pluginConfigPath: string;
|
||||
themePath: string;
|
||||
thumbnailerPath: string;
|
||||
}
|
||||
|
||||
export interface EnsureLinuxRuntimePluginAssetsResult {
|
||||
@@ -24,7 +23,6 @@ interface RuntimePluginAssetSources {
|
||||
pluginDirSource?: string;
|
||||
pluginConfigSource?: string;
|
||||
themeSourcePath?: string;
|
||||
thumbnailerSourcePath?: string;
|
||||
}
|
||||
|
||||
interface RuntimePluginDirentLike {
|
||||
@@ -74,11 +72,6 @@ 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',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,12 +95,11 @@ async function copyDirectoryRecursive(
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBundledAssetPath(options: {
|
||||
function resolveBundledThemePath(options: {
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
resourcesPath: string;
|
||||
existsSync: (candidate: string) => boolean;
|
||||
relativePath: string;
|
||||
}): string | null {
|
||||
const roots = [
|
||||
path.join(options.resourcesPath, 'assets'),
|
||||
@@ -119,7 +111,7 @@ function resolveBundledAssetPath(options: {
|
||||
];
|
||||
|
||||
for (const root of roots) {
|
||||
const candidate = path.join(root, options.relativePath);
|
||||
const candidate = path.join(root, 'themes', 'subminer.rasi');
|
||||
if (options.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
@@ -137,25 +129,16 @@ function resolveBundledAssetsDefault(
|
||||
existsSync,
|
||||
});
|
||||
|
||||
const themeSourcePath = resolveBundledAssetPath({
|
||||
const themeSourcePath = resolveBundledThemePath({
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -195,8 +178,7 @@ export async function ensureLinuxRuntimePluginAssets(
|
||||
const pluginAssetsExist =
|
||||
existsSync(managedPaths.pluginEntrypointPath) && existsSync(managedPaths.pluginConfigPath);
|
||||
const themeExists = existsSync(managedPaths.themePath);
|
||||
const thumbnailerExists = existsSync(managedPaths.thumbnailerPath);
|
||||
if (pluginAssetsExist && themeExists && thumbnailerExists) {
|
||||
if (pluginAssetsExist && themeExists) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 'already-present',
|
||||
@@ -211,7 +193,6 @@ export async function ensureLinuxRuntimePluginAssets(
|
||||
|
||||
const shouldInstallPluginAssets = !pluginAssetsExist;
|
||||
const shouldInstallTheme = !themeExists;
|
||||
const shouldInstallThumbnailer = !thumbnailerExists;
|
||||
if (
|
||||
shouldInstallPluginAssets &&
|
||||
(!bundledAssets.pluginDirSource || !bundledAssets.pluginConfigSource)
|
||||
@@ -229,13 +210,6 @@ 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}`);
|
||||
@@ -247,14 +221,9 @@ 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) {
|
||||
@@ -280,14 +249,6 @@ 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 });
|
||||
@@ -301,11 +262,6 @@ 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,
|
||||
@@ -322,13 +278,9 @@ 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' | 'thumbnailer' | 'plugin';
|
||||
component?: 'theme' | 'plugin';
|
||||
};
|
||||
|
||||
function sha256(data: Buffer): string {
|
||||
@@ -22,30 +22,18 @@ 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(
|
||||
@@ -105,7 +93,7 @@ test('detectSupportAssetDataDirs only returns Linux support-asset locations', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('buildProtectedSupportAssetsCommand installs theme, thumbnailer, and plugin assets', () => {
|
||||
test('buildProtectedSupportAssetsCommand installs both theme and plugin assets', () => {
|
||||
const command = buildProtectedSupportAssetsCommand(
|
||||
"https://example.test/subminer assets.tar.gz?sig='abc'",
|
||||
'ABCDEF1234',
|
||||
@@ -122,28 +110,11 @@ test('buildProtectedSupportAssetsCommand installs theme, thumbnailer, and plugin
|
||||
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/);
|
||||
@@ -238,12 +209,6 @@ 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',
|
||||
@@ -255,13 +220,6 @@ 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',
|
||||
@@ -387,13 +345,8 @@ 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'),
|
||||
@@ -418,12 +371,6 @@ 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',
|
||||
@@ -449,13 +396,8 @@ 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'),
|
||||
@@ -464,7 +406,6 @@ 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' }],
|
||||
@@ -483,12 +424,6 @@ 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',
|
||||
@@ -500,13 +435,6 @@ 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',
|
||||
@@ -551,12 +479,6 @@ test('updateSupportAssetsFromRelease returns protected commands for managed root
|
||||
path: dataDir,
|
||||
command: true,
|
||||
},
|
||||
{
|
||||
status: 'protected',
|
||||
component: 'thumbnailer',
|
||||
path: dataDir,
|
||||
command: true,
|
||||
},
|
||||
{
|
||||
status: 'protected',
|
||||
component: 'plugin',
|
||||
@@ -566,10 +488,6 @@ 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);
|
||||
@@ -604,25 +522,3 @@ 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,17 +9,13 @@ 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' | 'thumbnailer' | 'plugin';
|
||||
component?: 'theme' | 'plugin';
|
||||
path?: string;
|
||||
command?: string;
|
||||
message?: string;
|
||||
@@ -73,12 +69,11 @@ 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, hasThumbnailer, hasPlugin] = await Promise.all([
|
||||
const [hasTheme, 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 || hasThumbnailer || hasPlugin) {
|
||||
if (hasTheme || hasPlugin) {
|
||||
managedDataDirs.push(dataDir);
|
||||
}
|
||||
}
|
||||
@@ -168,14 +163,8 @@ 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}`,
|
||||
@@ -227,12 +216,6 @@ 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',
|
||||
@@ -261,13 +244,6 @@ 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',
|
||||
@@ -308,17 +284,6 @@ 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))) {
|
||||
@@ -363,33 +328,6 @@ 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);
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
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,5 +1,6 @@
|
||||
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;
|
||||
@@ -12,3 +13,48 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user