mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-18 20:17:09 +08:00
feat: cow desktop first version
This commit is contained in:
184
desktop/src/main/index.ts
Normal file
184
desktop/src/main/index.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { app, BrowserWindow, shell, ipcMain, dialog, nativeImage } from 'electron'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import http from 'http'
|
||||
import { PythonBackend } from './python-manager'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let pythonBackend: PythonBackend | null = null
|
||||
|
||||
const isDev = !app.isPackaged
|
||||
const VITE_DEV_PORTS = [5173, 5174, 5175, 5176]
|
||||
|
||||
function probePort(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`http://localhost:${port}`, (res) => {
|
||||
resolve(res.statusCode !== undefined)
|
||||
})
|
||||
req.on('error', () => resolve(false))
|
||||
req.setTimeout(500, () => { req.destroy(); resolve(false) })
|
||||
})
|
||||
}
|
||||
|
||||
async function findViteDevServer(): Promise<string | null> {
|
||||
for (const port of VITE_DEV_PORTS) {
|
||||
if (await probePort(port)) {
|
||||
return `http://localhost:${port}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getIconPath(ext: string = 'png'): string | undefined {
|
||||
const iconFile = `icon.${ext}`
|
||||
const iconPath = isDev
|
||||
? path.resolve(__dirname, '../../resources', iconFile)
|
||||
: path.join(process.resourcesPath, iconFile)
|
||||
if (fs.existsSync(iconPath)) return iconPath
|
||||
return undefined
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
trafficLightPosition: { x: 12, y: 18 },
|
||||
backgroundColor: '#111111',
|
||||
icon: getIconPath(),
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
})
|
||||
|
||||
const rendererHtml = path.join(__dirname, '../renderer/index.html')
|
||||
|
||||
if (isDev) {
|
||||
findViteDevServer().then((devUrl) => {
|
||||
if (devUrl) {
|
||||
console.log(`[Electron] Loading Vite dev server: ${devUrl}`)
|
||||
mainWindow?.loadURL(devUrl)
|
||||
mainWindow?.webContents.openDevTools()
|
||||
} else if (fs.existsSync(rendererHtml)) {
|
||||
console.log('[Electron] Vite dev server not found, loading built files')
|
||||
mainWindow?.loadFile(rendererHtml)
|
||||
} else {
|
||||
console.error('[Electron] No renderer available. Run "npm run build:renderer" first.')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
mainWindow.loadFile(rendererHtml)
|
||||
}
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url)
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
}
|
||||
|
||||
function getBackendPath(): string {
|
||||
if (isDev) {
|
||||
return path.resolve(__dirname, '../../..')
|
||||
}
|
||||
return path.join(process.resourcesPath, 'backend')
|
||||
}
|
||||
|
||||
async function startBackend() {
|
||||
const backendPath = getBackendPath()
|
||||
pythonBackend = new PythonBackend(backendPath)
|
||||
|
||||
pythonBackend.on('ready', (port: number) => {
|
||||
mainWindow?.webContents.send('backend-status', { status: 'ready', port })
|
||||
})
|
||||
|
||||
pythonBackend.on('error', (error: string) => {
|
||||
mainWindow?.webContents.send('backend-status', { status: 'error', error })
|
||||
})
|
||||
|
||||
pythonBackend.on('log', (line: string) => {
|
||||
mainWindow?.webContents.send('backend-log', line)
|
||||
})
|
||||
|
||||
await pythonBackend.start()
|
||||
}
|
||||
|
||||
function setupIPC() {
|
||||
ipcMain.handle('get-backend-port', () => {
|
||||
return pythonBackend?.getPort() ?? null
|
||||
})
|
||||
|
||||
ipcMain.handle('get-backend-status', () => {
|
||||
return pythonBackend?.getStatus() ?? 'stopped'
|
||||
})
|
||||
|
||||
ipcMain.handle('restart-backend', async () => {
|
||||
await pythonBackend?.restart()
|
||||
return true
|
||||
})
|
||||
|
||||
ipcMain.handle('select-directory', async () => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openDirectory'],
|
||||
})
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
})
|
||||
|
||||
ipcMain.handle('select-file', async (_event, filters?: Electron.FileFilter[]) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: filters || [{ name: 'All Files', extensions: ['*'] }],
|
||||
})
|
||||
return result.canceled ? null : result.filePaths[0]
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Set Dock icon on macOS (PNG is most reliable for nativeImage)
|
||||
if (process.platform === 'darwin') {
|
||||
const pngPath = getIconPath('png')
|
||||
if (pngPath) {
|
||||
const icon = nativeImage.createFromPath(pngPath)
|
||||
if (!icon.isEmpty()) {
|
||||
app.dock.setIcon(icon)
|
||||
console.log('[Electron] Dock icon set:', pngPath)
|
||||
} else {
|
||||
console.warn('[Electron] Dock icon loaded but empty:', pngPath)
|
||||
}
|
||||
} else {
|
||||
console.warn('[Electron] Dock icon not found in resources/')
|
||||
}
|
||||
}
|
||||
|
||||
setupIPC()
|
||||
createWindow()
|
||||
await startBackend()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
pythonBackend?.stop()
|
||||
})
|
||||
19
desktop/src/main/preload.ts
Normal file
19
desktop/src/main/preload.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getBackendPort: () => ipcRenderer.invoke('get-backend-port'),
|
||||
getBackendStatus: () => ipcRenderer.invoke('get-backend-status'),
|
||||
restartBackend: () => ipcRenderer.invoke('restart-backend'),
|
||||
selectDirectory: () => ipcRenderer.invoke('select-directory'),
|
||||
selectFile: (filters?: Electron.FileFilter[]) => ipcRenderer.invoke('select-file', filters),
|
||||
|
||||
onBackendStatus: (callback: (data: { status: string; port?: number; error?: string }) => void) => {
|
||||
ipcRenderer.on('backend-status', (_event, data) => callback(data))
|
||||
},
|
||||
|
||||
onBackendLog: (callback: (line: string) => void) => {
|
||||
ipcRenderer.on('backend-log', (_event, line) => callback(line))
|
||||
},
|
||||
|
||||
platform: process.platform,
|
||||
})
|
||||
196
desktop/src/main/python-manager.ts
Normal file
196
desktop/src/main/python-manager.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { ChildProcess, spawn } from 'child_process'
|
||||
import { EventEmitter } from 'events'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import http from 'http'
|
||||
|
||||
export class PythonBackend extends EventEmitter {
|
||||
private process: ChildProcess | null = null
|
||||
private backendPath: string
|
||||
private port: number = 9899
|
||||
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
|
||||
|
||||
constructor(backendPath: string) {
|
||||
super()
|
||||
this.backendPath = backendPath
|
||||
}
|
||||
|
||||
getPort(): number {
|
||||
return this.port
|
||||
}
|
||||
|
||||
getStatus(): string {
|
||||
return this.status
|
||||
}
|
||||
|
||||
private findPython(): string {
|
||||
const venvPaths = [
|
||||
path.join(this.backendPath, '.venv', 'bin', 'python'),
|
||||
path.join(this.backendPath, '.venv', 'Scripts', 'python.exe'),
|
||||
path.join(this.backendPath, 'venv', 'bin', 'python'),
|
||||
path.join(this.backendPath, 'venv', 'Scripts', 'python.exe'),
|
||||
]
|
||||
|
||||
for (const p of venvPaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
return process.platform === 'win32' ? 'python' : 'python3'
|
||||
}
|
||||
|
||||
private readPort(): number {
|
||||
try {
|
||||
const configPath = path.join(this.backendPath, 'config.json')
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
if (config.web_port) {
|
||||
return config.web_port
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return 9899
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.status === 'ready' || this.status === 'starting') {
|
||||
return
|
||||
}
|
||||
|
||||
this.status = 'starting'
|
||||
this.port = this.readPort()
|
||||
|
||||
const alreadyRunning = await this.probeHealth()
|
||||
if (alreadyRunning) {
|
||||
this.status = 'ready'
|
||||
this.emit('log', `Backend already running on port ${this.port}`)
|
||||
this.emit('ready', this.port)
|
||||
return
|
||||
}
|
||||
|
||||
const pythonPath = this.findPython()
|
||||
const appPath = path.join(this.backendPath, 'app.py')
|
||||
|
||||
if (!fs.existsSync(appPath)) {
|
||||
this.status = 'error'
|
||||
this.emit('error', `app.py not found at ${appPath}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.emit('log', `Starting Python backend: ${pythonPath} ${appPath}`)
|
||||
|
||||
this.process = spawn(pythonPath, [appPath], {
|
||||
cwd: this.backendPath,
|
||||
env: { ...process.env, PYTHONUNBUFFERED: '1' },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
this.process.stdout?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
this.emit('log', line)
|
||||
}
|
||||
})
|
||||
|
||||
this.process.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n').filter(Boolean)
|
||||
for (const line of lines) {
|
||||
this.emit('log', line)
|
||||
}
|
||||
})
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
this.status = 'stopped'
|
||||
this.emit('log', `Python process exited with code ${code}`)
|
||||
if (code !== 0 && code !== null) {
|
||||
this.emit('error', `Python process exited with code ${code}`)
|
||||
}
|
||||
})
|
||||
|
||||
this.process.on('error', (err) => {
|
||||
this.status = 'error'
|
||||
this.emit('error', `Failed to start Python: ${err.message}`)
|
||||
})
|
||||
|
||||
await this.waitForReady()
|
||||
}
|
||||
|
||||
private probeHealth(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
||||
resolve(res.statusCode === 200)
|
||||
})
|
||||
req.on('error', () => resolve(false))
|
||||
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
|
||||
})
|
||||
}
|
||||
|
||||
private waitForReady(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const maxAttempts = 120
|
||||
let attempts = 0
|
||||
|
||||
const check = () => {
|
||||
attempts++
|
||||
if (attempts % 10 === 0) {
|
||||
this.emit('log', `Waiting for backend... (${attempts}s)`)
|
||||
}
|
||||
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
this.status = 'ready'
|
||||
this.emit('log', `Backend ready on port ${this.port} after ${attempts}s`)
|
||||
this.emit('ready', this.port)
|
||||
resolve()
|
||||
} else {
|
||||
retry()
|
||||
}
|
||||
})
|
||||
|
||||
req.on('error', () => retry())
|
||||
req.setTimeout(2000, () => {
|
||||
req.destroy()
|
||||
retry()
|
||||
})
|
||||
}
|
||||
|
||||
const retry = () => {
|
||||
if (this.status === 'stopped') {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
if (attempts >= maxAttempts) {
|
||||
this.status = 'error'
|
||||
this.emit('error', `Backend failed to start within ${maxAttempts} seconds`)
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
setTimeout(check, 1000)
|
||||
}
|
||||
|
||||
setTimeout(check, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.process) {
|
||||
this.process.kill('SIGTERM')
|
||||
setTimeout(() => {
|
||||
if (this.process && !this.process.killed) {
|
||||
this.process.kill('SIGKILL')
|
||||
}
|
||||
}, 5000)
|
||||
this.process = null
|
||||
}
|
||||
this.status = 'stopped'
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
this.stop()
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
await this.start()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user