mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-28 06:22:45 -08:00
fix(renderer): add recovery boundary and normalize macOS tray icon
This commit is contained in:
122
src/renderer/error-recovery.test.ts
Normal file
122
src/renderer/error-recovery.test.ts
Normal 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);
|
||||
});
|
||||
177
src/renderer/error-recovery.ts
Normal file
177
src/renderer/error-recovery.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -290,6 +290,7 @@ export function createKikuModal(
|
||||
}
|
||||
|
||||
return {
|
||||
cancelKikuFieldGrouping,
|
||||
closeKikuFieldGroupingModal,
|
||||
handleKikuKeydown,
|
||||
openKikuFieldGroupingModal,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user