feat(desktop): redirect writable data to ~/.cow for packaged app

Introduce get_data_root() driven by the COW_DATA_DIR env var so the
packaged desktop build stores config.json, run.log, user data and
WeChat credentials under ~/.cow — surviving app updates and keeping the
app bundle read-only. Source deployments leave COW_DATA_DIR unset and
fall back to the repo root, so existing behavior is unchanged.
This commit is contained in:
zhayujie
2026-06-23 17:22:53 +08:00
parent 215ed24401
commit ec4c36f450
11 changed files with 223 additions and 79 deletions

View File

@@ -1,9 +1,15 @@
import { ChildProcess, spawn } from 'child_process'
import { EventEmitter } from 'events'
import path from 'path'
import os from 'os'
import fs from 'fs'
import http from 'http'
// Writable data dir for the packaged app (config.json, run.log, user data).
// Lives in the user's home so it survives app updates and avoids writing into
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
const COW_DATA_DIR = path.join(os.homedir(), '.cow')
export class PythonBackend extends EventEmitter {
private process: ChildProcess | null = null
private backendPath: string
@@ -23,6 +29,25 @@ export class PythonBackend extends EventEmitter {
return this.status
}
/**
* Locate the packaged onedir backend executable shipped with the app.
* Returns null when not present (e.g. during local development), so we can
* fall back to running app.py with a system/venv Python.
*/
private findBundledBackend(): string | null {
const exeName = process.platform === 'win32' ? 'cowagent-backend.exe' : 'cowagent-backend'
const candidates = [
path.join(this.backendPath, 'cowagent-backend', exeName),
path.join(this.backendPath, exeName),
]
for (const p of candidates) {
if (fs.existsSync(p)) {
return p
}
}
return null
}
private findPython(): string {
const venvPaths = [
path.join(this.backendPath, '.venv', 'bin', 'python'),
@@ -40,9 +65,14 @@ export class PythonBackend extends EventEmitter {
return process.platform === 'win32' ? 'python' : 'python3'
}
private readPort(): number {
/**
* Resolve config.json from the given data dir to read the web port. The
* packaged build keeps config in COW_DATA_DIR (~/.cow); dev reads it from the
* repo path. Returns the default port when no config (or web_port) is found.
*/
private readPort(dataDir: string): number {
try {
const configPath = path.join(this.backendPath, 'config.json')
const configPath = path.join(dataDir, 'config.json')
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (config.web_port) {
@@ -61,7 +91,13 @@ export class PythonBackend extends EventEmitter {
}
this.status = 'starting'
this.port = this.readPort()
// Prefer the packaged self-contained backend (production); fall back to
// running app.py with a Python interpreter (local development).
const bundled = this.findBundledBackend()
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
this.port = this.readPort(dataDir)
const alreadyRunning = await this.probeHealth()
if (alreadyRunning) {
@@ -71,20 +107,41 @@ export class PythonBackend extends EventEmitter {
return
}
const pythonPath = this.findPython()
const appPath = path.join(this.backendPath, 'app.py')
let command: string
let args: string[]
let cwd: string
if (!fs.existsSync(appPath)) {
this.status = 'error'
this.emit('error', `app.py not found at ${appPath}`)
return
if (bundled) {
command = bundled
args = []
// The onedir bundle reads data files relative to the executable's dir.
cwd = path.dirname(bundled)
this.emit('log', `Starting bundled backend: ${bundled}`)
} else {
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
}
command = pythonPath
args = [appPath]
cwd = this.backendPath
this.emit('log', `Starting Python backend: ${pythonPath} ${appPath}`)
}
this.emit('log', `Starting Python backend: ${pythonPath} ${appPath}`)
this.process = spawn(pythonPath, [appPath], {
cwd: this.backendPath,
env: { ...process.env, PYTHONUNBUFFERED: '1' },
this.process = spawn(command, args, {
cwd,
// COW_DESKTOP enables the lighter desktop runtime (no plugins, no MCP).
// COW_DATA_DIR (packaged only) redirects writable data to ~/.cow so the
// app bundle stays read-only; dev runs omit it and keep using the repo.
env: {
...process.env,
PYTHONUNBUFFERED: '1',
COW_DESKTOP: '1',
...(bundled ? { COW_DATA_DIR } : {}),
},
stdio: ['pipe', 'pipe', 'pipe'],
})