diff --git a/src/main/index.ts b/src/main/index.ts index abe2345..a2d3159 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,7 +2,14 @@ import { existsSync } from 'node:fs' import { isAbsolute, join, relative, resolve } from 'node:path' import { electronApp, optimizer } from '@electron-toolkit/utils' import { APP_PROTOCOL, APP_PROTOCOL_SCHEME } from '@shared/constants' -import { app, BrowserWindow, type BrowserWindowConstructorOptions, protocol, shell } from 'electron' +import { + app, + BrowserWindow, + type BrowserWindowConstructorOptions, + ipcMain, + protocol, + shell +} from 'electron' import log from 'electron-log/main' import { autoUpdater } from 'electron-updater' import appIcon from '../../build/icon.png?asset' @@ -194,10 +201,48 @@ export function createWindow(): void { flushPendingDeepLinks() }) + // Setup error handling for renderer process + setupRendererErrorHandling() + // Setup download engine event forwarding to renderer setupDownloadEvents() } +function setupRendererErrorHandling(): void { + if (!mainWindow) return + + // Handle uncaught exceptions in renderer process + mainWindow.webContents.on('unresponsive', () => { + log.error('Renderer process became unresponsive') + }) + + mainWindow.webContents.on('responsive', () => { + log.info('Renderer process became responsive again') + }) + + // Listen for renderer errors via IPC + ipcMain.on('error:renderer', (_event, errorData) => { + log.error('Renderer error received:', errorData) + + // Log detailed error information + if (errorData.error) { + log.error('Error name:', errorData.error.name) + log.error('Error message:', errorData.error.message) + if (errorData.error.stack) { + log.error('Error stack:', errorData.error.stack) + } + } + + if (errorData.errorInfo?.componentStack) { + log.error('Component stack:', errorData.errorInfo.componentStack) + } + + if (errorData.context) { + log.error('Error context:', errorData.context) + } + }) +} + function setupDownloadEvents(): void { downloadEngine.on('download-started', (id: string) => { mainWindow?.webContents.send('download:started', id) @@ -383,6 +428,17 @@ app.whenReady().then(async () => { // and ignore CommandOrControl + R in production. app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) + + // Enable F12 to toggle DevTools in both development and production + window.webContents.on('before-input-event', (_, input) => { + if (input.key === 'F12') { + if (window.webContents.isDevToolsOpened()) { + window.webContents.closeDevTools() + } else { + window.webContents.openDevTools() + } + } + }) }) // IPC services are automatically registered by electron-ipc-decorator when imported diff --git a/src/main/ipc/services/settings-service.ts b/src/main/ipc/services/settings-service.ts index 915af30..0a84128 100644 --- a/src/main/ipc/services/settings-service.ts +++ b/src/main/ipc/services/settings-service.ts @@ -1,6 +1,5 @@ import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator' import type { AppSettings } from '../../../shared/types' -import { subscriptionScheduler } from '../../lib/subscription-scheduler' import { settingsManager } from '../../settings' import { updateTrayMenu } from '../../tray' import { applyAutoLaunchSetting } from '../../utils/auto-launch' @@ -29,10 +28,6 @@ class SettingsService extends IpcService { if (key === 'launchAtLogin') { applyAutoLaunchSetting(value as AppSettings['launchAtLogin']) } - - if (key === 'subscriptionCheckIntervalHours') { - subscriptionScheduler.refreshInterval() - } } @IpcMethod() @@ -55,10 +50,6 @@ class SettingsService extends IpcService { if (typeof settings.launchAtLogin === 'boolean') { applyAutoLaunchSetting(settings.launchAtLogin) } - - if (settings.subscriptionCheckIntervalHours !== undefined) { - subscriptionScheduler.refreshInterval() - } } @IpcMethod() @@ -66,7 +57,6 @@ class SettingsService extends IpcService { settingsManager.reset() applyDockVisibility(settingsManager.get('hideDockIcon')) applyAutoLaunchSetting(settingsManager.get('launchAtLogin')) - subscriptionScheduler.refreshInterval() } } diff --git a/src/main/lib/subscription-scheduler.ts b/src/main/lib/subscription-scheduler.ts index 2b05cc4..82b59c1 100644 --- a/src/main/lib/subscription-scheduler.ts +++ b/src/main/lib/subscription-scheduler.ts @@ -52,13 +52,6 @@ const parser = new Parser<{ item: ParserItem }>({ } }) -const clampIntervalHours = (value: number | undefined): number => { - if (!value || Number.isNaN(value)) { - return 3 - } - return Math.min(24, Math.max(1, value)) -} - const sanitizeDownloadId = (subscriptionId: string, itemId: string): string => { const base = Buffer.from(`${subscriptionId}:${itemId}`).toString('base64url') return `sub_${base}` @@ -167,7 +160,7 @@ export class SubscriptionScheduler extends EventEmitter { if (this.timer) { clearTimeout(this.timer) } - const intervalHours = clampIntervalHours(settingsManager.get('subscriptionCheckIntervalHours')) + const intervalHours = 3 // Default check interval: 3 hours const delayMs = initialDelay ?? intervalHours * 60 * 60 * 1000 this.timer = setTimeout(() => { void this.checkAll().finally(() => this.scheduleNextRun()) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5bf885c..8d4bc36 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,6 +9,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router' import { toast } from 'sonner' +import { ErrorBoundary } from './components/error/ErrorBoundary' import { ipcEvents, ipcServices } from './lib/ipc' import { About } from './pages/About' import { Home } from './pages/Home' @@ -292,11 +293,13 @@ function AppContent() { function App() { return ( - - - - - + + + + + + + ) } diff --git a/src/renderer/src/components/error/ErrorBoundary.tsx b/src/renderer/src/components/error/ErrorBoundary.tsx new file mode 100644 index 0000000..0263f56 --- /dev/null +++ b/src/renderer/src/components/error/ErrorBoundary.tsx @@ -0,0 +1,152 @@ +import { ipcServices } from '@renderer/lib/ipc' +import { logger } from '@renderer/lib/logger' +import { Component, type ErrorInfo, type ReactNode } from 'react' +import { type ErrorInfo as ErrorInfoType, ErrorPage } from './ErrorPage' + +interface Props { + children: ReactNode + onError?: (error: Error, errorInfo: ErrorInfo) => void + fallback?: (errorInfo: ErrorInfoType) => ReactNode +} + +interface State { + hasError: boolean + errorInfo: ErrorInfoType | null +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props) + this.state = { + hasError: false, + errorInfo: null + } + } + + static getDerivedStateFromError(error: Error): Partial { + const errorInfo = { + error, + timestamp: Date.now(), + context: { + url: window.location.href, + userAgent: navigator.userAgent, + platform: navigator.platform + } + } + + // Log error details immediately + logger.error('ErrorBoundary: getDerivedStateFromError called', { + errorName: error.name, + errorMessage: error.message, + errorStack: error.stack, + url: errorInfo.context.url, + timestamp: errorInfo.timestamp + }) + + return { + hasError: true, + errorInfo + } + } + + async componentDidCatch(error: Error, errorInfo: ErrorInfo): Promise { + logger.error('ErrorBoundary caught an error:', { + errorName: error.name, + errorMessage: error.message, + errorStack: error.stack, + componentStack: errorInfo.componentStack, + errorInfo: JSON.stringify(errorInfo, null, 2) + }) + + // Get app version if available + let appVersion: string | undefined + try { + if (window?.api && ipcServices?.app) { + appVersion = await ipcServices.app.getVersion() + logger.info('ErrorBoundary: App version retrieved', { appVersion }) + } + } catch (err) { + logger.warn('Failed to get app version:', err) + } + + // Update state with component stack and version + if (this.state.errorInfo) { + this.setState({ + errorInfo: { + ...this.state.errorInfo, + context: { + ...this.state.errorInfo.context, + version: appVersion + }, + errorInfo: { + componentStack: errorInfo.componentStack || undefined + } + } + }) + } + + // Call optional error handler + if (this.props.onError) { + this.props.onError(error, errorInfo) + } + + // Send error to main process if available + if (window?.api) { + try { + window.api.send('error:renderer', { + error: { + name: error.name, + message: error.message, + stack: error.stack + }, + errorInfo: { + componentStack: errorInfo.componentStack + }, + timestamp: Date.now(), + context: { + url: window.location.href, + userAgent: navigator.userAgent, + platform: navigator.platform, + version: appVersion + } + }) + } catch (err) { + logger.error('Failed to send error to main process:', err) + } + } + } + + handleReload = (): void => { + this.setState({ + hasError: false, + errorInfo: null + }) + window.location.reload() + } + + handleGoHome = (): void => { + this.setState({ + hasError: false, + errorInfo: null + }) + window.location.hash = '/' + window.location.reload() + } + + render(): ReactNode { + if (this.state.hasError && this.state.errorInfo) { + if (this.props.fallback) { + return this.props.fallback(this.state.errorInfo) + } + return ( + + ) + } + + return this.props.children + } +} diff --git a/src/renderer/src/components/error/ErrorPage.tsx b/src/renderer/src/components/error/ErrorPage.tsx new file mode 100644 index 0000000..cc02281 --- /dev/null +++ b/src/renderer/src/components/error/ErrorPage.tsx @@ -0,0 +1,200 @@ +import { Button } from '@renderer/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle +} from '@renderer/components/ui/card' +import { ScrollArea } from '@renderer/components/ui/scroll-area' +import { Textarea } from '@renderer/components/ui/textarea' +import { logger } from '@renderer/lib/logger' +import { AlertTriangle, Copy, Home, RefreshCw } from 'lucide-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +export interface ErrorInfo { + error: Error + errorInfo?: { + componentStack?: string + } + timestamp: number + context?: { + url?: string + userAgent?: string + platform?: string + version?: string + } +} + +interface ErrorPageProps { + errorInfo: ErrorInfo + onReload?: () => void + onGoHome?: () => void +} + +export function ErrorPage({ errorInfo, onReload, onGoHome }: ErrorPageProps) { + const { t } = useTranslation() + const [showDetails, setShowDetails] = useState(false) + const [copied, setCopied] = useState(false) + + const errorReport = generateErrorReport(errorInfo) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(errorReport) + setCopied(true) + toast.success(t('error.copySuccess')) + setTimeout(() => setCopied(false), 2000) + } catch (error) { + logger.error('Failed to copy error report:', error) + toast.error(t('error.copyFailed')) + } + } + + const handleReload = () => { + if (onReload) { + onReload() + } else { + window.location.reload() + } + } + + return ( +
+ + +
+
+ +
+
+ {t('error.title')} + {t('error.description')} +
+
+
+ + {/* Error Message */} +
+

{t('error.message')}

+

+ {errorInfo.error.message || t('error.unknownError')} +

+
+ + {/* Actions */} +
+ {onGoHome && ( + + )} + + + +
+ + {/* Error Details */} + {showDetails && ( +
+
+

{t('error.stackTrace')}

+ +
+                    {errorInfo.error.stack || t('error.noStackTrace')}
+                  
+
+
+ + {errorInfo.errorInfo?.componentStack && ( +
+

{t('error.componentStack')}

+ +
+                      {errorInfo.errorInfo.componentStack}
+                    
+
+
+ )} + +
+

{t('error.fullReport')}

+