import { app, BrowserWindow, shell, ipcMain, dialog, nativeTheme, globalShortcut, clipboard } from 'electron'; import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; import fs from 'fs'; import log from 'electron-log'; import { generatePdf } from './services/pdfGenerator'; import { selectHtmlFile } from './services/htmlImport'; import { opencodeService } from './services/opencodeService'; import { xcOpenCodeWebService } from './services/xcOpenCodeWebService'; import { sddService } from './services/sddService'; import { terminalService } from './services/terminalService'; import { electronState } from './state'; log.initialize(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); electronState.setDevelopment(!app.isPackaged); let lastClipboardText = ''; let clipboardWatcherTimer: NodeJS.Timeout | null = null; function stopClipboardWatcher() { if (clipboardWatcherTimer) { clearInterval(clipboardWatcherTimer); clipboardWatcherTimer = null; log.info('[ClipboardWatcher] Stopped'); } } function startClipboardWatcher() { lastClipboardText = clipboard.readText(); clipboardWatcherTimer = setInterval(() => { try { const currentText = clipboard.readText(); if (currentText && currentText !== lastClipboardText) { lastClipboardText = currentText; log.info('Clipboard changed, syncing to remote'); const win = electronState.getMainWindow(); if (win) { win.webContents.send('remote-clipboard-auto-sync', currentText); } } } catch (e) { // ignore } }, 1000); } async function createWindow() { const initialSymbolColor = nativeTheme.shouldUseDarkColors ? '#ffffff' : '#000000'; const mainWindow = new BrowserWindow({ width: 1600, height: 900, minWidth: 1600, minHeight: 900, autoHideMenuBar: true, titleBarStyle: 'hidden', titleBarOverlay: { color: '#00000000', symbolColor: initialSymbolColor, height: 32, }, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: false, webviewTag: true, preload: path.join(__dirname, 'preload.cjs'), }, }); electronState.setMainWindow(mainWindow); mainWindow.setMenu(null); mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('http:') || url.startsWith('https:')) { shell.openExternal(url); return { action: 'deny' }; } return { action: 'allow' }; }); if (electronState.isDevelopment()) { log.info('Loading development URL...'); try { await mainWindow.loadURL('http://localhost:5173'); } catch (e) { log.error('Failed to load dev URL. Make sure npm run electron:dev is used.', e); } mainWindow.webContents.openDevTools(); } else { log.info(`Loading production URL with port ${electronState.getServerPort()}...`); await mainWindow.loadURL(`http://localhost:${electronState.getServerPort()}`); } } async function createSecondaryWindow(tabData: { route: string; title: string }): Promise { const initialSymbolColor = nativeTheme.shouldUseDarkColors ? '#ffffff' : '#000000'; const win = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 600, autoHideMenuBar: true, titleBarStyle: 'hidden', titleBarOverlay: { color: '#00000000', symbolColor: initialSymbolColor, height: 32, }, webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: false, webviewTag: true, preload: path.join(__dirname, 'preload.cjs'), }, }); electronState.addWindow(win); win.setMenu(null); win.on('closed', () => { electronState.removeWindow(win.id); }); win.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('http:') || url.startsWith('https:')) { shell.openExternal(url); return { action: 'deny' }; } return { action: 'allow' }; }); const baseUrl = electronState.isDevelopment() ? 'http://localhost:5173' : `http://localhost:${electronState.getServerPort()}`; const fullUrl = `${baseUrl}${tabData.route}`; log.info(`[PopOut] Loading secondary window with URL: ${fullUrl}`); try { await win.loadURL(fullUrl); } catch (e) { log.error('[PopOut] Failed to load URL:', e); } if (electronState.isDevelopment()) { win.webContents.openDevTools(); } return win.id; } ipcMain.handle('export-pdf', async (event, title, htmlContent) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) return { success: false, error: 'No window found' }; try { const { filePath } = await dialog.showSaveDialog(win, { title: '导出 PDF', defaultPath: `${title}.pdf`, filters: [{ name: 'PDF Files', extensions: ['pdf'] }] }); if (!filePath) return { success: false, canceled: true }; if (!htmlContent) { throw new Error('No HTML content provided for PDF export'); } const pdfData = await generatePdf(htmlContent); fs.writeFileSync(filePath, pdfData); return { success: true, filePath }; } catch (error: any) { log.error('Export PDF failed:', error); return { success: false, error: error.message }; } }); ipcMain.handle('select-html-file', async (event) => { const win = BrowserWindow.fromWebContents(event.sender); return selectHtmlFile(win); }); ipcMain.handle('update-titlebar-buttons', async (event, symbolColor: string) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { win.setTitleBarOverlay({ symbolColor }); return { success: true }; } return { success: false }; }); ipcMain.handle('clipboard-read-text', async () => { try { const text = clipboard.readText(); return { success: true, text }; } catch (error: any) { log.error('Clipboard read failed:', error); return { success: false, error: error.message }; } }); ipcMain.handle('clipboard-write-text', async (event, text: string) => { try { clipboard.writeText(text); return { success: true }; } catch (error: any) { log.error('Clipboard write failed:', error); return { success: false, error: error.message }; } }); ipcMain.handle('remote-fetch-drives', async (_event, serverHost: string, port: number, password?: string) => { try { let url = `http://${serverHost}:${port}/api/files/drives`; if (password) { url += `?password=${encodeURIComponent(password)}`; } const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch drives: ${response.statusText}`); } const data = await response.json(); const items = data.items || []; return { success: true, data: items.map((item: { name: string; isDirectory: boolean; size: number }) => ({ name: item.name, path: item.name, type: item.isDirectory ? 'dir' : 'file', size: item.size, modified: '', })) }; } catch (error: any) { log.error('Remote fetch drives failed:', error); return { success: false, error: error.message }; } }); ipcMain.handle('remote-fetch-files', async (_event, serverHost: string, port: number, filePath: string, password?: string) => { try { let url = `http://${serverHost}:${port}/api/files/browse?path=${encodeURIComponent(filePath)}&allowSystem=true`; if (password) { url += `&password=${encodeURIComponent(password)}`; } const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch files: ${response.statusText}`); } const data = await response.json(); const items = data.items || []; return { success: true, data: items.map((item: { name: string; isDirectory: boolean; size: number; modified: Date }) => ({ name: item.name, path: data.currentPath ? `${data.currentPath}/${item.name}` : item.name, type: item.isDirectory ? 'dir' : 'file', size: item.size, modified: item.modified?.toString(), })) }; } catch (error: any) { log.error('Remote fetch files failed:', error); return { success: false, error: error.message }; } }); ipcMain.handle('remote-upload-file', async (_event, id: string, serverHost: string, port: number, filePath: string, remotePath: string, password?: string) => { try { const win = electronState.getMainWindow(); if (!win) { throw new Error('No window found'); } const fullPath = path.resolve(filePath); if (!fs.existsSync(fullPath)) { throw new Error('File not found'); } const stats = fs.statSync(fullPath); const fileSize = stats.size; const fileName = path.basename(fullPath); let url = `http://${serverHost}:${port}/api/files/upload/start`; if (password) { url += `?password=${encodeURIComponent(password)}`; } const startResponse = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fileName, fileSize }), }); if (!startResponse.ok) { throw new Error(`Failed to start upload: ${startResponse.statusText}`); } const { fileId, chunkSize } = await startResponse.json(); const CHUNK_SIZE = chunkSize || (64 * 1024); const totalChunks = Math.ceil(fileSize / CHUNK_SIZE); const readStream = fs.createReadStream(fullPath, { highWaterMark: CHUNK_SIZE }); let chunkIndex = 0; let uploadedBytes = 0; for await (const chunk of readStream) { const formData = new FormData(); const blob = new Blob([chunk]); formData.append('chunk', blob, fileName); formData.append('fileId', fileId); formData.append('chunkIndex', chunkIndex.toString()); const chunkUrl = `http://${serverHost}:${port}/api/files/upload/chunk${password ? `?password=${encodeURIComponent(password)}` : ''}`; const chunkResponse = await fetch(chunkUrl, { method: 'POST', body: formData, }); if (!chunkResponse.ok) { throw new Error(`Failed to upload chunk ${chunkIndex}: ${chunkResponse.statusText}`); } uploadedBytes += chunk.length; const progress = Math.round((uploadedBytes / fileSize) * 100); win.webContents.send('upload-progress', { id, progress }); chunkIndex++; } const mergeUrl = `http://${serverHost}:${port}/api/files/upload/merge${password ? `?password=${encodeURIComponent(password)}` : ''}`; const mergeResponse = await fetch(mergeUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileId, totalChunks, filename: fileName, path: remotePath }), }); if (!mergeResponse.ok) { throw new Error(`Failed to merge chunks: ${mergeResponse.statusText}`); } return { success: true }; } catch (error: any) { log.error('Remote upload failed:', error); return { success: false, error: error.message }; } }); ipcMain.handle('remote-download-file', async (_event, id: string, serverHost: string, port: number, fileName: string, remotePath: string, localPath: string, password?: string) => { try { log.info('Remote download params:', { id, serverHost, port, fileName, remotePath, localPath, password }); const win = electronState.getMainWindow(); if (!win) { throw new Error('No window found'); } const fullRemotePath = remotePath ? `${remotePath}\\${fileName}` : fileName; let url = `http://${serverHost}:${port}/api/files/${encodeURIComponent(fullRemotePath)}`; if (password) { url += `?password=${encodeURIComponent(password)}`; } const response = await fetch(url); if (!response.ok) { throw new Error(`Download failed: ${response.statusText}`); } const contentLength = response.headers.get('Content-Length'); if (!contentLength) { throw new Error('Server did not return Content-Length'); } const totalSize = parseInt(contentLength, 10); const targetDir = localPath || 'C:\\'; const targetPath = path.join(targetDir, fileName); if (!fs.existsSync(targetDir)) { fs.mkdirSync(targetDir, { recursive: true }); } const fileStream = fs.createWriteStream(targetPath); const reader = response.body?.getReader(); if (!reader) { throw new Error('Failed to get response body reader'); } let downloadedSize = 0; const CHUNK_SIZE = 64 * 1024; while (true) { const { done, value } = await reader.read(); if (done) { break; } if (value) { downloadedSize += value.length; fileStream.write(value); const progress = Math.round((downloadedSize / totalSize) * 100); win.webContents.send('download-progress', { id, progress }); } } fileStream.end(); return { success: true, filePath: targetPath }; } catch (error: any) { log.error('Remote download failed:', error); return { success: false, error: error.message }; } }); ipcMain.handle('opencode-get-status', () => { return opencodeService.getStatus(); }); ipcMain.handle('opencode-start-server', async () => { return await opencodeService.start(); }); ipcMain.handle('opencode-stop-server', async () => { return await opencodeService.stop(); }); ipcMain.handle('xc-opencode-web-get-status', () => { return xcOpenCodeWebService.getStatus(); }); ipcMain.handle('xc-opencode-web-get-port', () => { return { port: xcOpenCodeWebService.port }; }); ipcMain.handle('xc-opencode-web-start', async () => { return await xcOpenCodeWebService.start(); }); ipcMain.handle('xc-opencode-web-stop', async () => { return await xcOpenCodeWebService.stop(); }); ipcMain.handle('sdd-get-status', () => { return sddService.getStatus(); }); ipcMain.handle('sdd-get-port', () => { return { port: sddService.port }; }); ipcMain.handle('sdd-start', async () => { return await sddService.start(); }); ipcMain.handle('sdd-stop', async () => { return await sddService.stop(); }); ipcMain.handle('terminal-get-status', () => { return terminalService.getStatus(); }); ipcMain.handle('terminal-get-port', () => { return { port: terminalService.port }; }); ipcMain.handle('terminal-start', async () => { return await terminalService.start(); }); ipcMain.handle('terminal-stop', async () => { return await terminalService.stop(); }); ipcMain.handle('create-window', async (_event, tabData: { route: string; title: string }) => { try { log.info('[PopOut] Creating new window for:', tabData); const windowId = await createSecondaryWindow(tabData); return { success: true, windowId }; } catch (error: any) { log.error('[PopOut] Failed to create window:', error); return { success: false, error: error.message }; } }); ipcMain.handle('transfer-tab-data', async (_event, windowId: number, tabData: any) => { try { const win = electronState.getWindow(windowId); if (!win) { return { success: false, error: 'Window not found' }; } await new Promise((resolve) => { if (win.webContents.isLoading()) { win.webContents.once('did-finish-load', () => resolve()); } else { resolve(); } setTimeout(resolve, 2000); }); win.webContents.send('tab-data-received', tabData); log.info('[PopOut] Tab data sent to window:', windowId); return { success: true }; } catch (error: any) { log.error('[PopOut] Failed to transfer tab data:', error); return { success: false, error: error.message }; } }); ipcMain.handle('window-minimize', async (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { win.minimize(); return { success: true }; } return { success: false }; }); ipcMain.handle('window-maximize', async (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { if (win.isMaximized()) { win.unmaximize(); } else { win.maximize(); } return { success: true, isMaximized: win.isMaximized() }; } return { success: false }; }); ipcMain.handle('window-close', async (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { win.close(); return { success: true }; } return { success: false }; }); ipcMain.handle('window-is-maximized', async (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (win) { return { success: true, isMaximized: win.isMaximized() }; } return { success: false, isMaximized: false }; }); async function startServer() { if (electronState.isDevelopment()) { log.info('In dev mode, assuming external servers are running.'); return; } const serverPath = path.join(__dirname, '../dist-api/server.js'); const serverUrl = pathToFileURL(serverPath).href; log.info(`Starting internal server from: ${serverPath}`); try { const serverModule = await import(serverUrl); if (serverModule.startServer) { const port = await serverModule.startServer(); electronState.setServerPort(port); log.info(`Internal server started successfully on port ${port}`); } else { log.warn('startServer function not found in server module, using default port 3001'); } } catch (e) { log.error('Failed to start internal server:', e); } } app.whenReady().then(async () => { process.env.NOTEBOOK_ROOT = path.join(app.getPath('documents'), 'XCDesktop'); if (!fs.existsSync(process.env.NOTEBOOK_ROOT)) { try { fs.mkdirSync(process.env.NOTEBOOK_ROOT, { recursive: true }); } catch (err) { log.error('Failed to create notebook directory:', err); } } await startServer(); await opencodeService.start(); await createWindow(); startClipboardWatcher(); globalShortcut.register('CommandOrControl+Shift+C', () => { log.info('Global shortcut: sync clipboard to remote'); const win = electronState.getMainWindow(); if (win) { win.webContents.send('remote-clipboard-sync-to-remote'); } }); globalShortcut.register('CommandOrControl+Shift+V', () => { log.info('Global shortcut: sync clipboard from remote'); const win = electronState.getMainWindow(); if (win) { win.webContents.send('remote-clipboard-sync-from-remote'); } }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); app.on('window-all-closed', () => { globalShortcut.unregisterAll(); opencodeService.stop(); xcOpenCodeWebService.stop(); sddService.stop(); terminalService.stop(); stopClipboardWatcher(); if (process.platform !== 'darwin') { app.quit(); } }); let isQuitting = false; app.on('before-quit', async (event) => { if (isQuitting) return; isQuitting = true; log.info('[App] before-quit received, cleaning up...'); stopClipboardWatcher(); await Promise.all([ opencodeService.stop(), xcOpenCodeWebService.stop(), sddService.stop(), terminalService.stop() ]); log.info('[App] All services stopped'); });