Compare commits

..
Author SHA1 Message Date
sudacode 727be5683a fix(youtube): keep mpv queue when advancing to a queued video on Windows (#281) 2026-10-02 22:13:35 -07:00
sudacode 138bfe9759 fix(subtitles): allow Whisper generation during Windows YouTube stream playback (#282) 2026-10-02 22:13:15 -07:00
sudacode 0aba17d95b chore(release): generate concise release notes per feature or area
- Rewrite polish prompt to default to inline items, nesting only for several distinct changes
- Add length/density example and drop internal details from release bodies
- Tell prerelease mode to shorten long baseline bullets
- Update changes/README.md and prompt assertions in tests
2026-10-02 19:52:16 -07:00
sudacode a5d0b63792 fix(storage): protect Yomitan profiles across Electron runtime changes (#211) 2026-10-01 21:44:53 -07:00
34 changed files with 1297 additions and 69 deletions

No files matched your search

+2 -2
View File
@@ -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=="],
+1 -1
View File
@@ -42,7 +42,7 @@ How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets.
- `CHANGELOG.md`, GitHub release notes, and prerelease notes all use one item per feature or area, inline by default: `- **Name**: one or two short sentences.` Items with several separate changes may use a few short nested bullets instead of a run-on sentence. Items say what changed and how to reach it; implementation details, edge cases, and step-by-step usage belong in the docs, not the release body.
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
+4
View File
@@ -0,0 +1,4 @@
type: internal
area: release
- Release notes, prerelease notes, and `CHANGELOG.md` are now generated as short items per feature or area, nesting only when an item has several separate changes.
@@ -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.
@@ -0,0 +1,4 @@
type: fixed
area: youtube
- The subtitle generation modal now works during Windows YouTube playback, where mpv plays a resolved stream URL instead of the YouTube page; it no longer asks you to open a local file or YouTube video, and generated subtitles load into the playing video.
@@ -0,0 +1,4 @@
type: fixed
area: youtube
- Fixed Windows YouTube playback dropping the rest of mpv's queue when it advanced to a video queued from the YouTube browser window. The direct stream now replaces only that playlist entry.
+4
View File
@@ -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
+10
View File
@@ -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`.
+4
View File
@@ -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).
+1 -1
View File
@@ -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",
+12 -17
View File
@@ -44,20 +44,12 @@ function fragmentTypesInPrompt(input: string): string[] {
.map((line) => line.slice('type: '.length).trim());
}
function assertPromptRequestsNestedBullets(input: string): void {
assert.match(input, /In both modes, split every item into one nested bullet per distinct change/);
assert.match(input, /Never stack several distinct changes into one long paragraph-shaped bullet/);
assert.match(input, /Keep nested bullets short, concrete, and readable by non-technical users/);
assert.match(input, /Avoid paragraph-style release-note bullets/);
}
function assertReleaseNotesPromptRequestsNestedBullets(input: string): void {
assertPromptRequestsNestedBullets(input);
assert.match(
input,
/In MODE: release-notes, nested bullets should also cover user benefit and any user action/,
);
assert.match(input, /Do not require the exact nested labels/);
function assertPromptRequestsConciseItems(input: string): void {
assert.match(input, /Default to inline/);
assert.match(input, /Use nested bullets only when an item has several distinct changes/);
assert.match(input, /Never write paragraph-length bullets/);
assert.match(input, /Merge aggressively: one item per feature or area/);
assert.match(input, /Release notes summarize; the docs hold the details/);
}
function defaultPolishedBody(input: string): string {
@@ -454,14 +446,13 @@ test('writeChangelogArtifacts prompts Claude to summarize the final stable outco
prompt,
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
);
assertPromptRequestsNestedBullets(prompt);
assertPromptRequestsConciseItems(prompt);
}
const releaseNotesPrompt = stub.calls.find(
(call) => modeFromPrompt(call.input) === 'release-notes',
);
assert.ok(releaseNotesPrompt, 'expected a release-notes Claude invocation');
assertReleaseNotesPromptRequestsNestedBullets(releaseNotesPrompt.input);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
@@ -817,7 +808,11 @@ test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease b
prompt,
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
);
assertReleaseNotesPromptRequestsNestedBullets(prompt);
assert.match(
prompt,
/shorten long baseline bullets and collapse nested lists that do not need to be nested/,
);
assertPromptRequestsConciseItems(prompt);
} finally {
fs.rmSync(workspace, { recursive: true, force: true });
}
+24 -16
View File
@@ -476,21 +476,29 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
</details>
Do not include the Internal section at all in MODE: release-notes; internal fragments will not be present in the input for that mode.
4. Each top-level change item should:
- Lead with a short feature/area name in title case. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
4. Start every item with a short bold feature/area name in title case and a colon. Pick the name from the fragment's bullet content, not the raw 'area:' slug.
- Default to inline: put one or two short sentences right after the colon. If the important details fit in two short sentences, stay inline.
- Use nested bullets only when an item has several distinct changes a user would care about separately and inline text would turn into a long run-on sentence. Then leave the top-level bullet as just the name and indent two to four nested bullets by two spaces.
- Each nested bullet is a single short sentence, roughly 20 words or fewer. Never write paragraph-length bullets. Cut minor details rather than adding sentences.
5. Keep items brief. Release notes summarize; the docs hold the details.
- Say what changed for the user and how to reach it (shortcut, command, setting, or menu). Stop there.
- Leave out how it works internally, edge cases, precedence rules, defaults, fallbacks, tool discovery, migration mechanics, and step-by-step usage.
- Name a config key only when users must set it to use the change or to keep their current behavior.
- Write in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
- Merge aggressively: one item per feature or area. If five fragments touch Jellyfin subtitles, audio, and auth, write one Jellyfin item, not five. Group small unrelated fixes for the same area into one item.
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
5. In both modes, split every item into one nested bullet per distinct change. Write a short bold name on the top-level bullet, then indent the details two spaces:
- **Playlist Browser**:
- Saved shows now open without rescanning the library.
- The picker remembers the last folder you browsed between launches.
Each nested bullet covers exactly one change, behavior, or user-visible outcome. Never stack several distinct changes into one long paragraph-shaped bullet.
Aim for two to five nested bullets per item. When an item genuinely has only one thing to say, put it inline on the top-level bullet ("- **Playlist Browser**: Saved shows now open without rescanning the library.") instead of emitting a single nested bullet.
Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets.
Bullets inside the Internal section may stay single-level.
6. In MODE: release-notes, nested bullets should also cover user benefit and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed.
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules, including any action users must take. Do not soften or omit them.
6. Match the length and density of this example:
### Added
- **Subtitle Selection Modal**: Optional modal for picking primary and secondary subtitle tracks. Enable it in Settings under Behavior, then press g followed by s.
### Changed
- **Faster Sync**: Sync between macOS and Linux machines now uses compressed, incremental rsync transfers. Windows and machines without rsync fall back to compressed scp.
### Fixed
- **Subtitle Sidebar**: Space keeps its playback action after clicking a cue, and the sidebar no longer jumps away from the current position during subtitle gaps.
- **Jellyfin 12**:
- Playback, subtitles, artwork, and "Play on SubMiner" now work on Jellyfin 12.
- Casting no longer drops after about a minute.
- Mined cards get the episode title again.
7. Do not invent features. Every bullet must be grounded in the input fragments.
8. Do not include the version heading (## v...) — that wrapper is added by the caller.
@@ -791,7 +799,7 @@ function polishFragmentsWithClaude(
? [
'## Existing Prerelease Notes',
'',
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. The output rules above win over the baseline format: shorten long baseline bullets and collapse nested lists that do not need to be nested. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, any "Changes since" section, or the Installation or Assets sections.',
'',
].join('\n')
: '';
@@ -814,7 +822,7 @@ Rules:
3. ADDED fragments describe changes that are new in this build; summarize them.
4. MODIFIED fragments include BEFORE and AFTER content. Describe only the behavioral difference between them. If the edit is editorial (rewording, deduplication, reformatting, reconciling stale phrasing) with no user-visible behavior change, omit it entirely.
5. DELETED fragments mean the described change was removed or reverted before this build; say so explicitly.
6. Keep bullets short and concrete. Use nested bullets sparingly.
6. Keep each bullet to one short, concrete sentence. Use nested bullets sparingly.
7. Do not invent changes. Every bullet must be grounded in the diffs.
8. If no bullet survives rules 2-5, output exactly this single line:
- No user-facing changes since PREVIOUS_TAG.
@@ -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,
+1
View File
@@ -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', () => {
+113
View File
@@ -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 });
}
});
+21 -4
View File
@@ -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 });
+20 -2
View File
@@ -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) {
+14 -1
View File
@@ -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()) {
@@ -6807,6 +6818,8 @@ const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
'youtube-whisper.ja.srt',
),
}),
resolveYoutubeSourceUrl: (mediaPath) =>
youtubePlaybackRuntime.getYoutubeSourceUrlForStream(mediaPath),
getMpvClient: () => appState.mpvClient,
onProgress: (progress) => {
for (const window of [overlayManager.getMainWindow(), overlayManager.getModalWindow()]) {
+53
View File
@@ -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' });
});
+10 -7
View File
@@ -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');
+196
View File
@@ -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/);
});
});
+171
View File
@@ -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<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 ||
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 };
}
@@ -368,7 +368,10 @@ test('cancelling a YouTube Whisper job resolves to null, and failures throw', as
);
});
function youtubeFixture(generateYoutube: SubtitleGenerationRuntimeDeps['generateYoutube']) {
function youtubeFixture(
generateYoutube: SubtitleGenerationRuntimeDeps['generateYoutube'],
overrides: Partial<SubtitleGenerationRuntimeDeps> = {},
) {
const player = {
path: 'https://www.youtube.com/watch?v=abcdefghijk&pp=search',
paused: false,
@@ -385,7 +388,7 @@ function youtubeFixture(generateYoutube: SubtitleGenerationRuntimeDeps['generate
return { error: 'success' };
},
};
const { runtime } = fixture({ getMpvClient: () => client, generateYoutube });
const { runtime } = fixture({ getMpvClient: () => client, generateYoutube, ...overrides });
return { runtime, player, commands };
}
@@ -443,3 +446,63 @@ test('switching videos cancels a YouTube job from the modal without loading its
false,
);
});
// Windows plays YouTube through a resolved googlevideo stream, so mpv's path is not a YouTube URL.
const STREAM_URL = 'https://rr1---sn.example.googlevideo.com/videoplayback?expire=1777777777';
const OTHER_STREAM_URL = 'https://rr2---sn.example.googlevideo.com/videoplayback?expire=1888888888';
const resolveStreamSource = (mediaPath: string): string | null =>
mediaPath === STREAM_URL
? 'https://www.youtube.com/watch?v=abcdefghijk'
: mediaPath === OTHER_STREAM_URL
? 'https://www.youtube.com/watch?v=zyxwvutsrqp'
: null;
test('the modal generates for a YouTube video playing through a resolved stream URL', async () => {
let jobSignal: AbortSignal | null = null;
const { runtime, player, commands } = youtubeFixture(
async (input) => {
jobSignal = input.signal;
assert.equal(input.url, 'https://www.youtube.com/watch?v=abcdefghijk');
// Reloading the same video's stream must not cancel the job.
runtime.handleMediaPathChange(STREAM_URL);
return '/tmp/subs/youtube-whisper.ja.srt';
},
{ resolveYoutubeSourceUrl: resolveStreamSource },
);
player.path = STREAM_URL;
assert.equal(
(await runtime.getStatus()).mediaPath,
'https://www.youtube.com/watch?v=abcdefghijk',
);
assert.deepEqual(await runtime.start(), {
ok: true,
outputPath: '/tmp/subs/youtube-whisper.ja.srt',
message: 'Japanese subtitles generated and loaded.',
});
assert.equal((jobSignal as AbortSignal | null)?.aborted, false);
assert.deepEqual(
commands.find((command) => command[0] === 'sub-add'),
['sub-add', '/tmp/subs/youtube-whisper.ja.srt', 'select', 'Generated Japanese', 'ja'],
);
});
test('switching to another resolved YouTube stream cancels the modal job', async () => {
let jobSignal: AbortSignal | null = null;
const { runtime, player } = youtubeFixture(
(input) =>
new Promise((_, reject) => {
jobSignal = input.signal;
input.signal.addEventListener('abort', () => reject(new Error('Aborted')), { once: true });
}),
{ resolveYoutubeSourceUrl: resolveStreamSource },
);
player.path = STREAM_URL;
const job = runtime.start();
while (!jobSignal) await new Promise((resolve) => setImmediate(resolve));
player.path = OTHER_STREAM_URL;
runtime.handleMediaPathChange(player.path);
assert.deepEqual(await job, { ok: false, message: 'Cancelled.' });
});
@@ -51,6 +51,11 @@ export interface SubtitleGenerationRuntimeDeps {
signal: AbortSignal;
onProgress: (progress: SubtitleGenerationProgress) => void;
}) => Promise<string>;
/**
* Maps a playing stream URL back to its YouTube page URL. Windows YouTube playback opens a
* resolved direct stream in mpv, so mpv's path alone does not identify the video.
*/
resolveYoutubeSourceUrl?: (mediaPath: string) => string | null;
}
async function currentLocalMedia(client: GenerationMpvClient | null): Promise<string | null> {
@@ -62,12 +67,6 @@ async function currentLocalMedia(client: GenerationMpvClient | null): Promise<st
return typeof directory === 'string' ? path.resolve(directory, media) : null;
}
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
if (!client?.connected) return null;
const media = await client.requestProperty('path');
return typeof media === 'string' ? toYoutubeWatchUrl(media) : null;
}
async function loadGeneratedSubtitles(client: GenerationMpvClient, outputPath: string) {
const loaded = await client.request([
'sub-add',
@@ -136,6 +135,15 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
: '',
};
}
// The YouTube page behind a resolved stream URL, or the media path itself.
function youtubeSource(mediaPath: string | null | undefined): string | null | undefined {
return (mediaPath && deps.resolveYoutubeSourceUrl?.(mediaPath)) || mediaPath;
}
async function currentYoutubeMedia(client: GenerationMpvClient | null): Promise<string | null> {
if (!client?.connected) return null;
const media = await client.requestProperty('path');
return typeof media === 'string' ? toYoutubeWatchUrl(youtubeSource(media)) : null;
}
const report = (update: SubtitleGenerationProgress) => {
progress = update;
deps.onProgress(update);
@@ -246,7 +254,7 @@ export function createSubtitleGenerationRuntime(deps: SubtitleGenerationRuntimeD
/** Stops a YouTube job once its video is no longer playing, which also deletes its audio. */
handleMediaPathChange(mediaPath: string | null | undefined): void {
if (!youtubeJobUrl) return;
if (extractYoutubeVideoId(mediaPath) !== extractYoutubeVideoId(youtubeJobUrl))
if (extractYoutubeVideoId(youtubeSource(mediaPath)) !== extractYoutubeVideoId(youtubeJobUrl))
controller?.abort();
},
async setVadEnabled(enabled: boolean): Promise<SubtitleGenerationStatus> {
@@ -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);
}
}
@@ -298,3 +298,116 @@ test('prepare youtube playback accepts a fresh-start path change when the direct
'replace',
]);
});
test('prepare youtube playback swaps a queued entry for its direct stream without clearing the playlist', async () => {
const commands: Array<Array<string>> = [];
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
const observedPaths = ['https://www.youtube.com/watch?v=queued2', directUrl];
let requestCount = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
requestCount += 1;
return value;
},
requestProperty: async (name) => {
if (name === 'playlist') {
return [
{ filename: 'https://www.youtube.com/watch?v=queued1' },
{ filename: 'https://www.youtube.com/watch?v=queued2', current: true, playing: true },
{ filename: 'https://www.youtube.com/watch?v=queued3' },
];
}
if (name === 'track-list') return [{ type: 'video', id: 1 }];
return null;
},
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
});
const ok = await prepare({
url: directUrl,
sourceUrl: 'https://www.youtube.com/watch?v=queued2',
timeoutMs: 1500,
pollIntervalMs: 1,
});
assert.equal(ok, true);
assert.deepEqual(commands.slice(4), [
['loadfile', directUrl, 'append'],
['playlist-move', '3', '2'],
['playlist-remove', '1'],
]);
});
test('prepare youtube playback replaces the playlist when the current entry is a different video', async () => {
const commands: Array<Array<string>> = [];
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
let nowTick = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => 'https://www.youtube.com/watch?v=other',
requestProperty: async (name) => {
if (name === 'playlist') {
return [
{ filename: 'https://www.youtube.com/watch?v=other', current: true, playing: true },
];
}
return [];
},
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
now: () => {
nowTick += 100;
return nowTick;
},
});
await prepare({
url: directUrl,
sourceUrl: 'https://www.youtube.com/watch?v=picked',
timeoutMs: 200,
pollIntervalMs: 1,
});
assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]);
});
test('prepare youtube playback does not touch the playlist when the playing entry is another video', async () => {
const commands: Array<Array<string>> = [];
const directUrl = 'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc';
let nowTick = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
// mpv's path still reports the queued video, but it is mid-advance: the selected entry moved
// on and the playing entry is another queued video.
requestPath: async () => 'https://www.youtube.com/watch?v=queued2',
requestProperty: async (name) => {
if (name === 'playlist') {
return [
{ filename: 'https://www.youtube.com/watch?v=queued1', playing: true },
{ filename: 'https://www.youtube.com/watch?v=queued2', current: true },
{ filename: 'https://www.youtube.com/watch?v=queued3' },
];
}
return [];
},
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
now: () => {
nowTick += 100;
return nowTick;
},
});
await prepare({
url: directUrl,
sourceUrl: 'https://www.youtube.com/watch?v=queued2',
timeoutMs: 200,
pollIntervalMs: 1,
});
assert.equal(
commands.some((command) => command[0] === 'playlist-remove'),
false,
);
assert.deepEqual(commands.slice(4), [['loadfile', directUrl, 'replace']]);
});
+59 -1
View File
@@ -2,6 +2,11 @@ import { isYoutubeMediaPath } from './youtube-playback';
type YoutubePlaybackLaunchInput = {
url: string;
/**
* YouTube page URL `url` was resolved from (Windows loads a direct stream URL). Lets a playlist
* entry for the same video be swapped in place instead of replacing the whole playlist.
*/
sourceUrl?: string;
timeoutMs?: number;
pollIntervalMs?: number;
};
@@ -81,6 +86,52 @@ function hasPlayableMediaTracks(trackListRaw: unknown): boolean {
});
}
/** Index of the entry mpv is actually playing, or null while it is between entries. */
function findPlayingPlaylistEntry(playlist: unknown[]): { index: number; filename: string } | null {
const index = playlist.findIndex(
(entry) =>
typeof entry === 'object' &&
entry !== null &&
(entry as Record<string, unknown>).playing === true,
);
if (index < 0) return null;
const filename = (playlist[index] as Record<string, unknown>).filename;
return typeof filename === 'string' ? { index, filename } : null;
}
/**
* Swaps mpv's playing playlist entry for `targetUrl` while keeping the rest of the playlist, e.g.
* a queued YouTube watch URL replaced by its resolved direct stream on Windows. Uses only
* long-standing playlist commands (no `loadfile insert-*`, which needs mpv 0.38+).
* Returns false unless the playing entry is confirmed to be `sourceUrl`'s video, so the caller
* falls back to `replace` instead of removing some other queued entry.
*/
async function swapCurrentPlaylistEntry(
deps: YoutubePlaybackLaunchDeps,
targetUrl: string,
sourceUrl: string,
): Promise<boolean> {
if (!deps.requestProperty) return false;
let playlist: unknown;
try {
// One snapshot so the position, count, and entry identity agree with each other.
playlist = await deps.requestProperty('playlist');
} catch {
return false;
}
if (!Array.isArray(playlist)) return false;
const playing = findPlayingPlaylistEntry(playlist);
if (!playing || !targetsSameYoutubeVideo(playing.filename, sourceUrl)) return false;
// The appended entry lands at index `count`; move it right after the playing entry, then remove
// the playing entry so mpv advances onto it.
const count = playlist.length;
deps.sendMpvCommand(['loadfile', targetUrl, 'append']);
deps.sendMpvCommand(['playlist-move', String(count), String(playing.index + 1)]);
deps.sendMpvCommand(['playlist-remove', String(playing.index)]);
return true;
}
function sendPlaybackPrepCommands(sendMpvCommand: (command: Array<string>) => void): void {
sendMpvCommand(['set_property', 'pause', 'yes']);
sendMpvCommand(['set_property', 'sub-auto', 'no']);
@@ -120,7 +171,14 @@ export function createPrepareYoutubePlaybackInMpvHandler(deps: YoutubePlaybackLa
// Keep polling; mpv can report the target path before tracks are ready.
}
} else {
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
const sourceUrl = input.sourceUrl?.trim() ?? '';
const swapped =
sourceUrl !== '' &&
targetsSameYoutubeVideo(previousPath, sourceUrl) &&
(await swapCurrentPlaylistEntry(deps, targetUrl, sourceUrl));
if (!swapped) {
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
}
}
const deadline = now() + timeoutMs;
@@ -112,8 +112,8 @@ test('youtube playback runtime resolves the socket path lazily for windows start
calls.push(`wait-connected:${timeoutMs}`);
return true;
},
prepareYoutubePlaybackInMpv: async ({ url }) => {
calls.push(`prepare:${url}`);
prepareYoutubePlaybackInMpv: async ({ url, sourceUrl }) => {
calls.push(`prepare:${url}:${sourceUrl}`);
return true;
},
runYoutubePlaybackFlow: async ({ url }) => {
@@ -143,6 +143,51 @@ test('youtube playback runtime resolves the socket path lazily for windows start
});
assert.ok(calls.some((entry) => entry.includes('--input-ipc-server=/tmp/updated.sock')));
// The page URL rides along so prepare can swap a queued entry for the direct stream in place.
assert.ok(calls.includes('prepare:https://example.com/direct:https://youtu.be/demo'));
});
test('youtube playback runtime maps resolved windows streams back to their page urls', async () => {
const streamFor = (url: string) =>
`https://rr1---sn.example.googlevideo.com/videoplayback?src=${encodeURIComponent(url)}`;
const firstUrl = 'https://www.youtube.com/watch?v=abcdefghijk';
const secondUrl = 'https://www.youtube.com/watch?v=bcdefghijkl';
const runtime = createYoutubePlaybackRuntime({
platform: 'win32',
directPlaybackFormat: 'b',
mpvYtdlFormat: 'bestvideo+bestaudio',
autoLaunchTimeoutMs: 2_000,
connectTimeoutMs: 1_000,
getSocketPath: () => '/tmp/mpv.sock',
getMpvConnected: () => true,
invalidatePendingAutoplayReadyFallbacks: () => {},
setAppOwnedFlowInFlight: () => {},
ensureYoutubePlaybackRuntimeReady: async () => {},
resolveYoutubePlaybackUrl: async (url) => streamFor(url),
launchWindowsMpv: async () => ({ ok: false }),
waitForYoutubeMpvConnected: async () => true,
// The second video never loads, so the first stream keeps playing.
prepareYoutubePlaybackInMpv: async ({ url }) => url === streamFor(firstUrl),
runYoutubePlaybackFlow: async () => {},
logInfo: () => {},
logWarn: () => {},
schedule: () => 1 as never,
clearScheduled: () => {},
});
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(firstUrl)), null);
await runtime.runYoutubePlaybackFlow({ url: firstUrl, source: 'second-instance' });
await runtime
.runYoutubePlaybackFlow({ url: secondUrl, source: 'second-instance' })
.catch(() => {});
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(firstUrl)), firstUrl);
assert.equal(runtime.getYoutubeSourceUrlForStream(streamFor(secondUrl)), secondUrl);
assert.equal(
runtime.getYoutubeSourceUrlForStream('https://rr2---sn.example.googlevideo.com/other'),
null,
);
assert.equal(runtime.getYoutubeSourceUrlForStream('/video/episode.mkv'), null);
});
test('youtube playback runtime starts media cache without blocking the subtitle flow', async () => {
+13 -2
View File
@@ -22,7 +22,7 @@ export type YoutubePlaybackRuntimeDeps = {
resolveYoutubePlaybackUrl: (url: string, format: string) => Promise<string>;
launchWindowsMpv: (playbackUrl: string, args: string[]) => Promise<LaunchResult>;
waitForYoutubeMpvConnected: (timeoutMs: number) => Promise<boolean>;
prepareYoutubePlaybackInMpv: (request: { url: string }) => Promise<boolean>;
prepareYoutubePlaybackInMpv: (request: { url: string; sourceUrl: string }) => Promise<boolean>;
startYoutubeMediaCache?: (url: string) => void | Promise<void>;
runYoutubePlaybackFlow: (request: { url: string }) => Promise<void>;
logInfo: (message: string) => void;
@@ -35,6 +35,10 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
let quitOnDisconnectArmed = false;
let quitOnDisconnectArmTimer: ReturnType<typeof setTimeout> | null = null;
let playbackFlowGeneration = 0;
// Windows mpv plays resolved stream URLs, so remember which YouTube page each came from. Kept for
// every resolved stream: a later failed load must not orphan the stream still playing, and mpv
// can return to an earlier swapped-in playlist entry.
const directPlaybackSources = new Map<string, string>();
const clearYoutubePlayQuitOnDisconnectArmTimer = (): void => {
if (quitOnDisconnectArmTimer) {
@@ -65,6 +69,7 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
request.url,
deps.directPlaybackFormat,
);
directPlaybackSources.set(playbackUrl, request.url);
deps.logInfo('Resolved direct YouTube playback URL for Windows MPV startup.');
} catch (error) {
deps.logWarn(
@@ -122,7 +127,10 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
}, 3000);
}
const mediaReady = await deps.prepareYoutubePlaybackInMpv({ url: playbackUrl });
const mediaReady = await deps.prepareYoutubePlaybackInMpv({
url: playbackUrl,
sourceUrl: request.url,
});
if (!mediaReady) {
throw new Error('Timed out waiting for mpv to load the requested YouTube URL.');
}
@@ -156,5 +164,8 @@ export function createYoutubePlaybackRuntime(deps: YoutubePlaybackRuntimeDeps) {
clearYoutubePlayQuitOnDisconnectArmTimer,
getQuitOnDisconnectArmed: (): boolean => quitOnDisconnectArmed,
runYoutubePlaybackFlow,
/** YouTube page URL for a stream URL this runtime resolved for Windows playback, else null. */
getYoutubeSourceUrlForStream: (mediaPath: string | null | undefined): string | null =>
directPlaybackSources.get(mediaPath?.trim() ?? '') ?? null,
};
}
+46
View File
@@ -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;
}
}