diff --git a/changes/tray-modal-lifecycle.md b/changes/tray-modal-lifecycle.md index 6a3261b1..e8b4a2fa 100644 --- a/changes/tray-modal-lifecycle.md +++ b/changes/tray-modal-lifecycle.md @@ -4,6 +4,7 @@ area: tray - Kept the tray app running when closing tray-launched Yomitan settings. - Kept tray-launched Yomitan settings loading from blocking other tray actions. - Replaced the default native Yomitan settings menu with a close-only menu so closing settings does not quit the tray app. +- Added an in-page close button for Yomitan settings on Linux window managers that do not show native decorations. - Disabled Yomitan's embedded popup preview in the tray-launched settings window to avoid renderer hangs during normal sidebar navigation. - Serialized copied Yomitan extension refreshes so startup cannot race itself and leave extension loading in an error state. - Fixed tray-launched session help focus handling so the modal can close without mpv running. diff --git a/src/core/services/startup.test.ts b/src/core/services/startup.test.ts index 156c9947..a349d60f 100644 --- a/src/core/services/startup.test.ts +++ b/src/core/services/startup.test.ts @@ -358,3 +358,89 @@ test('runAppReadyRuntime loads Yomitan before auto-initializing overlay runtime' assert.ok(calls.indexOf('init-overlay') !== -1); assert.ok(calls.indexOf('load-yomitan') < calls.indexOf('init-overlay')); }); + +test('runAppReadyRuntime reuses guarded Yomitan loader after scheduling startup warmups', async () => { + const calls: string[] = []; + + await runAppReadyRuntime({ + ensureDefaultConfigBootstrap: () => { + calls.push('bootstrap'); + }, + loadSubtitlePosition: () => { + calls.push('load-subtitle-position'); + }, + resolveKeybindings: () => { + calls.push('resolve-keybindings'); + }, + createMpvClient: () => { + calls.push('create-mpv'); + }, + reloadConfig: () => { + calls.push('reload-config'); + }, + getResolvedConfig: () => ({ + websocket: { enabled: false }, + annotationWebsocket: { enabled: false }, + texthooker: { launchAtStartup: false }, + }), + getConfigWarnings: () => [], + logConfigWarning: () => {}, + setLogLevel: () => { + calls.push('set-log-level'); + }, + initRuntimeOptionsManager: () => { + calls.push('init-runtime-options'); + }, + setSecondarySubMode: () => { + calls.push('set-secondary-sub-mode'); + }, + defaultSecondarySubMode: 'hover', + defaultWebsocketPort: 0, + defaultAnnotationWebsocketPort: 0, + defaultTexthookerPort: 0, + hasMpvWebsocketPlugin: () => false, + startSubtitleWebsocket: () => {}, + startAnnotationWebsocket: () => {}, + startTexthooker: () => {}, + log: () => { + calls.push('log'); + }, + createMecabTokenizerAndCheck: async () => {}, + createSubtitleTimingTracker: () => { + calls.push('subtitle-timing'); + }, + createImmersionTracker: () => { + calls.push('immersion'); + }, + startJellyfinRemoteSession: async () => {}, + loadYomitanExtension: async () => { + calls.push('load-yomitan-direct'); + }, + ensureYomitanExtensionLoaded: async () => { + calls.push('load-yomitan-guarded'); + }, + handleFirstRunSetup: async () => { + calls.push('first-run'); + }, + prewarmSubtitleDictionaries: async () => {}, + startBackgroundWarmups: () => { + calls.push('warmups'); + }, + texthookerOnlyMode: false, + shouldAutoInitializeOverlayRuntimeFromConfig: () => false, + setVisibleOverlayVisible: () => { + calls.push('visible-overlay'); + }, + initializeOverlayRuntime: () => { + calls.push('init-overlay'); + }, + handleInitialArgs: () => { + calls.push('handle-initial-args'); + }, + shouldUseMinimalStartup: () => false, + shouldSkipHeavyStartup: () => false, + }); + + assert.equal(calls.includes('load-yomitan-direct'), false); + assert.equal(calls.includes('load-yomitan-guarded'), true); +}); diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index cbd2d815..3dbbcf8f 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -131,6 +131,7 @@ export interface AppReadyRuntimeDeps { createImmersionTracker?: () => void; startJellyfinRemoteSession?: () => Promise; loadYomitanExtension: () => Promise; + ensureYomitanExtensionLoaded?: () => Promise; handleFirstRunSetup: () => Promise; prewarmSubtitleDictionaries?: () => Promise; startBackgroundWarmups: () => void; @@ -215,6 +216,8 @@ export function isAutoUpdateEnabledRuntime( export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { const now = deps.now ?? (() => Date.now()); const startupStartedAtMs = now(); + const ensureYomitanExtensionReady = + deps.ensureYomitanExtensionLoaded ?? deps.loadYomitanExtension; deps.ensureDefaultConfigBootstrap(); if (deps.shouldRunHeadlessInitialCommand?.()) { deps.reloadConfig(); @@ -224,7 +227,7 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise { assert.deepEqual(calls, []); }); +test('yomitan settings close button script installs an idempotent in-page close control', () => { + const script = buildYomitanSettingsCloseButtonScript(); + + assert.match(script, /subminer-yomitan-settings-close/); + assert.match(script, /aria-label', 'Close Yomitan settings'/); + assert.match(script, /window\.close\(\)/); + assert.match(script, /getElementById\(buttonId\)/); +}); + test('yomitan settings URL disables the embedded popup preview', () => { assert.equal( buildYomitanSettingsUrl('abc123'), diff --git a/src/core/services/yomitan-settings.ts b/src/core/services/yomitan-settings.ts index 2d85ead7..8059553d 100644 --- a/src/core/services/yomitan-settings.ts +++ b/src/core/services/yomitan-settings.ts @@ -36,6 +36,73 @@ export function buildYomitanSettingsWindowMenuTemplate( ]; } +export function buildYomitanSettingsCloseButtonScript(): string { + return ` +(() => { + const buttonId = 'subminer-yomitan-settings-close'; + const styleId = 'subminer-yomitan-settings-close-style'; + if (document.getElementById(buttonId)) { + return; + } + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.textContent = \` + #\${buttonId} { + position: fixed; + top: 10px; + left: 10px; + z-index: 2147483647; + width: 32px; + height: 32px; + display: grid; + place-items: center; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 4px; + background: rgba(24, 24, 24, 0.92); + color: #f2f2f2; + font: 22px/1 system-ui, sans-serif; + cursor: pointer; + } + #\${buttonId}:hover { + background: rgba(54, 54, 54, 0.96); + border-color: rgba(255, 255, 255, 0.5); + } + #\${buttonId}:focus-visible { + outline: 2px solid #8ab4f8; + outline-offset: 2px; + } + \`; + document.head.appendChild(style); + } + const button = document.createElement('button'); + button.id = buttonId; + button.type = 'button'; + button.title = 'Close'; + button.setAttribute('aria-label', 'Close Yomitan settings'); + button.textContent = '\\u00d7'; + button.addEventListener('click', () => { + window.close(); + }); + document.body.appendChild(button); +})(); +`; +} + +export function installYomitanSettingsCloseButton( + settingsWindow: Pick, +): void { + if (settingsWindow.isDestroyed()) { + return; + } + settingsWindow.webContents + .executeJavaScript(buildYomitanSettingsCloseButtonScript()) + .catch((error: Error) => { + logger.warn('Failed to install Yomitan settings close button:', error.message); + }); +} + export function configureYomitanSettingsWindowChrome( settingsWindow: Pick, buildMenu: (template: MenuItemConstructorOptions[]) => Menu = (template) => @@ -133,6 +200,7 @@ export function openYomitanSettingsWindow(options: OpenYomitanSettingsWindowOpti settingsWindow.webContents.on('did-finish-load', () => { logger.info('Settings page loaded successfully'); + installYomitanSettingsCloseButton(settingsWindow); }); setTimeout(() => { diff --git a/src/main.ts b/src/main.ts index 00eed3d3..893689dd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3854,6 +3854,9 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ loadYomitanExtension: async () => { await loadYomitanExtension(); }, + ensureYomitanExtensionLoaded: async () => { + await ensureYomitanExtensionLoaded(); + }, handleFirstRunSetup: async () => { const snapshot = await firstRunSetupService.ensureSetupStateInitialized(); appState.firstRunSetupCompleted = snapshot.state.status === 'completed'; diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index d0274bf0..dc907a72 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -44,6 +44,7 @@ export interface AppReadyRuntimeDepsFactoryInput { createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker']; startJellyfinRemoteSession?: AppReadyRuntimeDeps['startJellyfinRemoteSession']; loadYomitanExtension: AppReadyRuntimeDeps['loadYomitanExtension']; + ensureYomitanExtensionLoaded?: AppReadyRuntimeDeps['ensureYomitanExtensionLoaded']; handleFirstRunSetup: AppReadyRuntimeDeps['handleFirstRunSetup']; prewarmSubtitleDictionaries?: AppReadyRuntimeDeps['prewarmSubtitleDictionaries']; startBackgroundWarmups: AppReadyRuntimeDeps['startBackgroundWarmups']; @@ -109,6 +110,7 @@ export function createAppReadyRuntimeDeps( createImmersionTracker: params.createImmersionTracker, startJellyfinRemoteSession: params.startJellyfinRemoteSession, loadYomitanExtension: params.loadYomitanExtension, + ensureYomitanExtensionLoaded: params.ensureYomitanExtensionLoaded, handleFirstRunSetup: params.handleFirstRunSetup, prewarmSubtitleDictionaries: params.prewarmSubtitleDictionaries, startBackgroundWarmups: params.startBackgroundWarmups, diff --git a/src/main/runtime/app-ready-main-deps.test.ts b/src/main/runtime/app-ready-main-deps.test.ts index c3fd98c9..36438e88 100644 --- a/src/main/runtime/app-ready-main-deps.test.ts +++ b/src/main/runtime/app-ready-main-deps.test.ts @@ -36,6 +36,9 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async loadYomitanExtension: async () => { calls.push('load-yomitan'); }, + ensureYomitanExtensionLoaded: async () => { + calls.push('ensure-yomitan'); + }, handleFirstRunSetup: async () => { calls.push('handle-first-run-setup'); }, @@ -67,6 +70,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async onReady.createMpvClient(); await onReady.createMecabTokenizerAndCheck(); await onReady.loadYomitanExtension(); + await onReady.ensureYomitanExtensionLoaded?.(); await onReady.handleFirstRunSetup(); await onReady.prewarmSubtitleDictionaries?.(); onReady.startBackgroundWarmups(); @@ -79,6 +83,7 @@ test('app-ready main deps builder returns mapped app-ready runtime deps', async 'create-mpv-client', 'create-mecab', 'load-yomitan', + 'ensure-yomitan', 'handle-first-run-setup', 'prewarm-dicts', 'start-warmups', diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts index be13fce0..5fd1ab66 100644 --- a/src/main/runtime/app-ready-main-deps.ts +++ b/src/main/runtime/app-ready-main-deps.ts @@ -27,6 +27,7 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD createImmersionTracker: deps.createImmersionTracker, startJellyfinRemoteSession: deps.startJellyfinRemoteSession, loadYomitanExtension: deps.loadYomitanExtension, + ensureYomitanExtensionLoaded: deps.ensureYomitanExtensionLoaded, handleFirstRunSetup: deps.handleFirstRunSetup, prewarmSubtitleDictionaries: deps.prewarmSubtitleDictionaries, startBackgroundWarmups: deps.startBackgroundWarmups,