From 209ab73a31f5a0407b750b91f085408f14db0151 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 18 Feb 2026 22:59:15 -0800 Subject: [PATCH] fix(renderer): add recovery boundary and normalize macOS tray icon --- ...undary-and-recovery-in-renderer-overlay.md | 27 ++- ...ackground-tray-service-with-IPC-startup.md | 4 +- docs/troubleshooting.md | 6 + package.json | 2 +- src/main.ts | 6 + src/renderer/error-recovery.test.ts | 122 +++++++++++ src/renderer/error-recovery.ts | 177 +++++++++++++++ src/renderer/index.html | 6 + src/renderer/modals/kiku.ts | 1 + src/renderer/renderer.ts | 204 +++++++++++++++--- src/renderer/style.css | 26 +++ src/renderer/utils/dom.ts | 2 + 12 files changed, 544 insertions(+), 39 deletions(-) create mode 100644 src/renderer/error-recovery.test.ts create mode 100644 src/renderer/error-recovery.ts diff --git a/backlog/tasks/task-37 - Add-error-boundary-and-recovery-in-renderer-overlay.md b/backlog/tasks/task-37 - Add-error-boundary-and-recovery-in-renderer-overlay.md index 2a9f897..a9cf20d 100644 --- a/backlog/tasks/task-37 - Add-error-boundary-and-recovery-in-renderer-overlay.md +++ b/backlog/tasks/task-37 - Add-error-boundary-and-recovery-in-renderer-overlay.md @@ -1,9 +1,10 @@ --- id: TASK-37 title: Add error boundary and recovery in renderer overlay -status: To Do +status: Done assignee: [] created_date: '2026-02-14 01:01' +updated_date: '2026-02-19 21:50' labels: - renderer - reliability @@ -41,10 +42,22 @@ If a renderer modal throws (e.g., jimaku API timeout, DOM manipulation error, ma -- [ ] #1 Unhandled errors in modal flows are caught and do not crash the overlay. -- [ ] #2 After an error, the overlay returns to a functional state (subtitles render, shortcuts work). -- [ ] #3 A brief toast/notification informs the user that an error occurred. -- [ ] #4 Global unhandledrejection and onerror handlers are registered as safety nets. -- [ ] #5 Error details are logged with context (stack trace, active modal, subtitle state). -- [ ] #6 mpv playback is never interrupted by renderer errors. +- [x] #1 Unhandled errors in modal flows are caught and do not crash the overlay. +- [x] #2 After an error, the overlay returns to a functional state (subtitles render, shortcuts work). +- [x] #3 A brief toast/notification informs the user that an error occurred. +- [x] #4 Global unhandledrejection and onerror handlers are registered as safety nets. +- [x] #5 Error details are logged with context (stack trace, active modal, subtitle state). +- [x] #6 mpv playback is never interrupted by renderer errors. + +## Implementation Notes + +- Added renderer recovery module with guarded callback boundaries and global `window.onerror` / `window.unhandledrejection` handlers. +- Recovery now uses modal close/cancel APIs (including Kiku cancel) to preserve cleanup semantics and avoid hanging pending callbacks. +- Added overlay recovery toast UI and contextual recovery logging payloads. +- Added regression coverage in `src/renderer/error-recovery.test.ts` and wired it into `test:core:dist`. + +## Verification + +- `bun run build` +- `bun run test:core:dist` diff --git a/backlog/tasks/task-65 - Run-Electron-app-as-background-tray-service-with-IPC-startup.md b/backlog/tasks/task-65 - Run-Electron-app-as-background-tray-service-with-IPC-startup.md index 6e75122..9dc8577 100644 --- a/backlog/tasks/task-65 - Run-Electron-app-as-background-tray-service-with-IPC-startup.md +++ b/backlog/tasks/task-65 - Run-Electron-app-as-background-tray-service-with-IPC-startup.md @@ -4,7 +4,7 @@ title: Run Electron app as background tray service with IPC startup status: Done assignee: [] created_date: '2026-02-18 08:48' -updated_date: '2026-02-18 10:17' +updated_date: '2026-02-19 21:50' labels: - electron - tray @@ -51,6 +51,8 @@ Background launch now detaches from terminal via new `src/main-entry.ts` bootstr Background detached child now suppresses Node runtime warnings (`NODE_NO_WARNINGS=1`) and strips `VK_INSTANCE_LAYERS` when it contains `lsfg` to reduce non-actionable startup noise in background mode. Updated package entrypoint to `dist/main-entry.js` and docs usage note for detached background behavior. + +macOS follow-up: tray icon handling now normalizes to status-bar-safe form (`18x18` resize + template image mode) to prevent oversized/non-interactive menu bar icons when running in `--background` mode. ## Final Summary diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index c364278..2364e24 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -64,6 +64,12 @@ Shown when SubMiner tries to update a card that no longer exists, or when AnkiCo - On macOS/Windows, `setIgnoreMouseEvents` toggles automatically. If clicks stop working, toggle the overlay off and back on (`Alt+Shift+O`). - Make sure you are hovering over the subtitle area — the overlay only becomes interactive when the cursor is over subtitle text. +**Overlay briefly freezes after a modal/runtime error** + +- Renderer errors now trigger an automatic recovery path. You should see a short toast ("Renderer error recovered. Overlay is still running."). +- Recovery closes any open modal and restores click-through/shortcuts automatically without interrupting mpv playback. +- If errors keep recurring, open DevTools (`Alt+Shift+I`) and inspect the `renderer overlay recovery` error payload for stack trace + modal/subtitle context. + **Overlay is on the wrong monitor or position** SubMiner positions the overlay by tracking the mpv window. If tracking fails: diff --git a/package.json b/package.json index 14c4f0c..5a45b2f 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test:config:dist": "node --test dist/config/config.test.js", - "test:core:dist": "node --test 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/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/config-hot-reload.test.js dist/core/services/tokenizer.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/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.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/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", + "test:core:dist": "node --test 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/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/config-hot-reload.test.js dist/core/services/tokenizer.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/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.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/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/renderer/error-recovery.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:subtitle:dist": "echo \"Subtitle tests are currently not configured\"", "test": "bun run test:config && bun run test:core", "test:config": "bun run build && bun run test:config:dist", diff --git a/src/main.ts b/src/main.ts index de39f28..5b517a3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2782,6 +2782,12 @@ function ensureTray(): void { if (trayIcon.isEmpty()) { logger.warn('Tray icon asset not found; using empty icon placeholder.'); } + if (process.platform === 'darwin' && !trayIcon.isEmpty()) { + // macOS status bar expects a small monochrome-like template icon. + // Feeding the full-size app icon can produce oversized/non-interactive items. + trayIcon = trayIcon.resize({ width: 18, height: 18, quality: 'best' }); + trayIcon.setTemplateImage(true); + } if (process.platform === 'linux' && !trayIcon.isEmpty()) { trayIcon = trayIcon.resize({ width: 20, height: 20 }); } diff --git a/src/renderer/error-recovery.test.ts b/src/renderer/error-recovery.test.ts new file mode 100644 index 0000000..c8b86cb --- /dev/null +++ b/src/renderer/error-recovery.test.ts @@ -0,0 +1,122 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { createRendererRecoveryController } from './error-recovery.js'; + +test('handleError logs context and recovers overlay state', () => { + const payloads: unknown[] = []; + let dismissed = 0; + let restored = 0; + const shown: string[] = []; + + const controller = createRendererRecoveryController({ + dismissActiveUi: () => { + dismissed += 1; + }, + restoreOverlayInteraction: () => { + restored += 1; + }, + showToast: (message) => { + shown.push(message); + }, + getSnapshot: () => ({ + activeModal: 'jimaku', + subtitlePreview: '字幕テキスト', + secondarySubtitlePreview: 'secondary', + isOverlayInteractive: true, + isOverSubtitle: true, + invisiblePositionEditMode: false, + overlayLayer: 'visible', + }), + logError: (payload) => { + payloads.push(payload); + }, + }); + + controller.handleError(new Error('renderer boom'), { + source: 'callback', + action: 'onSubtitle', + }); + + assert.equal(dismissed, 1); + assert.equal(restored, 1); + assert.equal(shown.length, 1); + assert.match(shown[0], /recovered/i); + assert.equal(payloads.length, 1); + + const payload = payloads[0] as { + context: { action: string }; + error: { message: string; stack: string | null }; + snapshot: { activeModal: string | null; subtitlePreview: string }; + }; + assert.equal(payload.context.action, 'onSubtitle'); + assert.equal(payload.snapshot.activeModal, 'jimaku'); + assert.equal(payload.snapshot.subtitlePreview, '字幕テキスト'); + assert.equal(payload.error.message, 'renderer boom'); + assert.ok( + typeof payload.error.stack === 'string' && payload.error.stack.includes('renderer boom'), + ); +}); + +test('handleError normalizes non-Error values', () => { + const payloads: unknown[] = []; + + const controller = createRendererRecoveryController({ + dismissActiveUi: () => {}, + restoreOverlayInteraction: () => {}, + showToast: () => {}, + getSnapshot: () => ({ + activeModal: null, + subtitlePreview: '', + secondarySubtitlePreview: '', + isOverlayInteractive: false, + isOverSubtitle: false, + invisiblePositionEditMode: false, + overlayLayer: 'invisible', + }), + logError: (payload) => { + payloads.push(payload); + }, + }); + + controller.handleError({ code: 500, reason: 'timeout' }, { source: 'callback', action: 'modal' }); + + const payload = payloads[0] as { error: { message: string; stack: string | null } }; + assert.equal(payload.error.message, JSON.stringify({ code: 500, reason: 'timeout' })); + assert.equal(payload.error.stack, null); +}); + +test('nested recovery errors are ignored while current recovery is active', () => { + const payloads: unknown[] = []; + let restored = 0; + + let controllerRef: ReturnType | null = null; + + const controller = createRendererRecoveryController({ + dismissActiveUi: () => { + controllerRef?.handleError(new Error('nested'), { source: 'callback', action: 'nested' }); + }, + restoreOverlayInteraction: () => { + restored += 1; + }, + showToast: () => {}, + getSnapshot: () => ({ + activeModal: 'runtime-options', + subtitlePreview: '', + secondarySubtitlePreview: '', + isOverlayInteractive: true, + isOverSubtitle: false, + invisiblePositionEditMode: true, + overlayLayer: 'visible', + }), + logError: (payload) => { + payloads.push(payload); + }, + }); + controllerRef = controller; + + controller.handleError(new Error('outer'), { source: 'callback', action: 'outer' }); + + assert.equal(payloads.length, 1); + assert.equal(restored, 1); +}); diff --git a/src/renderer/error-recovery.ts b/src/renderer/error-recovery.ts new file mode 100644 index 0000000..e24e74a --- /dev/null +++ b/src/renderer/error-recovery.ts @@ -0,0 +1,177 @@ +export type RendererErrorSource = + | 'callback' + | 'window.onerror' + | 'window.unhandledrejection' + | 'bootstrap'; + +export type RendererRecoveryContext = { + source: RendererErrorSource; + action: string; + details?: Record; +}; + +export type RendererRecoverySnapshot = { + activeModal: string | null; + subtitlePreview: string; + secondarySubtitlePreview: string; + isOverlayInteractive: boolean; + isOverSubtitle: boolean; + invisiblePositionEditMode: boolean; + overlayLayer: 'visible' | 'invisible'; +}; + +type NormalizedRendererError = { + message: string; + stack: string | null; +}; + +export type RendererRecoveryLogPayload = { + kind: 'renderer-overlay-recovery'; + context: RendererRecoveryContext; + error: NormalizedRendererError; + snapshot: RendererRecoverySnapshot; + timestamp: string; +}; + +type RendererRecoveryDeps = { + dismissActiveUi: () => void; + restoreOverlayInteraction: () => void; + showToast: (message: string) => void; + getSnapshot: () => RendererRecoverySnapshot; + logError: (payload: RendererRecoveryLogPayload) => void; + toastMessage?: string; +}; + +type RendererRecoveryController = { + handleError: (error: unknown, context: RendererRecoveryContext) => void; +}; + +type RendererRecoveryWindow = Pick; + +const DEFAULT_TOAST_MESSAGE = 'Renderer error recovered. Overlay is still running.'; + +function normalizeRendererError(error: unknown): NormalizedRendererError { + if (error instanceof Error) { + return { + message: error.message || 'Unknown renderer error', + stack: typeof error.stack === 'string' ? error.stack : null, + }; + } + + if (typeof error === 'string') { + return { + message: error, + stack: null, + }; + } + + if (typeof error === 'object' && error !== null) { + try { + return { + message: JSON.stringify(error), + stack: null, + }; + } catch { + return { + message: '[unserializable error object]', + stack: null, + }; + } + } + + return { + message: String(error), + stack: null, + }; +} + +export function createRendererRecoveryController( + deps: RendererRecoveryDeps, +): RendererRecoveryController { + let inRecovery = false; + + const toastMessage = deps.toastMessage ?? DEFAULT_TOAST_MESSAGE; + + const invokeRecoveryStep = ( + step: 'dismissActiveUi' | 'restoreOverlayInteraction' | 'showToast', + fn: () => void, + ): void => { + try { + fn(); + } catch (error) { + try { + deps.logError({ + kind: 'renderer-overlay-recovery', + context: { + source: 'callback', + action: `recovery-step:${step}`, + }, + error: normalizeRendererError(error), + snapshot: deps.getSnapshot(), + timestamp: new Date().toISOString(), + }); + } catch { + // Avoid recursive failures from logging inside the recovery path. + } + } + }; + + const handleError = (error: unknown, context: RendererRecoveryContext): void => { + if (inRecovery) { + return; + } + + inRecovery = true; + try { + deps.logError({ + kind: 'renderer-overlay-recovery', + context, + error: normalizeRendererError(error), + snapshot: deps.getSnapshot(), + timestamp: new Date().toISOString(), + }); + + invokeRecoveryStep('dismissActiveUi', deps.dismissActiveUi); + invokeRecoveryStep('restoreOverlayInteraction', deps.restoreOverlayInteraction); + invokeRecoveryStep('showToast', () => deps.showToast(toastMessage)); + } finally { + inRecovery = false; + } + }; + + return { handleError }; +} + +export function registerRendererGlobalErrorHandlers( + recoveryWindow: RendererRecoveryWindow, + controller: RendererRecoveryController, +): () => void { + const onError = (event: Event): void => { + const errorEvent = event as ErrorEvent; + controller.handleError(errorEvent.error ?? errorEvent.message, { + source: 'window.onerror', + action: 'global-error', + details: { + filename: errorEvent.filename, + lineno: errorEvent.lineno, + colno: errorEvent.colno, + }, + }); + }; + + const onUnhandledRejection = (event: Event): void => { + const rejectionEvent = event as PromiseRejectionEvent; + controller.handleError(rejectionEvent.reason, { + source: 'window.unhandledrejection', + action: 'global-unhandledrejection', + }); + }; + + recoveryWindow.addEventListener('error', onError); + recoveryWindow.addEventListener('unhandledrejection', onUnhandledRejection); + + return () => { + recoveryWindow.removeEventListener('error', onError); + recoveryWindow.removeEventListener('unhandledrejection', onUnhandledRejection); + }; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 1323894..df4d105 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -30,6 +30,12 @@
+
diff --git a/src/renderer/modals/kiku.ts b/src/renderer/modals/kiku.ts index 2cbaf27..11c84df 100644 --- a/src/renderer/modals/kiku.ts +++ b/src/renderer/modals/kiku.ts @@ -290,6 +290,7 @@ export function createKikuModal( } return { + cancelKikuFieldGrouping, closeKikuFieldGroupingModal, handleKikuKeydown, openKikuFieldGroupingModal, diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index acc2d2f..81efe11 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -37,9 +37,16 @@ import { createPositioningController } from './positioning.js'; import { createOverlayContentMeasurementReporter } from './overlay-content-measurement.js'; import { createRendererState } from './state.js'; import { createSubtitleRenderer } from './subtitle-render.js'; +import { + createRendererRecoveryController, + registerRendererGlobalErrorHandlers, +} from './error-recovery.js'; import { resolveRendererDom } from './utils/dom.js'; import { resolvePlatformInfo } from './utils/platform.js'; -import { buildMpvLoadfileCommands, collectDroppedVideoPaths } from '../core/services/overlay-drop.js'; +import { + buildMpvLoadfileCommands, + collectDroppedVideoPaths, +} from '../core/services/overlay-drop.js'; const ctx = { dom: resolveRendererDom(), @@ -126,44 +133,165 @@ const mouseHandlers = createMouseHandlers(ctx, { persistSubtitlePositionPatch: positioning.persistSubtitlePositionPatch, }); +let lastSubtitlePreview = ''; +let lastSecondarySubtitlePreview = ''; +let overlayErrorToastTimeout: ReturnType | null = null; + +function truncateForErrorLog(text: string): string { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (normalized.length <= 180) { + return normalized; + } + return `${normalized.slice(0, 177)}...`; +} + +function getActiveModal(): string | null { + if (ctx.state.jimakuModalOpen) return 'jimaku'; + if (ctx.state.kikuModalOpen) return 'kiku'; + if (ctx.state.runtimeOptionsModalOpen) return 'runtime-options'; + if (ctx.state.subsyncModalOpen) return 'subsync'; + if (ctx.state.sessionHelpModalOpen) return 'session-help'; + return null; +} + +function dismissActiveUiAfterError(): void { + if (ctx.state.jimakuModalOpen) { + jimakuModal.closeJimakuModal(); + } + if (ctx.state.runtimeOptionsModalOpen) { + runtimeOptionsModal.closeRuntimeOptionsModal(); + } + if (ctx.state.subsyncModalOpen) { + subsyncModal.closeSubsyncModal(); + } + if (ctx.state.kikuModalOpen) { + kikuModal.cancelKikuFieldGrouping(); + } + if (ctx.state.sessionHelpModalOpen) { + sessionHelpModal.closeSessionHelpModal(); + } + + syncSettingsModalSubtitleSuppression(); +} + +function restoreOverlayInteractionAfterError(): void { + ctx.state.isOverSubtitle = false; + if (ctx.state.invisiblePositionEditMode) { + positioning.setInvisiblePositionEditMode(false); + } + ctx.dom.overlay.classList.remove('interactive'); + if (ctx.platform.shouldToggleMouseIgnore) { + window.electronAPI.setIgnoreMouseEvents(true, { forward: true }); + } +} + +function showOverlayErrorToast(message: string): void { + if (overlayErrorToastTimeout) { + clearTimeout(overlayErrorToastTimeout); + overlayErrorToastTimeout = null; + } + ctx.dom.overlayErrorToast.textContent = message; + ctx.dom.overlayErrorToast.classList.remove('hidden'); + overlayErrorToastTimeout = setTimeout(() => { + ctx.dom.overlayErrorToast.classList.add('hidden'); + ctx.dom.overlayErrorToast.textContent = ''; + overlayErrorToastTimeout = null; + }, 3200); +} + +const recovery = createRendererRecoveryController({ + dismissActiveUi: dismissActiveUiAfterError, + restoreOverlayInteraction: restoreOverlayInteractionAfterError, + showToast: showOverlayErrorToast, + getSnapshot: () => ({ + activeModal: getActiveModal(), + subtitlePreview: lastSubtitlePreview, + secondarySubtitlePreview: lastSecondarySubtitlePreview, + isOverlayInteractive: ctx.dom.overlay.classList.contains('interactive'), + isOverSubtitle: ctx.state.isOverSubtitle, + invisiblePositionEditMode: ctx.state.invisiblePositionEditMode, + overlayLayer: ctx.platform.overlayLayer, + }), + logError: (payload) => { + console.error('renderer overlay recovery', payload); + }, +}); + +registerRendererGlobalErrorHandlers(window, recovery); + +function runGuarded(action: string, fn: () => void): void { + try { + fn(); + } catch (error) { + recovery.handleError(error, { source: 'callback', action }); + } +} + +function runGuardedAsync(action: string, fn: () => Promise | void): void { + Promise.resolve() + .then(fn) + .catch((error) => { + recovery.handleError(error, { source: 'callback', action }); + }); +} + async function init(): Promise { document.body.classList.add(`layer-${ctx.platform.overlayLayer}`); window.electronAPI.onSubtitle((data: SubtitleData) => { - subtitleRenderer.renderSubtitle(data); - measurementReporter.schedule(); + runGuarded('subtitle:update', () => { + if (typeof data === 'string') { + lastSubtitlePreview = truncateForErrorLog(data); + } else if (data && typeof data.text === 'string') { + lastSubtitlePreview = truncateForErrorLog(data.text); + } + subtitleRenderer.renderSubtitle(data); + measurementReporter.schedule(); + }); }); window.electronAPI.onSubtitlePosition((position: SubtitlePosition | null) => { - if (ctx.platform.isInvisibleLayer) { - positioning.applyInvisibleStoredSubtitlePosition(position, 'media-change'); - } else { - positioning.applyStoredSubtitlePosition(position, 'media-change'); - } - measurementReporter.schedule(); + runGuarded('subtitle-position:update', () => { + if (ctx.platform.isInvisibleLayer) { + positioning.applyInvisibleStoredSubtitlePosition(position, 'media-change'); + } else { + positioning.applyStoredSubtitlePosition(position, 'media-change'); + } + measurementReporter.schedule(); + }); }); if (ctx.platform.isInvisibleLayer) { window.electronAPI.onMpvSubtitleRenderMetrics((metrics: MpvSubtitleRenderMetrics) => { - positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, 'event'); - measurementReporter.schedule(); + runGuarded('mpv-metrics:update', () => { + positioning.applyInvisibleSubtitleLayoutFromMpvMetrics(metrics, 'event'); + measurementReporter.schedule(); + }); }); window.electronAPI.onOverlayDebugVisualization((enabled: boolean) => { - document.body.classList.toggle('debug-invisible-visualization', enabled); + runGuarded('overlay-debug-visualization:update', () => { + document.body.classList.toggle('debug-invisible-visualization', enabled); + }); }); } const initialSubtitle = await window.electronAPI.getCurrentSubtitleRaw(); + lastSubtitlePreview = truncateForErrorLog(initialSubtitle); subtitleRenderer.renderSubtitle(initialSubtitle); measurementReporter.schedule(); window.electronAPI.onSecondarySub((text: string) => { - subtitleRenderer.renderSecondarySub(text); - measurementReporter.schedule(); + runGuarded('secondary-subtitle:update', () => { + lastSecondarySubtitlePreview = truncateForErrorLog(text); + subtitleRenderer.renderSecondarySub(text); + measurementReporter.schedule(); + }); }); window.electronAPI.onSecondarySubMode((mode: SecondarySubMode) => { - subtitleRenderer.updateSecondarySubMode(mode); - measurementReporter.schedule(); + runGuarded('secondary-subtitle-mode:update', () => { + subtitleRenderer.updateSecondarySubMode(mode); + measurementReporter.schedule(); + }); }); subtitleRenderer.updateSecondarySubMode(await window.electronAPI.getSecondarySubMode()); @@ -195,30 +323,44 @@ async function init(): Promise { sessionHelpModal.wireDomEvents(); window.electronAPI.onRuntimeOptionsChanged((options: RuntimeOptionState[]) => { - runtimeOptionsModal.updateRuntimeOptions(options); + runGuarded('runtime-options:changed', () => { + runtimeOptionsModal.updateRuntimeOptions(options); + }); }); window.electronAPI.onConfigHotReload((payload: ConfigHotReloadPayload) => { - keyboardHandlers.updateKeybindings(payload.keybindings); - subtitleRenderer.applySubtitleStyle(payload.subtitleStyle); - subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode); - measurementReporter.schedule(); + runGuarded('config:hot-reload', () => { + keyboardHandlers.updateKeybindings(payload.keybindings); + subtitleRenderer.applySubtitleStyle(payload.subtitleStyle); + subtitleRenderer.updateSecondarySubMode(payload.secondarySubMode); + measurementReporter.schedule(); + }); }); window.electronAPI.onOpenRuntimeOptions(() => { - runtimeOptionsModal.openRuntimeOptionsModal().catch(() => { - runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true); - window.electronAPI.notifyOverlayModalClosed('runtime-options'); - syncSettingsModalSubtitleSuppression(); + runGuardedAsync('runtime-options:open', async () => { + try { + await runtimeOptionsModal.openRuntimeOptionsModal(); + } catch { + runtimeOptionsModal.setRuntimeOptionsStatus('Failed to load runtime options', true); + window.electronAPI.notifyOverlayModalClosed('runtime-options'); + syncSettingsModalSubtitleSuppression(); + } }); }); window.electronAPI.onOpenJimaku(() => { - jimakuModal.openJimakuModal(); + runGuarded('jimaku:open', () => { + jimakuModal.openJimakuModal(); + }); }); window.electronAPI.onSubsyncManualOpen((payload: SubsyncManualPayload) => { - subsyncModal.openSubsyncModal(payload); + runGuarded('subsync:manual-open', () => { + subsyncModal.openSubsyncModal(payload); + }); }); window.electronAPI.onKikuFieldGroupingRequest( (data: { original: KikuDuplicateCardInfo; duplicate: KikuDuplicateCardInfo }) => { - kikuModal.openKikuFieldGroupingModal(data); + runGuarded('kiku:field-grouping-open', () => { + kikuModal.openKikuFieldGroupingModal(data); + }); }, ); @@ -318,7 +460,9 @@ function setupDragDropToMpvQueue(): void { } if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); + document.addEventListener('DOMContentLoaded', () => { + runGuardedAsync('bootstrap:init', init); + }); } else { - void init(); + runGuardedAsync('bootstrap:init', init); } diff --git a/src/renderer/style.css b/src/renderer/style.css index 761f766..ce9eddc 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -47,6 +47,32 @@ body { pointer-events: auto; } +.overlay-error-toast { + position: absolute; + top: 16px; + right: 16px; + max-width: min(420px, calc(100vw - 32px)); + padding: 10px 14px; + border-radius: 10px; + border: 1px solid rgba(255, 148, 148, 0.5); + background: rgba(48, 12, 12, 0.9); + color: rgba(255, 238, 238, 0.98); + font-size: 13px; + line-height: 1.35; + pointer-events: none; + opacity: 0; + transform: translateY(-6px); + transition: + opacity 160ms ease, + transform 160ms ease; + z-index: 1300; +} + +.overlay-error-toast:not(.hidden) { + opacity: 1; + transform: translateY(0); +} + .modal { position: absolute; inset: 0; diff --git a/src/renderer/utils/dom.ts b/src/renderer/utils/dom.ts index 6ca2fff..d415350 100644 --- a/src/renderer/utils/dom.ts +++ b/src/renderer/utils/dom.ts @@ -2,6 +2,7 @@ export type RendererDom = { subtitleRoot: HTMLElement; subtitleContainer: HTMLElement; overlay: HTMLElement; + overlayErrorToast: HTMLDivElement; secondarySubContainer: HTMLElement; secondarySubRoot: HTMLElement; @@ -77,6 +78,7 @@ export function resolveRendererDom(): RendererDom { subtitleRoot: getRequiredElement('subtitleRoot'), subtitleContainer: getRequiredElement('subtitleContainer'), overlay: getRequiredElement('overlay'), + overlayErrorToast: getRequiredElement('overlayErrorToast'), secondarySubContainer: getRequiredElement('secondarySubContainer'), secondarySubRoot: getRequiredElement('secondarySubRoot'),