From db45a70ec2f188de1bf31aa0170b380ff92f85d6 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 1 Oct 2026 01:21:59 -0700 Subject: [PATCH 1/3] chore(release): prepare v0.20.1-beta.1 --- package.json | 2 +- release/prerelease-notes.md | 169 ++++++++++++++---------------------- 2 files changed, 64 insertions(+), 107 deletions(-) diff --git a/package.json b/package.json index 6732f1ba..9b40045c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "subminer", "productName": "SubMiner", "desktopName": "SubMiner.desktop", - "version": "0.20.0", + "version": "0.20.1-beta.1", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "packageManager": "bun@1.3.5", "main": "dist/main-entry.js", diff --git a/release/prerelease-notes.md b/release/prerelease-notes.md index 60889b1f..9555498f 100644 --- a/release/prerelease-notes.md +++ b/release/prerelease-notes.md @@ -1,133 +1,90 @@ > This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release. - - -## Changes since v0.20.0-beta.1 - -- Added an optional subtitle selection modal for primary and secondary mpv subtitle tracks. Enable it in Settings > Behavior, then press `g` followed by `s`. Disabling it restores mpv's subtitle selection binding. - - Single-key shortcut actions now take priority over configured multi-key sequence prefixes, conflicting sequences are disabled with a warning, and existing `y` commands remain reserved. -- Jellyfin casting and playback now honor a configured mpv executable path, allowing playback when mpv is installed outside the system PATH, and portable plugins located beside that executable are now detected. -- Fixed word-card sentence furigana falling out of sync with full stats-search context and expanded timing-review selections; stale furigana is now cleared if regeneration fails. + ## Highlights ### Added -- **Japanese Subtitle Generation**: - - Generate Japanese subtitles locally with whisper.cpp, right from a modal (`Ctrl+Shift+G`), the subtitle sidebar's generation button when no subtitles are loaded, or `subminer generate-subs`, with progress, cancellation, and automatic loading into mpv when it's done. - - Pick and download an official multilingual Whisper model in-app (including smaller quantized variants), or point Settings at one you already have. SubMiner recommends `large-v3-turbo` when CUDA is available and `small` otherwise, and tells you up front if `whisper-cli`, `ffmpeg`, or `ffprobe` can't be found. - - An optional "Focus on spoken dialogue" mode uses a Silero VAD model to keep quiet or music-covered dialogue that would otherwise get dropped. - - Long passages split near natural speech pauses, guided by an existing subtitle track when one is loaded, giving tighter timing and fewer repeated-word glitches. - -- **Media Timing Review Frame Picker**: - - The screenshot used for a mined card can now be chosen independently of the audio clip, with its own live preview, time slider, and frame-by-frame stepping. - - Works for local video and for seekable remote streams like Jellyfin. - -- **Overlay Keybinding Pickup**: The overlay now recognizes your mpv keybindings (from mpv's defaults, `input.conf`, and loaded scripts) as long as they don't conflict with SubMiner's own controls. Picked-up bindings work for the session but won't show up in the help menu. - -- **Subtitle Selection Modal**: - - An optional subtitle selection modal lets you pick primary and secondary mpv subtitle tracks without leaving the overlay. - - Enable it in Settings under Behavior, then trigger it with `g` followed by `s`; turning it off restores mpv's normal subtitle selection binding. - - Single-key shortcuts always take priority over multi-key sequences, and any conflicting sequence is disabled with a warning instead of misbehaving. - -- **Subtitle Sidebar Selection & Copy**: You can now select dialogue across multiple subtitle sidebar rows and copy it, without timestamps, using Ctrl/Cmd+C or the Copy button, without seeking or mining a card. - -- **Jimaku Live Action Search**: The Jimaku modal has separate Anime and Live Action tabs (switch with Arrow Left/Right) so you can search Jimaku's live-action subtitle catalogue directly. - -- **Live-Action TMDB Library**: - - Live-action dramas and movies in the stats Library now get posters, synopses, and titles from TMDB. - - Titles AniList can't match are looked up on TMDB automatically when the parsed filename matches a title exactly; otherwise use the new **Link to TMDB** action. Entries linked to the same TMDB title merge into one card, and the Library kind selector gained a Live Action option. - - Release builds already include a TMDB key; if you run from source, set `tmdb.apiKey` (or `tmdb.apiKeyCommand`) yourself. - -- **YouTube Library Kind**: - - YouTube channels are now their own Library media kind, with new All Titles, Anime, and YouTube filters. Existing channel entries migrate automatically with viewing history and manual video assignments intact. - - Channels stay out of AniList matching, season repair, and duplicate recommendations, and can't be merged or moved into an anime entry. +- **YouTube Browser Window**: + - Open a YouTube browser with `subminer youtube` (or `subminer yt`), or pick **Browse YouTube** from the tray. It stays logged in to YouTube across restarts. + - Clicking a video plays it in mpv instead of on the page. + - Middle-click, Shift-click, Ctrl-click, or the right-click menu adds a video to mpv's playlist. + - When a queued video starts, it gets the same Japanese subtitle setup. + - If you started SubMiner with `subminer youtube`, closing the YouTube window quits SubMiner. If a video is still playing, SubMiner quits after mpv closes. +- **Whisper Subtitles for YouTube**: + - Set `youtube.subtitleSource` to `whisper` to make subtitles with Whisper instead of downloading YouTube's captions. The default is still `youtube`. + - The video stays paused while Whisper works, and progress shows in the subtitle generation modal. Close the modal to keep watching, or cancel to carry on without subtitles. + - The subtitle generation modal (`Ctrl+Shift+G`) now works on YouTube videos with either `youtube.subtitleSource` setting. + - Whisper downloads only a small audio stream and deletes it when generation ends, or when you switch videos, close mpv, or quit. +- **Mouse and Scroll Wheel Bindings**: + - `keybindings` now accepts scroll wheel keys (`WHEEL_UP`, `WHEEL_DOWN`, `WHEEL_LEFT`, `WHEEL_RIGHT`), with or without modifiers. You can also record them in the settings key editor. + - Mouse button and scroll wheel bindings from mpv now work while the cursor is over the overlay. This covers your `input.conf` and mpv's defaults, such as double-click for fullscreen, the wheel for volume, and back/forward for the playlist. + - Hyprland users benefit most: the overlay always takes input there, so these bindings did nothing before. + - SubMiner's own bindings and right-click pause still win over mpv's. +- **Session Help Commands**: + - During playback, press `Enter` or double-click a row in session help to run that command. + - Hovering over a row makes it the target for `Enter`. + - Session help opened from the tray with no video loaded is still read-only. ### Changed -- **Bundled Bun Runtime**: Every SubMiner launcher, installed or downloaded, now runs on the Bun runtime bundled with the app instead of a system-wide Bun install. Recognized legacy launchers migrate automatically, Windows users get a new `subminer.cmd` download, and first-run setup now shows a single optional launcher control with runtime repair guidance only when something actually needs it. - -- **Compressed Incremental Sync**: Cross-machine sync between compatible macOS/Linux machines now transfers only what changed, compressed, using a cached snapshot from the last sync to cut traffic further. Machines without a compatible rsync (including Windows) fall back to compressed scp automatically, older peers keep working, and transfers now time out after 30 minutes instead of hanging indefinitely. - -- **Smaller Install Size**: Installers and the unpacked app are smaller after dropping demo media, source maps, TypeScript sources, test fixtures, and unused binaries, and sharing one Japanese UI font across windows. Release builds now publish a package-size comparison against the previous release. - -- **Stats Server Request Safety**: The stats server, including the in-app stats overlay which now loads through it, only accepts requests from the local machine and requires a JSON content type for anything that changes data. If you were exposing the dashboard through a reverse proxy or Tailscale Serve, that's no longer supported, and any script posting to the stats API needs to send `Content-Type: application/json`. - -- **Yomitan Updated**: Bundled Yomitan is updated to upstream 26.9.8, adding historical Japanese kana transformations and Ukrainian language support, plus improvements to Anki duplicate search and audio retrieval. +- **Release Assets**: Releases no longer include package-size JSON reports. Package contents are still validated before release. ### Fixed -- **Jellyfin**: - - Playback, subtitles, artwork, and remote control now authenticate with an `ApiKey` parameter instead of legacy headers, so Jellyfin 12 works correctly even with legacy authorization disabled. - - "Play on SubMiner" no longer silently drops the connection after about a minute on Jellyfin 12. - - Casting now honors your configured mpv executable path, so playback works and portable plugins are detected correctly when mpv isn't on PATH. - - The "now playing" bar clears when you close or finish a cast video instead of running to the end of the episode. - - Anki cards mined from Jellyfin now get the real episode title in the misc info field instead of "Unknown media". - - Jellyfin streams no longer leak URL-derived titles or credential-bearing URLs into metadata, Anki fields, Discord presence, stats, or AniList lookups; previously cached data that had credentials in it is cleaned up automatically. +- **Jellyfin Subtitles**: + - The Japanese subtitle track is selected, and annotations start, as soon as that track downloads. SubMiner no longer waits for every other track. + - Subtitle tracks now download in parallel, so a slow embedded track no longer holds up the main subtitles. + - Episodes with image-based subtitles (PGS, DVD, DVB) now select the Japanese and English tracks automatically. + - If one subtitle track fails to download, the other tracks are still selected. +- **Jellyfin Audio Track**: On direct-play Jellyfin streams, timing review previews and mined card audio now use the audio track you're playing, not the one Jellyfin marks as default. +- **Media Timing Review**: + - Trim handles are now thin lines centered on the clip edges. The start and end of the clip are easier to see, and less of the waveform is hidden. + - The original subtitle boundaries are now plain orange bars without text labels, so the waveform stays visible. + - On macOS with Bluetooth headphones such as AirPods, the preview cursor now moves smoothly. Before, it stuttered and jumped to the end partway through. This affected Opus audio, including Jellyfin streams. +- **Hyprland Fullscreen**: + - The subtitle overlay now stays visible when mpv enters or leaves fullscreen, so focus changes no longer cancel fullscreen. Sway still refreshes the overlay as before. + - With `mpv.backend: x11`, subtitles and the sidebar no longer stop responding to clicks after mpv goes fullscreen. +- **mpv Key Bindings in the Overlay**: + - mpv key bindings, such as `9`/`0` for volume, now work while the overlay has focus. + - They used to do nothing if the overlay loaded before SubMiner connected to mpv, which was common with `mpv.backend: x11`. The overlay now reloads mpv's bindings once mpv connects. +- **YouTube Japanese Subtitles**: + - Some videos also list a machine-translated Japanese track. On those, Japanese subtitles no longer fail with "HTTP 429", because SubMiner now picks the real Japanese track. + - If YouTube refuses a direct subtitle download, SubMiner retries with yt-dlp. +- **AppImage in Sandboxes**: The AppImage now opens its window when started in a sandbox that mounts the image itself, such as `firejail --appimage`. Before, it exited without opening a window. +- **Ctrl+C Crash**: Stopping the terminal command that started SubMiner with Ctrl+C no longer crashes SubMiner with a "write EPIPE" error. +- **Security Updates**: Electron is updated to 42.10.0 and undici to 7.29.1 to fix known security advisories. -- **Anki & Mining**: - - Word audio now reads from its own configured field (`ankiConnect.fields.wordAudio`) instead of the sentence-audio field, fixing animated word images that started moving immediately instead of on demand. - - Setting `ankiConnect.media.maxMediaDuration` to `0` for unlimited duration now also applies when mining from the stats dashboard, matching overlay mining. - - Closing the overlay while a media timing review is still loading now properly cancels setup, restores playback if the review had paused it, and cleans up the hidden preview player. - - Word-card sentence furigana now stays in sync with the full stats-search context and expanded timing-review selections, and clears stale readings automatically if regeneration fails. +### Docs -- **Settings**: - - AnkiConnect, Kiku, and Senren settings are now validated before use, with a warning and a safe default for anything invalid instead of a bad value reaching runtime. - - Settings marked as applying live now correctly avoid showing a restart warning, and mixed saves apply the live parts immediately while listing only the sections that actually need a restart. - -- **Overlay**: - - Clicking a subtitle sidebar cue no longer leaves Space bound to seeking back to it; Enter still seeks the focused cue, and Space keeps whatever playback action you've configured. - - Hyprland recovery dialogs now stay above SubMiner windows instead of being covered by overlay placement updates. - - Fixed a rare case on Linux where a delayed window-close callback could reopen the overlay after it was torn down. - -- **Stats**: - - Malformed or partly invalid resource IDs are now rejected before they can affect library mutations or cover-art backfills. - - Stats server port conflicts now surface as a status notification instead of crashing SubMiner, and startup/shutdown are more robust: concurrent startup requests share one attempt, stopping a background instance no longer disconnects an open dashboard, and shutdown no longer waits indefinitely on active requests. - -- **Subtitle Sidebar Gap Follow**: The subtitle sidebar now stays near actual playback position during gaps in files where a cue starts at time zero. - -- **First Launch on macOS**: Fixed first launch exiting immediately when the SubMiner config directory didn't exist yet. +- **Docs Site Rewrite**: + - Pages are shorter and easier to scan. Each one starts with setup and usage, and reference details are in compact tables. + - The configuration reference now gives each config block a short explanation and a table of keys and defaults. + - Pages that no longer matched how SubMiner works have been corrected. +- **Arch Linux Install**: + - The MeCab install command is fixed. MeCab is only in the AUR, as `mecab-git`. + - The README's requirements and quick start now match the installation guide. ## What's Changed -- feat(sidebar): add dialogue selection and copying by @ksyasuda in #238 -- feat(subtitles): add local Japanese subtitle generation by @ksyasuda in #240 -- perf(stats): use compressed incremental snapshot transfers by @ksyasuda in #241 -- fix(startup): create config directory before singleton lock by @ksyasuda in #242 -- feat(launcher): bundle Bun and use it across all launchers by @ksyasuda in #243 -- build(release): reduce package size and report release sizes by @ksyasuda in #244 -- fix(overlay): keep Hyprland recovery dialogs above overlays by @ksyasuda in #245 -- feat(overlay): discover unclaimed mpv key bindings by @ksyasuda in #246 -- fix(sidebar): preserve Space playback after cue seeking by @ksyasuda in #247 -- fix(jellyfin): fix jellyfin media metadata by @ksyasuda in #250 -- feat(jimaku): add live-action subtitle search by @ksyasuda in #251 -- feat(stats): add TMDB metadata for live-action dramas in the Library by @ksyasuda in #252 -- feat(stats): separate YouTube channels in the Library by @ksyasuda in #253 -- feat(mining): add a screenshot frame picker to media review by @aalhendi in #254 -- fix(config): align live save feedback with hot reload policy by @ksyasuda in #255 -- fix(anki): separate word audio mapping for animation sync by @ksyasuda in #256 -- fix(config): validate AnkiConnect and field grouping settings by @ksyasuda in #257 -- fix(anki): honor unlimited duration in stats mining by @ksyasuda in #258 -- fix(stats): reject malformed resource IDs before mutations by @ksyasuda in #259 -- fix(stats): harden server lifecycle and verify compiled runtime by @ksyasuda in #261 -- fix(overlay): cancel pending window transitions and timing reviews by @ksyasuda in #262 -- fix(stats): restrict local requests and serve the dashboard over HTTP by @ksyasuda in #263 -- fix(jellyfin): support modern authentication by @ksyasuda in #264 -- feat(overlay): add optional subtitle selection modal by @ksyasuda in #265 -- fix(jellyfin): respect Windows mpv configuration when casting by @aalhendi in #267 -- fix(anki): regenerate sentence furigana from the final sentence by @ksyasuda in #268 - -## New Contributors - -- @aalhendi made their first contribution in #254 +- fix(jellyfin): select subtitles faster and survive failed track downloads by @ksyasuda in #270 +- fix(jellyfin): use the current audio track for media generation by @ksyasuda in #271 +- feat(youtube): add YouTube browser window and Whisper subtitle source by @ksyasuda in #273 +- fix(overlay): thin centered trim handles and label-free subtitle boundaries in timing review by @ksyasuda in #274 +- feat(overlay): run session help commands with Enter or double-click by @ksyasuda in #275 +- fix(overlay): keep overlay mapped during Hyprland/Sway fullscreen by @ksyasuda in #276 +- fix(linux): skip AppImage mount keepalive on sandbox squashfs mounts by @ksyasuda in #277 +- fix(overlay): reload imported mpv keys after mpv connects by @ksyasuda in #278 +- feat(overlay): forward mpv mouse button and wheel bindings by @ksyasuda in #279 ## Installation -See the README and docs/installation guide for full setup steps. +See the [installation guide](https://docs.subminer.moe/installation) for full setup steps. ## Assets -- Linux: `SubMiner.AppImage` +- Linux: `SubMiner-*.AppImage` - macOS: `SubMiner-*.dmg` and `SubMiner-*.zip` - Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip` - Optional extras: `subminer-assets.tar.gz`, the `subminer` launcher, and the Windows `subminer.cmd` launcher From 15cb9fe91843353d93ef3515951a4ac7540cfe49 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 1 Oct 2026 01:42:10 -0700 Subject: [PATCH 2/3] test(launcher): allow Windows bootstrap integration startup time Cold and cached launches each have a 15-second command timeout. Give the integration test time for both launches and executable copies instead of the default five-second budget on hosted Windows runners. --- .../windows-launcher-bootstrap.test.ts | 121 ++++++++++-------- 1 file changed, 66 insertions(+), 55 deletions(-) diff --git a/src/main/runtime/windows-launcher-bootstrap.test.ts b/src/main/runtime/windows-launcher-bootstrap.test.ts index 5f17c30c..6cd1a918 100644 --- a/src/main/runtime/windows-launcher-bootstrap.test.ts +++ b/src/main/runtime/windows-launcher-bootstrap.test.ts @@ -68,61 +68,72 @@ test('preparation failures keep their exit code and never continue to the launch assert.match(content, /set "ELECTRON_RUN_AS_NODE="/); }); -test('Windows bootstrap prepares once and forwards metacharacter arguments', async (t) => { - if (process.platform !== 'win32') return; +// Cold and cached launches each allow 15 seconds, plus executable copies during setup. +test( + 'Windows bootstrap prepares once and forwards metacharacter arguments', + { timeout: 45_000 }, + async (t) => { + if (process.platform !== 'win32') return; - const root = workspace(t); - const appDirectory = path.join(root, 'Installed & App 100% !'); - const appPath = path.join(appDirectory, 'SubMiner.exe'); - const resourcesPath = path.join(appDirectory, 'resources'); - const launcherDirectory = path.join(resourcesPath, 'launcher'); - const localAppData = path.join(root, 'Local App Data'); - const bootstrapPath = path.join(root, 'subminer.cmd'); - const version = '1.2.3-test'; - const cachedBunPath = path.join(localAppData, 'SubMiner', 'launcher-runtime', version, 'bun.exe'); - fs.mkdirSync(launcherDirectory, { recursive: true }); - fs.copyFileSync(process.execPath, appPath); - fs.writeFileSync(path.join(launcherDirectory, 'version'), version); - fs.writeFileSync( - path.join(launcherDirectory, 'subminer.js'), - 'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,resources:process.env.SUBMINER_RESOURCES_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));', - ); - fs.writeFileSync( - path.join(launcherDirectory, 'prepare.cjs'), - `const fs=require('node:fs');const path=require('node:path');exports.prepareLauncherRuntime=()=>{const target=${JSON.stringify(cachedBunPath)};fs.mkdirSync(path.dirname(target),{recursive:true});fs.copyFileSync(process.execPath,target);};`, - ); - fs.writeFileSync(bootstrapPath, windowsLauncherBootstrapContent(appPath)); + const root = workspace(t); + const appDirectory = path.join(root, 'Installed & App 100% !'); + const appPath = path.join(appDirectory, 'SubMiner.exe'); + const resourcesPath = path.join(appDirectory, 'resources'); + const launcherDirectory = path.join(resourcesPath, 'launcher'); + const localAppData = path.join(root, 'Local App Data'); + const bootstrapPath = path.join(root, 'subminer.cmd'); + const version = '1.2.3-test'; + const cachedBunPath = path.join( + localAppData, + 'SubMiner', + 'launcher-runtime', + version, + 'bun.exe', + ); + fs.mkdirSync(launcherDirectory, { recursive: true }); + fs.copyFileSync(process.execPath, appPath); + fs.writeFileSync(path.join(launcherDirectory, 'version'), version); + fs.writeFileSync( + path.join(launcherDirectory, 'subminer.js'), + 'console.log(JSON.stringify({args:process.argv.slice(2),app:process.env.SUBMINER_BINARY_PATH,resources:process.env.SUBMINER_RESOURCES_PATH,managed:process.env.SUBMINER_MANAGED_LAUNCHER}));', + ); + fs.writeFileSync( + path.join(launcherDirectory, 'prepare.cjs'), + `const fs=require('node:fs');const path=require('node:path');exports.prepareLauncherRuntime=()=>{const target=${JSON.stringify(cachedBunPath)};fs.mkdirSync(path.dirname(target),{recursive:true});fs.copyFileSync(process.execPath,target);};`, + ); + fs.writeFileSync(bootstrapPath, windowsLauncherBootstrapContent(appPath)); - const args = [ - 'spaces here', - '100%', - 'p%TEMP%q', - 'bang!', - 'a&b', - 'x|y', - '', - 'caret^', - 'say "hi"', - '日本語', - ]; - const env = { ...process.env, PATH: '', Path: '', LOCALAPPDATA: localAppData }; - const first = await getRunCommand({})(bootstrapPath, args, { env }); - assert.equal(first.exitCode, 0, first.stderr); - assert.ok(fs.existsSync(cachedBunPath)); - const firstPayload: unknown = JSON.parse(first.stdout); - assert.deepEqual(firstPayload, { - args, - app: appPath, - resources: resourcesPath, - managed: '1', - }); + const args = [ + 'spaces here', + '100%', + 'p%TEMP%q', + 'bang!', + 'a&b', + 'x|y', + '', + 'caret^', + 'say "hi"', + '日本語', + ]; + const env = { ...process.env, PATH: '', Path: '', LOCALAPPDATA: localAppData }; + const first = await getRunCommand({})(bootstrapPath, args, { env }); + assert.equal(first.exitCode, 0, first.stderr); + assert.ok(fs.existsSync(cachedBunPath)); + const firstPayload: unknown = JSON.parse(first.stdout); + assert.deepEqual(firstPayload, { + args, + app: appPath, + resources: resourcesPath, + managed: '1', + }); - fs.writeFileSync( - path.join(launcherDirectory, 'prepare.cjs'), - "throw new Error('cached launch should not prepare');", - ); - const second = await getRunCommand({})(bootstrapPath, args, { env }); - assert.equal(second.exitCode, 0, second.stderr); - const secondPayload: unknown = JSON.parse(second.stdout); - assert.deepEqual(secondPayload, firstPayload); -}); + fs.writeFileSync( + path.join(launcherDirectory, 'prepare.cjs'), + "throw new Error('cached launch should not prepare');", + ); + const second = await getRunCommand({})(bootstrapPath, args, { env }); + assert.equal(second.exitCode, 0, second.stderr); + const secondPayload: unknown = JSON.parse(second.stdout); + assert.deepEqual(secondPayload, firstPayload); + }, +); From a5d0b63792bdd105a925a8ac534d819497748cc4 Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 1 Oct 2026 21:44:53 -0700 Subject: [PATCH 3/3] fix(storage): protect Yomitan profiles across Electron runtime changes (#211) --- bun.lock | 4 +- changes/electron43-profile-storage-safety.md | 6 + docs-site/development.md | 4 + docs-site/troubleshooting.md | 10 + docs-site/usage.md | 4 + package.json | 2 +- .../anilist/cover-art-fetcher.test.ts | 20 +- .../services/overlay-window-config.test.ts | 1 + src/core/services/overlay-window-options.ts | 1 + src/core/services/stats-window-runtime.ts | 1 + src/core/services/stats-window.test.ts | 1 + src/main-entry-runtime.test.ts | 113 ++++++++++ src/main-entry-runtime.ts | 25 ++- src/main-entry.ts | 22 +- src/main.ts | 13 +- src/main/boot/services.test.ts | 53 +++++ src/main/boot/services.ts | 17 +- src/main/electron-runtime-guard.test.ts | 196 ++++++++++++++++++ src/main/electron-runtime-guard.ts | 171 +++++++++++++++ .../yomitan-dictionary-integrity.test.ts | 133 ++++++++++++ .../runtime/yomitan-dictionary-integrity.ts | 112 ++++++++++ src/shared/fs-utils.ts | 46 ++++ 22 files changed, 934 insertions(+), 21 deletions(-) create mode 100644 changes/electron43-profile-storage-safety.md create mode 100644 src/main/electron-runtime-guard.test.ts create mode 100644 src/main/electron-runtime-guard.ts create mode 100644 src/main/runtime/yomitan-dictionary-integrity.test.ts create mode 100644 src/main/runtime/yomitan-dictionary-integrity.ts diff --git a/bun.lock b/bun.lock index 6a72408b..828ba640 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ "@electron/asar": "3.4.1", "@types/node": "^24.10.0", "@types/ws": "^8.18.1", - "electron": "42.10.0", + "electron": "43.7.7", "electron-builder": "26.16.1", "esbuild": "^0.25.12", "eslint": "^10.8.0", @@ -347,7 +347,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron": ["electron@42.10.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-yBWnZO8tKId0ADvGa4+imPmm3+8Kfs8MtkUPhQQJz1AjTnUUDwCTWkQEU0CrArVVL97Jo4Q/0mVcLHciicBDGQ=="], + "electron": ["electron@43.7.7", "", { "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-y0pg+KhWLAkW2Ts2njkFFNFl3KtB1FmbUp9Te9i+R2Og2v1fcI/I6MADJ7jvnzB5UKdt+U+0EUoCnVL79xMPMA=="], "electron-builder": ["electron-builder@26.16.1", "", { "dependencies": { "app-builder-lib": "26.16.1", "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.16.1", "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-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg=="], diff --git a/changes/electron43-profile-storage-safety.md b/changes/electron43-profile-storage-safety.md new file mode 100644 index 00000000..b81570ae --- /dev/null +++ b/changes/electron43-profile-storage-safety.md @@ -0,0 +1,6 @@ +type: fixed +area: dictionary + +- Upgraded the desktop runtime to Electron 43.7.7 and added profile guards that block unsupported runtimes and Electron major or minor downgrades before Yomitan storage is loaded. Patch downgrades within the same major and minor version are allowed. +- 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. diff --git a/docs-site/development.md b/docs-site/development.md index 87f7538b..0f90b7a8 100644 --- a/docs-site/development.md +++ b/docs-site/development.md @@ -47,6 +47,10 @@ electron . --start --dev --log-level debug # verbose launch of an existing bui electron . --background # tray/background mode ``` +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. + +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. + To test through the mpv plugin without exporting `SUBMINER_BINARY_PATH` each time, point `mpv.subminerBinaryPath` in your config at the dev script. The launcher passes it to the plugin at runtime: ```json diff --git a/docs-site/troubleshooting.md b/docs-site/troubleshooting.md index aefd9c1a..514e4e50 100644 --- a/docs-site/troubleshooting.md +++ b/docs-site/troubleshooting.md @@ -67,6 +67,16 @@ If you have not set up dictionaries yet, start with [Yomitan setup](/usage#yomit Word boundaries come from Yomitan's parser. Some splits will be wrong, since Japanese has no spaces. +## "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 major or minor version. Patch downgrades within the same major and minor version are allowed, such as `43.7.3` to `43.7.2`. Downgrades from `43.7.x` to `43.6.x`, or from Electron 43 to 42, are blocked. Launch the packaged app or use the repository's `bun run dev` command. Do not delete the runtime safety record to force an older major or minor version to open the profile. + +Older SubMiner builds without this guard cannot enforce the safety record. When testing Electron 43 before returning to an older Electron 42 build, use the separate `SubMiner-dev` profile described in [development](/development#run-locally) so the older build never opens the upgraded 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 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" The bundled Yomitan is missing. Re-download the AppImage, or place an unpacked Yomitan extension in `~/.config/SubMiner/yomitan`. Source builds must run `bun run build` first to produce `build/yomitan`. diff --git a/docs-site/usage.md b/docs-site/usage.md index 3c812fdc..b1500385 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -145,6 +145,10 @@ Press `Alt+C` to choose a controller and remap buttons. Click an action's **Lear On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`. Remap it with `Alt+C`. All options are in [Configuration](/configuration#controller-support). +## Development profiles + +`--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. See [Development](/development#run-locally). + ## Changing settings while you watch SubMiner watches your config file and applies most changes without a restart, including subtitle style, keybindings, and most Anki settings. If a change needs a restart, SubMiner tells you. If the file has an error, it keeps the last working config and shows a notification. See [Configuration](/configuration). diff --git a/package.json b/package.json index 9b40045c..6d97ec9c 100644 --- a/package.json +++ b/package.json @@ -128,7 +128,7 @@ "@electron/asar": "3.4.1", "@types/node": "^24.10.0", "@types/ws": "^8.18.1", - "electron": "42.10.0", + "electron": "43.7.7", "electron-builder": "26.16.1", "esbuild": "^0.25.12", "eslint": "^10.8.0", diff --git a/src/core/services/anilist/cover-art-fetcher.test.ts b/src/core/services/anilist/cover-art-fetcher.test.ts index 6851ad0a..a23ed18c 100644 --- a/src/core/services/anilist/cover-art-fetcher.test.ts +++ b/src/core/services/anilist/cover-art-fetcher.test.ts @@ -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 { 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 { 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), { diff --git a/src/core/services/overlay-window-config.test.ts b/src/core/services/overlay-window-config.test.ts index d0f6c2e8..e0ad3cc8 100644 --- a/src/core/services/overlay-window-config.test.ts +++ b/src/core/services/overlay-window-config.test.ts @@ -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', () => { diff --git a/src/core/services/overlay-window-options.ts b/src/core/services/overlay-window-options.ts index 2757fd5b..be58f908 100644 --- a/src/core/services/overlay-window-options.ts +++ b/src/core/services/overlay-window-options.ts @@ -37,6 +37,7 @@ export function buildOverlayWindowOptions( paintWhenInitiallyHidden: true, backgroundColor: '#00000000', frame: false, + ...(platform === 'linux' ? { roundedCorners: false } : {}), alwaysOnTop: shouldStartAlwaysOnTop, skipTaskbar: true, resizable: shouldAllowCompositorResize, diff --git a/src/core/services/stats-window-runtime.ts b/src/core/services/stats-window-runtime.ts index 82ef0fc6..db4a52cf 100644 --- a/src/core/services/stats-window-runtime.ts +++ b/src/core/services/stats-window-runtime.ts @@ -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, diff --git a/src/core/services/stats-window.test.ts b/src/core/services/stats-window.test.ts index da1776bb..ed123482 100644 --- a/src/core/services/stats-window.test.ts +++ b/src/core/services/stats-window.test.ts @@ -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', () => { diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index a6dcff24..bfde0ba1 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -640,3 +640,116 @@ test('configureEarlyAppPaths creates a fresh macOS config directory before Elect fs.rmSync(homeDir, { recursive: true, force: true }); } }); + +test('configureEarlyAppPaths isolates development runs from the production profile', () => { + const calls: string[] = []; + const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-dev-profile-')); + const devProfileDir = path.posix.join(xdgConfigHome, 'SubMiner-dev'); + try { + const userDataPath = configureEarlyAppPaths( + { + setName: (name) => calls.push(`name:${name}`), + setPath: (key, value) => calls.push(`path:${key}:${value}`), + }, + { + platform: 'linux', + homeDir: xdgConfigHome, + xdgConfigHome, + existsSync: () => false, + argv: ['electron', '.', '--start', '--dev'], + env: {}, + }, + ); + + assert.equal(userDataPath, devProfileDir); + assert.deepEqual(calls, ['name:SubMiner', `path:userData:${devProfileDir}`]); + } finally { + fs.rmSync(xdgConfigHome, { recursive: true, force: true }); + } +}); + +test('configureEarlyAppPaths ignores development flags forwarded to mpv', () => { + const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-mpv-flags-')); + const configDir = path.posix.join(xdgConfigHome, 'SubMiner'); + try { + for (const forwardedFlag of ['--dev', '--debug']) { + let selectedPath = ''; + const userDataPath = configureEarlyAppPaths( + { + setName: () => {}, + setPath: (_key, value) => { + selectedPath = value; + }, + }, + { + platform: 'linux', + homeDir: xdgConfigHome, + xdgConfigHome, + existsSync: () => false, + argv: ['electron', '.', '--launch-mpv', forwardedFlag], + env: {}, + }, + ); + + assert.equal(userDataPath, configDir); + assert.equal(selectedPath, configDir); + } + } finally { + fs.rmSync(xdgConfigHome, { recursive: true, force: true }); + } +}); + +test('configureEarlyAppPaths uses the supplied environment for config discovery', () => { + const paths: string[] = []; + const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-injected-env-')); + const configDir = path.posix.join(xdgConfigHome, 'SubMiner'); + try { + const userDataPath = configureEarlyAppPaths( + { + setName: () => {}, + setPath: (_key, value) => paths.push(value), + }, + { + platform: 'linux', + homeDir: path.posix.join(xdgConfigHome, 'home'), + existsSync: () => false, + argv: ['electron', '.', '--start'], + env: { XDG_CONFIG_HOME: xdgConfigHome }, + }, + ); + + assert.equal(userDataPath, configDir); + assert.deepEqual(paths, [configDir]); + } finally { + fs.rmSync(xdgConfigHome, { recursive: true, force: true }); + } +}); + +test('configureEarlyAppPaths allows an explicit production-profile development run', () => { + const paths: string[] = []; + const configBaseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-entry-prod-profile-')); + // Use the host platform so the selected directory is a real path mkdirSync can create. + const configDir = path.join(configBaseDir, 'SubMiner'); + try { + const userDataPath = configureEarlyAppPaths( + { + setName: () => {}, + setPath: (_key, value) => paths.push(value), + }, + { + platform: process.platform, + appDataDir: configBaseDir, + xdgConfigHome: configBaseDir, + homeDir: configBaseDir, + existsSync: () => false, + argv: ['electron', '.', '--debug'], + env: { SUBMINER_USE_PRODUCTION_PROFILE: '1' }, + }, + ); + + assert.equal(userDataPath, configDir); + assert.deepEqual(paths, [configDir]); + } finally { + fs.rmSync(configBaseDir, { recursive: true, force: true }); + } +}); diff --git a/src/main-entry-runtime.ts b/src/main-entry-runtime.ts index f86e5016..29c9c166 100644 --- a/src/main-entry-runtime.ts +++ b/src/main-entry-runtime.ts @@ -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; // The entry process requests its singleton lock before main-process config bootstrap. fs.mkdirSync(userDataPath, { recursive: true }); diff --git a/src/main-entry.ts b/src/main-entry.ts index 09a9cbe7..c3704bc0 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -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) { diff --git a/src/main.ts b/src/main.ts index 61bd61c9..44035cb3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,6 +38,10 @@ import { readMpvInputBindings } from './main/runtime/mpv-input-bindings'; 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 { @@ -772,6 +776,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(), @@ -1480,6 +1485,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: () => @@ -2627,10 +2636,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()) { diff --git a/src/main/boot/services.test.ts b/src/main/boot/services.test.ts index 3725aa6f..4f6ba5ad 100644 --- a/src/main/boot/services.test.ts +++ b/src/main/boot/services.test.ts @@ -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' }); +}); diff --git a/src/main/boot/services.ts b/src/main/boot/services.ts index b29753ee..ef6014ab 100644 --- a/src/main/boot/services.ts +++ b/src/main/boot/services.ts @@ -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'); diff --git a/src/main/electron-runtime-guard.test.ts b/src/main/electron-runtime-guard.test.ts new file mode 100644 index 00000000..b069dc4f --- /dev/null +++ b/src/main/electron-runtime-guard.test.ts @@ -0,0 +1,196 @@ +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 minor downgrade even when the patch is higher', () => { + 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.99', + 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 allows a patch downgrade while preserving minor downgrade protection', () => { + withTempDir((userDataPath) => { + const statePath = path.join(userDataPath, 'electron-runtime.json'); + fs.writeFileSync( + statePath, + JSON.stringify({ highestElectronMajor: 43, lastElectronVersion: '43.7.3' }), + 'utf8', + ); + + const result = enforceElectronRuntimeGuard({ + electronVersion: '43.7.2', + userDataPath, + }); + + assert.equal(result.ok, true); + const updatedState = fs.readFileSync(statePath, 'utf8'); + assert.deepEqual(JSON.parse(updatedState), { + highestElectronMajor: 43, + lastElectronVersion: '43.7.2', + }); + + const minorDowngrade = enforceElectronRuntimeGuard({ + electronVersion: '43.6.99', + userDataPath, + }); + assert.equal(minorDowngrade.ok, false); + if (minorDowngrade.ok) return; + assert.equal(minorDowngrade.title, 'Electron downgrade blocked'); + assert.equal(fs.readFileSync(statePath, 'utf8'), updatedState); + }); +}); + +test('runtime guard blocks an Electron 42 build from reopening an Electron 43 profile', () => { + withTempDir((userDataPath) => { + const statePath = path.join(userDataPath, 'electron-runtime.json'); + const previousState = JSON.stringify({ + highestElectronMajor: 43, + lastElectronVersion: '43.7.2', + }); + fs.writeFileSync(statePath, previousState, 'utf8'); + + const result = enforceElectronRuntimeGuard({ + electronVersion: '42.11.8', + userDataPath, + supportedElectronMajor: 42, + }); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.title, 'Electron downgrade blocked'); + assert.equal(fs.readFileSync(statePath, 'utf8'), previousState); + }); +}); + +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/); + }); +}); diff --git a/src/main/electron-runtime-guard.ts b/src/main/electron-runtime-guard.ts new file mode 100644 index 00000000..5d2a26a5 --- /dev/null +++ b/src/main/electron-runtime-guard.ts @@ -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 compareElectronMajorMinorVersions( + left: ParsedElectronVersion, + right: ParsedElectronVersion, +): number { + return left.major - right.major || left.minor - right.minor; +} + +function readRuntimeState(statePath: string): ValidatedElectronRuntimeState | null { + if (!fs.existsSync(statePath)) return null; + + const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as Partial; + 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 || + compareElectronMajorMinorVersions(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 major or minor version can destroy Yomitan dictionaries. Upgrade SubMiner before using this profile. Patch downgrades within the same major and minor version are allowed.', + ].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 }; +} diff --git a/src/main/runtime/yomitan-dictionary-integrity.test.ts b/src/main/runtime/yomitan-dictionary-integrity.test.ts new file mode 100644 index 00000000..35a7874f --- /dev/null +++ b/src/main/runtime/yomitan-dictionary-integrity.test.ts @@ -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; + }, + ); + }); +}); diff --git a/src/main/runtime/yomitan-dictionary-integrity.ts b/src/main/runtime/yomitan-dictionary-integrity.ts new file mode 100644 index 00000000..26099c88 --- /dev/null +++ b/src/main/runtime/yomitan-dictionary-integrity.ts @@ -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; + 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); + } +} diff --git a/src/shared/fs-utils.ts b/src/shared/fs-utils.ts index 21c18891..de6028ee 100644 --- a/src/shared/fs-utils.ts +++ b/src/shared/fs-utils.ts @@ -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; + } +}