From 504793eaedd4fa93a735361025b0699815321b43 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 11 Mar 2026 18:32:16 -0700 Subject: [PATCH] Harden Yomitan settings open flow for external profile mode - Return status from `openYomitanSettings` and show user-facing warning when external read-only profile mode blocks settings - Thread `yomitanSession` through settings runtime/opener deps so settings window uses the active session - Expand tests for session forwarding and external profile path propagation - Move AniList setup/token/CLI docs into the AniList section in configuration docs --- docs-site/configuration.md | 44 +++++++++---------- src/main.ts | 28 +++++++++--- .../runtime/app-runtime-main-deps.test.ts | 11 +++-- src/main/runtime/app-runtime-main-deps.ts | 4 ++ .../runtime/yomitan-extension-runtime.test.ts | 20 +++++++-- .../runtime/yomitan-settings-opener.test.ts | 6 ++- src/main/runtime/yomitan-settings-opener.ts | 4 ++ .../runtime/yomitan-settings-runtime.test.ts | 8 ++-- 8 files changed, 84 insertions(+), 41 deletions(-) diff --git a/docs-site/configuration.md b/docs-site/configuration.md index c5f57f1..8be469d 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -912,6 +912,28 @@ Current post-watch behavior: - If embedded AniList auth UI fails to render, SubMiner opens the authorize URL in your default browser and shows fallback instructions in-app. - Failed updates are retried with a persistent backoff queue in the background. +Setup flow details: + +1. Set `anilist.enabled` to `true`. +2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup. +3. Approve access in AniList. +4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically. + - Encryption backend: Linux defaults to `gnome-libsecret`. + Override with `--password-store=` (for example `--password-store=basic_text`). + +Token + detection notes: + +- `anilist.accessToken` can be set directly in config; when blank, SubMiner uses the locally stored encrypted token from setup. +- Detection quality is best when `guessit` is installed and available on `PATH`. +- When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing. + +AniList CLI commands: + +- `--anilist-status`: print current AniList token resolution state and retry queue counters. +- `--anilist-logout`: clear stored AniList token from local persisted state. +- `--anilist-setup`: open AniList setup/auth flow helper window. +- `--anilist-retry-queue`: process one ready retry queue item immediately. + ### Yomitan SubMiner normally uses its bundled Yomitan profile under the app config directory. If you want to reuse dictionaries and profile settings from another Electron app, point SubMiner at that app's Yomitan Electron profile in read-only mode. @@ -938,28 +960,6 @@ External-profile mode behavior: - SubMiner does not import, delete, or update dictionaries/settings in the external profile. - SubMiner character-dictionary auto-sync is effectively disabled in this mode because it requires Yomitan writes. -Setup flow details: - -1. Set `anilist.enabled` to `true`. -2. Leave `anilist.accessToken` empty and restart SubMiner (or run `--anilist-setup`) to trigger setup. -3. Approve access in AniList. -4. Callback flow returns to SubMiner via `subminer://anilist-setup?...`, and SubMiner stores the token automatically. - - Encryption backend: Linux defaults to `gnome-libsecret`. - Override with `--password-store=` (for example `--password-store=basic_text`). - -Token + detection notes: - -- `anilist.accessToken` can be set directly in config; when blank, SubMiner uses the locally stored encrypted token from setup. -- Detection quality is best when `guessit` is installed and available on `PATH`. -- When `guessit` cannot parse or is missing, SubMiner falls back automatically to internal filename parsing. - -AniList CLI commands: - -- `--anilist-status`: print current AniList token resolution state and retry queue counters. -- `--anilist-logout`: clear stored AniList token from local persisted state. -- `--anilist-setup`: open AniList setup/auth flow helper window. -- `--anilist-retry-queue`: process one ready retry queue item immediately. - ### Jellyfin Jellyfin integration is optional and disabled by default. When enabled, SubMiner can authenticate, list libraries/items, and resolve direct/transcoded playback URLs for mpv launch. diff --git a/src/main.ts b/src/main.ts index 96f1f9d..b967c52 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,6 +23,7 @@ import { shell, protocol, Extension, + Session, Menu, nativeImage, Tray, @@ -1848,8 +1849,9 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({ return; } if (submission.action === 'open-yomitan-settings') { - openYomitanSettings(); - firstRunSetupMessage = 'Opened Yomitan settings. Install dictionaries, then refresh status.'; + firstRunSetupMessage = openYomitanSettings() + ? 'Opened Yomitan settings. Install dictionaries, then refresh status.' + : 'Yomitan settings are unavailable while external read-only profile mode is enabled.'; return; } if (submission.action === 'refresh') { @@ -3021,7 +3023,7 @@ function getPreferredYomitanAnkiServerUrl(): string { } function getConfiguredExternalYomitanProfilePath(): string { - return getResolvedConfig().yomitan.externalProfilePath.trim(); + return configuredExternalYomitanProfilePath; } function isYomitanExternalReadOnlyMode(): boolean { @@ -3111,14 +3113,19 @@ function initializeOverlayRuntime(): void { syncOverlayMpvSubtitleSuppression(); } -function openYomitanSettings(): void { +function openYomitanSettings(): boolean { if (isYomitanExternalReadOnlyMode()) { + const message = + 'Yomitan settings unavailable while using read-only external-profile mode.'; logger.warn( 'Yomitan settings window disabled while yomitan.externalProfilePath is configured because external profile mode is read-only.', ); - return; + showDesktopNotification('SubMiner', { body: message }); + showMpvOsd(message); + return false; } openYomitanSettingsHandler(); + return true; } const { @@ -3613,6 +3620,7 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = }, buildMenuFromTemplate: (template) => Menu.buildFromTemplate(template), }); +const configuredExternalYomitanProfilePath = getResolvedConfig().yomitan.externalProfilePath.trim(); const yomitanExtensionRuntime = createYomitanExtensionRuntime({ loadYomitanExtensionCore, userDataPath: USER_DATA_PATH, @@ -3674,12 +3682,18 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } = }); const { openYomitanSettings: openYomitanSettingsHandler } = createYomitanSettingsRuntime({ ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded(), - openYomitanSettingsWindow: ({ yomitanExt, getExistingWindow, setWindow }) => { + getYomitanSession: () => appState.yomitanSession, + openYomitanSettingsWindow: ({ + yomitanExt, + getExistingWindow, + setWindow, + yomitanSession, + }) => { openYomitanSettingsWindow({ yomitanExt: yomitanExt as Extension, getExistingWindow: () => getExistingWindow() as BrowserWindow | null, setWindow: (window) => setWindow(window as BrowserWindow | null), - yomitanSession: appState.yomitanSession, + yomitanSession: (yomitanSession as Session | null | undefined) ?? appState.yomitanSession, onWindowClosed: () => { if (appState.yomitanParserWindow) { clearYomitanParserCachesForWindow(appState.yomitanParserWindow); diff --git a/src/main/runtime/app-runtime-main-deps.test.ts b/src/main/runtime/app-runtime-main-deps.test.ts index 23e6cc1..74468f0 100644 --- a/src/main/runtime/app-runtime-main-deps.test.ts +++ b/src/main/runtime/app-runtime-main-deps.test.ts @@ -68,15 +68,19 @@ test('open yomitan settings main deps map async open callbacks', async () => { const calls: string[] = []; let currentWindow: unknown = null; const extension = { id: 'ext' }; + const yomitanSession = { id: 'session' }; const deps = createBuildOpenYomitanSettingsMainDepsHandler({ ensureYomitanExtensionLoaded: async () => extension, - openYomitanSettingsWindow: ({ yomitanExt }) => - calls.push(`open:${(yomitanExt as { id: string }).id}`), + openYomitanSettingsWindow: ({ yomitanExt, yomitanSession: forwardedSession }) => + calls.push( + `open:${(yomitanExt as { id: string }).id}:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`, + ), getExistingWindow: () => currentWindow, setWindow: (window) => { currentWindow = window; calls.push('set-window'); }, + getYomitanSession: () => yomitanSession, logWarn: (message) => calls.push(`warn:${message}`), logError: (message) => calls.push(`error:${message}`), })(); @@ -88,9 +92,10 @@ test('open yomitan settings main deps map async open callbacks', async () => { yomitanExt: extension, getExistingWindow: () => deps.getExistingWindow(), setWindow: (window) => deps.setWindow(window), + yomitanSession: deps.getYomitanSession(), }); deps.logWarn('warn'); deps.logError('error', new Error('boom')); - assert.deepEqual(calls, ['set-window', 'open:ext', 'warn:warn', 'error:error']); + assert.deepEqual(calls, ['set-window', 'open:ext:session', 'warn:warn', 'error:error']); assert.deepEqual(currentWindow, { id: 'win' }); }); diff --git a/src/main/runtime/app-runtime-main-deps.ts b/src/main/runtime/app-runtime-main-deps.ts index feeed18..5dd5d57 100644 --- a/src/main/runtime/app-runtime-main-deps.ts +++ b/src/main/runtime/app-runtime-main-deps.ts @@ -66,10 +66,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler TWindow | null; setWindow: (window: TWindow | null) => void; + yomitanSession?: unknown | null; onWindowClosed?: () => void; }) => void; getExistingWindow: () => TWindow | null; setWindow: (window: TWindow | null) => void; + getYomitanSession?: () => unknown | null; logWarn: (message: string) => void; logError: (message: string, error: unknown) => void; }) { @@ -79,10 +81,12 @@ export function createBuildOpenYomitanSettingsMainDepsHandler TWindow | null; setWindow: (window: TWindow | null) => void; + yomitanSession?: unknown | null; onWindowClosed?: () => void; }) => deps.openYomitanSettingsWindow(params), getExistingWindow: () => deps.getExistingWindow(), setWindow: (window: TWindow | null) => deps.setWindow(window), + getYomitanSession: () => deps.getYomitanSession?.() ?? null, logWarn: (message: string) => deps.logWarn(message), logError: (message: string, error: unknown) => deps.logError(message, error), }); diff --git a/src/main/runtime/yomitan-extension-runtime.test.ts b/src/main/runtime/yomitan-extension-runtime.test.ts index 1e37d98..7139869 100644 --- a/src/main/runtime/yomitan-extension-runtime.test.ts +++ b/src/main/runtime/yomitan-extension-runtime.test.ts @@ -10,6 +10,7 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after let readyPromise: Promise | null = null; let initPromise: Promise | null = null; let yomitanSession: unknown = null; + let receivedExternalProfilePath = ''; let loadCalls = 0; const releaseLoadState: { releaseLoad: ((value: Extension | null) => void) | null } = { releaseLoad: null, @@ -18,9 +19,11 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after const runtime = createYomitanExtensionRuntime({ loadYomitanExtensionCore: async (options) => { loadCalls += 1; + receivedExternalProfilePath = options.externalProfilePath ?? ''; options.setYomitanParserWindow(null); options.setYomitanParserReadyPromise(Promise.resolve()); options.setYomitanParserInitPromise(Promise.resolve(true)); + options.setYomitanSession({ id: 'session' } as never); return await new Promise((resolve) => { releaseLoadState.releaseLoad = (value) => { options.setYomitanExtension(value); @@ -60,7 +63,8 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after assert.equal(parserWindow, null); assert.ok(readyPromise); assert.ok(initPromise); - assert.equal(yomitanSession, null); + assert.deepEqual(yomitanSession, { id: 'session' }); + assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile'); const fakeExtension = { id: 'yomitan' } as Extension; const releaseLoad = releaseLoadState.releaseLoad; @@ -80,20 +84,26 @@ test('yomitan extension runtime reuses in-flight ensure load and clears it after test('yomitan extension runtime direct load delegates to core', async () => { let loadCalls = 0; + let receivedExternalProfilePath = ''; + let yomitanSession: unknown = null; const runtime = createYomitanExtensionRuntime({ - loadYomitanExtensionCore: async () => { + loadYomitanExtensionCore: async (options) => { loadCalls += 1; + receivedExternalProfilePath = options.externalProfilePath ?? ''; + options.setYomitanSession({ id: 'session' } as never); return null; }, userDataPath: '/tmp', - externalProfilePath: '', + externalProfilePath: '/tmp/gsm-profile', getYomitanParserWindow: () => null, setYomitanParserWindow: () => {}, setYomitanParserReadyPromise: () => {}, setYomitanParserInitPromise: () => {}, setYomitanExtension: () => {}, - setYomitanSession: () => {}, + setYomitanSession: (next) => { + yomitanSession = next; + }, getYomitanExtension: () => null, getLoadInFlight: () => null, setLoadInFlight: () => {}, @@ -101,4 +111,6 @@ test('yomitan extension runtime direct load delegates to core', async () => { assert.equal(await runtime.loadYomitanExtension(), null); assert.equal(loadCalls, 1); + assert.equal(receivedExternalProfilePath, '/tmp/gsm-profile'); + assert.deepEqual(yomitanSession, { id: 'session' }); }); diff --git a/src/main/runtime/yomitan-settings-opener.test.ts b/src/main/runtime/yomitan-settings-opener.test.ts index 2bc9728..76620a4 100644 --- a/src/main/runtime/yomitan-settings-opener.test.ts +++ b/src/main/runtime/yomitan-settings-opener.test.ts @@ -23,13 +23,15 @@ test('yomitan opener warns when extension cannot be loaded', async () => { test('yomitan opener opens settings window when extension is available', async () => { let opened = false; + const yomitanSession = { id: 'session' }; const openSettings = createOpenYomitanSettingsHandler({ ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }), - openYomitanSettingsWindow: () => { - opened = true; + openYomitanSettingsWindow: ({ yomitanSession: forwardedSession }) => { + opened = (forwardedSession as { id: string } | null)?.id === 'session'; }, getExistingWindow: () => null, setWindow: () => {}, + getYomitanSession: () => yomitanSession, logWarn: () => {}, logError: () => {}, }); diff --git a/src/main/runtime/yomitan-settings-opener.ts b/src/main/runtime/yomitan-settings-opener.ts index 85339b6..6c59fb2 100644 --- a/src/main/runtime/yomitan-settings-opener.ts +++ b/src/main/runtime/yomitan-settings-opener.ts @@ -1,5 +1,6 @@ type YomitanExtensionLike = unknown; type BrowserWindowLike = unknown; +type SessionLike = unknown; export function createOpenYomitanSettingsHandler(deps: { ensureYomitanExtensionLoaded: () => Promise; @@ -7,10 +8,12 @@ export function createOpenYomitanSettingsHandler(deps: { yomitanExt: YomitanExtensionLike; getExistingWindow: () => BrowserWindowLike | null; setWindow: (window: BrowserWindowLike | null) => void; + yomitanSession?: SessionLike | null; onWindowClosed?: () => void; }) => void; getExistingWindow: () => BrowserWindowLike | null; setWindow: (window: BrowserWindowLike | null) => void; + getYomitanSession?: () => SessionLike | null; logWarn: (message: string) => void; logError: (message: string, error: unknown) => void; }) { @@ -25,6 +28,7 @@ export function createOpenYomitanSettingsHandler(deps: { yomitanExt: extension, getExistingWindow: deps.getExistingWindow, setWindow: deps.setWindow, + yomitanSession: deps.getYomitanSession?.() ?? null, }); })().catch((error) => { deps.logError('Failed to open Yomitan settings window.', error); diff --git a/src/main/runtime/yomitan-settings-runtime.test.ts b/src/main/runtime/yomitan-settings-runtime.test.ts index f5b843b..a14e950 100644 --- a/src/main/runtime/yomitan-settings-runtime.test.ts +++ b/src/main/runtime/yomitan-settings-runtime.test.ts @@ -5,11 +5,12 @@ import { createYomitanSettingsRuntime } from './yomitan-settings-runtime'; test('yomitan settings runtime composes opener with built deps', async () => { let existingWindow: { id: string } | null = null; const calls: string[] = []; + const yomitanSession = { id: 'session' }; const runtime = createYomitanSettingsRuntime({ ensureYomitanExtensionLoaded: async () => ({ id: 'ext' }), - openYomitanSettingsWindow: ({ getExistingWindow, setWindow }) => { - calls.push('open-window'); + openYomitanSettingsWindow: ({ getExistingWindow, setWindow, yomitanSession: forwardedSession }) => { + calls.push(`open-window:${(forwardedSession as { id: string } | null)?.id ?? 'null'}`); const current = getExistingWindow(); if (!current) { setWindow({ id: 'settings' }); @@ -19,6 +20,7 @@ test('yomitan settings runtime composes opener with built deps', async () => { setWindow: (window) => { existingWindow = window as { id: string } | null; }, + getYomitanSession: () => yomitanSession, logWarn: (message) => calls.push(`warn:${message}`), logError: (message) => calls.push(`error:${message}`), }); @@ -27,5 +29,5 @@ test('yomitan settings runtime composes opener with built deps', async () => { await new Promise((resolve) => setTimeout(resolve, 0)); assert.deepEqual(existingWindow, { id: 'settings' }); - assert.deepEqual(calls, ['open-window']); + assert.deepEqual(calls, ['open-window:session']); });