Files
CFspider/cfspider-browser/electron/main.ts
2026-01-28 17:18:11 +08:00

613 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { app, BrowserWindow, ipcMain, session, Menu, webContents, dialog } from 'electron'
import { join } from 'path'
import { writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import https from 'https'
import http from 'http'
let mainWindow: BrowserWindow | null = null
let webviewContents: Electron.WebContents | null = null
function createWindow() {
// 隐藏菜单栏
Menu.setApplicationMenu(null)
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1000,
minHeight: 700,
title: 'cfspider-智能浏览器',
autoHideMenuBar: true,
backgroundColor: '#ffffff',
webPreferences: {
preload: join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
webviewTag: true
}
})
// 开发模式加载本地服务器
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
mainWindow.loadURL('http://localhost:5173')
} else {
mainWindow.loadFile(join(__dirname, '../dist/index.html'))
}
mainWindow.on('closed', () => {
mainWindow = null
})
// 注册快捷键
registerShortcuts()
}
// 注册快捷键
function registerShortcuts() {
if (!mainWindow) return
// 监听快捷键
mainWindow.webContents.on('before-input-event', (event, input) => {
// F12 - 打开/关闭 webview 的开发者工具(内嵌在底部)
if (input.key === 'F12') {
if (webviewContents && !webviewContents.isDestroyed()) {
if (webviewContents.isDevToolsOpened()) {
webviewContents.closeDevTools()
} else {
// 使用 'bottom' 模式让开发者工具显示在底部,像真实浏览器一样
webviewContents.openDevTools({ mode: 'bottom' })
}
}
event.preventDefault()
}
// Ctrl+Shift+I - 打开主窗口开发者工具(调试 Electron 应用本身)
if (input.control && input.shift && input.key.toLowerCase() === 'i') {
if (mainWindow?.webContents.isDevToolsOpened()) {
mainWindow.webContents.closeDevTools()
} else {
mainWindow?.webContents.openDevTools({ mode: 'right' })
}
event.preventDefault()
}
// F5 或 Ctrl+R - 刷新 webview
if (input.key === 'F5' || (input.control && input.key.toLowerCase() === 'r')) {
mainWindow?.webContents.send('reload-webview')
event.preventDefault()
}
// Alt+Left - 后退
if (input.alt && input.key === 'ArrowLeft') {
mainWindow?.webContents.send('navigate-back')
event.preventDefault()
}
// Alt+Right - 前进
if (input.alt && input.key === 'ArrowRight') {
mainWindow?.webContents.send('navigate-forward')
event.preventDefault()
}
// Ctrl+L - 聚焦地址栏
if (input.control && input.key.toLowerCase() === 'l') {
mainWindow?.webContents.send('focus-addressbar')
event.preventDefault()
}
// Ctrl+T - 新建标签页
if (input.control && input.key.toLowerCase() === 't') {
mainWindow?.webContents.send('new-tab')
event.preventDefault()
}
// Ctrl+W - 关闭当前标签页
if (input.control && input.key.toLowerCase() === 'w') {
mainWindow?.webContents.send('close-tab')
event.preventDefault()
}
})
}
app.whenReady().then(() => {
// 配置 webview 的独立 sessionpersist: 前缀确保数据持久化到磁盘)
const webviewSession = session.fromPartition('persist:cfspider')
// 设置真实的 User-Agent
const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
webviewSession.setUserAgent(userAgent)
// 移除 X-Frame-Options 和 CSP 限制,允许在 webview 中加载任何网站
webviewSession.webRequest.onHeadersReceived((details, callback) => {
const headers = { ...details.responseHeaders }
// 移除阻止嵌入的响应头
delete headers['x-frame-options']
delete headers['X-Frame-Options']
delete headers['content-security-policy']
delete headers['Content-Security-Policy']
delete headers['content-security-policy-report-only']
delete headers['Content-Security-Policy-Report-Only']
callback({ responseHeaders: headers })
})
// 允许所有权限请求
webviewSession.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(true)
})
// 处理 webview 中的新窗口请求
app.on('web-contents-created', (_event, contents) => {
// 处理 webview 类型的 webContents
if (contents.getType() === 'webview') {
// 保存 webview 的 webContents 引用
webviewContents = contents
// 拦截新窗口请求,在当前 webview 中打开
contents.setWindowOpenHandler(({ url }) => {
// 不允许打开新窗口,改为在当前页面导航
if (url && !url.startsWith('javascript:')) {
contents.loadURL(url)
}
return { action: 'deny' }
})
// 当 webview 被销毁时清除引用
contents.on('destroyed', () => {
if (webviewContents === contents) {
webviewContents = null
}
})
}
})
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// IPC 处理AI API 调用(非流式,用于工具调用)
ipcMain.handle('ai:chat', async (_event, { endpoint, apiKey, model, messages, tools }) => {
try {
// 验证 endpoint
if (!endpoint || typeof endpoint !== 'string') {
throw new Error('请先配置 API 地址')
}
// Local/LAN services (Ollama etc.) do not require API Key
const isLocalEndpoint = (url: string) => {
return url.includes('localhost') ||
url.includes('127.0.0.1') ||
url.includes('192.168.') ||
url.includes('10.') ||
/172\.(1[6-9]|2[0-9]|3[01])\./.test(url) ||
url.includes(':11434') // Ollama default port
}
if (!isLocalEndpoint(endpoint) && (!apiKey || typeof apiKey !== 'string')) {
throw new Error('请先配置 API Key')
}
// 添加超时控制
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 60000) // 60秒超时
// 构建请求头
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`
}
try {
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model,
messages,
tools,
stream: false
}),
signal: controller.signal
})
clearTimeout(timeout)
if (!response.ok) {
const errorText = await response.text().catch(() => '')
throw new Error(`API 错误 ${response.status}: ${errorText.slice(0, 100) || response.statusText}`)
}
return await response.json()
} catch (fetchError) {
clearTimeout(timeout)
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
throw new Error('请求超时,请检查网络连接')
}
throw fetchError
}
} catch (error) {
console.error('AI API error:', error)
const message = error instanceof Error ? error.message : '未知错误'
// 友好的错误信息
if (message.includes('fetch failed') || message.includes('ECONNREFUSED') || message.includes('ENOTFOUND')) {
throw new Error('网络连接失败,请检查:\n1. 网络是否正常\n2. API 地址是否正确\n3. 是否需要代理')
}
throw new Error(message)
}
})
// IPC 处理AI API 流式调用
ipcMain.on('ai:chat-stream', async (event, { requestId, endpoint, apiKey, model, messages }) => {
try {
// Local/LAN services do not require API Key
const isLocalEndpoint = (url: string) => {
return url?.includes('localhost') ||
url?.includes('127.0.0.1') ||
url?.includes('192.168.') ||
url?.includes('10.') ||
/172\.(1[6-9]|2[0-9]|3[01])\./.test(url || '') ||
url?.includes(':11434') // Ollama default port
}
if (!endpoint || (!isLocalEndpoint(endpoint) && !apiKey)) {
event.sender.send('ai:chat-stream-error', { requestId, error: '请先配置 API 地址和 Key' })
return
}
// 添加超时控制
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 60000)
// 构建请求头
const headers: Record<string, string> = {
'Content-Type': 'application/json'
}
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`
}
let response: Response
try {
response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model,
messages,
stream: true
}),
signal: controller.signal
})
clearTimeout(timeout)
} catch (fetchError) {
clearTimeout(timeout)
const msg = fetchError instanceof Error && fetchError.name === 'AbortError'
? '请求超时'
: '网络连接失败,请检查网络和 API 配置'
event.sender.send('ai:chat-stream-error', { requestId, error: msg })
return
}
if (!response.ok) {
const errorText = await response.text().catch(() => '')
event.sender.send('ai:chat-stream-error', { requestId, error: `API 错误 ${response.status}: ${errorText.slice(0, 100) || response.statusText}` })
return
}
const reader = response.body?.getReader()
if (!reader) {
event.sender.send('ai:chat-stream-error', { requestId, error: 'No response body' })
return
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed === 'data: [DONE]') continue
if (!trimmed.startsWith('data: ')) continue
try {
const json = JSON.parse(trimmed.slice(6))
const content = json.choices?.[0]?.delta?.content
if (content) {
event.sender.send('ai:chat-stream-data', { requestId, content })
}
} catch (e) {
// 忽略解析错误
}
}
}
event.sender.send('ai:chat-stream-end', { requestId })
} catch (error) {
console.error('AI stream error:', error)
event.sender.send('ai:chat-stream-error', {
requestId,
error: error instanceof Error ? error.message : 'Unknown error'
})
}
})
// IPC 处理:保存文件(支持用户自定义路径)
ipcMain.handle('file:save', async (_event, { filename, content, type, isBase64 }) => {
const fs = await import('fs/promises')
// 根据类型设置过滤器
let filters: Electron.FileFilter[]
switch (type) {
case 'json':
filters = [{ name: 'JSON 文件', extensions: ['json'] }]
break
case 'csv':
filters = [{ name: 'CSV 文件', extensions: ['csv'] }]
break
case 'excel':
filters = [{ name: 'Excel 文件', extensions: ['xlsx'] }]
break
case 'txt':
filters = [{ name: '文本文件', extensions: ['txt'] }]
break
default:
filters = [{ name: '所有文件', extensions: ['*'] }]
}
// 显示保存对话框让用户选择路径
const result = await dialog.showSaveDialog(mainWindow!, {
title: '保存文件',
defaultPath: filename,
filters,
properties: ['showOverwriteConfirmation']
})
if (!result.canceled && result.filePath) {
try {
// 处理 base64 编码的内容(用于 Excel
if (isBase64) {
const buffer = Buffer.from(content, 'base64')
await fs.writeFile(result.filePath, buffer)
} else {
await fs.writeFile(result.filePath, content, 'utf-8')
}
return { success: true, filePath: result.filePath }
} catch (error) {
return { success: false, error: `保存失败: ${error}` }
}
}
return { success: false, canceled: true }
})
// IPC 处理:读取保存的规则
ipcMain.handle('rules:load', async () => {
const fs = await import('fs/promises')
const rulesPath = join(app.getPath('userData'), 'rules.json')
try {
const content = await fs.readFile(rulesPath, 'utf-8')
return JSON.parse(content)
} catch {
return []
}
})
// IPC 处理:保存规则
ipcMain.handle('rules:save', async (_event, rules) => {
const fs = await import('fs/promises')
const rulesPath = join(app.getPath('userData'), 'rules.json')
await fs.writeFile(rulesPath, JSON.stringify(rules, null, 2))
return true
})
// IPC 处理:读取 AI 配置
ipcMain.handle('config:load', async () => {
const fs = await import('fs/promises')
const configPath = join(app.getPath('userData'), 'ai-config.json')
try {
const content = await fs.readFile(configPath, 'utf-8')
return JSON.parse(content)
} catch {
return {
endpoint: 'https://api.openai.com/v1/chat/completions',
apiKey: '',
model: 'gpt-4'
}
}
})
// IPC 处理:保存 AI 配置
ipcMain.handle('config:save', async (_event, config) => {
const fs = await import('fs/promises')
const configPath = join(app.getPath('userData'), 'ai-config.json')
await fs.writeFile(configPath, JSON.stringify(config, null, 2))
return true
})
// IPC 处理:读取已保存的配置列表
ipcMain.handle('saved-configs:load', async () => {
const fs = await import('fs/promises')
const configsPath = join(app.getPath('userData'), 'saved-configs.json')
try {
const content = await fs.readFile(configsPath, 'utf-8')
return JSON.parse(content)
} catch {
return []
}
})
// IPC 处理:保存配置列表
ipcMain.handle('saved-configs:save', async (_event, configs) => {
const fs = await import('fs/promises')
const configsPath = join(app.getPath('userData'), 'saved-configs.json')
await fs.writeFile(configsPath, JSON.stringify(configs, null, 2))
return true
})
// IPC 处理:读取浏览器设置
ipcMain.handle('browser-settings:load', async () => {
const fs = await import('fs/promises')
const settingsPath = join(app.getPath('userData'), 'browser-settings.json')
try {
const content = await fs.readFile(settingsPath, 'utf-8')
return JSON.parse(content)
} catch {
return {
searchEngine: 'bing',
homepage: 'https://www.bing.com',
defaultZoom: 100
}
}
})
// IPC 处理:保存浏览器设置
ipcMain.handle('browser-settings:save', async (_event, settings) => {
const fs = await import('fs/promises')
const settingsPath = join(app.getPath('userData'), 'browser-settings.json')
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2))
return true
})
// IPC 处理:读取历史记录
ipcMain.handle('history:load', async () => {
const fs = await import('fs/promises')
const historyPath = join(app.getPath('userData'), 'history.json')
try {
const content = await fs.readFile(historyPath, 'utf-8')
return JSON.parse(content)
} catch {
return []
}
})
// IPC 处理:保存历史记录
ipcMain.handle('history:save', async (_event, history) => {
const fs = await import('fs/promises')
const historyPath = join(app.getPath('userData'), 'history.json')
await fs.writeFile(historyPath, JSON.stringify(history, null, 2))
return true
})
// IPC 处理:下载图片
ipcMain.handle('download:image', async (_event, url: string, filename: string) => {
try {
// 创建下载目录
const downloadsPath = join(app.getPath('downloads'), 'cfspider-images')
if (!existsSync(downloadsPath)) {
await mkdir(downloadsPath, { recursive: true })
}
// 从 URL 获取扩展名
const urlObj = new URL(url)
let ext = '.jpg'
const pathExt = urlObj.pathname.split('.').pop()?.toLowerCase()
if (pathExt && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'].includes(pathExt)) {
ext = `.${pathExt}`
}
// 清理文件名
const cleanFilename = filename.replace(/[<>:"/\\|?*]/g, '_')
const fullFilename = `${cleanFilename}${ext}`
const filePath = join(downloadsPath, fullFilename)
// 下载图片
const protocol = url.startsWith('https') ? https : http
return new Promise((resolve) => {
const request = protocol.get(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'image/*,*/*;q=0.8',
'Referer': urlObj.origin
}
}, async (response) => {
// 处理重定向
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location
if (redirectUrl) {
// 递归处理重定向
const result = await ipcMain.emit('download:image', _event, redirectUrl, filename)
resolve(result)
return
}
}
if (response.statusCode !== 200) {
resolve({ success: false, error: `HTTP ${response.statusCode}` })
return
}
const chunks: Buffer[] = []
response.on('data', (chunk) => chunks.push(chunk))
response.on('end', async () => {
try {
const buffer = Buffer.concat(chunks)
await writeFile(filePath, buffer)
resolve({
success: true,
filename: fullFilename,
path: filePath
})
} catch (writeError) {
resolve({ success: false, error: `写入失败: ${writeError}` })
}
})
response.on('error', (err) => {
resolve({ success: false, error: `下载失败: ${err.message}` })
})
})
request.on('error', (err) => {
resolve({ success: false, error: `请求失败: ${err.message}` })
})
request.setTimeout(30000, () => {
request.destroy()
resolve({ success: false, error: '下载超时' })
})
})
} catch (error) {
return { success: false, error: `下载失败: ${error}` }
}
})
// IPC 处理:打开下载文件夹
ipcMain.handle('download:openFolder', async () => {
const { shell } = await import('electron')
const downloadsPath = join(app.getPath('downloads'), 'cfspider-images')
if (!existsSync(downloadsPath)) {
await mkdir(downloadsPath, { recursive: true })
}
shell.openPath(downloadsPath)
return true
})