Compare commits

..

28 Commits

Author SHA1 Message Date
sudacode fcd6511aa1 fix: default hoverTokenBackgroundColor to transparent
- Change default from rgba(54, 58, 79, 0.84) to transparent in config, CSS, and sanitizer fallbacks
- Update tests and docs to reflect new default
2026-05-20 16:31:59 -07:00
sudacode e18b6eda77 fix: reset WS subtitle state and parsed cues on media path change
- Clear activeParsedSubtitleCues, activeParsedSubtitleSource, lastObservedTimePos
- Broadcast reset payload to subtitleWsService and annotationSubtitleWsService
- Extend wiring test to cover new reset fields and WS broadcasts
2026-05-20 11:40:21 -07:00
sudacode 1145e131da fix: clear stale CSS properties and subtitle state on style/media update
- Remove CSS properties absent from subsequent subtitle style updates
- Broadcast subtitle:set clear when media path changes
- Preserve launcher lifecycle ownership for already-managed overlay apps
- Clamp negative autoplay current time to zero
- Reject blank subminerBinaryPath values via parseNonEmptyString
- Log and rethrow legacy config migration errors instead of swallowing
- Normalize modifier aliases (e.g. CommandOrControl) in keybinding display
2026-05-20 10:14:28 -07:00
sudacode dde19ad0da fix: prime startup subtitle before autoplay resumes
- Add `selectAutoplayStartupCue` to pick active or imminent cue at startup
- Call `primeCurrentSubtitle` in warm-release before signaling autoplay ready
- Reset primed state on media path change to avoid stale cue leaks
2026-05-20 08:09:49 -07:00
sudacode 4813ce1fea fix: drop stale deferred autoplay-ready signals on media change
- autoplay-ready gate now stamps pending signal with mediaPath and discards it on flush if media has changed
- tokenization warm release skips signaling when current media path is cleared (null)
- tighten regex matchers in main-wiring and overlay-legacy-cleanup tests
2026-05-20 01:45:14 -07:00
sudacode 403ee32579 fix: managed playback overlay lifecycle for launcher-owned sessions
- Remove --background from launcher-owned mpv starts; quit only non-tray/non-background managed sessions
- Defer autoplay-ready signal until overlay window content is loaded; retry after flush
- Retry socket availability before auto-starting overlay (up to 25 attempts, 200ms apart)
- Extract warm tokenization signal into autoplay-tokenization-warm-release with stale-media guard
- Queue second-instance commands until app ready runtime completes
- Guard globalShortcut cleanup with isAppReady check to avoid pre-ready crash
- Recognize "osx" as a macOS platform alias in Lua environment detection
2026-05-20 01:45:14 -07:00
sudacode e4165a418c refactor: deduplicate mpv plugin config, fix CSS font fallbacks
- Extract shared getMpvPluginRuntimeConfig() helper to eliminate duplicate inline objects
- Call ensureImmersionTrackerStarted() before markActiveVideoWatched action
- Quote multi-word font names and add sans-serif generic fallback in subtitle sidebar CSS
- Add main-wiring tests asserting deduplication and tracker start ordering
2026-05-20 01:45:14 -07:00
sudacode 2772c61aba feat: add mark-watched action, background app reuse, and N+1 compat
- Add `--mark-watched` CLI flag + mpv session binding; marks video watched, shows OSD, advances playlist
- Launcher detects running background app via `--app-ping` and borrows it instead of owning its lifecycle
- Preserve N+1 highlighting for existing configs with `knownWords.highlightEnabled` set
- Fix `resolveConfiguredShortcuts` to respect explicit `null` overrides (disabling defaults)
- Split session-help modal into focused modules (colors, render, sections, tabs)
2026-05-20 01:45:14 -07:00
sudacode 5c710ffcaf test: add 20s timeout to 365d trends dashboard test 2026-05-20 01:45:14 -07:00
sudacode ab29d56649 feat: include unconfigured secret paths in config settings snapshot
- Export SECRET_PATHS from registry for reuse
- Populate snapshot with `{ configured: true }` for non-empty secrets not already covered by registered fields
2026-05-20 01:45:14 -07:00
sudacode 1f7318d615 feat: expand hot-reload to logging, jimaku, subsync, and Anki sub-fields
- Mark logging.level, stats keys, jimaku.*, subsync.*, and granular ankiConnect fields (knownWords, nPlusOne, fields, isLapis, isKiku, behavior) as hot-reloadable
- Refactor classifyConfigHotReloadDiff to path-walk diffing instead of per-key branches
- Wire setLogLevel, invalidateTokenizationCache, refreshSubtitlePrefetch, refreshCurrentSubtitle into hot-reload applied handler
- Exclude ai.* and ankiConnect.ai.* prefixes from config window; hide fields.translation
- Update docs and config.example.jsonc hot-reload annotations
2026-05-20 01:45:14 -07:00
sudacode cc7c3939e9 docs: add config subcommand and --config flag to CLI reference 2026-05-20 01:45:14 -07:00
sudacode 887de056c5 docs: simplify and restructure installation guide
- Consolidate requirements into a single table with status column
- Rewrite installation.md as a numbered 3-step guide
- Remove verbose platform-specific notes; fold essentials into platform sections
- Trim README quick-start to minimal install/launch commands
2026-05-20 01:45:14 -07:00
sudacode 553117356d fix: disable macOS mpv menu shortcuts, buffer latest subtitle IPC state
- Pass --macos-menu-shortcuts=no on Darwin so SubMiner bindings reach mpv
- Replace queued IPC listener with latest-value variant for subtitle channels
- Skip JSONC line/block comments in duplicate-key offset helpers
- Preserve configured Anki note model name in selectPreferredNoteFieldModelName
- Guard known-words deck rename against collision; add chooseKnownWordsDeckRenameValue
- Apply asCssColor on hover token CSS compat reads
2026-05-20 01:45:14 -07:00
sudacode 193b3136f2 migrate subtitle style config to CSS declaration shape
- Flat style keys (fontFamily, fontSize, hoverTokenColor, etc.) consolidated into subtitleStyle.css, secondary.css, and subtitleSidebar.css objects
- Hover token colors migrated to --subtitle-hover-token-color CSS custom properties
- Plugin app-ping now checks result.status (0=running, 1=stopped) to avoid treating transient failures as stopped
- Note-fields note type picker defaults to configured deck's note type before falling back to Kiku/Lapis
- New migration for legacy ankiConnect N+1 config paths
2026-05-20 01:45:14 -07:00
sudacode 1bb7b26641 fix: transport AppImage args via env and gate restart on app-ping
- Transport Linux AppImage CLI args through SUBMINER_APP_ARGC/ARG_* env vars instead of argv
- Add --app-ping command to probe single-instance lock ownership (exit 0 = running, 1 = not)
- Gate manual restart: poll app-ping until old app releases lock, then until new app owns it
- Preserve user-paused playback when disarming the auto-play-ready gate on restart
- Snapshot subtitles before connection side effects (sub-visibility hide) can suppress them
- Reapply overlay bounds after first show for Hyprland compatibility
2026-05-20 01:45:14 -07:00
sudacode 48447c2f1a fix: curl fetch for Linux updater, overlay restart restore, Yomitan late
- Use /usr/bin/curl on Linux for update checks to avoid Electron net-service crashes
- Restore visible overlay on manual restart even when auto-start visibility is disabled
- Reload overlay windows after Yomitan extension loads to fix popup race on startup
2026-05-20 01:45:14 -07:00
sudacode 2b13c82d69 feat(settings): move restart badge inline with option title
- Remove field-meta row (config path, advanced chip) from option rows
- Inline live/restart status badge beside each option label
- Extract getFieldTitleBadges into settings-field-layout module with tests
2026-05-20 01:43:20 -07:00
sudacode db60365b0e fix: normalize anki deck sample size 2026-05-20 01:43:20 -07:00
sudacode 93d9ed81a2 fix: address follow-up review feedback 2026-05-20 01:43:20 -07:00
sudacode 6f48d4b65b fix: address config modal review feedback 2026-05-20 01:43:20 -07:00
sudacode 7fb1e6d7a5 fix: add missing changelog metadata 2026-05-20 01:43:20 -07:00
sudacode 1ff44e0d69 feat(config): unify mpv plugin options under main config and add CSS/Ani
- Replace subminer.conf plugin config with mpv.* fields in config.jsonc
- Add socketPath, backend, autoStartSubMiner, pauseUntilOverlayReady, aniskipEnabled/buttonKey, subminerBinaryPath to mpv config
- Add subtitleSidebar.css field; migrate legacy sidebar appearance fields
- Add paintOrder and WebkitTextStroke to subtitle style options
- Update default subtitle/sidebar fontFamily to CJK-first stack
- Fix overlay visible state surviving mpv y-r restart
- Fix live config saves applying subtitle CSS immediately to open overlays
- Migrate legacy primary/secondary subtitle appearance into subtitleStyle.css on load
- Switch AniSkip button key setting to click-to-learn key capture
2026-05-20 01:43:20 -07:00
sudacode 0354a0e74b feat(config): add subtitle CSS editor, nPlusOne.enabled flag, and fix se
- subtitleStyle.css / subtitleStyle.secondary.css replace flat style fields in the settings window
- ankiConnect.nPlusOne.enabled gates known-word cache independently of knownWords.highlightEnabled
- Settings search now covers all categories, narrows on multi-word terms, and hides editor-owned fields
- Default note-type picker to Kiku then Lapis; rename isLapis.sentenceCardModel default to "Lapis"
2026-05-20 01:43:20 -07:00
sudacode b0fd7bd9e8 fix(launcher): suppress Electron menu diagnostics 2026-05-20 01:43:20 -07:00
sudacode 58f5fff6ad style: format config settings changes 2026-05-20 01:43:20 -07:00
sudacode 309ce6ef8f feat(config): reorganize settings window and move annotation colors to subtitleStyle
- Reorganize Configuration window into Appearance, Behavior, Anki, Input, and Integration sections
- Add AnkiConnect-backed deck, note-type, and field pickers in the Anki section
- Add click-to-learn keybinding controls
- Move known-word and N+1 highlight colors to subtitleStyle.knownWordColor / subtitleStyle.nPlusOneColor; legacy ankiConnect.knownWords.color and ankiConnect.nPlusOne.nPlusOne keys still accepted with deprecation warnings
- Add deckNames, modelNames, modelFieldNames, and fieldNamesForDeck methods to AnkiConnectClient
- Mark discordPresence.presenceStyle as an enum in the config registry
2026-05-20 01:43:20 -07:00
sudacode a54f03f0cd Fix Jellyfin Login (#76) 2026-05-20 00:46:11 -07:00
54 changed files with 1589 additions and 188 deletions
-11
View File
@@ -15,7 +15,6 @@
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"vscode-json-languageservice": "^5.7.2",
"ws": "^8.19.0",
},
"devDependencies": {
@@ -189,8 +188,6 @@
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
@@ -725,14 +722,6 @@
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
"vscode-json-languageservice": ["vscode-json-languageservice@5.7.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w=="],
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Fixed the Jellyfin setup popup login path on Windows by using an IPC bridge, showing immediate login progress, and timing out unreachable server login attempts with an inline error.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Primed the first startup subtitle before autoplay resumes so the overlay can render text before video playback begins.
@@ -0,0 +1,4 @@
type: changed
area: jellyfin
- Removed the Jellyfin setup server presets dropdown; setup now shows a single editable server URL field.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: windows
- Windows startup failures now show a native error dialog and write fatal details to the SubMiner app log instead of exiting silently.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: updates
- Windows automatic updates now keep the native `electron-updater`/NSIS install path enabled while routing updater HTTP through main-process fetch, avoiding the delayed app exit seen shortly after launch without requiring `curl.exe`.
+1 -1
View File
@@ -378,7 +378,7 @@
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle hover token background color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
+1 -1
View File
@@ -390,7 +390,7 @@ See `config.example.jsonc` for detailed configuration options.
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
+2 -2
View File
@@ -6,7 +6,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
- listing libraries and media items
- launching item playback in the connected mpv instance
- receiving Jellyfin remote cast-to-device playback events in-app
- opening an in-app setup window for server selection and authentication
- opening an in-app setup window for server URL and authentication
- toggling Jellyfin cast discovery from the tray once configured
## Requirements
@@ -50,7 +50,7 @@ subminer jellyfin -l \
--password 'your-password'
```
`subminer jellyfin` opens the setup window. It offers the configured server, recent servers, and a manual server URL field. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored.
`subminer jellyfin` opens the setup window. It pre-fills the server URL from the configured server, a recent successful server, or the local default. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored.
3. List libraries:
+1 -1
View File
@@ -378,7 +378,7 @@
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
"--subtitle-hover-token-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle hover token background color setting.
"--subtitle-hover-token-background-color": "transparent" // Subtitle hover token background color setting.
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
+3 -1
View File
@@ -89,7 +89,9 @@ Notes:
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks. Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason.
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
- Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks.
- Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason.
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
+10
View File
@@ -106,6 +106,16 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
assert.equal(parsed.aniskipButtonKey, 'F8');
});
test('parseLauncherMpvConfig ignores blank subminer binary paths', () => {
const parsed = parseLauncherMpvConfig({
mpv: {
subminerBinaryPath: ' ',
},
});
assert.equal(parsed.subminerBinaryPath, undefined);
});
test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
const parsed = parseLauncherMpvConfig({
mpv: {
+1 -2
View File
@@ -37,8 +37,7 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
typeof mpv.autoStartSubMiner === 'boolean' ? mpv.autoStartSubMiner : undefined,
pauseUntilOverlayReady:
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
subminerBinaryPath:
typeof mpv.subminerBinaryPath === 'string' ? mpv.subminerBinaryPath.trim() : undefined,
subminerBinaryPath: parseNonEmptyString(mpv.subminerBinaryPath),
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
};
+41
View File
@@ -697,6 +697,47 @@ test('startOverlay borrows an already-running background app instead of owning i
}
});
test('startOverlay keeps lifecycle ownership for its already-managed app', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
state.appPath = appPath;
state.overlayManagedByLauncher = true;
net.createConnection = (() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => socket) as net.Socket['destroy'];
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
setTimeout(() => socket.emit('connect'), 10);
return socket;
}) as typeof net.createConnection;
await startOverlay(appPath, makeArgs(), socketPath);
assert.equal(state.overlayManagedByLauncher, true);
assert.equal(state.appPath, appPath);
} finally {
net.createConnection = originalCreateConnection;
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
+1 -1
View File
@@ -1016,7 +1016,7 @@ export async function startOverlay(
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(state.overlayProc);
if (appAlreadyRunning) {
if (appAlreadyRunning && !(state.overlayManagedByLauncher && state.appPath === appPath)) {
log(
'debug',
args.logLevel,
-38
View File
@@ -19,7 +19,6 @@
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"vscode-json-languageservice": "^5.7.2",
"ws": "^8.19.0"
},
"devDependencies": {
@@ -744,12 +743,6 @@
"npm": ">=7.0.0"
}
},
"node_modules/@vscode/l10n": {
"version": "0.0.18",
"resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz",
"integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==",
"license": "MIT"
},
"node_modules/@xhayper/discord-rpc": {
"version": "1.3.3",
"license": "ISC",
@@ -3950,37 +3943,6 @@
"node": ">=0.6.0"
}
},
"node_modules/vscode-json-languageservice": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.2.tgz",
"integrity": "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w==",
"license": "MIT",
"dependencies": {
"@vscode/l10n": "^0.0.18",
"jsonc-parser": "^3.3.1",
"vscode-languageserver-textdocument": "^1.0.12",
"vscode-languageserver-types": "^3.17.5",
"vscode-uri": "^3.1.0"
}
},
"node_modules/vscode-languageserver-textdocument": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/wcwidth": {
"version": "1.0.1",
"dev": true,
+2 -3
View File
@@ -50,8 +50,8 @@
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
"test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
"test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
@@ -118,7 +118,6 @@
"jsonc-parser": "^3.3.1",
"koffi": "^2.15.6",
"libsql": "^0.5.22",
"vscode-json-languageservice": "^5.7.2",
"ws": "^8.19.0"
},
"devDependencies": {
+11 -2
View File
@@ -102,7 +102,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
assert.equal(config.subtitleSidebar.enabled, true);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'transparent');
assert.equal(config.subtitleStyle.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
assert.equal(config.subtitleStyle.fontWeight, '600');
assert.equal(config.subtitleStyle.lineHeight, 1.35);
@@ -2096,10 +2096,19 @@ test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
subtitleStyle: { nPlusOneColor?: string; knownWordColor?: string };
};
assert.equal(parsed.subtitleStyle.nPlusOneColor, '#c6a0f6');
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
assert.equal(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false);
});
test('legacy migration failures are logged and rethrown', () => {
const source = fs.readFileSync(path.join(process.cwd(), 'src/config/service.ts'), 'utf-8');
const catchBlock = source.match(/catch\s*\(error\)\s*\{(?<body>[\s\S]*?)\n \}/)?.groups?.body;
assert.ok(catchBlock);
assert.match(catchBlock, /legacy config migration failed/);
assert.match(catchBlock, /console\.error/);
assert.match(catchBlock, /throw error;/);
});
test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', () => {
const dir = makeTempDir();
const configPath = path.join(dir, 'config.jsonc');
+1 -1
View File
@@ -9,7 +9,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
autoPauseVideoOnHover: true,
autoPauseVideoOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
hoverTokenBackgroundColor: 'transparent',
nameMatchEnabled: true,
nameMatchColor: '#f5bde6',
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
@@ -132,7 +132,7 @@ test('n+1 annotation color has one public config path', () => {
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
assert.ok(!leaves.includes('ankiConnect.nPlusOne.nPlusOne'));
assert.ok(!leaves.includes('ankiConnect.nPlusOne.color'));
});
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
+3 -2
View File
@@ -151,8 +151,9 @@ export class ConfigService {
}
fs.writeFileSync(configPath, content, 'utf-8');
return rawConfig;
} catch {
return config;
} catch (error) {
console.error(`[ConfigService] legacy config migration failed for ${configPath}:`, error);
throw error;
}
}
}
+34
View File
@@ -654,6 +654,40 @@ test('authenticateWithPassword surfaces invalid credentials and server status fa
}
});
test('authenticateWithPassword surfaces unreachable server failures', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new TypeError('fetch failed');
}) as typeof fetch;
try {
await assert.rejects(
() => authenticateWithPassword('http://jellyfin.local:8096/', 'kyle', 'pw', clientInfo),
/Could not reach Jellyfin server \(fetch failed\)\./,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('authenticateWithPassword surfaces login timeouts', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
const error = new Error('aborted') as Error & { name: string };
error.name = 'AbortError';
throw error;
}) as typeof fetch;
try {
await assert.rejects(
() => authenticateWithPassword('http://jellyfin.local:8096/', 'kyle', 'pw', clientInfo),
/Jellyfin login timed out\./,
);
} finally {
globalThis.fetch = originalFetch;
}
});
test('listLibraries surfaces token-expiry auth errors', async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
+40 -11
View File
@@ -1,6 +1,7 @@
import { JellyfinConfig } from '../../types';
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
const JELLYFIN_LOGIN_TIMEOUT_MS = 15_000;
export interface JellyfinAuthSession {
serverUrl: string;
@@ -116,6 +117,21 @@ function asIntegerOrNull(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) ? value : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isAbortError(error: unknown): boolean {
return isRecord(error) && error.name === 'AbortError';
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
return String(error || 'unknown error');
}
function resolveDeliveryUrl(
session: JellyfinAuthSession,
stream: JellyfinMediaStream,
@@ -309,17 +325,30 @@ export async function authenticateWithPassword(
if (!username.trim()) throw new Error('Missing Jellyfin username.');
if (!password) throw new Error('Missing Jellyfin password.');
const response = await fetch(`${normalizedUrl}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: createAuthorizationHeader(client),
},
body: JSON.stringify({
Username: username,
Pw: password,
}),
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), JELLYFIN_LOGIN_TIMEOUT_MS);
let response: Response;
try {
response = await fetch(`${normalizedUrl}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: createAuthorizationHeader(client),
},
body: JSON.stringify({
Username: username,
Pw: password,
}),
signal: controller.signal,
});
} catch (error) {
if (isAbortError(error)) {
throw new Error('Jellyfin login timed out. Check the server URL and network connection.');
}
throw new Error(`Could not reach Jellyfin server (${getErrorMessage(error)}).`);
} finally {
clearTimeout(timeout);
}
if (response.status === 401 || response.status === 403) {
throw new Error('Invalid Jellyfin username or password.');
+18 -1
View File
@@ -29,6 +29,7 @@ import {
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { parseMpvLaunchMode } from './shared/mpv-launch-mode';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
const DEFAULT_TEXTHOOKER_PORT = 5174;
@@ -174,6 +175,14 @@ function readConfiguredWindowsMpvLaunch(configDir: string): {
process.argv = normalizeStartupArgv(process.argv, process.env);
applySanitizedEnv(sanitizeStartupEnv(process.env));
const userDataPath = configureEarlyAppPaths(app);
const reportFatalError = createFatalErrorReporter({
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
consoleError: (message, error) => console.error(message, error),
});
registerFatalErrorHandlers({
reportFatalError,
exit: (code) => app.exit(code),
});
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
@@ -228,5 +237,13 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
if (!gotSingleInstanceLock) {
app.exit(0);
}
require('./main.js');
try {
require('./main.js');
} catch (error) {
reportFatalError(error, {
title: 'SubMiner startup failed',
context: 'SubMiner failed while loading the main process.',
});
app.exit(1);
}
}
+182 -5
View File
@@ -104,6 +104,7 @@ import type {
RuntimeOptionState,
SessionActionDispatchRequest,
SecondarySubMode,
SubtitleCue,
SubtitleData,
SubtitlePosition,
UpdateChannel,
@@ -119,6 +120,7 @@ import {
resolveDefaultLogFilePath,
type LogLevelSource,
} from './logger';
import { createFatalErrorReporter } from './main/fatal-error';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import {
bindWindowsOverlayAboveMpv,
@@ -362,6 +364,7 @@ import {
createYoutubePrimarySubtitleNotificationRuntime,
} from './main/runtime/youtube-primary-subtitle-notification';
import { createAutoplayReadyGate } from './main/runtime/autoplay-ready-gate';
import { selectAutoplayStartupCue } from './main/runtime/autoplay-subtitle-primer';
import { createAutoplayTokenizationWarmRelease } from './main/runtime/autoplay-tokenization-warm-release';
import { createManagedLocalSubtitleSelectionRuntime } from './main/runtime/local-subtitle-selection';
import {
@@ -502,8 +505,13 @@ import {
createElectronAppUpdater,
isNativeUpdaterSupported,
} from './main/runtime/update/app-updater';
import { createCurlFetch, createElectronNetFetch } from './main/runtime/update/fetch-adapter';
import {
createCurlFetch,
createElectronNetFetch,
createGlobalFetch,
} from './main/runtime/update/fetch-adapter';
import { createCurlHttpExecutor } from './main/runtime/update/curl-http-executor';
import { createFetchHttpExecutor } from './main/runtime/update/fetch-http-executor';
import {
fetchLatestStableRelease,
fetchReleaseAssetBuffer,
@@ -610,6 +618,7 @@ const ANILIST_UPDATE_MIN_WATCH_SECONDS = 10 * 60;
const ANILIST_DURATION_RETRY_INTERVAL_MS = 15_000;
const ANILIST_MAX_ATTEMPTED_UPDATE_KEYS = 1000;
const TRAY_TOOLTIP = 'SubMiner';
const JELLYFIN_SETUP_PRELOAD_PATH = path.join(__dirname, 'preload-jellyfin-setup.js');
let anilistMediaGuessRuntimeState: AnilistMediaGuessRuntimeState =
createInitialAnilistMediaGuessRuntimeState();
@@ -872,6 +881,10 @@ const appLogger = {
);
},
};
const reportFatalError = createFatalErrorReporter({
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
consoleError: (message, error) => logger.error(message, error),
});
let forceQuitTimer: ReturnType<typeof setTimeout> | null = null;
let statsServer: ReturnType<typeof startStatsServer> | null = null;
@@ -1614,6 +1627,88 @@ let lastObservedTimePos = 0;
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
null;
const SEEK_THRESHOLD_SECONDS = 3;
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
let autoplaySubtitlePrimedMediaPath: string | null = null;
function getCurrentAutoplayMediaPath(): string | null {
return appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null;
}
function isCurrentAutoplayMediaPath(mediaPath: string): boolean {
return getCurrentAutoplayMediaPath() === mediaPath;
}
function markAutoplaySubtitlePrimeConsumed(mediaPath: string): boolean {
if (autoplaySubtitlePrimedMediaPath === mediaPath) {
return false;
}
autoplaySubtitlePrimedMediaPath = mediaPath;
return true;
}
function emitAutoplayPrimedSubtitle(mediaPath: string, text: string): boolean {
if (!text.trim() || !isCurrentAutoplayMediaPath(mediaPath)) {
return false;
}
if (!markAutoplaySubtitlePrimeConsumed(mediaPath)) {
return false;
}
appState.currentSubText = text;
const rawPayload = withCurrentSubtitleTiming({ text, tokens: null });
appState.currentSubtitleData = rawPayload;
broadcastToOverlayWindows('subtitle:set', rawPayload);
subtitlePrefetchService?.pause();
subtitleProcessingController.onSubtitleChange(text);
return true;
}
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
const client = appState.mpvClient;
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
return;
}
const subTextRaw = await client.requestProperty('sub-text').catch((error) => {
logger.debug(
`[autoplay-subtitle-prime] failed to read sub-text: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
});
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
emitAutoplayPrimedSubtitle(mediaPath, text);
}
async function primeAutoplaySubtitleFromParsedCues(
mediaPath: string,
cues: SubtitleCue[],
): Promise<void> {
if (
cues.length === 0 ||
autoplaySubtitlePrimedMediaPath === mediaPath ||
!isCurrentAutoplayMediaPath(mediaPath)
) {
return;
}
const client = appState.mpvClient;
const timePosRaw = await client?.requestProperty('time-pos').catch(() => null);
const currentTimeSeconds = Number(
timePosRaw ?? client?.currentTimePos ?? lastObservedTimePos ?? 0,
);
const cue = selectAutoplayStartupCue(
cues,
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS,
);
if (!cue) {
return;
}
emitAutoplayPrimedSubtitle(mediaPath, cue.text);
}
function clearScheduledSubtitlePrefetchRefresh(): void {
if (subtitlePrefetchRefreshTimer) {
@@ -1646,6 +1741,16 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
appState.activeParsedSubtitleCues = cues ?? [];
appState.activeParsedSubtitleSource = sourceKey;
const mediaPath = getCurrentAutoplayMediaPath();
if (mediaPath && cues?.length) {
void primeAutoplaySubtitleFromParsedCues(mediaPath, cues).catch((error) => {
logger.debug(
`[autoplay-subtitle-prime] failed to prime from parsed cues: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}
},
});
const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSidebarSourceHandler({
@@ -2826,6 +2931,7 @@ const {
openJellyfinSetupWindowMainDeps: {
createSetupWindow: createCreateJellyfinSetupWindowHandler({
createBrowserWindow: (options) => new BrowserWindow(options),
preloadPath: JELLYFIN_SETUP_PRELOAD_PATH,
}),
buildSetupFormHtml: (state) => buildJellyfinSetupFormHtml(state),
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
@@ -2873,6 +2979,24 @@ const {
setSetupWindow: (window) => {
appState.jellyfinSetupWindow = window as BrowserWindow;
},
registerSetupIpcHandler: (handler) => {
const channel = IPC_CHANNELS.request.jellyfinSetupSubmit;
ipcMain.removeHandler(channel);
ipcMain.handle(channel, async (event, payload) => {
const setupWindow = appState.jellyfinSetupWindow;
if (!setupWindow || event.sender !== setupWindow.webContents) {
return {
handled: false,
statusMessage: 'This Jellyfin setup window is no longer active.',
statusKind: 'error',
};
}
return handler(payload);
});
return () => {
ipcMain.removeHandler(channel);
};
},
encodeURIComponent: (value) => encodeURIComponent(value),
defaultServerUrl: DEFAULT_CONFIG.jellyfin.serverUrl || 'http://127.0.0.1:8096',
hasStoredSession: () => Boolean(jellyfinTokenStore.loadSession()),
@@ -3995,6 +4119,20 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
immersionTrackerStartupMainDeps,
});
async function runAppReadyRuntimeWithFatalReporting(): Promise<void> {
try {
await appReadyRuntimeRunner();
} catch (error) {
reportFatalError(error, {
title: 'SubMiner startup failed',
context: 'SubMiner failed during app-ready startup.',
});
process.exitCode = 1;
requestAppQuit();
return;
}
}
function ensureOverlayStartupPrereqs(): void {
if (appState.subtitlePosition === null) {
loadSubtitlePosition();
@@ -4106,6 +4244,27 @@ const {
tokenizeSubtitleForImmersion: async (text): Promise<SubtitleData | null> =>
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
updateCurrentMediaPath: (path) => {
const normalizedPath = path.trim();
const previousPath = appState.currentMediaPath?.trim() || null;
if ((normalizedPath || null) !== previousPath) {
const resetSubtitlePayload = { text: '', tokens: null };
const frequencyDictionary = getResolvedConfig().subtitleStyle.frequencyDictionary;
const frequencyOptions = {
enabled: frequencyDictionary.enabled,
topX: frequencyDictionary.topX,
mode: frequencyDictionary.mode,
};
autoplaySubtitlePrimedMediaPath = null;
lastObservedTimePos = 0;
appState.currentSubText = '';
appState.currentSubAssText = '';
appState.currentSubtitleData = null;
appState.activeParsedSubtitleCues = [];
appState.activeParsedSubtitleSource = null;
broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
}
autoplayReadyGate.invalidatePendingAutoplayReadyFallbacks();
currentMediaTokenizationGate.updateCurrentMediaPath(path);
managedLocalSubtitleSelectionRuntime.handleMediaPathChange(path);
@@ -4115,7 +4274,8 @@ const {
youtubePrimarySubtitleNotificationRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
// Delay slightly to allow MPV's track-list to be populated.
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
// Retry after a short delay because MPV can populate track-list after path.
subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500);
}
mediaRuntime.updateCurrentMediaPath(path);
@@ -4366,6 +4526,7 @@ signalAutoplayReadyFromWarmTokenization = createAutoplayTokenizationWarmRelease(
},
getCurrentMediaPath: () =>
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
primeCurrentSubtitle: (mediaPath) => primeCurrentSubtitleForAutoplay(mediaPath),
signalAutoplayReady: () => {
autoplayReadyGate.maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
@@ -4736,9 +4897,23 @@ let updateService: ReturnType<typeof createUpdateService> | null = null;
const electronNetFetch = createElectronNetFetch({
fetch: (url, init) => net.fetch(url, init as RequestInit),
});
const globalFetchForUpdater = createGlobalFetch();
const curlFetch = createCurlFetch();
function createNativeUpdaterHttpExecutor() {
if (process.platform === 'darwin') {
return createCurlHttpExecutor();
}
if (process.platform === 'win32') {
return createFetchHttpExecutor();
}
return undefined;
}
function getFetchForUpdater() {
if (process.platform === 'win32') {
return globalFetchForUpdater;
}
if (process.platform === 'linux') return curlFetch;
return electronNetFetch;
}
@@ -4788,8 +4963,10 @@ function getUpdateService() {
log: (message) => logger.info(message),
getChannel: () => getResolvedConfig().updates.channel,
configureHttpExecutor:
process.platform === 'darwin' ? () => createCurlHttpExecutor() : undefined,
disableDifferentialDownload: process.platform === 'darwin',
process.platform === 'darwin' || process.platform === 'win32'
? createNativeUpdaterHttpExecutor
: undefined,
disableDifferentialDownload: process.platform === 'darwin' || process.platform === 'win32',
isNativeUpdaterSupported: () =>
isNativeUpdaterSupported({
platform: process.platform,
@@ -5605,7 +5782,7 @@ const { runAndApplyStartupState } = composeHeadlessStartupHandlers<
handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: appReadyRuntimeRunner,
onReady: runAppReadyRuntimeWithFatalReporting,
onWillQuitCleanup: () => onWillQuitCleanupHandler(),
shouldRestoreWindowsOnActivate: () => shouldRestoreWindowsOnActivateHandler(),
restoreWindowsOnActivate: () => restoreWindowsOnActivateHandler(),
+44
View File
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildFatalErrorDetails,
createFatalErrorReporter,
resetFatalErrorReporterForTests,
} from './fatal-error';
test('buildFatalErrorDetails includes context, error, and log path', () => {
const details = buildFatalErrorDetails({
context: 'Startup failed.',
error: new Error('boom'),
logFilePath: 'C:\\Users\\tester\\AppData\\Roaming\\SubMiner\\logs\\app.log',
});
assert.match(details, /Startup failed\./);
assert.match(details, /Error: boom/);
assert.match(details, /Log file: C:\\Users\\tester\\AppData\\Roaming\\SubMiner\\logs\\app\.log/);
});
test('fatal error reporter writes one log entry and shows one dialog', () => {
resetFatalErrorReporterForTests();
const logLines: string[] = [];
const dialogs: string[] = [];
const reporter = createFatalErrorReporter({
appendLogLine: (line) => logLines.push(line),
showErrorBox: (title, details) => dialogs.push(`${title}:${details}`),
resolveLogFilePath: () => 'C:\\SubMiner\\logs\\app.log',
now: () => new Date('2026-05-20T01:02:03.000Z'),
});
reporter('first failure', {
title: 'SubMiner startup failed',
context: 'SubMiner could not start.',
});
reporter('second failure');
assert.equal(logLines.length, 1);
assert.match(logLines[0]!, /\[main:fatal\] SubMiner could not start\./);
assert.match(logLines[0]!, /first failure/);
assert.equal(dialogs.length, 1);
assert.match(dialogs[0]!, /^SubMiner startup failed:SubMiner could not start\./);
});
+131
View File
@@ -0,0 +1,131 @@
import { appendLogLine, resolveDefaultLogFilePath } from '../shared/log-files';
export type FatalErrorReportOptions = {
title: string;
context: string;
};
export type FatalErrorReporterDeps = {
showErrorBox: (title: string, details: string) => void;
consoleError?: (message: string, error?: unknown) => void;
appendLogLine?: (line: string) => void;
resolveLogFilePath?: () => string;
now?: () => Date;
};
export type FatalErrorReporter = (
error: unknown,
options?: Partial<FatalErrorReportOptions>,
) => void;
const DEFAULT_TITLE = 'SubMiner crashed';
const DEFAULT_CONTEXT = 'SubMiner encountered a fatal error';
let fatalErrorReported = false;
function pad(value: number): string {
return String(value).padStart(2, '0');
}
function formatTimestamp(date: Date): string {
return [
date.getFullYear(),
'-',
pad(date.getMonth() + 1),
'-',
pad(date.getDate()),
' ',
pad(date.getHours()),
':',
pad(date.getMinutes()),
':',
pad(date.getSeconds()),
].join('');
}
function stringifyUnknownError(error: unknown): string {
if (error instanceof Error) {
return error.stack || error.message || error.name;
}
if (typeof error === 'string') {
return error;
}
try {
return JSON.stringify(error) ?? String(error);
} catch {
return String(error);
}
}
export function buildFatalErrorDetails(options: {
context: string;
error: unknown;
logFilePath: string;
}): string {
return [
options.context,
'',
stringifyUnknownError(options.error),
'',
`Log file: ${options.logFilePath}`,
].join('\n');
}
export function createFatalErrorReporter(deps: FatalErrorReporterDeps): FatalErrorReporter {
return (error, options = {}) => {
if (fatalErrorReported) {
return;
}
fatalErrorReported = true;
const title = options.title ?? DEFAULT_TITLE;
const context = options.context ?? DEFAULT_CONTEXT;
const logFilePath = deps.resolveLogFilePath?.() ?? resolveDefaultLogFilePath('app');
const details = buildFatalErrorDetails({ context, error, logFilePath });
const timestamp = formatTimestamp(deps.now?.() ?? new Date());
const line = `[subminer] - ${timestamp} - ERROR - [main:fatal] ${details.replace(/\r?\n/g, ' | ')}`;
try {
(deps.appendLogLine ?? ((entry: string) => appendLogLine(logFilePath, entry)))(line);
} catch {
// Fatal reporting must never throw while handling the original failure.
}
try {
deps.consoleError?.(line, error);
} catch {
// ignore console sink failures
}
try {
deps.showErrorBox(title, details);
} catch {
// If native dialogs are unavailable, the file log above is still the source of truth.
}
};
}
export function registerFatalErrorHandlers(deps: {
reportFatalError: FatalErrorReporter;
exit: (code: number) => void;
}): void {
process.on('uncaughtException', (error) => {
deps.reportFatalError(error, {
title: 'SubMiner crashed',
context: 'SubMiner main process threw an uncaught exception.',
});
deps.exit(1);
});
process.on('unhandledRejection', (reason) => {
deps.reportFatalError(reason, {
title: 'SubMiner crashed',
context: 'SubMiner main process had an unhandled promise rejection.',
});
deps.exit(1);
});
}
export function resetFatalErrorReporterForTests(): void {
fatalErrorReported = false;
}
+22
View File
@@ -21,6 +21,28 @@ test('manual watched session action starts immersion tracker before marking watc
);
});
test('media path changes clear rendered subtitle state', () => {
const source = readMainSource();
const actionBlock = source.match(
/updateCurrentMediaPath:\s*\(path\)\s*=>\s*\{(?<body>[\s\S]*?)autoplayReadyGate\.invalidatePendingAutoplayReadyFallbacks\(\);/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /appState\.currentSubText = '';/);
assert.match(actionBlock, /appState\.currentSubAssText = '';/);
assert.match(actionBlock, /appState\.currentSubtitleData = null;/);
assert.match(actionBlock, /appState\.activeParsedSubtitleCues = \[\];/);
assert.match(actionBlock, /appState\.activeParsedSubtitleSource = null;/);
assert.match(actionBlock, /lastObservedTimePos = 0;/);
assert.match(actionBlock, /broadcastToOverlayWindows\('subtitle:set',/);
assert.match(actionBlock, /subtitleWsService\.broadcast\(/);
assert.match(actionBlock, /annotationSubtitleWsService\.broadcast\(/);
assert.ok(
actionBlock.indexOf('appState.currentSubtitleData = null;') <
actionBlock.indexOf("broadcastToOverlayWindows('subtitle:set'"),
);
});
test('main process uses one shared mpv plugin runtime config helper', () => {
const source = readMainSource();
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
test('selectAutoplayStartupCue returns the active cue at the current time', () => {
assert.deepEqual(
selectAutoplayStartupCue(
[
{ startTime: 1, endTime: 3, text: 'first' },
{ startTime: 4, endTime: 5, text: 'second' },
],
2,
1,
),
{ startTime: 1, endTime: 3, text: 'first' },
);
});
test('selectAutoplayStartupCue returns the next imminent cue before playback starts', () => {
assert.deepEqual(
selectAutoplayStartupCue(
[
{ startTime: 1.2, endTime: 3, text: 'first' },
{ startTime: 4, endTime: 5, text: 'second' },
],
0,
2,
),
{ startTime: 1.2, endTime: 3, text: 'first' },
);
});
test('selectAutoplayStartupCue clamps negative current time to startup', () => {
assert.deepEqual(
selectAutoplayStartupCue([{ startTime: 0, endTime: 1, text: 'startup' }], -0.5, 0),
{ startTime: 0, endTime: 1, text: 'startup' },
);
});
test('selectAutoplayStartupCue does not reveal far future subtitle text', () => {
assert.equal(
selectAutoplayStartupCue([{ startTime: 12, endTime: 15, text: 'later' }], 0, 2),
null,
);
});
test('selectAutoplayStartupCue skips blank cues', () => {
assert.deepEqual(
selectAutoplayStartupCue(
[
{ startTime: 0, endTime: 1, text: ' ' },
{ startTime: 0.5, endTime: 2, text: 'visible' },
],
0.75,
1,
),
{ startTime: 0.5, endTime: 2, text: 'visible' },
);
});
@@ -0,0 +1,31 @@
import type { SubtitleCue } from '../../types';
export function selectAutoplayStartupCue(
cues: SubtitleCue[],
currentTimeSeconds: number,
lookaheadSeconds: number,
): SubtitleCue | null {
const currentTime = Math.max(0, Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0);
const lookahead = Math.max(0, Number.isFinite(lookaheadSeconds) ? lookaheadSeconds : 0);
const latestStartTime = currentTime + lookahead;
for (const cue of cues) {
if (!cue.text.trim()) {
continue;
}
if (cue.startTime <= currentTime && cue.endTime > currentTime) {
return cue;
}
}
for (const cue of cues) {
if (!cue.text.trim()) {
continue;
}
if (cue.startTime >= currentTime && cue.startTime <= latestStartTime) {
return cue;
}
}
return null;
}
@@ -19,6 +19,60 @@ test('autoplay tokenization warm release signals immediately when warmups are re
assert.deepEqual(calls, ['signal']);
});
test('autoplay tokenization warm release primes subtitles before waiting for warmups', async () => {
const calls: string[] = [];
let resolveWarmup!: () => void;
const warmup = new Promise<void>((resolve) => {
resolveWarmup = resolve;
});
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => false,
startTokenizationWarmups: async () => {
calls.push('warmup');
await warmup;
},
getCurrentMediaPath: () => '/tmp/video.mkv',
primeCurrentSubtitle: () => {
calls.push('prime');
},
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video.mkv');
await Promise.resolve();
assert.deepEqual(calls, ['prime', 'warmup']);
resolveWarmup();
await warmup;
await Promise.resolve();
assert.deepEqual(calls, ['prime', 'warmup', 'signal']);
});
test('autoplay tokenization warm release does not await subtitle priming before signaling ready media', async () => {
const calls: string[] = [];
const never = new Promise<void>(() => {});
const release = createAutoplayTokenizationWarmRelease({
isTokenizationWarmupReady: () => true,
startTokenizationWarmups: async () => {
calls.push('warmup');
},
getCurrentMediaPath: () => '/tmp/video.mkv',
primeCurrentSubtitle: () => {
calls.push('prime');
return never;
},
signalAutoplayReady: () => calls.push('signal'),
warn: () => {},
});
release('/tmp/video.mkv');
await Promise.resolve();
assert.deepEqual(calls, ['prime', 'signal']);
});
test('autoplay tokenization warm release waits for warmups before signaling current media', async () => {
const calls: string[] = [];
let resolveWarmup!: () => void;
@@ -10,6 +10,7 @@ export function createAutoplayTokenizationWarmRelease(deps: {
isTokenizationWarmupReady: () => boolean;
startTokenizationWarmups: () => Promise<void>;
getCurrentMediaPath: () => string | null | undefined;
primeCurrentSubtitle?: (mediaPath: string) => void | Promise<void>;
signalAutoplayReady: () => void;
warn: (message: string, error: unknown) => void;
}): (mediaPath: string | null | undefined) => void {
@@ -26,6 +27,13 @@ export function createAutoplayTokenizationWarmRelease(deps: {
if (!normalizedPath) {
return;
}
try {
void Promise.resolve(deps.primeCurrentSubtitle?.(normalizedPath)).catch((error) => {
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
});
} catch (error) {
deps.warn('Startup subtitle priming failed before autoplay readiness release:', error);
}
if (deps.isTokenizationWarmupReady()) {
signalIfCurrent(normalizedPath);
return;
@@ -5,7 +5,6 @@ import { createBuildOpenJellyfinSetupWindowMainDepsHandler } from './jellyfin-se
test('open jellyfin setup window main deps builder maps callbacks', async () => {
const calls: string[] = [];
const expectedState = {
servers: [],
selectedServerUrl: 'a',
username: 'b',
hasStoredSession: false,
@@ -46,6 +45,10 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
showMpvOsd: (message) => calls.push(`osd:${message}`),
clearSetupWindow: () => calls.push('clear'),
setSetupWindow: () => calls.push('set-window'),
registerSetupIpcHandler: () => {
calls.push('register-ipc');
return () => calls.push('unregister-ipc');
},
encodeURIComponent: (value) => encodeURIComponent(value),
defaultServerUrl: 'http://127.0.0.1:8096',
hasStoredSession: () => true,
@@ -97,6 +100,8 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
deps.showMpvOsd('toast');
deps.clearSetupWindow();
deps.setSetupWindow({} as never);
const unregister = deps.registerSetupIpcHandler?.(async () => ({ handled: true }));
unregister?.();
assert.equal(deps.encodeURIComponent('a b'), 'a%20b');
assert.equal(deps.defaultServerUrl, 'http://127.0.0.1:8096');
assert.equal(deps.hasStoredSession(), true);
@@ -110,5 +115,7 @@ test('open jellyfin setup window main deps builder maps callbacks', async () =>
'osd:toast',
'clear',
'set-window',
'register-ipc',
'unregister-ipc',
]);
});
@@ -25,6 +25,9 @@ export function createBuildOpenJellyfinSetupWindowMainDepsHandler(
showMpvOsd: (message: string) => deps.showMpvOsd(message),
clearSetupWindow: () => deps.clearSetupWindow(),
setSetupWindow: (window) => deps.setSetupWindow(window),
registerSetupIpcHandler: deps.registerSetupIpcHandler
? (handler) => deps.registerSetupIpcHandler?.(handler) ?? (() => undefined)
: undefined,
encodeURIComponent: (value: string) => deps.encodeURIComponent(value),
defaultServerUrl: deps.defaultServerUrl,
hasStoredSession: () => deps.hasStoredSession(),
+156 -17
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildJellyfinSetupSubmissionUrl,
buildJellyfinSetupFormHtml,
buildJellyfinSetupViewState,
createHandleJellyfinSetupWindowClosedHandler,
@@ -9,18 +10,12 @@ import {
createHandleJellyfinSetupWindowOpenedHandler,
createMaybeFocusExistingJellyfinSetupWindowHandler,
createOpenJellyfinSetupWindowHandler,
normalizeJellyfinSetupIpcSubmission,
parseJellyfinSetupSubmissionUrl,
} from './jellyfin-setup-window';
test('buildJellyfinSetupFormHtml escapes default values', () => {
const html = buildJellyfinSetupFormHtml({
servers: [
{
serverUrl: 'http://host/"x"',
label: 'Configured "Server"',
source: 'config',
},
],
selectedServerUrl: 'http://host/"x"',
username: 'user"name',
hasStoredSession: true,
@@ -31,11 +26,16 @@ test('buildJellyfinSetupFormHtml escapes default values', () => {
assert.ok(html.includes('user&quot;name'));
assert.ok(html.includes('Ready &quot;now&quot;'));
assert.ok(html.includes('Logout'));
assert.equal(html.includes('Server presets'), false);
assert.equal(html.includes('serverSelect'), false);
assert.ok(html.includes('window.subminerJellyfinSetup'));
assert.ok(html.includes('Logging in to Jellyfin'));
assert.ok(html.includes('subminer://jellyfin-setup?'));
assert.equal(html.includes('params.set("password"'), false);
assert.equal(html.includes('params.set("password", passwordValue)'), false);
assert.ok(html.includes('window.__subminerJellyfinPassword = passwordValue'));
});
test('buildJellyfinSetupViewState composes config, recent, and default servers', () => {
test('buildJellyfinSetupViewState prefills configured server URL', () => {
const state = buildJellyfinSetupViewState({
config: {
serverUrl: ' http://configured:8096/ ',
@@ -46,19 +46,25 @@ test('buildJellyfinSetupViewState composes config, recent, and default servers',
hasStoredSession: false,
});
assert.deepEqual(
state.servers.map((server) => [server.serverUrl, server.source]),
[
['http://configured:8096', 'config'],
['http://recent:8096', 'recent'],
['http://127.0.0.1:8096', 'default'],
],
);
assert.equal(state.selectedServerUrl, 'http://configured:8096');
assert.equal(state.username, 'alice');
assert.equal(state.statusKind, 'idle');
});
test('buildJellyfinSetupViewState falls back to recent server URL', () => {
const state = buildJellyfinSetupViewState({
config: {
serverUrl: '',
username: 'alice',
recentServers: ['http://recent:8096'],
},
defaultServerUrl: 'http://127.0.0.1:8096',
hasStoredSession: false,
});
assert.equal(state.selectedServerUrl, 'http://recent:8096');
});
test('maybe focus jellyfin setup window no-ops without window', () => {
const handler = createMaybeFocusExistingJellyfinSetupWindowHandler({
getSetupWindow: () => null,
@@ -92,6 +98,38 @@ test('parseJellyfinSetupSubmissionUrl parses setup url parameters', () => {
assert.equal(parseJellyfinSetupSubmissionUrl('https://example.com'), null);
});
test('jellyfin setup ipc submissions normalize to password-free setup urls', () => {
const submission = normalizeJellyfinSetupIpcSubmission({
action: 'login',
server: 'http://localhost:8096',
username: 'alice',
password: 'secret',
});
assert.deepEqual(submission, {
action: 'login',
server: 'http://localhost:8096',
username: 'alice',
password: 'secret',
});
if (!submission) {
throw new Error('missing normalized submission');
}
const setupUrl = buildJellyfinSetupSubmissionUrl(submission);
assert.equal(
setupUrl,
'subminer://jellyfin-setup?action=login&server=http%3A%2F%2Flocalhost%3A8096&username=alice',
);
assert.equal(setupUrl.includes('secret'), false);
assert.deepEqual(normalizeJellyfinSetupIpcSubmission({ action: 'done' }), {
action: 'done',
server: '',
username: '',
password: '',
});
assert.equal(normalizeJellyfinSetupIpcSubmission('bad'), null);
});
test('createHandleJellyfinSetupSubmissionHandler applies successful login', async () => {
const calls: string[] = [];
let patchPayload: unknown = null;
@@ -512,3 +550,104 @@ test('createOpenJellyfinSetupWindowHandler wires navigation, load, and window li
onClosed();
assert.ok(calls.includes('clear-window'));
});
test('createOpenJellyfinSetupWindowHandler handles ipc bridge submissions', async () => {
const bridge: { handler?: (payload: unknown) => Promise<{ handled: boolean }> } = {};
let closedHandler: (() => void) | null = null;
const calls: string[] = [];
const fakeWindow = {
focus: () => {},
webContents: {
on: () => {},
executeJavaScript: async () => {
throw new Error('bridge path should not read from page');
},
},
loadURL: () => {
calls.push('load');
},
on: (event: 'closed', handler: () => void) => {
if (event === 'closed') {
closedHandler = handler;
}
},
isDestroyed: () => false,
close: () => calls.push('close'),
};
const handler = createOpenJellyfinSetupWindowHandler({
maybeFocusExistingSetupWindow: () => false,
createSetupWindow: () => fakeWindow,
getResolvedJellyfinConfig: () => ({
serverUrl: 'http://localhost:8096',
username: 'alice',
recentServers: [],
}),
buildSetupFormHtml: () => '<html></html>',
parseSubmissionUrl: (rawUrl) => parseJellyfinSetupSubmissionUrl(rawUrl),
authenticateWithPassword: async (_server, _username, password) => {
calls.push(`password:${password}`);
return {
serverUrl: 'http://localhost:8096',
username: 'alice',
accessToken: 'token',
userId: 'uid',
};
},
getJellyfinClientInfo: () => ({
clientName: 'SubMiner',
clientVersion: '1.0',
deviceId: 'did',
}),
saveStoredSession: () => calls.push('save'),
clearStoredSession: () => calls.push('clear'),
patchJellyfinConfig: () => calls.push('patch'),
logInfo: () => calls.push('info'),
logError: () => calls.push('error'),
showMpvOsd: (message) => calls.push(`osd:${message}`),
clearSetupWindow: () => calls.push('clear-window'),
setSetupWindow: () => calls.push('set-window'),
registerSetupIpcHandler: (nextHandler) => {
bridge.handler = nextHandler;
calls.push('register-ipc');
return () => calls.push('unregister-ipc');
},
encodeURIComponent: (value) => encodeURIComponent(value),
defaultServerUrl: 'http://127.0.0.1:8096',
hasStoredSession: () => false,
});
handler();
const bridgeHandler = bridge.handler;
if (!bridgeHandler) {
throw new Error('missing bridge handler');
}
assert.deepEqual(await bridgeHandler('bad'), {
handled: false,
statusMessage: 'Invalid Jellyfin setup request.',
statusKind: 'error',
});
assert.equal(calls.includes('password:'), false);
calls.length = 0;
assert.deepEqual(
await bridgeHandler({
action: 'login',
server: 'http://localhost:8096',
username: 'alice',
password: 'secret',
}),
{ handled: true },
);
assert.ok(calls.includes('password:secret'));
assert.ok(calls.includes('save'));
assert.ok(calls.includes('patch'));
const onClosed = closedHandler as (() => void) | null;
if (!onClosed) {
throw new Error('missing closed handler');
}
onClosed();
assert.ok(calls.includes('unregister-ipc'));
assert.ok(calls.includes('clear-window'));
});
+125 -54
View File
@@ -32,15 +32,14 @@ type JellyfinSetupWindowLike = FocusableWindowLike & {
export type JellyfinSetupAction = 'login' | 'logout' | 'done';
export type JellyfinSetupServerOption = {
serverUrl: string;
label: string;
source: 'config' | 'recent' | 'default';
username?: string;
export type JellyfinSetupSubmission = {
action: JellyfinSetupAction;
server: string;
username: string;
password: string;
};
export type JellyfinSetupViewState = {
servers: JellyfinSetupServerOption[];
selectedServerUrl: string;
username: string;
hasStoredSession: boolean;
@@ -55,6 +54,16 @@ type JellyfinSetupViewOverrides = {
statusKind?: JellyfinSetupViewState['statusKind'];
};
export type JellyfinSetupIpcResult = {
handled: boolean;
statusMessage?: string;
statusKind?: JellyfinSetupViewState['statusKind'];
};
type RegisterJellyfinSetupIpcHandler = (
handler: (submission: unknown) => Promise<JellyfinSetupIpcResult>,
) => () => void;
function escapeHtmlAttr(value: string): string {
return value.replace(/"/g, '&quot;');
}
@@ -67,6 +76,18 @@ function escapeHtml(value: string): string {
.replace(/"/g, '&quot;');
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function normalizeSetupAction(value: unknown): JellyfinSetupAction {
return value === 'logout' || value === 'done' ? value : 'login';
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
export function createMaybeFocusExistingJellyfinSetupWindowHandler(deps: {
getSetupWindow: () => FocusableWindowLike | null;
}) {
@@ -96,27 +117,6 @@ export function buildJellyfinSetupViewState(input: {
const configServer = normalizeJellyfinRecentServers([input.config.serverUrl || ''])[0] || '';
const recentServers = normalizeJellyfinRecentServers(input.config.recentServers || []);
const defaultServer = normalizeJellyfinRecentServers([input.defaultServerUrl])[0] || '';
const seen = new Set<string>();
const servers: JellyfinSetupServerOption[] = [];
const addServer = (serverUrl: string, source: JellyfinSetupServerOption['source']) => {
if (!serverUrl || seen.has(serverUrl)) return;
seen.add(serverUrl);
servers.push({
serverUrl,
label:
source === 'config'
? `${serverUrl} (configured)`
: source === 'default'
? `${serverUrl} (default)`
: serverUrl,
source,
});
};
addServer(configServer, 'config');
for (const recent of recentServers) addServer(recent, 'recent');
addServer(defaultServer, 'default');
const selectedServerUrl =
normalizeJellyfinRecentServers([input.selectedServerUrl || ''])[0] ||
@@ -125,7 +125,6 @@ export function buildJellyfinSetupViewState(input: {
defaultServer;
return {
servers,
selectedServerUrl,
username: input.username ?? input.config.username ?? '',
hasStoredSession: input.hasStoredSession,
@@ -135,14 +134,6 @@ export function buildJellyfinSetupViewState(input: {
}
export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): string {
const options = state.servers
.map(
(server) =>
`<option value="${escapeHtmlAttr(server.serverUrl)}"${
server.serverUrl === state.selectedServerUrl ? ' selected' : ''
}>${escapeHtml(server.label)}</option>`,
)
.join('');
const statusClass = `status ${state.statusKind}`;
return `<!doctype html>
<html>
@@ -156,8 +147,9 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin
h1 { margin: 0 0 8px; font-size: 24px; letter-spacing: 0; }
p { margin: 0 0 16px; color: var(--muted); font-size: 13px; line-height: 1.45; }
label { display: block; margin: 12px 0 5px; font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
input, select { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; }
input { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; }
button { padding: 10px 12px; border: 1px solid #6f831f; border-radius: 6px; font-weight: 700; cursor: pointer; background: var(--accent); color: #14170f; }
button:disabled { cursor: wait; opacity: .68; }
button.secondary { background: transparent; color: var(--text); border-color: var(--line); }
button.danger { background: transparent; color: var(--danger); border-color: #6b332f; }
.actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 16px; }
@@ -171,17 +163,15 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin
<body>
<main>
<h1>Jellyfin Setup</h1>
<p>Choose a server, sign in, and SubMiner will save a session token for Jellyfin commands and cast discovery.</p>
<p>Enter your Jellyfin server URL, sign in, and SubMiner will save a session token for Jellyfin commands and cast discovery.</p>
<form id="form">
<label for="serverSelect">Known servers</label>
<select id="serverSelect">${options}</select>
<label for="server">Server URL</label>
<input id="server" name="server" value="${escapeHtmlAttr(state.selectedServerUrl)}" required />
<label for="username">Username</label>
<input id="username" name="username" value="${escapeHtmlAttr(state.username)}" required />
<label for="password">Password</label>
<input id="password" name="password" type="password" required />
<div id="status" class="${statusClass}">${escapeHtml(state.statusMessage)}</div>
<div id="status" class="${statusClass}" aria-live="polite">${escapeHtml(state.statusMessage)}</div>
<div class="actions">
<button class="primary" type="submit">Login</button>
${
@@ -196,19 +186,54 @@ export function buildJellyfinSetupFormHtml(state: JellyfinSetupViewState): strin
</main>
<script>
const form = document.getElementById("form");
const select = document.getElementById("serverSelect");
const server = document.getElementById("server");
select?.addEventListener("change", () => {
server.value = select.value || server.value;
});
function submitAction(action) {
const username = document.getElementById("username");
const password = document.getElementById("password");
const status = document.getElementById("status");
const buttons = Array.from(document.querySelectorAll("button"));
function setBusy(message) {
if (status) {
status.textContent = message;
status.className = "status loading";
}
for (const button of buttons) button.disabled = true;
}
function setStatus(message, kind) {
if (status) {
status.textContent = message;
status.className = "status " + kind;
}
for (const button of buttons) button.disabled = false;
}
async function submitAction(action) {
const serverValue = String(server?.value || "");
const usernameValue = String(username?.value || "");
const passwordValue = String(password?.value || "");
setBusy(action === "login" ? "Logging in to Jellyfin..." : action === "logout" ? "Logging out..." : "Closing...");
const bridge = window.subminerJellyfinSetup;
if (bridge?.submit) {
try {
const result = await bridge.submit({
action,
server: serverValue,
username: usernameValue,
password: passwordValue,
});
if (result?.handled === false) {
setStatus(result.statusMessage || "Jellyfin setup action was not accepted.", result.statusKind || "error");
}
} catch (error) {
const message = error && typeof error === "object" && "message" in error ? String(error.message) : String(error || "Unknown error");
setStatus("Jellyfin setup action failed: " + message, "error");
}
return;
}
const params = new URLSearchParams();
params.set("action", action);
if (action === "login") {
const data = new FormData(form);
params.set("server", String(data.get("server") || ""));
params.set("username", String(data.get("username") || ""));
window.__subminerJellyfinPassword = String(data.get("password") || "");
params.set("server", serverValue);
params.set("username", usernameValue);
window.__subminerJellyfinPassword = passwordValue;
}
window.location.href = "subminer://jellyfin-setup?" + params.toString();
}
@@ -244,6 +269,30 @@ export function parseJellyfinSetupSubmissionUrl(rawUrl: string): {
};
}
export function normalizeJellyfinSetupIpcSubmission(
value: unknown,
): JellyfinSetupSubmission | null {
if (!isRecord(value)) {
return null;
}
return {
action: normalizeSetupAction(value.action),
server: normalizeString(value.server),
username: normalizeString(value.username),
password: normalizeString(value.password),
};
}
export function buildJellyfinSetupSubmissionUrl(submission: JellyfinSetupSubmission): string {
const params = new URLSearchParams();
params.set('action', submission.action);
if (submission.action === 'login') {
params.set('server', submission.server);
params.set('username', submission.username);
}
return `subminer://jellyfin-setup?${params.toString()}`;
}
export function createHandleJellyfinSetupSubmissionHandler(deps: {
parseSubmissionUrl: (
rawUrl: string,
@@ -432,6 +481,7 @@ export function createOpenJellyfinSetupWindowHandler<
showMpvOsd: (message: string) => void;
clearSetupWindow: () => void;
setSetupWindow: (window: TWindow) => void;
registerSetupIpcHandler?: RegisterJellyfinSetupIpcHandler;
encodeURIComponent: (value: string) => string;
defaultServerUrl: string;
hasStoredSession: () => boolean;
@@ -480,14 +530,34 @@ export function createOpenJellyfinSetupWindowHandler<
}
},
});
const unregisterSetupIpcHandler = deps.registerSetupIpcHandler?.(async (payload) => {
const submission = normalizeJellyfinSetupIpcSubmission(payload);
if (!submission) {
return {
handled: false,
statusMessage: 'Invalid Jellyfin setup request.',
statusKind: 'error',
};
}
const handled = await handleSubmission(
buildJellyfinSetupSubmissionUrl(submission),
submission.password,
);
return { handled };
});
const handleNavigation = createHandleJellyfinSetupNavigationHandler({
setupSchemePrefix: 'subminer://jellyfin-setup',
handleSubmission: async (rawUrl) => {
const submission = deps.parseSubmissionUrl(rawUrl);
const password =
submission?.action === 'login' && !submission.password
? await readJellyfinSetupPasswordFromWindow(setupWindow)
: undefined;
let password: string | undefined;
if (submission?.action === 'login' && !submission.password) {
try {
password = await readJellyfinSetupPasswordFromWindow(setupWindow);
} catch (error) {
deps.logError('Failed reading Jellyfin setup password', error);
password = '';
}
}
return handleSubmission(rawUrl, password);
},
logError: (message, error) => deps.logError(message, error),
@@ -512,6 +582,7 @@ export function createOpenJellyfinSetupWindowHandler<
});
loadSetupForm();
setupWindow.on('closed', () => {
unregisterSetupIpcHandler?.();
handleWindowClosed();
});
handleWindowOpened();
@@ -56,6 +56,23 @@ test('createCreateJellyfinSetupWindowHandler builds jellyfin setup window', () =
});
});
test('createCreateJellyfinSetupWindowHandler wires optional preload bridge', () => {
const captured: { options?: Electron.BrowserWindowConstructorOptions } = {};
const createSetupWindow = createCreateJellyfinSetupWindowHandler({
createBrowserWindow: (nextOptions) => {
captured.options = nextOptions;
return { id: 'jellyfin' } as never;
},
preloadPath: 'C:\\SubMiner\\dist\\preload-jellyfin-setup.js',
});
assert.deepEqual(createSetupWindow(), { id: 'jellyfin' });
const options = captured.options;
assert.ok(options);
assert.equal(options.webPreferences?.preload, 'C:\\SubMiner\\dist\\preload-jellyfin-setup.js');
assert.equal(options.webPreferences?.sandbox, true);
});
test('createCreateAnilistSetupWindowHandler builds anilist setup window', () => {
let options: Electron.BrowserWindowConstructorOptions | null = null;
const createSetupWindow = createCreateAnilistSetupWindowHandler({
+2
View File
@@ -49,11 +49,13 @@ export function createCreateFirstRunSetupWindowHandler<TWindow>(deps: {
export function createCreateJellyfinSetupWindowHandler<TWindow>(deps: {
createBrowserWindow: (options: Electron.BrowserWindowConstructorOptions) => TWindow;
preloadPath?: string;
}) {
return createSetupWindowHandler(deps, {
width: 520,
height: 560,
title: 'Jellyfin Setup',
...(deps.preloadPath ? { preloadPath: deps.preloadPath, sandbox: true } : {}),
});
}
+2 -2
View File
@@ -336,12 +336,12 @@ test('known Linux package-managed AppImage detection follows the canonical AUR p
);
});
test('native updater is unsupported on Windows by default', async () => {
test('windows native updater is supported for packaged builds', async () => {
const supported = await isNativeUpdaterSupported({
platform: 'win32',
isPackaged: true,
execPath: 'C:\\Program Files\\SubMiner\\SubMiner.exe',
});
assert.equal(supported, false);
assert.equal(supported, true);
});
+3
View File
@@ -114,6 +114,9 @@ export async function isNativeUpdaterSupported(options: {
);
return false;
}
if (options.platform === 'win32') {
return true;
}
if (options.platform !== 'darwin') {
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
return false;
@@ -44,9 +44,9 @@ test('curl HTTP executor requests updater metadata without Electron networking',
});
test('curl HTTP executor downloads updater assets to the requested destination', async () => {
const calls: Array<{ args: readonly string[] }> = [];
const execFile: CurlExecFile = (_file, args, _options, callback) => {
calls.push({ args });
const calls: Array<{ args: readonly string[]; timeout?: number }> = [];
const execFile: CurlExecFile = (_file, args, options, callback) => {
calls.push({ args, timeout: options.timeout });
queueMicrotask(() => callback(null, Buffer.alloc(0), Buffer.alloc(0)));
return { kill: () => true };
};
@@ -54,6 +54,7 @@ test('curl HTTP executor downloads updater assets to the requested destination',
execFile,
curlPath: '/usr/bin/curl',
mkdir: async () => undefined,
downloadTimeoutMs: 120_000,
});
await executor.download(
@@ -75,12 +76,15 @@ test('curl HTTP executor downloads updater assets to the requested destination',
'--show-error',
'--connect-timeout',
'30',
'--max-time',
'120',
'--header',
'User-Agent: SubMiner updater',
'--output',
'/tmp/subminer/update.zip',
'https://github.com/ksyasuda/SubMiner/releases/download/v1/app.zip',
]);
assert.equal(calls[0]?.timeout, 120_000);
});
test('curl HTTP executor verifies downloaded updater asset hashes', async () => {
@@ -30,6 +30,7 @@ type CurlDownloadOptions = {
sha2?: string | null;
sha512?: string | null;
cancellationToken: CancellationTokenLike;
timeout?: number;
};
export type CurlHttpExecutor = {
@@ -132,6 +133,7 @@ export function createCurlHttpExecutor(
curlPath?: string;
mkdir?: (targetPath: string) => Promise<unknown>;
readFile?: (targetPath: string) => Promise<Buffer>;
downloadTimeoutMs?: number;
} = {},
): CurlHttpExecutor {
const execFile = options.execFile ?? (defaultExecFile as unknown as CurlExecFile);
@@ -139,6 +141,7 @@ export function createCurlHttpExecutor(
const mkdir =
options.mkdir ?? ((targetPath: string) => fs.promises.mkdir(targetPath, { recursive: true }));
const readFile = options.readFile ?? ((targetPath: string) => fs.promises.readFile(targetPath));
const downloadTimeoutMs = options.downloadTimeoutMs ?? 120_000;
async function verifyDownloadedFile(destination: string, downloadOptions: CurlDownloadOptions) {
if (!downloadOptions.sha512 && !downloadOptions.sha2) return;
@@ -181,7 +184,8 @@ export function createCurlHttpExecutor(
},
async download(url, destination, downloadOptions): Promise<string> {
await mkdir(path.dirname(destination));
const args = buildBaseArgs();
const timeout = downloadOptions.timeout ?? downloadTimeoutMs;
const args = buildBaseArgs(timeout);
addHeaderArgs(args, downloadOptions.headers);
args.push('--output', destination, url.href);
await runCurl<Buffer>({
@@ -190,13 +194,15 @@ export function createCurlHttpExecutor(
args,
encoding: 'buffer',
maxBuffer: 1024 * 1024,
timeout,
cancellationToken: downloadOptions.cancellationToken,
});
await verifyDownloadedFile(destination, downloadOptions);
return destination;
},
async downloadToBuffer(url, downloadOptions): Promise<Buffer> {
const args = buildBaseArgs();
const timeout = downloadOptions.timeout ?? downloadTimeoutMs;
const args = buildBaseArgs(timeout);
addHeaderArgs(args, downloadOptions.headers);
args.push(url.href);
return await runCurl<Buffer>({
@@ -205,6 +211,7 @@ export function createCurlHttpExecutor(
args,
encoding: 'buffer',
maxBuffer: 600 * 1024 * 1024,
timeout,
cancellationToken: downloadOptions.cancellationToken,
});
},
+30 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCurlFetch, createElectronNetFetch } from './fetch-adapter';
import { createCurlFetch, createElectronNetFetch, createGlobalFetch } from './fetch-adapter';
import type { FetchResponseLike } from './release-assets';
test('createElectronNetFetch delegates updater requests to Electron net.fetch', async () => {
@@ -34,6 +34,35 @@ test('createElectronNetFetch delegates updater requests to Electron net.fetch',
]);
});
test('createGlobalFetch delegates updater requests to main-process fetch', async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const response: FetchResponseLike = {
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ ok: true }),
text: async () => 'ok',
arrayBuffer: async () => new ArrayBuffer(0),
};
const fetch = createGlobalFetch(async (url, init) => {
calls.push({ url, init });
return response;
});
const result = await fetch('https://api.github.com/repos/ksyasuda/SubMiner/releases', {
headers: { 'User-Agent': 'SubMiner updater' },
});
assert.equal(result, response);
assert.deepEqual(calls, [
{
url: 'https://api.github.com/repos/ksyasuda/SubMiner/releases',
init: { headers: { 'User-Agent': 'SubMiner updater' } } as RequestInit,
},
]);
});
test('createCurlFetch requests updater metadata without Electron networking', async () => {
const calls: Array<{
file: string;
+13
View File
@@ -6,10 +6,23 @@ export interface ElectronNetFetchLike {
fetch: (url: string, init?: Record<string, unknown>) => Promise<FetchResponseLike>;
}
export type GlobalFetchLike = (url: string, init?: RequestInit) => Promise<FetchResponseLike>;
export function createElectronNetFetch(net: ElectronNetFetchLike): FetchLike {
return (url, init) => net.fetch(url, init);
}
function getGlobalFetch(): GlobalFetchLike {
if (typeof globalThis.fetch !== 'function') {
throw new Error('Global fetch is not available for updater requests.');
}
return globalThis.fetch.bind(globalThis) as GlobalFetchLike;
}
export function createGlobalFetch(fetchImpl?: GlobalFetchLike): FetchLike {
return (url, init) => (fetchImpl ?? getGlobalFetch())(url, init as RequestInit);
}
type CurlFetchOptions = {
execFile?: CurlExecFile;
curlPath?: string;
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import test from 'node:test';
import { createFetchHttpExecutor } from './fetch-http-executor';
function neverCancel<T>(
callback: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (error: Error) => void,
onCancel: (callback: () => void) => void,
) => void,
): Promise<T> {
return new Promise<T>((resolve, reject) => callback(resolve, reject, () => {}));
}
test('fetch HTTP executor requests updater metadata without Electron networking', async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const executor = createFetchHttpExecutor({
fetch: async (url, init) => {
calls.push({ url, init });
return new Response('version: 0.15.0');
},
});
const result = await executor.request({
protocol: 'https:',
hostname: 'example.test',
path: '/latest.yml',
headers: { Accept: 'application/octet-stream' },
timeout: 5_000,
});
assert.equal(result, 'version: 0.15.0');
assert.equal(calls[0]?.url, 'https://example.test/latest.yml');
assert.equal((calls[0]?.init?.headers as Headers).get('Accept'), 'application/octet-stream');
assert.equal(calls[0]?.init?.method, 'GET');
});
test('fetch HTTP executor downloads updater assets to the requested destination', async () => {
const data = Buffer.from('installer');
const written: Array<{ path: string; data: Buffer }> = [];
const executor = createFetchHttpExecutor({
fetch: async () => new Response(new Uint8Array(data)),
mkdir: async () => undefined,
writeFile: async (targetPath, body) => {
written.push({ path: targetPath, data: body });
},
});
const destination = await executor.download(
new URL('https://example.test/SubMiner-0.15.0.exe'),
'C:\\Temp\\SubMiner-0.15.0.exe',
{
cancellationToken: {
createPromise: neverCancel,
},
sha2: createHash('sha256').update(data).digest('hex'),
},
);
assert.equal(destination, 'C:\\Temp\\SubMiner-0.15.0.exe');
assert.deepEqual(written, [{ path: destination, data }]);
});
test('fetch HTTP executor verifies updater asset hashes', async () => {
const executor = createFetchHttpExecutor({
fetch: async () => new Response('wrong data'),
mkdir: async () => undefined,
writeFile: async () => {
throw new Error('should not write mismatched data');
},
});
await assert.rejects(
executor.download(new URL('https://example.test/SubMiner.exe'), 'C:\\Temp\\SubMiner.exe', {
cancellationToken: {
createPromise: neverCancel,
},
sha2: createHash('sha256').update('expected data').digest('hex'),
}),
/sha2 mismatch/,
);
});
test('fetch HTTP executor applies download timeout to updater asset fetches', async () => {
const executor = createFetchHttpExecutor({
downloadTimeoutMs: 1,
fetch: async (_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('download aborted')), {
once: true,
});
}),
mkdir: async () => undefined,
writeFile: async () => {
throw new Error('should not write timed-out data');
},
});
await assert.rejects(
executor.download(new URL('https://example.test/SubMiner.exe'), 'C:\\Temp\\SubMiner.exe', {
cancellationToken: {
createPromise: neverCancel,
},
}),
/download aborted/,
);
});
test('fetch HTTP executor applies download timeout to buffer fetches', async () => {
const executor = createFetchHttpExecutor({
downloadTimeoutMs: 1,
fetch: async (_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('buffer aborted')), {
once: true,
});
}),
});
await assert.rejects(
executor.downloadToBuffer(new URL('https://example.test/SubMiner.exe'), {
cancellationToken: {
createPromise: neverCancel,
},
}),
/buffer aborted/,
);
});
@@ -0,0 +1,197 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import type { OutgoingHttpHeaders, RequestOptions } from 'node:http';
type CancellationTokenLike = {
createPromise: <T>(
callback: (
resolve: (value: T | PromiseLike<T>) => void,
reject: (error: Error) => void,
onCancel: (callback: () => void) => void,
) => void,
) => Promise<T>;
};
type FetchDownloadOptions = {
headers?: OutgoingHttpHeaders | null;
sha2?: string | null;
sha512?: string | null;
cancellationToken: CancellationTokenLike;
timeout?: number;
};
export type FetchHttpExecutor = {
request: (
options: RequestOptions,
cancellationToken?: CancellationTokenLike,
data?: Record<string, unknown> | null,
) => Promise<string | null>;
download: (url: URL, destination: string, options: FetchDownloadOptions) => Promise<string>;
downloadToBuffer: (url: URL, options: FetchDownloadOptions) => Promise<Buffer>;
};
type FetchImpl = (url: string, init?: RequestInit) => Promise<Response>;
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
function requestOptionsToUrl(options: RequestOptions): string {
const protocol = options.protocol ?? 'https:';
const hostname = options.hostname ?? options.host;
if (!hostname) throw new Error('Updater request is missing a hostname.');
const port = options.port ? `:${options.port}` : '';
const requestPath = options.path ?? '/';
return `${protocol}//${hostname}${port}${requestPath}`;
}
function toHeaders(headers: RequestOptions['headers'] | OutgoingHttpHeaders | null | undefined) {
const result = new Headers();
if (Array.isArray(headers)) {
for (let index = 0; index < headers.length; index += 2) {
const name = headers[index];
const value = headers[index + 1];
if (name !== undefined && value !== undefined) {
result.append(String(name), String(value));
}
}
return result;
}
for (const [name, value] of Object.entries(headers ?? {})) {
if (value === undefined || value === null) continue;
const values = Array.isArray(value) ? value : [value];
for (const item of values) {
result.append(name, String(item));
}
}
return result;
}
function runWithCancellation<T>(
operation: (signal: AbortSignal) => Promise<T>,
cancellationToken?: CancellationTokenLike,
timeoutMs?: number,
): Promise<T> {
const run = (
resolve: (value: T | PromiseLike<T>) => void,
reject: (error: Error) => void,
onCancel: (callback: () => void) => void,
) => {
const controller = new AbortController();
const timeout =
typeof timeoutMs === 'number' && timeoutMs > 0
? setTimeout(() => controller.abort(), timeoutMs)
: null;
onCancel(() => {
controller.abort();
});
operation(controller.signal)
.then(resolve, reject)
.finally(() => {
if (timeout) clearTimeout(timeout);
});
};
if (cancellationToken) {
return cancellationToken.createPromise<T>(run);
}
return new Promise<T>((resolve, reject) => run(resolve, reject, () => {}));
}
async function fetchBuffer(
fetchImpl: FetchImpl,
url: string,
init: RequestInit,
cancellationToken?: CancellationTokenLike,
timeoutMs?: number,
): Promise<Buffer> {
const response = await runWithCancellation(
(signal) => fetchImpl(url, { ...init, signal }),
cancellationToken,
timeoutMs,
);
if (!response.ok) {
throw new Error(`Updater request failed with ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
function verifyDownloadedData(data: Buffer, downloadOptions: FetchDownloadOptions) {
if (downloadOptions.sha512) {
const actual = createHash('sha512').update(data).digest('base64');
if (actual !== downloadOptions.sha512) {
throw new Error(`sha512 mismatch: expected ${downloadOptions.sha512}, got ${actual}`);
}
}
if (downloadOptions.sha2) {
const actual = createHash('sha256').update(data).digest('hex');
if (actual !== downloadOptions.sha2.toLowerCase()) {
throw new Error(`sha2 mismatch: expected ${downloadOptions.sha2}, got ${actual}`);
}
}
}
export function createFetchHttpExecutor(
options: {
fetch?: FetchImpl;
mkdir?: (targetPath: string) => Promise<unknown>;
writeFile?: (targetPath: string, data: Buffer) => Promise<unknown>;
downloadTimeoutMs?: number;
} = {},
): FetchHttpExecutor {
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
const mkdir =
options.mkdir ?? ((targetPath: string) => fs.promises.mkdir(targetPath, { recursive: true }));
const writeFile =
options.writeFile ??
((targetPath: string, data: Buffer) => fs.promises.writeFile(targetPath, data));
const downloadTimeoutMs = options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
return {
async request(requestOptions, cancellationToken, data): Promise<string | null> {
const headers = toHeaders(requestOptions.headers);
const body = data ? JSON.stringify(data) : undefined;
const result = await fetchBuffer(
fetchImpl,
requestOptionsToUrl(requestOptions),
{
method: requestOptions.method ?? (body ? 'POST' : 'GET'),
headers,
body,
redirect: 'follow',
},
cancellationToken,
requestOptions.timeout,
);
return result.length === 0 ? null : result.toString('utf8');
},
async download(url, destination, downloadOptions): Promise<string> {
await mkdir(path.dirname(destination));
const data = await fetchBuffer(
fetchImpl,
url.href,
{
headers: toHeaders(downloadOptions.headers),
redirect: 'follow',
},
downloadOptions.cancellationToken,
downloadOptions.timeout ?? downloadTimeoutMs,
);
verifyDownloadedData(data, downloadOptions);
await writeFile(destination, data);
return destination;
},
async downloadToBuffer(url, downloadOptions): Promise<Buffer> {
const data = await fetchBuffer(
fetchImpl,
url.href,
{
headers: toHeaders(downloadOptions.headers),
redirect: 'follow',
},
downloadOptions.cancellationToken,
downloadOptions.timeout ?? downloadTimeoutMs,
);
verifyDownloadedData(data, downloadOptions);
return data;
},
};
}
+39
View File
@@ -0,0 +1,39 @@
import { contextBridge, ipcRenderer } from 'electron';
const JELLYFIN_SETUP_SUBMIT_CHANNEL = 'jellyfin:setup-submit';
type JellyfinSetupAction = 'login' | 'logout' | 'done';
type JellyfinSetupSubmission = {
action?: unknown;
server?: unknown;
username?: unknown;
password?: unknown;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function normalizeAction(value: unknown): JellyfinSetupAction {
return value === 'logout' || value === 'done' ? value : 'login';
}
function normalizeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function normalizeSubmission(value: unknown): Required<JellyfinSetupSubmission> {
const record = isRecord(value) ? value : {};
return {
action: normalizeAction(record.action),
server: normalizeString(record.server),
username: normalizeString(record.username),
password: normalizeString(record.password),
};
}
contextBridge.exposeInMainWorld('subminerJellyfinSetup', {
submit: (submission: JellyfinSetupSubmission): Promise<unknown> =>
ipcRenderer.invoke(JELLYFIN_SETUP_SUBMIT_CHANNEL, normalizeSubmission(submission)),
});
+5 -2
View File
@@ -68,10 +68,13 @@ function normalizeKeyToken(token: string): string {
}
function formatKeybinding(rawBinding: string): string {
const parts = rawBinding.split('+');
const parts = rawBinding
.split('+')
.map((part) => part.trim())
.filter(Boolean);
const key = parts.pop();
if (!key) return rawBinding;
const normalized = [...parts, normalizeKeyToken(key)];
const normalized = [...parts.map(normalizeKeyToken), normalizeKeyToken(key)];
return normalized.join(' + ');
}
+4
View File
@@ -33,6 +33,10 @@ test('session help formats bracket keybindings as physical keys', () => {
assert.equal(formatSessionHelpKeybinding('Shift+BracketLeft'), 'Shift + [');
});
test('session help normalizes configured modifier aliases', () => {
assert.equal(formatSessionHelpKeybinding('CommandOrControl+KeyS'), 'Cmd/Ctrl + S');
});
test('session help imports browser-safe special command constants', () => {
const source = fs.readFileSync(
path.join(process.cwd(), 'src', 'renderer', 'modals', 'session-help-sections.ts'),
+6 -6
View File
@@ -660,7 +660,7 @@ body.subtitle-sidebar-embedded-open #subtitleContainer {
--subtitle-jlpt-n4-color: #a6e3a1;
--subtitle-jlpt-n5-color: #8aadf4;
--subtitle-hover-token-color: #f4dbd6;
--subtitle-hover-token-background-color: rgba(54, 58, 79, 0.84);
--subtitle-hover-token-background-color: transparent;
--subtitle-frequency-single-color: #f5a97f;
--subtitle-frequency-band-1-color: #ed8796;
--subtitle-frequency-band-2-color: #f5a97f;
@@ -719,7 +719,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
}
#subtitleRoot .c:hover {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
background: var(--subtitle-hover-token-background-color, transparent);
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
border-radius: 2px;
@@ -884,7 +884,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
):not(.word-frequency-band-1):not(.word-frequency-band-2):not(.word-frequency-band-3):not(
.word-frequency-band-4
):not(.word-frequency-band-5):hover {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
background: var(--subtitle-hover-token-background-color, transparent);
border-radius: 3px;
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
@@ -899,7 +899,7 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot .word.word-frequency-band-3:hover,
#subtitleRoot .word.word-frequency-band-4:hover,
#subtitleRoot .word.word-frequency-band-5:hover {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
background: var(--subtitle-hover-token-background-color, transparent);
border-radius: 3px;
filter: brightness(1.18) saturate(1.08);
}
@@ -933,13 +933,13 @@ body.settings-modal-open [data-subminer-yomitan-popup-host='true'] {
#subtitleRoot::selection,
#subtitleRoot .word::selection,
#subtitleRoot .c::selection {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84));
background: var(--subtitle-hover-token-background-color, transparent);
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
}
#subtitleRoot *::selection {
background: var(--subtitle-hover-token-background-color, rgba(54, 58, 79, 0.84)) !important;
background: var(--subtitle-hover-token-background-color, transparent) !important;
color: var(--subtitle-hover-token-color, #f4dbd6) !important;
-webkit-text-fill-color: var(--subtitle-hover-token-color, #f4dbd6) !important;
}
+62 -8
View File
@@ -45,6 +45,12 @@ class FakeStyleDeclaration {
setProperty(name: string, value: string) {
this.values.set(name, value);
}
removeProperty(name: string) {
const previous = this.values.get(name) ?? '';
this.values.delete(name);
return previous;
}
}
class FakeElement {
@@ -475,6 +481,57 @@ test('applySubtitleStyle applies primary and secondary css declaration objects',
}
});
test('applySubtitleStyle removes css declarations missing from later updates', () => {
const restoreDocument = installFakeDocument();
try {
const subtitleRoot = new FakeElement('div');
const subtitleContainer = new FakeElement('div');
const secondarySubRoot = new FakeElement('div');
const secondarySubContainer = new FakeElement('div');
const ctx = {
state: createRendererState(),
dom: {
subtitleRoot,
subtitleContainer,
secondarySubRoot,
secondarySubContainer,
},
} as never;
const renderer = createSubtitleRenderer(ctx);
renderer.applySubtitleStyle({
css: {
'font-size': '42px',
'text-wrap': 'balance',
},
secondary: {
css: {
'text-transform': 'uppercase',
},
},
} as never);
renderer.applySubtitleStyle({
css: {
'font-size': '44px',
},
secondary: {
css: {},
},
} as never);
const primaryValues = (subtitleRoot.style as unknown as { values?: Map<string, string> })
.values;
const secondaryValues = (secondarySubRoot.style as unknown as { values?: Map<string, string> })
.values;
assert.equal(primaryValues?.get('font-size'), '44px');
assert.equal(primaryValues?.has('text-wrap'), false);
assert.equal(secondaryValues?.has('text-transform'), false);
} finally {
restoreDocument();
}
});
test('annotated subtitle tokens inherit configured base subtitle typography', () => {
const restoreDocument = installFakeDocument();
try {
@@ -1009,10 +1066,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
const subtitleRootBlock = extractClassBlock(cssText, '#subtitleRoot');
assert.match(subtitleRootBlock, /--subtitle-hover-token-color:\s*#f4dbd6;/);
assert.match(
subtitleRootBlock,
/--subtitle-hover-token-background-color:\s*rgba\(54,\s*58,\s*79,\s*0\.84\);/,
);
assert.match(subtitleRootBlock, /--subtitle-hover-token-background-color:\s*transparent;/);
assert.match(subtitleRootBlock, /-webkit-text-fill-color:\s*currentColor;/);
const charBlock = extractClassBlock(cssText, '#subtitleRoot .c');
@@ -1066,7 +1120,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
);
assert.match(
plainWordHoverBlock,
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
/background:\s*var\(--subtitle-hover-token-background-color,\s*transparent\);/,
);
assert.match(
plainWordHoverBlock,
@@ -1080,7 +1134,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
const coloredWordHoverBlock = extractClassBlock(cssText, '#subtitleRoot .word.word-known:hover');
assert.match(
coloredWordHoverBlock,
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
/background:\s*var\(--subtitle-hover-token-background-color,\s*transparent\);/,
);
assert.match(coloredWordHoverBlock, /border-radius:\s*3px;/);
assert.match(coloredWordHoverBlock, /filter:\s*brightness\(1\.18\) saturate\(1\.08\);/);
@@ -1189,7 +1243,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
const selectionBlock = extractClassBlock(cssText, '#subtitleRoot::selection');
assert.match(
selectionBlock,
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\);/,
/background:\s*var\(--subtitle-hover-token-background-color,\s*transparent\);/,
);
assert.match(
selectionBlock,
@@ -1203,7 +1257,7 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
const descendantSelectionBlock = extractClassBlock(cssText, '#subtitleRoot *::selection');
assert.match(
descendantSelectionBlock,
/background:\s*var\(--subtitle-hover-token-background-color,\s*rgba\(54,\s*58,\s*79,\s*0\.84\)\)\s*!important;/,
/background:\s*var\(--subtitle-hover-token-background-color,\s*transparent\)\s*!important;/,
);
assert.match(
descendantSelectionBlock,
+47 -6
View File
@@ -80,12 +80,10 @@ export function sanitizeSubtitleHoverTokenColor(value: unknown): string {
function sanitizeSubtitleHoverTokenBackgroundColor(value: unknown): string {
if (typeof value !== 'string') {
return 'rgba(54, 58, 79, 0.84)';
return 'transparent';
}
const trimmed = value.trim();
return trimmed.length > 0 && SAFE_CSS_COLOR_PATTERN.test(trimmed)
? trimmed
: 'rgba(54, 58, 79, 0.84)';
return trimmed.length > 0 && SAFE_CSS_COLOR_PATTERN.test(trimmed) ? trimmed : 'transparent';
}
const DEFAULT_FREQUENCY_RENDER_SETTINGS: FrequencyRenderSettings = {
@@ -158,6 +156,49 @@ function applyInlineStyleDeclarations(
}
}
const appliedCssKeys = new WeakMap<HTMLElement, Set<string>>();
function inlineStyleDeclarationKeys(
declarations: Record<string, unknown>,
excludedKeys: ReadonlySet<string>,
): Set<string> {
const keys = new Set<string>();
for (const [key, value] of Object.entries(declarations)) {
if (excludedKeys.has(key)) continue;
if (value === null || value === undefined || typeof value === 'object') continue;
keys.add(key);
}
return keys;
}
function clearInlineStyleDeclaration(target: HTMLElement, key: string): void {
if (key.includes('-')) {
target.style.removeProperty(key);
if (key === '--webkit-text-stroke') {
target.style.removeProperty('-webkit-text-stroke');
}
return;
}
(target.style as unknown as Record<string, string>)[key] = '';
}
function replaceInlineStyleDeclarations(
target: HTMLElement,
declarations: Record<string, unknown>,
excludedKeys: ReadonlySet<string> = new Set<string>(),
): void {
const nextKeys = inlineStyleDeclarationKeys(declarations, excludedKeys);
const previousKeys = appliedCssKeys.get(target) ?? new Set<string>();
for (const key of previousKeys) {
if (!nextKeys.has(key)) {
clearInlineStyleDeclaration(target, key);
}
}
applyInlineStyleDeclarations(target, declarations, excludedKeys);
appliedCssKeys.set(target, nextKeys);
}
function normalizeCssDeclarationObject(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
@@ -177,8 +218,8 @@ function applySubtitleCssDeclarations(
container: HTMLElement,
declarations: Record<string, string>,
): void {
applyInlineStyleDeclarations(root, declarations, CONTAINER_STYLE_KEYS);
applyInlineStyleDeclarations(
replaceInlineStyleDeclarations(root, declarations, CONTAINER_STYLE_KEYS);
replaceInlineStyleDeclarations(
container,
pickInlineStyleDeclarations(declarations, CONTAINER_STYLE_KEYS),
);
+2 -2
View File
@@ -14,7 +14,7 @@ test('serializeSubtitleCssDeclarations builds primary CSS from all managed appea
'subtitleStyle.fontColor': '#cad3f5',
'subtitleStyle.backgroundColor': 'transparent',
'subtitleStyle.hoverTokenColor': '#f4dbd6',
'subtitleStyle.hoverTokenBackgroundColor': 'rgba(54, 58, 79, 0.84)',
'subtitleStyle.hoverTokenBackgroundColor': 'transparent',
'subtitleStyle.paintOrder': 'stroke fill',
'subtitleStyle.WebkitTextStroke': '1.5px #000',
'subtitleStyle.textShadow': '0 2px 6px rgba(0,0,0,0.9)',
@@ -29,7 +29,7 @@ test('serializeSubtitleCssDeclarations builds primary CSS from all managed appea
assert.match(css, /color: #cad3f5;/);
assert.match(css, /background-color: transparent;/);
assert.match(css, /--subtitle-hover-token-color: #f4dbd6;/);
assert.match(css, /--subtitle-hover-token-background-color: rgba\(54, 58, 79, 0.84\);/);
assert.match(css, /--subtitle-hover-token-background-color: transparent;/);
assert.match(css, /paint-order: stroke fill;/);
assert.match(css, /-webkit-text-stroke: 1.5px #000;/);
assert.doesNotMatch(css, /--subtitle-known-word-color:/);
+1
View File
@@ -71,6 +71,7 @@ export const IPC_CHANNELS = {
openAnilistSetup: 'anilist:open-setup',
getAnilistQueueStatus: 'anilist:get-queue-status',
retryAnilistNow: 'anilist:retry-now',
jellyfinSetupSubmit: 'jellyfin:setup-submit',
getCharacterDictionarySelection: 'character-dictionary:get-selection',
setCharacterDictionarySelection: 'character-dictionary:set-selection',
appendClipboardVideoToQueue: 'clipboard:append-video-to-queue',