mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
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:
@@ -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'],
|
||||
})
|
||||
|
||||
|
||||
@@ -46,12 +46,27 @@ const CapabilityCard: React.FC<CapabilityCardProps> = ({
|
||||
const [customModel, setCustomModel] = useState('')
|
||||
const [showCustom, setShowCustom] = useState(false)
|
||||
|
||||
// A provider is configured when it has credentials (custom providers always
|
||||
// carry their own). Unconfigured ones stay selectable but are flagged so the
|
||||
// user is guided to set up the API key.
|
||||
const isConfigured = (id: string): boolean => {
|
||||
const p = data?.providers?.find((x) => x.id === id)
|
||||
if (!p) return true
|
||||
return p.configured || (p.is_custom && !!p.custom_name)
|
||||
}
|
||||
|
||||
const providerOptions: DropdownOption[] = useMemo(() => {
|
||||
const opts = (state.providers || []).map((id) => ({ value: id, label: providerLabel(data, id) }))
|
||||
const opts = (state.providers || []).map((id) => ({
|
||||
value: id,
|
||||
label: providerLabel(data, id),
|
||||
hint: isConfigured(id) ? undefined : t('config_provider_unconfigured'),
|
||||
}))
|
||||
if (allowAuto) return [{ value: '', label: autoLabel || t('models_auto') }, ...opts]
|
||||
return opts
|
||||
}, [state.providers, data, allowAuto, autoLabel])
|
||||
|
||||
const currentUnconfigured = !!provider && !isConfigured(provider)
|
||||
|
||||
const modelOptions: DropdownOption[] = useMemo(() => {
|
||||
const list = resolveModels(data, provider, state.provider_models).map((o) => ({
|
||||
value: o.value,
|
||||
@@ -98,6 +113,11 @@ const CapabilityCard: React.FC<CapabilityCardProps> = ({
|
||||
placeholder={t('models_select_provider')}
|
||||
onChange={handleProvider}
|
||||
/>
|
||||
{/* The provider's API key is configured in the vendor cards above on
|
||||
this same tab, so warn instead of linking elsewhere. */}
|
||||
{currentUnconfigured && (
|
||||
<p className="text-xs text-danger mt-1.5">{t('config_provider_unconfigured_hint')}</p>
|
||||
)}
|
||||
</Field>
|
||||
{!isAuto && (
|
||||
<Field label={t('models_model')}>
|
||||
|
||||
Reference in New Issue
Block a user