import { app, BrowserWindow, ipcMain, screen, globalShortcut, clipboard } from 'electron' import { join } from 'path' import { chromium, BrowserContext } from 'playwright' import { existsSync, rmSync, readFileSync, writeFileSync, mkdirSync } from 'fs' import { ScraperFactory } from './scrapers' import { XiaoheiheScrap } from './scrapers/xiaoheihe' import { GenericScraper } from './scrapers/generic' import { PlatformServiceFactory } from './platforms' import { XiaoheiheService } from './platforms/xiaoheihe' let floatingWindow: BrowserWindow | null = null let settingsWindow: BrowserWindow | null = null let chatWindow: BrowserWindow | null = null let toolsPanelWindow: BrowserWindow | null = null // Initialize scraper factory const scraperFactory = new ScraperFactory() scraperFactory.register(new XiaoheiheScrap()) scraperFactory.register(new GenericScraper()) // Generic must be last (catch-all) // Initialize platform service factory const platformServiceFactory = new PlatformServiceFactory() platformServiceFactory.register(new XiaoheiheService()) // Persistent browser context for maintaining login state let persistentContext: BrowserContext | null = null const userDataDir = join(app.getPath('userData'), 'browser-data') let contextInitializing = false let contextInitRetries = 0 const MAX_CONTEXT_INIT_RETRIES = 3 // Settings file path const settingsDir = join(app.getPath('userData'), 'settings') const settingsFilePath = join(settingsDir, 'config.json') const loginInfoFilePath = join(settingsDir, 'login-info.json') // Constants const RATE_LIMIT_CONFIG = { MIN_INTERVAL_MS: 3000, // 最小间隔 3 秒 MAX_SEARCH_PER_MINUTE: 10, // 每分钟最多 10 次 RESET_INTERVAL_MS: 60000, // 1 分钟重置计数 PAGE_TIMEOUT_MS: 30000, // 页面加载超时 30 秒 BROWSER_INIT_TIMEOUT_MS: 30000 // 浏览器初始化超时 30 秒 } as const // Rate limiter for search operations class SearchRateLimiter { private lastSearchTime: number = 0 private searchCount: number = 0 private readonly minInterval: number = RATE_LIMIT_CONFIG.MIN_INTERVAL_MS private readonly maxSearchPerMinute: number = RATE_LIMIT_CONFIG.MAX_SEARCH_PER_MINUTE private readonly resetInterval: number = RATE_LIMIT_CONFIG.RESET_INTERVAL_MS canSearch(): { allowed: boolean; waitTime?: number; reason?: string } { const now = Date.now() const timeSinceLastSearch = now - this.lastSearchTime // 检查是否需要重置计数器 if (timeSinceLastSearch > this.resetInterval) { this.searchCount = 0 } // 检查是否超过频率限制 if (this.searchCount >= this.maxSearchPerMinute) { const waitTime = this.resetInterval - timeSinceLastSearch return { allowed: false, waitTime: Math.ceil(waitTime / 1000), reason: '搜索过于频繁,请稍后再试' } } // 检查最小间隔 if (timeSinceLastSearch < this.minInterval) { const waitTime = this.minInterval - timeSinceLastSearch return { allowed: false, waitTime: Math.ceil(waitTime / 1000), reason: '请求过快,请稍后再试' } } return { allowed: true } } recordSearch(): void { this.lastSearchTime = Date.now() this.searchCount++ } reset(): void { this.searchCount = 0 this.lastSearchTime = 0 } } const searchRateLimiter = new SearchRateLimiter() // Ensure settings directory exists function ensureSettingsDir(): void { if (!existsSync(settingsDir)) { mkdirSync(settingsDir, { recursive: true }) } } // Read settings from file function readSettings(): any { try { ensureSettingsDir() if (existsSync(settingsFilePath)) { const data = readFileSync(settingsFilePath, 'utf-8') return JSON.parse(data) } return {} } catch (error) { console.error('Failed to read settings:', error) return {} } } // Write settings to file function writeSettings(settings: any): { success: boolean; error?: string } { try { ensureSettingsDir() writeFileSync(settingsFilePath, JSON.stringify(settings, null, 2), 'utf-8') return { success: true } } catch (error) { console.error('Failed to write settings:', error) return { success: false, error: error instanceof Error ? error.message : '保存设置失败' } } } // Read login info from file function readLoginInfo(): any { try { ensureSettingsDir() if (existsSync(loginInfoFilePath)) { const data = readFileSync(loginInfoFilePath, 'utf-8') return JSON.parse(data) } return {} } catch (error) { console.error('Failed to read login info:', error) return {} } } // Write login info to file function writeLoginInfo(loginInfo: any): { success: boolean; error?: string } { try { ensureSettingsDir() writeFileSync(loginInfoFilePath, JSON.stringify(loginInfo, null, 2), 'utf-8') return { success: true } } catch (error) { console.error('Failed to write login info:', error) return { success: false, error: error instanceof Error ? error.message : '保存登录信息失败' } } } function createFloatingWindow(): void { const { width, height } = screen.getPrimaryDisplay().workAreaSize floatingWindow = new BrowserWindow({ width: 160, height: 200, x: width - 160, y: Math.floor((height - 200) / 2), frame: false, transparent: true, alwaysOnTop: true, skipTaskbar: true, resizable: false, hasShadow: false, webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, nodeIntegration: false, contextIsolation: true } }) floatingWindow.setIgnoreMouseEvents(true, { forward: true }) floatingWindow.setAlwaysOnTop(true, 'floating') floatingWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }) // Load the floating window HTML if (process.env['ELECTRON_RENDERER_URL']) { floatingWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/floating.html`) } else { floatingWindow.loadFile(join(__dirname, '../renderer/floating.html')) } } function createSettingsWindow(): void { // If settings window already exists, focus it if (settingsWindow && !settingsWindow.isDestroyed()) { settingsWindow.focus() return } settingsWindow = new BrowserWindow({ width: 900, height: 600, title: '设置', resizable: false, webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, nodeIntegration: false, contextIsolation: true } }) // Load settings page if (process.env['ELECTRON_RENDERER_URL']) { settingsWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/settings.html`) } else { settingsWindow.loadFile(join(__dirname, '../renderer/settings.html')) } settingsWindow.on('closed', () => { settingsWindow = null }) } function createChatWindow(initialText?: string): void { const startTime = Date.now() console.log('[PERF] createChatWindow called at:', startTime) // If chat window already exists, focus it and send new text if provided if (chatWindow && !chatWindow.isDestroyed()) { console.log('Chat window already exists, focusing and sending text') chatWindow.focus() if (initialText) { // Add a small delay to ensure the renderer is ready setTimeout(() => { chatWindow?.webContents.send('set-initial-text', initialText) console.log('Sent initial text to existing window:', initialText) }, 100) } return } console.log('[PERF] Creating new chat window') chatWindow = new BrowserWindow({ width: 800, height: 600, title: 'AI 对话', show: false, // Hide window during load for better perceived performance backgroundColor: '#ffffff', // Set background color to avoid flash webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, nodeIntegration: false, contextIsolation: true, spellcheck: false, // Disable spell check to avoid conflicts with IME backgroundThrottling: false // Prevent throttling for better performance } }) // Load chat page if (process.env['ELECTRON_RENDERER_URL']) { chatWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/chat.html`) } else { chatWindow.loadFile(join(__dirname, '../renderer/chat.html')) } // Show window when ready to avoid showing loading state chatWindow.once('ready-to-show', () => { const readyTime = Date.now() console.log('[PERF] Chat window ready-to-show at:', readyTime) console.log('[PERF] Time from create to ready:', readyTime - startTime, 'ms') chatWindow?.show() }) // Send initial text after page loads if (initialText) { console.log('Setting up did-finish-load listener for initial text') chatWindow.webContents.once('did-finish-load', () => { console.log('Chat window did-finish-load event fired') // Add a small delay to ensure Vue components are mounted setTimeout(() => { if (chatWindow && !chatWindow.isDestroyed()) { console.log('Sending initial text to new window:', initialText) chatWindow.webContents.send('set-initial-text', initialText) } }, 200) }) } chatWindow.on('closed', () => { chatWindow = null }) } // Create tools panel window function createToolsPanelWindow(): void { console.log('Creating tools panel window') // If tools panel already exists, focus it if (toolsPanelWindow && !toolsPanelWindow.isDestroyed()) { console.log('Tools panel window already exists, focusing') toolsPanelWindow.focus() return } toolsPanelWindow = new BrowserWindow({ width: 900, height: 700, title: '工具箱', show: false, webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, nodeIntegration: false, contextIsolation: true, spellcheck: false } }) // Load tools panel page if (process.env['ELECTRON_RENDERER_URL']) { toolsPanelWindow.loadURL(`${process.env['ELECTRON_RENDERER_URL']}/tools.html`) } else { toolsPanelWindow.loadFile(join(__dirname, '../renderer/tools.html')) } // Show window when ready toolsPanelWindow.once('ready-to-show', () => { console.log('Tools panel window ready-to-show') toolsPanelWindow?.show() }) toolsPanelWindow.on('closed', () => { toolsPanelWindow = null }) } // Fetch article content using Playwright with factory pattern async function fetchArticleContent(url: string): Promise<{ title: string author?: string authorIp?: string publishTime?: string content: string tags?: string[] comments: Array<{ author: string content: string time?: string replies?: Array<{ author: string; content: string; time?: string }> }> stats?: { likes: number favorites: number commentCount: number hotScore: number } }> { console.log('fetchArticleContent: Starting to fetch article from URL:', url) let browser try { console.log('fetchArticleContent: Launching browser...') browser = await chromium.launch({ headless: true }) const context = await browser.newContext({ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' }) const page = await context.newPage() console.log('fetchArticleContent: Navigating to URL...') // Navigate to the URL await page.goto(url, { waitUntil: 'networkidle', timeout: RATE_LIMIT_CONFIG.PAGE_TIMEOUT_MS }) // Get appropriate scraper for this URL console.log('fetchArticleContent: Getting scraper for URL...') const scraper = scraperFactory.getScraper(url) if (!scraper) { throw new Error('No suitable scraper found for this URL') } console.log('fetchArticleContent: Using scraper:', scraper.constructor.name) // Use scraper to extract content const result = await scraper.scrape(page) console.log('fetchArticleContent: Scraping completed, article title:', result.title) return result } catch (error) { console.error('fetchArticleContent: Error occurred:', error) throw error } finally { // Always close browser in finally block to ensure cleanup if (browser) { try { await browser.close() console.log('fetchArticleContent: Browser closed') } catch (closeError) { console.error('Failed to close browser:', closeError) } } } } // Get or create persistent browser context async function getPersistentContext(headless = true): Promise { // Return existing context if available and not closed if (persistentContext && !persistentContext.pages().length) { console.log('Browser context exists but has no pages, reinitializing') try { await persistentContext.close() } catch (error) { console.error('Error closing stale context:', error) } persistentContext = null } if (persistentContext) { try { // Test if context is still alive await persistentContext.pages() return persistentContext } catch (error) { console.error('Browser context is dead, reinitializing:', error) persistentContext = null } } // Prevent multiple simultaneous initialization attempts if (contextInitializing) { console.log('Context initialization in progress, waiting...') // Wait for initialization to complete while (contextInitializing && contextInitRetries < MAX_CONTEXT_INIT_RETRIES) { await new Promise(resolve => setTimeout(resolve, 1000)) } if (persistentContext) { return persistentContext } } // Initialize new context with retry logic contextInitializing = true let lastError: Error | null = null for (let attempt = 1; attempt <= MAX_CONTEXT_INIT_RETRIES; attempt++) { try { console.log(`Initializing browser context (attempt ${attempt}/${MAX_CONTEXT_INIT_RETRIES})`) persistentContext = await chromium.launchPersistentContext(userDataDir, { headless, viewport: { width: 1280, height: 800 }, userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', // Add timeout for initialization timeout: RATE_LIMIT_CONFIG.BROWSER_INIT_TIMEOUT_MS }) // Setup crash handler persistentContext.on('close', () => { console.log('Browser context closed') persistentContext = null contextInitRetries = 0 }) console.log('Browser context initialized successfully') contextInitializing = false contextInitRetries = 0 return persistentContext } catch (error) { lastError = error as Error console.error(`Browser context initialization failed (attempt ${attempt}):`, error) // Clean up failed context if (persistentContext) { try { await persistentContext.close() } catch (closeError) { console.error('Error closing failed context:', closeError) } persistentContext = null } // Wait before retry (exponential backoff) if (attempt < MAX_CONTEXT_INIT_RETRIES) { const delayMs = 1000 * attempt console.log(`Waiting ${delayMs}ms before retry...`) await new Promise(resolve => setTimeout(resolve, delayMs)) } } } contextInitializing = false contextInitRetries++ throw new Error( `Failed to initialize browser context after ${MAX_CONTEXT_INIT_RETRIES} attempts: ${lastError?.message}` ) } // Get login QR code async function getLoginQrCode(): Promise<{ success: boolean qrCodeDataUrl?: string error?: string }> { try { // 使用无头模式 const context = await getPersistentContext(true) const service = new XiaoheiheService() return await service.getLoginQrCode(context) } catch (error) { console.error('Get login QR code error:', error) return { success: false, error: error instanceof Error ? error.message : '获取二维码失败' } } } // Wait for QR code login async function waitForQrCodeLogin(): Promise<{ success: boolean username?: string error?: string }> { try { if (!persistentContext) { return { success: false, error: '浏览器上下文未初始化' } } const service = new XiaoheiheService() const result = await service.waitForQrCodeLogin(persistentContext) // Save login info if successful if (result.success && result.username) { const savedLoginInfo = readLoginInfo() savedLoginInfo['www.xiaoheihe.cn'] = { username: result.username, lastUpdate: new Date().toISOString() } writeLoginInfo(savedLoginInfo) console.log('Login info saved for user:', result.username) } return result } catch (error) { console.error('Wait for QR code login error:', error) return { success: false, error: error instanceof Error ? error.message : '等待登录失败' } } } // Check login status for a platform (fast - cookie-based only) async function checkPlatformLoginFast(url: string): Promise<{ success: boolean isLoggedIn: boolean username?: string error?: string }> { try { const service = platformServiceFactory.getService(url) if (!service) { return { success: false, isLoggedIn: false, error: '不支持的平台' } } const context = await getPersistentContext() const loginStatus = await service.checkLoginStatusFast(context) // 如果已登录,尝试从本地文件读取用户名(如果 service 没有返回用户名) if (loginStatus.isLoggedIn && !loginStatus.username) { const savedLoginInfo = readLoginInfo() const platformKey = url.replace(/https?:\/\//, '').split('/')[0] if (savedLoginInfo[platformKey]?.username) { loginStatus.username = savedLoginInfo[platformKey].username } } // 如果已登录且有用户名,保存到本地文件 if (loginStatus.isLoggedIn && loginStatus.username) { const savedLoginInfo = readLoginInfo() const platformKey = url.replace(/https?:\/\//, '').split('/')[0] savedLoginInfo[platformKey] = { username: loginStatus.username, lastUpdate: new Date().toISOString() } writeLoginInfo(savedLoginInfo) } return { success: true, ...loginStatus } } catch (error) { console.error('Check platform login fast error:', error) return { success: false, isLoggedIn: false, error: error instanceof Error ? error.message : '检查登录状态失败' } } } // Check login status for a platform async function checkPlatformLogin(url: string): Promise<{ success: boolean isLoggedIn: boolean username?: string error?: string }> { let page: Awaited> | undefined try { const service = platformServiceFactory.getService(url) if (!service) { return { success: false, isLoggedIn: false, error: '不支持的平台' } } const context = await getPersistentContext() page = await context.newPage() await page.goto(url, { waitUntil: 'networkidle', timeout: RATE_LIMIT_CONFIG.PAGE_TIMEOUT_MS }) const loginStatus = await service.checkLoginStatus(page) return { success: true, ...loginStatus } } catch (error) { console.error('Check platform login error:', error) return { success: false, isLoggedIn: false, error: error instanceof Error ? error.message : '检查登录状态失败' } } finally { // Always close page in finally block to ensure cleanup if (page) { try { await page.close() } catch (closeError) { console.error('Failed to close page:', closeError) } } } } // Post comment to platform async function postCommentToPlatform( url: string, comment: string ): Promise<{ success: boolean; error?: string }> { try { console.log('postCommentToPlatform: Starting with URL:', url) console.log('postCommentToPlatform: Comment length:', comment?.length) const service = platformServiceFactory.getService(url) if (!service) { console.error('postCommentToPlatform: No service found for URL') return { success: false, error: '不支持的平台' } } console.log('postCommentToPlatform: Service found') console.log('postCommentToPlatform: Getting persistent context...') const context = await getPersistentContext() console.log('postCommentToPlatform: Context obtained, calling service.postComment...') const result = await service.postComment(context, url, comment) console.log('postCommentToPlatform: Result from service.postComment:', JSON.stringify(result)) return result } catch (error) { console.error('postCommentToPlatform: Exception occurred:', error) console.error( 'postCommentToPlatform: Error stack:', error instanceof Error ? error.stack : 'No stack trace' ) return { success: false, error: error instanceof Error ? error.message : '发送评论失败' } } } // Search platform content async function searchPlatform( platform: string, query: string ): Promise<{ success: boolean results?: Array<{ title: string url: string author?: string publishTime?: string summary?: string commentCount?: number likeCount?: number }> error?: string }> { try { console.log('searchPlatform: Starting search on platform:', platform) console.log('searchPlatform: Query:', query) // 检查搜索频率限制 const rateLimitCheck = searchRateLimiter.canSearch() if (!rateLimitCheck.allowed) { console.log('searchPlatform: Rate limit exceeded') return { success: false, error: 'RATE_LIMIT_EXCEEDED', results: [] } } // 根据平台名称构造一个URL来获取对应的服务 const platformUrls: Record = { xiaoheihe: 'https://www.xiaoheihe.cn' } const platformUrl = platformUrls[platform] if (!platformUrl) { return { success: false, error: '不支持的平台' } } const service = platformServiceFactory.getService(platformUrl) if (!service) { return { success: false, error: '未找到平台服务' } } console.log('searchPlatform: Getting persistent context...') const context = await getPersistentContext() console.log('searchPlatform: Context obtained, calling service.search...') // 记录本次搜索 searchRateLimiter.recordSearch() const result = await service.search(context, query) console.log('searchPlatform: Search completed, found', result.results?.length || 0, 'results') return result } catch (error) { console.error('searchPlatform: Exception occurred:', error) return { success: false, error: error instanceof Error ? error.message : '搜索失败' } } } function registerGlobalShortcuts(): void { // Register Command+K (Mac) or Ctrl+K (Windows/Linux) const shortcut = process.platform === 'darwin' ? 'Command+K' : 'Control+K' const registered = globalShortcut.register(shortcut, () => { if (floatingWindow && !floatingWindow.isDestroyed()) { // Read clipboard content (user should copy text with Command+C first) // We only read the main clipboard, not the selection clipboard const text = clipboard.readText() console.log('Command+K pressed, clipboard content:', text?.substring(0, 50)) // Always send the event to toggle action menu // Pass clipboard text (empty string if clipboard is empty) floatingWindow.webContents.send('show-text-prompt', text || '') floatingWindow.focus() } }) if (!registered) { console.error('Global shortcut registration failed') } } // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.whenReady().then(() => { // IPC test ipcMain.on('ping', () => console.log('pong')) // Handle mouse enter/leave events to control click-through ipcMain.on('set-ignore-mouse-events', (_, ignore: boolean, options?: { forward: boolean }) => { if (floatingWindow) { floatingWindow.setIgnoreMouseEvents(ignore, options) } }) // Handle open settings from renderer ipcMain.on('open-settings', () => { createSettingsWindow() }) // Handle open tools panel from renderer ipcMain.on('open-tools-panel', () => { createToolsPanelWindow() }) // Handle close tools panel from renderer ipcMain.on('close-tools-panel', () => { if (toolsPanelWindow && !toolsPanelWindow.isDestroyed()) { toolsPanelWindow.close() } }) // Handle open chat window from renderer ipcMain.on('open-chat', (_, selectedText?: string) => { console.log('open-chat event received, selectedText:', selectedText) createChatWindow(selectedText) }) // Handle quit app from renderer ipcMain.on('quit-app', () => { app.quit() }) // Handle window drag ipcMain.on('floating-window-move', (_, { x, y }) => { if (floatingWindow) { floatingWindow.setPosition(x, y) } }) // Handle get window bounds ipcMain.handle('get-window-bounds', () => { if (floatingWindow) { const bounds = floatingWindow.getBounds() return { x: bounds.x, y: bounds.y } } return { x: 0, y: 0 } }) // Handle fetch article content ipcMain.handle('fetch-article', async (_, url: string) => { try { const result = await fetchArticleContent(url) return { success: true, ...result } } catch (error) { console.error('Failed to fetch article:', error) return { success: false, error: error instanceof Error ? error.message : '抓取文章失败' } } }) // Handle check platform login status (fast - cookie-based only) ipcMain.handle('check-platform-login-fast', async (_, url: string) => { return await checkPlatformLoginFast(url) }) // Handle check platform login status (accepts platform name or URL) ipcMain.handle('check-platform-login', async (_, arg: string | { platform: string }) => { // Support both old format (URL string) and new format (object with platform) let url: string if (typeof arg === 'string') { url = arg } else { const platformUrls: Record = { xiaoheihe: 'https://www.xiaoheihe.cn' } url = platformUrls[arg.platform] if (!url) { return { success: false, isLoggedIn: false, error: '不支持的平台' } } } return await checkPlatformLoginFast(url) }) // Handle post comment to platform ipcMain.handle('post-comment', async (_, { url, comment }: { url: string; comment: string }) => { return await postCommentToPlatform(url, comment) }) // Handle get login QR code ipcMain.handle('get-login-qrcode', async () => { return await getLoginQrCode() }) // Handle wait for QR code login ipcMain.handle('wait-qrcode-login', async () => { return await waitForQrCodeLogin() }) // Handle logout ipcMain.handle('logout-platform', async (_, platform: string) => { try { if (platform === 'xiaoheihe') { // Close the persistent context to clear cookies and session if (persistentContext) { await persistentContext.close() persistentContext = null } // Delete the user data directory to completely clear all browser data if (existsSync(userDataDir)) { console.log('Deleting user data directory:', userDataDir) rmSync(userDataDir, { recursive: true, force: true }) console.log('User data directory deleted successfully') } // Clear saved login info from file const savedLoginInfo = readLoginInfo() delete savedLoginInfo['www.xiaoheihe.cn'] writeLoginInfo(savedLoginInfo) console.log('Login info cleared for xiaoheihe') return { success: true } } return { success: false, error: '不支持的平台' } } catch (error) { console.error('Logout error:', error) return { success: false, error: error instanceof Error ? error.message : '退出登录失败' } } }) // Handle search platform ipcMain.handle( 'search-platform', async (_, { platform, query }: { platform: string; query: string }) => { return await searchPlatform(platform, query) } ) // Handle read settings ipcMain.handle('read-settings', () => { return readSettings() }) // Handle write settings ipcMain.handle('write-settings', (_, settings: any) => { return writeSettings(settings) }) createFloatingWindow() registerGlobalShortcuts() app.on('activate', function () { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) { createFloatingWindow() } }) }) // Quit when all windows are closed, except on macOS. There, it's common // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } }) // Unregister all shortcuts when app is about to quit app.on('will-quit', async () => { globalShortcut.unregisterAll() // Clean up browser context if (persistentContext) { console.log('Cleaning up browser context on app quit') try { await persistentContext.close() persistentContext = null } catch (error) { console.error('Error closing browser context on quit:', error) } } }) // In this file you can include the rest of your app's specific main process // code. You can also put them in separate files and require them here.