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

21
app.py
View File

@@ -15,6 +15,11 @@ import threading
_channel_mgr = None
# Desktop mode: a lighter runtime for the packaged Electron client. The plugin
# framework is still bundled (it's tiny and on the web channel's import path),
# but we skip loading actual plugins and MCP tools to keep startup fast.
DESKTOP_MODE = os.environ.get("COW_DESKTOP") == "1"
def get_channel_manager():
return _channel_mgr
@@ -75,7 +80,7 @@ class ChannelManager:
if self._primary_channel is None and channels:
self._primary_channel = channels[0][1]
if first_start:
if first_start and not DESKTOP_MODE:
PluginManager().load_plugins()
# Cloud client is optional. It is only started when
@@ -364,10 +369,18 @@ def run():
_sync_builtin_skills()
# Kick off MCP server loading in the background so first-message
# latency isn't dominated by npx package downloads.
_warmup_mcp_tools()
# latency isn't dominated by npx package downloads. Skipped in desktop
# mode (MCP relies on external npx/uvx runtimes that aren't bundled).
if not DESKTOP_MODE:
_warmup_mcp_tools()
_warmup_scheduler()
if DESKTOP_MODE:
# Defer the (heavy) AgentBridge/scheduler warmup to a background
# thread so the web API becomes available within a couple seconds.
# The scheduler still starts; it just doesn't block UI readiness.
threading.Thread(target=_warmup_scheduler, daemon=True).start()
else:
_warmup_scheduler()
logger.info(f"[App] Starting channels: {channel_names}")

View File

@@ -24,7 +24,7 @@ from common import const
from common import i18n
from common.log import logger
from common.singleton import singleton
from config import conf
from config import conf, get_data_root, get_weixin_credentials_path
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg"}
VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
@@ -1114,18 +1114,25 @@ class WebChannel(ChatChannel):
else:
logger.info(f"[WebChannel] 🔒 Listening on {host} only (local access). For public access, set web_host to 0.0.0.0 and configure web_password")
try:
import webbrowser
webbrowser.open(f"http://localhost:{port}")
logger.debug(f"[WebChannel] Opened browser at http://localhost:{port}")
except Exception as e:
logger.debug(f"[WebChannel] Could not open browser: {e}")
# In desktop mode the Electron shell renders the UI, so don't pop a
# browser window (also avoids issues when running detached/headless).
if os.environ.get("COW_DESKTOP") != "1":
try:
import webbrowser
webbrowser.open(f"http://localhost:{port}")
logger.debug(f"[WebChannel] Opened browser at http://localhost:{port}")
except Exception as e:
logger.debug(f"[WebChannel] Could not open browser: {e}")
# 确保静态文件目录存在
# Ensure the static dir exists. In a packaged build it ships read-only
# inside the bundle, so swallow errors instead of failing startup.
static_dir = os.path.join(os.path.dirname(__file__), 'static')
if not os.path.exists(static_dir):
os.makedirs(static_dir)
logger.debug(f"[WebChannel] Created static directory: {static_dir}")
try:
os.makedirs(static_dir)
logger.debug(f"[WebChannel] Created static directory: {static_dir}")
except OSError as e:
logger.debug(f"[WebChannel] Skipped creating static dir (read-only bundle?): {e}")
urls = (
'/', 'RootHandler',
@@ -1730,8 +1737,7 @@ class ConfigHandler:
if not applied:
return json.dumps({"status": "error", "message": "no valid keys to update"})
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "config.json")
config_path = os.path.join(get_data_root(), "config.json")
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f)
@@ -2164,10 +2170,7 @@ class ModelsHandler:
@staticmethod
def _config_path() -> str:
return os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"config.json",
)
return os.path.join(get_data_root(), "config.json")
@classmethod
def _read_file_config(cls) -> dict:
@@ -3550,8 +3553,12 @@ class ChannelsHandler:
try:
local_config = conf()
active_channels = self._active_channel_set()
# Desktop build ships without lark-oapi, so hide Feishu from the list.
desktop_mode = os.environ.get("COW_DESKTOP") == "1"
channels = []
for ch_name, ch_def in self.CHANNEL_DEFS.items():
if desktop_mode and ch_name == "feishu":
continue
fields_out = []
for f in ch_def["fields"]:
raw_val = local_config.get(f["key"], f.get("default", ""))
@@ -3633,8 +3640,7 @@ class ChannelsHandler:
if not applied:
return json.dumps({"status": "error", "message": "no valid fields to update"})
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "config.json")
config_path = os.path.join(get_data_root(), "config.json")
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f)
@@ -3704,8 +3710,7 @@ class ChannelsHandler:
new_channel_type = ",".join(existing)
local_config["channel_type"] = new_channel_type
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "config.json")
config_path = os.path.join(get_data_root(), "config.json")
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f)
@@ -3760,8 +3765,7 @@ class ChannelsHandler:
local_config = conf()
local_config["channel_type"] = new_channel_type
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
os.path.abspath(__file__)))), "config.json")
config_path = os.path.join(get_data_root(), "config.json")
if os.path.exists(config_path):
with open(config_path, "r", encoding="utf-8") as f:
file_cfg = json.load(f)
@@ -3911,9 +3915,7 @@ class WeixinQrHandler:
if not bot_token or not bot_id:
return json.dumps({"status": "error", "message": "Login confirmed but missing token"})
cred_path = os.path.expanduser(
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
cred_path = get_weixin_credentials_path()
from channel.weixin.weixin_channel import _save_credentials
_save_credentials(cred_path, {
"token": bot_token,
@@ -4577,8 +4579,7 @@ class LogsHandler:
web.header('Cache-Control', 'no-cache')
web.header('X-Accel-Buffering', 'no')
from config import get_root
log_path = os.path.join(get_root(), "run.log")
log_path = os.path.join(get_data_root(), "run.log")
def generate():
if not os.path.isfile(log_path):

View File

@@ -24,7 +24,7 @@ from channel.weixin.weixin_message import WeixinMessage
from common.expired_dict import ExpiredDict
from common.log import logger
from common.singleton import singleton
from config import conf
from config import conf, get_weixin_credentials_path
MAX_CONSECUTIVE_FAILURES = 3
BACKOFF_DELAY = 30
@@ -96,9 +96,7 @@ class WeixinChannel(ChatChannel):
cdn_base_url = conf().get("weixin_cdn_base_url", CDN_BASE_URL)
token = conf().get("weixin_token", "")
self._credentials_path = os.path.expanduser(
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
self._credentials_path = get_weixin_credentials_path()
# Always load credentials so we can restore context_tokens even when
# the bot token itself comes from config.

View File

@@ -21,7 +21,7 @@ from bridge.context import Context, ContextType
from bridge.reply import Reply, ReplyType
from common.log import logger
from linkai import LinkAIClient, PushMsg
from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root
from config import conf, pconf, plugin_config, available_setting, write_plugin_config, get_root, get_weixin_credentials_path
from plugins import PluginManager
import threading
import time
@@ -336,9 +336,7 @@ class CloudClient(LinkAIClient):
@staticmethod
def _remove_weixin_credentials():
"""Remove the weixin token credentials file so next connect triggers QR login."""
cred_path = os.path.expanduser(
conf().get("weixin_credentials_path", "~/.weixin_cow_credentials.json")
)
cred_path = get_weixin_credentials_path()
try:
if os.path.exists(cred_path):
os.remove(cred_path)

View File

@@ -1,8 +1,21 @@
import logging
import os
import sys
import io
def _log_path():
# Mirror config.get_data_root() without importing config (avoids a circular
# import, since config imports this module). The desktop build sets
# COW_DATA_DIR (e.g. ~/.cow); source deployments fall back to CWD.
data_dir = os.environ.get("COW_DATA_DIR")
if data_dir:
data_dir = os.path.expanduser(data_dir)
os.makedirs(data_dir, exist_ok=True)
return os.path.join(data_dir, "run.log")
return "run.log"
def _reset_logger(log):
for handler in log.handlers:
handler.close()
@@ -20,7 +33,7 @@ def _reset_logger(log):
datefmt="%Y-%m-%d %H:%M:%S",
)
)
file_handle = logging.FileHandler("run.log", encoding="utf-8")
file_handle = logging.FileHandler(_log_path(), encoding="utf-8")
file_handle.setFormatter(
logging.Formatter(
"[%(levelname)s][%(asctime)s][%(filename)s:%(lineno)d] - %(message)s",

View File

@@ -1,18 +1,21 @@
import os
import pathlib
from common.utils import expand_path
from config import conf
class TmpDir(object):
"""A temporary directory that is deleted when the object is destroyed."""
"""Temporary directory for transient artifacts (e.g. synthesized voice).
tmpFilePath = pathlib.Path("./tmp/")
Resolves to ``<agent_workspace>/tmp`` (default ``~/cow/tmp``) so temp files
land inside the agent workspace instead of a CWD-relative ``./tmp``, which
is unreliable for the packaged desktop app where CWD is undefined.
"""
def __init__(self):
pathExists = os.path.exists(self.tmpFilePath)
if not pathExists:
os.makedirs(self.tmpFilePath)
ws_root = expand_path(conf().get("agent_workspace", "~/cow"))
self.tmpFilePath = os.path.join(ws_root, "tmp")
os.makedirs(self.tmpFilePath, exist_ok=True)
def path(self):
return str(self.tmpFilePath) + "/"

View File

@@ -5,6 +5,7 @@ import json
import logging
import os
import pickle
import sys
from common.log import logger
from common import i18n
@@ -377,10 +378,16 @@ def load_config():
logger.info(" \\____\\___/ \\_/\\_//_/ \\_\\__, |\\___|_| |_|\\__|")
logger.info(" |___/ ")
logger.info("")
config_path = "./config.json"
# User config lives in the data root: source deployments use CWD (./), while
# the desktop build points COW_DATA_DIR at ~/.cow so config survives updates.
config_path = os.path.join(get_data_root(), "config.json")
if not os.path.exists(config_path):
logger.info("config file not found, falling back to config-template.json")
config_path = "./config-template.json"
# Resolve the template via get_resource_root() so it works both from
# source and from a frozen (PyInstaller) bundle, where the template
# ships inside the bundle (sys._MEIPASS) and CWD may differ.
template_path = os.path.join(get_resource_root(), "config-template.json")
config_path = template_path if os.path.exists(template_path) else "./config-template.json"
config_str = read_file(config_path)
logger.debug("[INIT] config str: {}".format(drag_sensitive(config_str)))
@@ -620,6 +627,34 @@ def get_root():
return os.path.dirname(os.path.abspath(__file__))
def get_resource_root():
"""Directory holding bundled read-only resources (e.g. config-template.json).
Under PyInstaller, data files live in sys._MEIPASS (the onedir _internal
folder), which differs from get_root() — the latter is used for writable
user data and should stay next to the executable, not inside the bundle.
"""
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return sys._MEIPASS
return os.path.dirname(os.path.abspath(__file__))
def get_data_root():
"""Directory for writable user data (config.json, user_datas.pkl, run.log).
The desktop build sets COW_DATA_DIR (e.g. ~/.cow) so data lives in the
user's home rather than inside the read-only app bundle and survives app
updates. When unset (source deployment), it falls back to get_root(), so
existing behavior is unchanged.
"""
data_dir = os.environ.get("COW_DATA_DIR")
if data_dir:
data_dir = os.path.expanduser(data_dir)
os.makedirs(data_dir, exist_ok=True)
return data_dir
return get_root()
def read_file(path):
with open(path, mode="r", encoding="utf-8-sig") as f:
return f.read()
@@ -630,13 +665,29 @@ def conf():
def get_appdata_dir():
data_path = os.path.join(get_root(), conf().get("appdata_dir", ""))
data_path = os.path.join(get_data_root(), conf().get("appdata_dir", ""))
if not os.path.exists(data_path):
logger.info("[INIT] data path not exists, create it: {}".format(data_path))
os.makedirs(data_path)
return data_path
def get_weixin_credentials_path():
"""Resolve the Weixin credentials (token) file path.
Honors an explicit ``weixin_credentials_path`` from config. Otherwise the
packaged desktop build (COW_DATA_DIR set) keeps it under the data dir
(~/.cow) so all user data stays together, while source deployments retain
the legacy ~/.weixin_cow_credentials.json default unchanged.
"""
configured = conf().get("weixin_credentials_path")
if configured:
return os.path.expanduser(configured)
if os.environ.get("COW_DATA_DIR"):
return os.path.join(get_data_root(), "weixin_credentials.json")
return os.path.expanduser("~/.weixin_cow_credentials.json")
def subscribe_msg():
trigger_prefix = conf().get("single_chat_prefix", [""])[0]
msg = conf().get("subscribe_msg", "")

View File

@@ -52,18 +52,8 @@
],
"extraResources": [
{
"from": "../",
"to": "backend",
"filter": [
"**/*",
"!desktop/**",
"!.git/**",
"!__pycache__/**",
"!**/__pycache__/**",
"!*.pyc",
"!run.log",
"!workspace/**"
]
"from": "build/dist/cowagent-backend",
"to": "backend/cowagent-backend"
}
],
"mac": {
@@ -73,7 +63,7 @@
{
"target": "dmg",
"arch": [
"universal"
"arm64"
]
}
]

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'],
})

View File

@@ -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')}>

View File

@@ -12,6 +12,7 @@ from dashscope import MultiModalConversation
from bridge.reply import Reply, ReplyType
from common.log import logger
from common.tmp_dir import TmpDir
from config import conf
from voice import audio_convert
from voice.voice import Voice
@@ -121,8 +122,7 @@ class DashScopeVoice(Voice):
@staticmethod
def _download_audio(url: str) -> Optional[str]:
try:
tmp_dir = os.path.join(os.getcwd(), "tmp")
os.makedirs(tmp_dir, exist_ok=True)
tmp_dir = TmpDir().path()
ts = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
ext = os.path.splitext(url.split("?", 1)[0])[1].lower() or ".wav"
if ext not in (".mp3", ".wav", ".m4a", ".aac", ".opus"):