fix(renderer): add recovery boundary and normalize macOS tray icon

This commit is contained in:
2026-02-18 22:59:15 -08:00
parent d1aeb3b754
commit 209ab73a31
12 changed files with 544 additions and 39 deletions

View File

@@ -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<typeof createRendererRecoveryController> | 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);
});

View File

@@ -0,0 +1,177 @@
export type RendererErrorSource =
| 'callback'
| 'window.onerror'
| 'window.unhandledrejection'
| 'bootstrap';
export type RendererRecoveryContext = {
source: RendererErrorSource;
action: string;
details?: Record<string, unknown>;
};
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<Window, 'addEventListener' | 'removeEventListener'>;
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);
};
}

View File

@@ -30,6 +30,12 @@
<body>
<!-- Programmatic focus fallback target for Electron/window focus management. -->
<div id="overlay" tabindex="-1">
<div
id="overlayErrorToast"
class="overlay-error-toast hidden"
role="status"
aria-live="polite"
></div>
<div id="secondarySubContainer" class="secondary-sub-hidden">
<div id="secondarySubRoot"></div>
</div>

View File

@@ -290,6 +290,7 @@ export function createKikuModal(
}
return {
cancelKikuFieldGrouping,
closeKikuFieldGroupingModal,
handleKikuKeydown,
openKikuFieldGroupingModal,

View File

@@ -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<typeof setTimeout> | 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): void {
Promise.resolve()
.then(fn)
.catch((error) => {
recovery.handleError(error, { source: 'callback', action });
});
}
async function init(): Promise<void> {
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<void> {
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);
}

View File

@@ -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;

View File

@@ -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<HTMLElement>('subtitleRoot'),
subtitleContainer: getRequiredElement<HTMLElement>('subtitleContainer'),
overlay: getRequiredElement<HTMLElement>('overlay'),
overlayErrorToast: getRequiredElement<HTMLDivElement>('overlayErrorToast'),
secondarySubContainer: getRequiredElement<HTMLElement>('secondarySubContainer'),
secondarySubRoot: getRequiredElement<HTMLElement>('secondarySubRoot'),