Compare commits

...

4 Commits

Author SHA1 Message Date
Nexmoe
e332ec4ddc chore: release v1.1.5 2026-01-10 19:18:55 +08:00
Nexmoe
3b74712f57 fix(subscription): improve cover image selection (#73)
* fix(subscriptions): improve rss cover fallback

* fix(subscriptions): match img src first
2026-01-10 19:18:03 +08:00
Nexmoe
475e4127c2 fix(remote-image): add load timeout (#72) 2026-01-10 18:39:00 +08:00
Nexmoe
3041307aa2 fix: Advanced settings tab broken (#71) 2026-01-10 18:30:18 +08:00
25 changed files with 931 additions and 196 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}
}

View 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')
}

View File

@@ -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])

View 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')
}

View File

@@ -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 عناصر المتراكمة القديمة ويأخذ فقط آخر تحميل."
},

View File

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

View File

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

View File

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

View File

@@ -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é lorsquun 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é lorsquun 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)",

View File

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

View File

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

View 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": "ファイル名のテンプレート (ファイルのみ)",

View 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": "파일 이름 템플릿(파일만)",

View File

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

View File

@@ -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 пропускает старые элементы из очереди и загружает только последнюю загрузку."
},

View File

@@ -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": "文件名模板(僅限文件)",

View File

@@ -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": "文件名模板(仅限文件)",

View File

@@ -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) {

View File

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

View File

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