Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e332ec4ddc | ||
|
|
3b74712f57 | ||
|
|
475e4127c2 | ||
|
|
3041307aa2 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "1.1.4",
|
||||
"version": "1.1.5",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,11 @@ type ParserItem = {
|
||||
isoDate?: string
|
||||
pubDate?: string
|
||||
youtubeId?: string
|
||||
content?: string
|
||||
contentSnippet?: string
|
||||
contentEncoded?: string
|
||||
summary?: string
|
||||
description?: string
|
||||
mediaThumbnail?: Array<{ url?: string }> | { url?: string }
|
||||
mediaContent?: Array<{ url?: string }> | { url?: string }
|
||||
enclosure?: Array<{ url?: string; type?: string }> | { url?: string; type?: string }
|
||||
@@ -41,24 +46,19 @@ type FeedItem = {
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
const parser = new Parser<{ item: ParserItem }>({
|
||||
const parser = new Parser<Record<string, never>, ParserItem>({
|
||||
customFields: {
|
||||
item: [
|
||||
['yt:videoId', 'youtubeId'],
|
||||
['media:thumbnail', 'mediaThumbnail'],
|
||||
['media:content', 'mediaContent'],
|
||||
['enclosure', 'enclosure']
|
||||
['enclosure', 'enclosure'],
|
||||
['content:encoded', 'contentEncoded'],
|
||||
['description', 'description']
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
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 +167,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())
|
||||
@@ -240,13 +240,18 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
}
|
||||
|
||||
const latestItem = normalizedItems[0]
|
||||
const coverUrl = this.resolveSubscriptionCover(
|
||||
feed,
|
||||
normalizedItems,
|
||||
feedItems as ParserItem[]
|
||||
)
|
||||
subscriptionManager.update(subscription.id, {
|
||||
status: 'up-to-date',
|
||||
lastSuccessAt: Date.now(),
|
||||
lastError: undefined,
|
||||
latestVideoTitle: latestItem?.title ?? subscription.latestVideoTitle,
|
||||
latestVideoPublishedAt: latestItem?.publishedAt ?? subscription.latestVideoPublishedAt,
|
||||
coverUrl: (feed.image as { url?: string } | undefined)?.url ?? subscription.coverUrl,
|
||||
coverUrl: coverUrl ?? subscription.coverUrl,
|
||||
title:
|
||||
typeof feed.title === 'string' && feed.title.trim().length > 0
|
||||
? feed.title.trim()
|
||||
@@ -366,6 +371,74 @@ export class SubscriptionScheduler extends EventEmitter {
|
||||
return mediaContent.url as string | undefined
|
||||
}
|
||||
|
||||
// Try to parse an image from HTML fields
|
||||
const htmlCandidates = [
|
||||
item.content,
|
||||
item.contentEncoded,
|
||||
item.description,
|
||||
item.summary,
|
||||
item.contentSnippet
|
||||
]
|
||||
for (const html of htmlCandidates) {
|
||||
const imageUrl = this.extractImageFromHtml(html)
|
||||
if (imageUrl) {
|
||||
return imageUrl
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private resolveSubscriptionCover(
|
||||
feed: Parser.Output<ParserItem>,
|
||||
items: FeedItem[],
|
||||
rawItems: ParserItem[]
|
||||
): string | undefined {
|
||||
const feedImageUrl = typeof feed.image?.url === 'string' ? feed.image.url : undefined
|
||||
if (feedImageUrl) {
|
||||
return feedImageUrl
|
||||
}
|
||||
|
||||
const itunesImageUrl = typeof feed.itunes?.image === 'string' ? feed.itunes.image : undefined
|
||||
if (itunesImageUrl) {
|
||||
return itunesImageUrl
|
||||
}
|
||||
|
||||
const itemThumbnail = items.find((item) => item.thumbnail)?.thumbnail
|
||||
if (itemThumbnail) {
|
||||
return itemThumbnail
|
||||
}
|
||||
|
||||
for (const item of rawItems) {
|
||||
const thumbnail = this.resolveThumbnail(item)
|
||||
if (thumbnail) {
|
||||
return thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private extractImageFromHtml(html?: string): string | undefined {
|
||||
if (!html) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const srcMatch = html.match(
|
||||
/<img\b[^>]*\b(?:src|data-src|data-original)\b\s*=\s*(['"]?)([^'">\s]+)\1/i
|
||||
)
|
||||
if (srcMatch?.[2]) {
|
||||
return srcMatch[2]
|
||||
}
|
||||
|
||||
const srcsetMatch = html.match(/<img[^>]+srcset\s*=\s*(['"])([^'"]+)\1/i)
|
||||
if (srcsetMatch?.[2]) {
|
||||
const firstCandidate = srcsetMatch[2].split(',')[0]?.trim().split(/\s+/)[0]
|
||||
if (firstCandidate) {
|
||||
return firstCandidate
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<HashRouter>
|
||||
<AppContent />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<HashRouter>
|
||||
<AppContent />
|
||||
</HashRouter>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
152
src/renderer/src/components/error/ErrorBoundary.tsx
Normal file
152
src/renderer/src/components/error/ErrorBoundary.tsx
Normal file
@@ -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<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
hasError: false,
|
||||
errorInfo: null
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
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<void> {
|
||||
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 (
|
||||
<ErrorPage
|
||||
errorInfo={this.state.errorInfo}
|
||||
onReload={this.handleReload}
|
||||
onGoHome={this.handleGoHome}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
200
src/renderer/src/components/error/ErrorPage.tsx
Normal file
200
src/renderer/src/components/error/ErrorPage.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background p-4">
|
||||
<Card className="w-full max-w-3xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-8 w-8 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-2xl">{t('error.title')}</CardTitle>
|
||||
<CardDescription className="mt-2">{t('error.description')}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Error Message */}
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-4">
|
||||
<p className="text-sm font-medium text-destructive mb-1">{t('error.message')}</p>
|
||||
<p className="text-sm text-foreground break-words">
|
||||
{errorInfo.error.message || t('error.unknownError')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{onGoHome && (
|
||||
<Button variant="outline" onClick={onGoHome}>
|
||||
<Home className="h-4 w-4 mr-2" />
|
||||
{t('error.goHome')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleReload}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('error.reload')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleCopy}>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
{copied ? t('error.copied') : t('error.copyReport')}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setShowDetails(!showDetails)}>
|
||||
{showDetails ? t('error.hideDetails') : t('error.showDetails')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Details */}
|
||||
{showDetails && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t('error.stackTrace')}</p>
|
||||
<ScrollArea className="h-48 rounded-md border bg-muted/50 p-4">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
|
||||
{errorInfo.error.stack || t('error.noStackTrace')}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{errorInfo.errorInfo?.componentStack && (
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t('error.componentStack')}</p>
|
||||
<ScrollArea className="h-32 rounded-md border bg-muted/50 p-4">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
|
||||
{errorInfo.errorInfo.componentStack}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">{t('error.fullReport')}</p>
|
||||
<Textarea
|
||||
readOnly
|
||||
value={errorReport}
|
||||
className="font-mono text-xs min-h-48"
|
||||
onClick={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.select()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="rounded-md bg-muted/50 border p-4">
|
||||
<p className="text-sm text-muted-foreground">{t('error.helpText')}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function generateErrorReport(errorInfo: ErrorInfo): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push('=== VidBee Error Report ===')
|
||||
lines.push(`Timestamp: ${new Date(errorInfo.timestamp).toISOString()}`)
|
||||
lines.push('')
|
||||
|
||||
if (errorInfo.context) {
|
||||
lines.push('--- Context ---')
|
||||
if (errorInfo.context.version) {
|
||||
lines.push(`App Version: ${errorInfo.context.version}`)
|
||||
}
|
||||
if (errorInfo.context.platform) {
|
||||
lines.push(`Platform: ${errorInfo.context.platform}`)
|
||||
}
|
||||
if (errorInfo.context.url) {
|
||||
lines.push(`URL: ${errorInfo.context.url}`)
|
||||
}
|
||||
if (errorInfo.context.userAgent) {
|
||||
lines.push(`User Agent: ${errorInfo.context.userAgent}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push('--- Error ---')
|
||||
lines.push(`Name: ${errorInfo.error.name}`)
|
||||
lines.push(`Message: ${errorInfo.error.message}`)
|
||||
lines.push('')
|
||||
|
||||
if (errorInfo.error.stack) {
|
||||
lines.push('--- Stack Trace ---')
|
||||
lines.push(errorInfo.error.stack)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (errorInfo.errorInfo?.componentStack) {
|
||||
lines.push('--- Component Stack ---')
|
||||
lines.push(errorInfo.errorInfo.componentStack)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push('=== End of Report ===')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -67,6 +67,8 @@ interface RemoteImageProps {
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
const IMAGE_LOAD_TIMEOUT_MS = 30000
|
||||
|
||||
export function RemoteImage({
|
||||
src,
|
||||
alt,
|
||||
@@ -91,8 +93,10 @@ export function RemoteImage({
|
||||
const imageSrc = shouldUseCache ? cachedSrc : (src ?? undefined)
|
||||
|
||||
const [isImageLoading, setIsImageLoading] = useState(true)
|
||||
const [timedOutSrc, setTimedOutSrc] = useState<string | null>(null)
|
||||
const isCacheLoading = shouldUseCache && src && cachedSrc === undefined
|
||||
const isLoading = isCacheLoading || isImageLoading
|
||||
const hasTimedOut = Boolean(src) && timedOutSrc === src
|
||||
const isLoading = !hasTimedOut && (isCacheLoading || isImageLoading)
|
||||
|
||||
useEffect(() => {
|
||||
if (imageSrc) {
|
||||
@@ -102,6 +106,19 @@ export function RemoteImage({
|
||||
}
|
||||
}, [imageSrc])
|
||||
|
||||
useEffect(() => {
|
||||
if (!src || hasTimedOut || !isLoading) return
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setTimedOutSrc(src)
|
||||
setIsImageLoading(false)
|
||||
}, IMAGE_LOAD_TIMEOUT_MS)
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId)
|
||||
}
|
||||
}, [src, hasTimedOut, isLoading])
|
||||
|
||||
useEffect(() => {
|
||||
onLoadingChange?.(isLoading)
|
||||
}, [isLoading, onLoadingChange])
|
||||
|
||||
20
src/renderer/src/lib/logger.ts
Normal file
20
src/renderer/src/lib/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Renderer process logger utility
|
||||
* Use electron-log/renderer which automatically forwards logs to main process
|
||||
*/
|
||||
|
||||
import log from 'electron-log/renderer'
|
||||
|
||||
// Export electron-log instance
|
||||
export default log
|
||||
|
||||
// Export commonly used logging methods
|
||||
export const logger = log
|
||||
|
||||
// Predefined scoped loggers
|
||||
export const scopedLoggers = {
|
||||
renderer: log.scope('renderer'),
|
||||
error: log.scope('error'),
|
||||
component: log.scope('component'),
|
||||
api: log.scope('api')
|
||||
}
|
||||
@@ -371,8 +371,7 @@
|
||||
"showMoreFormats": "إظهار المزيد من خيارات التنسيق",
|
||||
"showMoreFormatsDescription": "عرض خيارات التنسيق الإضافية في الواجهة",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه.",
|
||||
"intervalDescription": "عدد مرات فحص VidBee لكل تغذية اشتراك (1-24 ساعة)."
|
||||
"filenameDescription": "النمط المستخدم عندما لا يتجاوز الاشتراك اسم ملفه."
|
||||
},
|
||||
"system": "النظام",
|
||||
"theme": "المظهر",
|
||||
@@ -393,7 +392,6 @@
|
||||
"description": "التحكم في مكان تخزين تحميلات الاشتراكات وعدد مرات فحص VidBee للفيديوهات الجديدة.",
|
||||
"downloadDirectory": "مجلد التحميل",
|
||||
"filenameTemplate": "قالب اسم الملف (الملف فقط)",
|
||||
"checkInterval": "فترة الفحص (ساعات)",
|
||||
"onlyLatest": "تحميل أحدث فيديو فقط",
|
||||
"onlyLatestDescription": "عند التفعيل، يتخطى VidBee عناصر المتراكمة القديمة ويأخذ فقط آخر تحميل."
|
||||
},
|
||||
|
||||
@@ -371,8 +371,7 @@
|
||||
"showMoreFormats": "Mehr Formatoptionen anzeigen",
|
||||
"showMoreFormatsDescription": "Zusätzliche Formatoptionen in der Benutzeroberfläche anzeigen",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt.",
|
||||
"intervalDescription": "Wie oft VidBee jeden Abonnement-Feed überprüft (1-24 Stunden)."
|
||||
"filenameDescription": "Muster, das verwendet wird, wenn ein Abonnement seinen Dateinamen nicht überschreibt."
|
||||
},
|
||||
"system": "System",
|
||||
"theme": "Design",
|
||||
@@ -393,7 +392,6 @@
|
||||
"description": "Steuern Sie, wo Abonnement-Downloads gespeichert werden und wie oft VidBee nach neuen Videos sucht.",
|
||||
"downloadDirectory": "Download-Verzeichnis",
|
||||
"filenameTemplate": "Dateinamen-Vorlage (nur Datei)",
|
||||
"checkInterval": "Prüfintervall (Stunden)",
|
||||
"onlyLatest": "Nur das neueste Video herunterladen",
|
||||
"onlyLatestDescription": "Wenn aktiviert, überspringt VidBee ältere Backlog-Elemente und lädt nur den neuesten Upload."
|
||||
},
|
||||
|
||||
@@ -206,6 +206,25 @@
|
||||
"subscription": "Subscription"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"title": "Something went wrong",
|
||||
"description": "An unexpected error occurred. Please try reloading the application or report this issue if it persists.",
|
||||
"message": "Error Message",
|
||||
"unknownError": "Unknown error occurred",
|
||||
"goHome": "Go Home",
|
||||
"reload": "Reload App",
|
||||
"copyReport": "Copy Error Report",
|
||||
"copied": "Copied!",
|
||||
"copySuccess": "Error report copied to clipboard",
|
||||
"copyFailed": "Failed to copy error report",
|
||||
"showDetails": "Show Details",
|
||||
"hideDetails": "Hide Details",
|
||||
"stackTrace": "Stack Trace",
|
||||
"componentStack": "Component Stack",
|
||||
"noStackTrace": "No stack trace available",
|
||||
"fullReport": "Full Error Report",
|
||||
"helpText": "If this error persists, please copy the error report above and share it with the support team. You can find contact information in the About page."
|
||||
},
|
||||
"errors": {
|
||||
"clickToCopy": "Click to copy details",
|
||||
"clipboardEmpty": "Clipboard is empty",
|
||||
@@ -415,8 +434,7 @@
|
||||
"showMoreFormats": "Show more format options",
|
||||
"showMoreFormatsDescription": "Display additional format options in the interface",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Pattern used when a subscription does not override its filename.",
|
||||
"intervalDescription": "How often VidBee checks each subscription feed (1-24 hours)."
|
||||
"filenameDescription": "Pattern used when a subscription does not override its filename."
|
||||
},
|
||||
"system": "System",
|
||||
"theme": "Theme",
|
||||
@@ -437,7 +455,6 @@
|
||||
"description": "Control where subscription downloads are stored and how often VidBee checks for new videos.",
|
||||
"downloadDirectory": "Download directory",
|
||||
"filenameTemplate": "Filename template",
|
||||
"checkInterval": "Check interval (hours)",
|
||||
"onlyLatest": "Download only the latest video",
|
||||
"onlyLatestDescription": "When enabled, VidBee skips older backlog items and only grabs the newest upload."
|
||||
},
|
||||
|
||||
@@ -371,8 +371,7 @@
|
||||
"showMoreFormats": "Mostrar más opciones de formato",
|
||||
"showMoreFormatsDescription": "Mostrar opciones de formato adicionales en la interfaz",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Patrón usado cuando una suscripción no sobrescribe su nombre de archivo.",
|
||||
"intervalDescription": "Con qué frecuencia VidBee verifica cada feed de suscripción (1-24 horas)."
|
||||
"filenameDescription": "Patrón usado cuando una suscripción no sobrescribe su nombre de archivo."
|
||||
},
|
||||
"system": "Sistema",
|
||||
"theme": "Tema",
|
||||
@@ -393,7 +392,6 @@
|
||||
"description": "Controla dónde se almacenan las descargas de suscripciones y con qué frecuencia VidBee verifica nuevos videos.",
|
||||
"downloadDirectory": "Directorio de descarga",
|
||||
"filenameTemplate": "Plantilla de nombre de archivo (solo archivo)",
|
||||
"checkInterval": "Intervalo de verificación (horas)",
|
||||
"onlyLatest": "Descargar solo el último video",
|
||||
"onlyLatestDescription": "Cuando está habilitado, VidBee omite elementos antiguos del backlog y solo toma la última carga."
|
||||
},
|
||||
|
||||
@@ -371,8 +371,7 @@
|
||||
"showMoreFormats": "Afficher plus d'options de format",
|
||||
"showMoreFormatsDescription": "Afficher des options de format supplémentaires dans l'interface",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Modèle utilisé lorsqu’un abonnement ne remplace pas son nom de fichier.",
|
||||
"intervalDescription": "À quelle fréquence VidBee vérifie chaque flux d'abonnement (1 à 24 heures)."
|
||||
"filenameDescription": "Modèle utilisé lorsqu’un abonnement ne remplace pas son nom de fichier."
|
||||
},
|
||||
"system": "Système",
|
||||
"theme": "Thème",
|
||||
@@ -485,7 +484,6 @@
|
||||
"title": "Ajouter un flux RSS"
|
||||
},
|
||||
"defaults": {
|
||||
"checkInterval": "Intervalle de vérification (heures)",
|
||||
"description": "Contrôlez où les téléchargements par abonnement sont stockés et à quelle fréquence VidBee recherche de nouvelles vidéos.",
|
||||
"downloadDirectory": "Répertoire de téléchargement",
|
||||
"filenameTemplate": "Modèle de nom de fichier (fichier uniquement)",
|
||||
|
||||
@@ -371,8 +371,7 @@
|
||||
"showMoreFormats": "Tampilkan lebih banyak opsi format",
|
||||
"showMoreFormatsDescription": "Tampilkan opsi format tambahan di antarmuka",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Pola yang digunakan ketika berlangganan tidak menimpa nama filenya.",
|
||||
"intervalDescription": "Seberapa sering VidBee memeriksa setiap feed berlangganan (1-24 jam)."
|
||||
"filenameDescription": "Pola yang digunakan ketika berlangganan tidak menimpa nama filenya."
|
||||
},
|
||||
"system": "Sistem",
|
||||
"theme": "Tema",
|
||||
@@ -393,7 +392,6 @@
|
||||
"description": "Kontrol di mana unduhan berlangganan disimpan dan seberapa sering VidBee memeriksa video baru.",
|
||||
"downloadDirectory": "Direktori unduhan",
|
||||
"filenameTemplate": "Template nama file (hanya file)",
|
||||
"checkInterval": "Interval pemeriksaan (jam)",
|
||||
"onlyLatest": "Unduh hanya video terbaru",
|
||||
"onlyLatestDescription": "Saat diaktifkan, VidBee melewati item backlog lama dan hanya mengambil unggahan terbaru."
|
||||
},
|
||||
|
||||
@@ -371,8 +371,7 @@
|
||||
"showMoreFormats": "Mostra più opzioni formato",
|
||||
"showMoreFormatsDescription": "Visualizza opzioni di formato aggiuntive nell'interfaccia",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Modello utilizzato quando una sottoscrizione non sovrascrive il relativo nome file.",
|
||||
"intervalDescription": "La frequenza con cui VidBee controlla ciascun feed di abbonamento (1-24 ore)."
|
||||
"filenameDescription": "Modello utilizzato quando una sottoscrizione non sovrascrive il relativo nome file."
|
||||
},
|
||||
"system": "Sistema",
|
||||
"theme": "Tema",
|
||||
@@ -485,7 +484,6 @@
|
||||
"title": "Aggiungi RSS"
|
||||
},
|
||||
"defaults": {
|
||||
"checkInterval": "Intervallo di controllo (ore)",
|
||||
"description": "Controlla dove vengono archiviati i download degli abbonamenti e la frequenza con cui VidBee verifica la presenza di nuovi video.",
|
||||
"downloadDirectory": "Scarica la directory",
|
||||
"filenameTemplate": "Modello nome file (solo file)",
|
||||
|
||||
@@ -371,8 +371,7 @@
|
||||
"showMoreFormats": "より多くのフォーマットオプションを表示",
|
||||
"showMoreFormatsDescription": "インターフェースに追加のフォーマットオプションを表示",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。",
|
||||
"intervalDescription": "VidBee が各サブスクリプション フィードをチェックする頻度 (1 ~ 24 時間)。"
|
||||
"filenameDescription": "サブスクリプションがそのファイル名をオーバーライドしない場合に使用されるパターン。"
|
||||
},
|
||||
"system": "システム",
|
||||
"theme": "テーマ",
|
||||
@@ -485,7 +484,6 @@
|
||||
"title": "RSSを追加"
|
||||
},
|
||||
"defaults": {
|
||||
"checkInterval": "チェック間隔(時間)",
|
||||
"description": "サブスクリプションのダウンロードが保存される場所と、VidBee が新しいビデオをチェックする頻度を制御します。",
|
||||
"downloadDirectory": "ダウンロードディレクトリ",
|
||||
"filenameTemplate": "ファイル名のテンプレート (ファイルのみ)",
|
||||
|
||||
@@ -371,8 +371,7 @@
|
||||
"showMoreFormats": "더 많은 형식 옵션 표시",
|
||||
"showMoreFormatsDescription": "인터페이스에 추가 형식 옵션 표시",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다.",
|
||||
"intervalDescription": "VidBee가 각 구독 피드를 확인하는 빈도(1~24시간)."
|
||||
"filenameDescription": "구독이 파일 이름을 재정의하지 않을 때 사용되는 패턴입니다."
|
||||
},
|
||||
"system": "시스템",
|
||||
"theme": "테마",
|
||||
@@ -485,7 +484,6 @@
|
||||
"title": "RSS 추가"
|
||||
},
|
||||
"defaults": {
|
||||
"checkInterval": "확인 간격(시간)",
|
||||
"description": "구독 다운로드가 저장되는 위치와 VidBee가 새 비디오를 확인하는 빈도를 제어합니다.",
|
||||
"downloadDirectory": "디렉토리 다운로드",
|
||||
"filenameTemplate": "파일 이름 템플릿(파일만)",
|
||||
|
||||
@@ -371,8 +371,7 @@
|
||||
"showMoreFormats": "Mostrar mais opções de formato",
|
||||
"showMoreFormatsDescription": "Exibir opções de formato adicionais na interface",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Padrão usado quando uma assinatura não substitui seu nome de arquivo.",
|
||||
"intervalDescription": "Com que frequência o VidBee verifica cada feed de assinatura (1 a 24 horas)."
|
||||
"filenameDescription": "Padrão usado quando uma assinatura não substitui seu nome de arquivo."
|
||||
},
|
||||
"system": "Sistema",
|
||||
"theme": "Tema",
|
||||
@@ -485,7 +484,6 @@
|
||||
"title": "Adicionar RSS"
|
||||
},
|
||||
"defaults": {
|
||||
"checkInterval": "Intervalo de verificação (horas)",
|
||||
"description": "Controle onde os downloads de assinaturas são armazenados e com que frequência o VidBee verifica novos vídeos.",
|
||||
"downloadDirectory": "Baixar diretório",
|
||||
"filenameTemplate": "Modelo de nome de arquivo (somente arquivo)",
|
||||
|
||||
@@ -371,8 +371,7 @@
|
||||
"showMoreFormats": "Показать больше вариантов форматов",
|
||||
"showMoreFormatsDescription": "Отображать дополнительные варианты форматов в интерфейсе",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "Шаблон, используемый, когда подписка не переопределяет имя файла.",
|
||||
"intervalDescription": "Как часто VidBee проверяет каждый канал подписки (1-24 часа)."
|
||||
"filenameDescription": "Шаблон, используемый, когда подписка не переопределяет имя файла."
|
||||
},
|
||||
"system": "Системная",
|
||||
"theme": "Тема",
|
||||
@@ -393,7 +392,6 @@
|
||||
"description": "Управляйте, где хранятся загрузки подписок и как часто VidBee проверяет новые видео.",
|
||||
"downloadDirectory": "Директория загрузки",
|
||||
"filenameTemplate": "Шаблон имени файла (только файл)",
|
||||
"checkInterval": "Интервал проверки (часы)",
|
||||
"onlyLatest": "Загружать только последнее видео",
|
||||
"onlyLatestDescription": "При включении VidBee пропускает старые элементы из очереди и загружает только последнюю загрузку."
|
||||
},
|
||||
|
||||
@@ -372,8 +372,7 @@
|
||||
"showMoreFormats": "顯示更多格式選項",
|
||||
"showMoreFormatsDescription": "在介面中顯示額外的格式選項",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。",
|
||||
"intervalDescription": "VidBee 檢查每個訂閱源的頻率(1-24 小時)。"
|
||||
"filenameDescription": "當訂閱不覆蓋其文件名時使用的模式。"
|
||||
},
|
||||
"system": "系統",
|
||||
"theme": "主題",
|
||||
@@ -486,7 +485,6 @@
|
||||
"title": "添加RSS"
|
||||
},
|
||||
"defaults": {
|
||||
"checkInterval": "檢查間隔(小時)",
|
||||
"description": "控制訂閱下載的存儲位置以及 VidBee 檢查新視頻的頻率。",
|
||||
"downloadDirectory": "下載目錄",
|
||||
"filenameTemplate": "文件名模板(僅限文件)",
|
||||
|
||||
@@ -372,8 +372,7 @@
|
||||
"showMoreFormats": "显示更多格式选项",
|
||||
"showMoreFormatsDescription": "在界面中显示额外的格式选项",
|
||||
"subscriptionDefaults": {
|
||||
"filenameDescription": "当订阅不覆盖其文件名时使用的模式。",
|
||||
"intervalDescription": "VidBee 检查每个订阅源的频率(1-24 小时)。"
|
||||
"filenameDescription": "当订阅不覆盖其文件名时使用的模式。"
|
||||
},
|
||||
"system": "系统",
|
||||
"theme": "主题",
|
||||
@@ -486,7 +485,6 @@
|
||||
"title": "添加RSS"
|
||||
},
|
||||
"defaults": {
|
||||
"checkInterval": "检查间隔(小时)",
|
||||
"description": "控制订阅下载的存储位置以及 VidBee 检查新视频的频率。",
|
||||
"downloadDirectory": "下载目录",
|
||||
"filenameTemplate": "文件名模板(仅限文件)",
|
||||
|
||||
@@ -6,6 +6,82 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './i18n'
|
||||
import { logger } from './lib/logger'
|
||||
|
||||
// Setup global error handlers
|
||||
setupGlobalErrorHandlers()
|
||||
|
||||
// Get app version asynchronously
|
||||
let appVersion: string | undefined
|
||||
if (window?.api && window.electron?.ipcRenderer) {
|
||||
import('./lib/ipc')
|
||||
.then(({ ipcServices }) => ipcServices.app.getVersion())
|
||||
.then((version) => {
|
||||
appVersion = version
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.warn('Failed to get app version for error reporting:', err)
|
||||
})
|
||||
}
|
||||
|
||||
function setupGlobalErrorHandlers(): void {
|
||||
// Handle uncaught JavaScript errors
|
||||
window.addEventListener('error', (event) => {
|
||||
logger.error('Uncaught error:', event.error)
|
||||
|
||||
if (window?.api) {
|
||||
try {
|
||||
window.api.send('error:renderer', {
|
||||
error: {
|
||||
name: event.error?.name || 'Error',
|
||||
message: event.error?.message || event.message || 'Unknown error',
|
||||
stack: event.error?.stack || event.filename
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
context: {
|
||||
url: window.location.href,
|
||||
userAgent: navigator.userAgent,
|
||||
platform: navigator.platform,
|
||||
version: appVersion,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to send error to main process:', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Handle unhandled promise rejections
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
logger.error('Unhandled promise rejection:', event.reason)
|
||||
|
||||
if (window?.api) {
|
||||
try {
|
||||
const error = event.reason instanceof Error ? event.reason : new Error(String(event.reason))
|
||||
|
||||
window.api.send('error:renderer', {
|
||||
error: {
|
||||
name: error.name || 'UnhandledPromiseRejection',
|
||||
message: error.message || String(event.reason),
|
||||
stack: error.stack
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) {
|
||||
|
||||
@@ -25,16 +25,10 @@ import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcServices } from '../lib/ipc'
|
||||
import { logger } from '../lib/logger'
|
||||
import { loadSettingsAtom, saveSettingAtom, settingsAtom } from '../store/settings'
|
||||
|
||||
const clampSubscriptionInterval = (value: string) => {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(parsed)) {
|
||||
return 3
|
||||
}
|
||||
return Math.min(24, Math.max(1, parsed))
|
||||
}
|
||||
|
||||
export function Settings() {
|
||||
const { t, i18n: i18nInstance } = useTranslation()
|
||||
const { theme, setTheme } = useTheme()
|
||||
@@ -42,19 +36,32 @@ export function Settings() {
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
const [platform, setPlatform] = useState<string>('')
|
||||
const [activeTab, setActiveTab] = useState<string>('general')
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings()
|
||||
logger.info('[Settings] Component mounted, loading settings...')
|
||||
try {
|
||||
loadSettings()
|
||||
// Note: settings will be logged in the next useEffect after it's loaded
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Failed to load settings:', error)
|
||||
}
|
||||
}, [loadSettings])
|
||||
|
||||
useEffect(() => {
|
||||
logger.info('[Settings] Settings state updated', {
|
||||
settingsKeys: Object.keys(settings),
|
||||
settingsValues: settings
|
||||
})
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPlatform = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const platformInfo = await ipcServices.app.getPlatform()
|
||||
setPlatform(platformInfo)
|
||||
} catch (error) {
|
||||
console.error('Failed to get platform info:', error)
|
||||
logger.error('Failed to get platform info:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,57 +74,60 @@ export function Settings() {
|
||||
key: keyof typeof settings,
|
||||
value: (typeof settings)[keyof typeof settings]
|
||||
) => {
|
||||
await saveSetting({ key, value })
|
||||
toast.success(t('notifications.settingsSaved'))
|
||||
try {
|
||||
logger.info('[Settings] Changing setting', { key, value, currentValue: settings[key] })
|
||||
await saveSetting({ key, value })
|
||||
toast.success(t('notifications.settingsSaved'))
|
||||
logger.info('[Settings] Setting changed successfully', { key, value })
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Failed to change setting', { key, value, error })
|
||||
toast.error(t('settings.saveError') || 'Failed to save setting')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectPath = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
await handleSettingChange('downloadPath', path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
logger.error('Failed to select directory:', error)
|
||||
toast.error(t('settings.directorySelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectConfigFile = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const path = await ipcServices.fs.selectFile()
|
||||
if (path) {
|
||||
await handleSettingChange('configPath', path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select file:', error)
|
||||
logger.error('Failed to select file:', error)
|
||||
toast.error(t('settings.fileSelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectCookiesFile = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
const path = await ipcServices.fs.selectFile()
|
||||
if (path) {
|
||||
await handleSettingChange('cookiesPath', path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select cookies file:', error)
|
||||
logger.error('Failed to select cookies file:', error)
|
||||
toast.error(t('settings.fileSelectError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenCookiesFaq = async () => {
|
||||
try {
|
||||
const { ipcServices } = await import('../lib/ipc')
|
||||
await ipcServices.fs.openExternal(
|
||||
'https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp'
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to open cookies FAQ:', error)
|
||||
logger.error('Failed to open cookies FAQ:', error)
|
||||
toast.error(t('settings.openLinkError'))
|
||||
}
|
||||
}
|
||||
@@ -155,7 +165,29 @@ export function Settings() {
|
||||
<p className="text-muted-foreground">{t('settings.description')}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
logger.info('[Settings] Tab changed', { from: activeTab, to: value })
|
||||
setActiveTab(value)
|
||||
try {
|
||||
if (value === 'advanced') {
|
||||
logger.info('[Settings] Entering advanced tab', {
|
||||
settings: settings,
|
||||
settingsKeys: Object.keys(settings),
|
||||
maxConcurrentDownloads: settings.maxConcurrentDownloads,
|
||||
browserForCookies: settings.browserForCookies,
|
||||
cookiesPath: settings.cookiesPath,
|
||||
proxy: settings.proxy,
|
||||
configPath: settings.configPath,
|
||||
enableAnalytics: settings.enableAnalytics
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error when entering advanced tab:', error)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="general">{t('settings.general')}</TabsTrigger>
|
||||
<TabsTrigger value="advanced">{t('settings.advanced')}</TabsTrigger>
|
||||
@@ -379,41 +411,21 @@ export function Settings() {
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.showMoreFormats}
|
||||
onCheckedChange={(value) => handleSettingChange('showMoreFormats', value)}
|
||||
checked={settings.showMoreFormats ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
logger.info('[Settings] Toggling showMoreFormats', { value })
|
||||
handleSettingChange('showMoreFormats', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling showMoreFormats:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('subscriptions.defaults.checkInterval')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{t('settings.subscriptionDefaults.intervalDescription')}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={24}
|
||||
defaultValue={settings.subscriptionCheckIntervalHours}
|
||||
key={`subscription-interval-${settings.subscriptionCheckIntervalHours}`}
|
||||
onBlur={(event) =>
|
||||
void handleSettingChange(
|
||||
'subscriptionCheckIntervalHours',
|
||||
clampSubscriptionInterval(event.target.value)
|
||||
)
|
||||
}
|
||||
className="w-24"
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.maxConcurrentDownloads')}</ItemTitle>
|
||||
@@ -422,23 +434,55 @@ export function Settings() {
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
value={settings.maxConcurrentDownloads.toString()}
|
||||
onValueChange={(value) =>
|
||||
handleSettingChange('maxConcurrentDownloads', Number(value))
|
||||
{(() => {
|
||||
try {
|
||||
const maxConcurrent = settings.maxConcurrentDownloads ?? 5
|
||||
const maxConcurrentStr = maxConcurrent.toString()
|
||||
logger.info('[Settings] Rendering max concurrent downloads select', {
|
||||
maxConcurrent,
|
||||
maxConcurrentStr,
|
||||
type: typeof maxConcurrent
|
||||
})
|
||||
return (
|
||||
<Select
|
||||
value={maxConcurrentStr}
|
||||
onValueChange={(value) => {
|
||||
try {
|
||||
const numValue = Number(value)
|
||||
logger.info('[Settings] Max concurrent downloads changed', {
|
||||
oldValue: maxConcurrent,
|
||||
newValue: numValue,
|
||||
stringValue: value
|
||||
})
|
||||
handleSettingChange('maxConcurrentDownloads', numValue)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'[Settings] Error changing max concurrent downloads:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
'[Settings] Error rendering max concurrent downloads select:',
|
||||
error
|
||||
)
|
||||
return <div>Error loading max concurrent downloads setting</div>
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-20">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((num) => (
|
||||
<SelectItem key={num} value={num.toString()}>
|
||||
{num}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
@@ -450,12 +494,33 @@ export function Settings() {
|
||||
<ItemDescription>{t('settings.proxyDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Input
|
||||
placeholder={t('settings.proxyPlaceholder')}
|
||||
value={settings.proxy}
|
||||
onChange={(e) => handleSettingChange('proxy', e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
{(() => {
|
||||
try {
|
||||
const proxyValue = settings.proxy ?? ''
|
||||
logger.info('[Settings] Rendering proxy input', { proxyValue })
|
||||
return (
|
||||
<Input
|
||||
placeholder={t('settings.proxyPlaceholder')}
|
||||
value={proxyValue}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
logger.info('[Settings] Proxy value changed', {
|
||||
oldValue: proxyValue,
|
||||
newValue: e.target.value
|
||||
})
|
||||
handleSettingChange('proxy', e.target.value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error changing proxy:', error)
|
||||
}
|
||||
}}
|
||||
className="w-64"
|
||||
/>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error rendering proxy input:', error)
|
||||
return <div>Error loading proxy setting</div>
|
||||
}
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
@@ -467,17 +532,37 @@ export function Settings() {
|
||||
<ItemDescription>{t('settings.configFileDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={settings.configPath} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectConfigFile}>{t('settings.selectPath')}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleSettingChange('configPath', '')}
|
||||
disabled={!settings.configPath}
|
||||
>
|
||||
{t('settings.clearConfigFile')}
|
||||
</Button>
|
||||
</div>
|
||||
{(() => {
|
||||
try {
|
||||
const configPathValue = settings.configPath ?? ''
|
||||
logger.info('[Settings] Rendering config file input', { configPathValue })
|
||||
return (
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={configPathValue} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectConfigFile}>
|
||||
{t('settings.selectPath')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
try {
|
||||
logger.info('[Settings] Clearing config path')
|
||||
void handleSettingChange('configPath', '')
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error clearing config path:', error)
|
||||
}
|
||||
}}
|
||||
disabled={!configPathValue}
|
||||
>
|
||||
{t('settings.clearConfigFile')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error rendering config file input:', error)
|
||||
return <div>Error loading config file setting</div>
|
||||
}
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
@@ -489,27 +574,58 @@ export function Settings() {
|
||||
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Select
|
||||
value={settings.browserForCookies}
|
||||
onValueChange={(value) => handleSettingChange('browserForCookies', value)}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
||||
<SelectItem value="chrome">{t('settings.browserOptions.chrome')}</SelectItem>
|
||||
<SelectItem value="chromium">
|
||||
{t('settings.browserOptions.chromium')}
|
||||
</SelectItem>
|
||||
<SelectItem value="firefox">
|
||||
{t('settings.browserOptions.firefox')}
|
||||
</SelectItem>
|
||||
<SelectItem value="edge">{t('settings.browserOptions.edge')}</SelectItem>
|
||||
<SelectItem value="safari">{t('settings.browserOptions.safari')}</SelectItem>
|
||||
<SelectItem value="brave">{t('settings.browserOptions.brave')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(() => {
|
||||
try {
|
||||
const browserValue = settings.browserForCookies ?? 'none'
|
||||
logger.info('[Settings] Rendering browser for cookies select', {
|
||||
browserValue
|
||||
})
|
||||
return (
|
||||
<Select
|
||||
value={browserValue}
|
||||
onValueChange={(value) => {
|
||||
try {
|
||||
logger.info('[Settings] Browser for cookies changed', {
|
||||
oldValue: browserValue,
|
||||
newValue: value
|
||||
})
|
||||
handleSettingChange('browserForCookies', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error changing browser for cookies:', error)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('settings.none')}</SelectItem>
|
||||
<SelectItem value="chrome">
|
||||
{t('settings.browserOptions.chrome')}
|
||||
</SelectItem>
|
||||
<SelectItem value="chromium">
|
||||
{t('settings.browserOptions.chromium')}
|
||||
</SelectItem>
|
||||
<SelectItem value="firefox">
|
||||
{t('settings.browserOptions.firefox')}
|
||||
</SelectItem>
|
||||
<SelectItem value="edge">
|
||||
{t('settings.browserOptions.edge')}
|
||||
</SelectItem>
|
||||
<SelectItem value="safari">
|
||||
{t('settings.browserOptions.safari')}
|
||||
</SelectItem>
|
||||
<SelectItem value="brave">
|
||||
{t('settings.browserOptions.brave')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error rendering browser for cookies select:', error)
|
||||
return <div>Error loading browser for cookies setting</div>
|
||||
}
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
@@ -521,17 +637,37 @@ export function Settings() {
|
||||
<ItemDescription>{t('settings.cookiesFileDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={settings.cookiesPath ?? ''} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectCookiesFile}>{t('settings.selectPath')}</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void handleSettingChange('cookiesPath', '')}
|
||||
disabled={!settings.cookiesPath}
|
||||
>
|
||||
{t('settings.clearCookiesFile')}
|
||||
</Button>
|
||||
</div>
|
||||
{(() => {
|
||||
try {
|
||||
const cookiesPathValue = settings.cookiesPath ?? ''
|
||||
logger.info('[Settings] Rendering cookies file input', { cookiesPathValue })
|
||||
return (
|
||||
<div className="flex gap-2 w-full max-w-md">
|
||||
<Input value={cookiesPathValue} readOnly className="flex-1" />
|
||||
<Button onClick={handleSelectCookiesFile}>
|
||||
{t('settings.selectPath')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
try {
|
||||
logger.info('[Settings] Clearing cookies path')
|
||||
void handleSettingChange('cookiesPath', '')
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error clearing cookies path:', error)
|
||||
}
|
||||
}}
|
||||
disabled={!cookiesPathValue}
|
||||
>
|
||||
{t('settings.clearCookiesFile')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error rendering cookies file input:', error)
|
||||
return <div>Error loading cookies file setting</div>
|
||||
}
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
@@ -540,12 +676,10 @@ export function Settings() {
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.cookiesHelpTitle')}</ItemTitle>
|
||||
<ItemDescription>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>{t('settings.cookiesHelpBrowser')}</li>
|
||||
<li>{t('settings.cookiesHelpFile')}</li>
|
||||
</ul>
|
||||
</ItemDescription>
|
||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground text-sm leading-normal">
|
||||
<li>{t('settings.cookiesHelpBrowser')}</li>
|
||||
<li>{t('settings.cookiesHelpFile')}</li>
|
||||
</ul>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button variant="link" className="px-0" onClick={handleOpenCookiesFaq}>
|
||||
@@ -562,10 +696,33 @@ export function Settings() {
|
||||
<ItemDescription>{t('settings.enableAnalyticsDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.enableAnalytics}
|
||||
onCheckedChange={(value) => handleSettingChange('enableAnalytics', value)}
|
||||
/>
|
||||
{(() => {
|
||||
try {
|
||||
const analyticsValue = settings.enableAnalytics ?? true
|
||||
logger.info('[Settings] Rendering enable analytics switch', {
|
||||
analyticsValue
|
||||
})
|
||||
return (
|
||||
<Switch
|
||||
checked={analyticsValue}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
logger.info('[Settings] Enable analytics changed', {
|
||||
oldValue: analyticsValue,
|
||||
newValue: value
|
||||
})
|
||||
handleSettingChange('enableAnalytics', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error changing enable analytics:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error rendering enable analytics switch:', error)
|
||||
return <div>Error loading enable analytics setting</div>
|
||||
}
|
||||
})()}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
@@ -270,7 +270,6 @@ export interface AppSettings {
|
||||
launchAtLogin: boolean
|
||||
autoUpdate: boolean
|
||||
subscriptionOnlyLatestDefault: boolean
|
||||
subscriptionCheckIntervalHours: number
|
||||
enableAnalytics: boolean
|
||||
}
|
||||
|
||||
@@ -295,6 +294,5 @@ export const defaultSettings: AppSettings = {
|
||||
launchAtLogin: false,
|
||||
autoUpdate: true,
|
||||
subscriptionOnlyLatestDefault: true,
|
||||
subscriptionCheckIntervalHours: 3,
|
||||
enableAnalytics: true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user