mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
fix(renderer): add recovery boundary and normalize macOS tray icon
This commit is contained in:
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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user