feat: cow desktop first version

This commit is contained in:
zhayujie
2026-03-21 16:11:05 +08:00
parent b8b57e34ff
commit 3bc6e89b74
34 changed files with 12765 additions and 0 deletions

184
desktop/src/main/index.ts Normal file
View 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()
})

View 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,
})

View 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()
}
}