- Restore transfer-tab-data IPC for transferring tab state - Create usePopOutTab hook to receive tab data in new window - Update handlePopOut to transfer data and close tab in main window - Add PopOutTabData interface for type safety
644 lines
18 KiB
TypeScript
644 lines
18 KiB
TypeScript
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<number> {
|
|
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' };
|
|
}
|
|
win.webContents.send('tab-data-received', tabData);
|
|
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 terminalService.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');
|
|
});
|