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

@@ -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
<!-- AC:BEGIN -->
- [ ] #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.
<!-- AC:END -->
## 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`

View File

@@ -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.
<!-- SECTION:NOTES:END -->
## Final Summary

View File

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

View File

@@ -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",

View File

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

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'),