This commit is contained in:
Your Name
2026-07-29 09:34:02 +08:00
parent 0ff8943ee2
commit f913a57529
54 changed files with 2453 additions and 378 deletions
+153 -32
View File
@@ -14,11 +14,16 @@ import urllib.request
from pathlib import Path
from typing import Any
from app_version import APP_VERSION, release_status
from runtime_paths import application_data_dir
SCRIPT_DIR = Path(__file__).resolve().parent
SCRIPT_DIR = application_data_dir()
CONNECTION_FILE = SCRIPT_DIR / "backend_connection.json"
RUNTIME_FILE = SCRIPT_DIR / "backend_runtime.json"
DEFAULT_SERVER_URL = "http://127.0.0.1:8765"
DEFAULT_SERVER_URL = "http://xchat.zhenyangtang.com.cn"
DESKTOP_SYNC_KEY = "wcrpa-v1-H3q9mT7xK2pN8cR5vL4sF6dB1yG0uJ"
DESKTOP_CONFIG_PATH = "/api/v1/desktop/config"
_LOCK = threading.RLock()
@@ -30,19 +35,75 @@ class AuthenticationError(BackendError):
"""登录状态无效。"""
def _process_is_running(pid: int) -> bool:
"""检查运行状态文件中的进程是否仍存在,不向进程发送终止信号。"""
if pid <= 0:
return False
if pid == os.getpid():
return True
if os.name == "nt":
try:
import ctypes
process_query_limited_information = 0x1000
still_active = 259
kernel32 = ctypes.windll.kernel32
kernel32.OpenProcess.argtypes = [
ctypes.c_ulong,
ctypes.c_int,
ctypes.c_ulong,
]
kernel32.OpenProcess.restype = ctypes.c_void_p
kernel32.GetExitCodeProcess.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_ulong),
]
kernel32.GetExitCodeProcess.restype = ctypes.c_int
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
kernel32.CloseHandle.restype = ctypes.c_int
handle = kernel32.OpenProcess(
process_query_limited_information, False, pid
)
if not handle:
return False
try:
exit_code = ctypes.c_ulong()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return False
return exit_code.value == still_active
finally:
kernel32.CloseHandle(handle)
except (AttributeError, OSError, ValueError):
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def discover_local_runtime() -> dict[str, Any]:
"""读取并校验同项目后台发布的运行信息。"""
try:
info = json.loads(RUNTIME_FILE.read_text(encoding="utf-8"))
url = normalize_server_url(info.get("server_url"))
port = int(info.get("port", 0))
pid = int(info.get("pid", 0))
parsed = urllib.parse.urlparse(url)
if not 1 <= port <= 65535 or parsed.port != port:
if (
not 1 <= port <= 65535
or parsed.port != port
or not _process_is_running(pid)
):
raise ValueError
return {
"server_url": url,
"port": port,
"pid": int(info.get("pid", 0)),
"pid": pid,
"local_sync_token": str(info.get("local_sync_token") or ""),
"started_at": str(info.get("started_at") or ""),
}
@@ -77,6 +138,13 @@ def default_settings() -> dict[str, Any]:
"last_version": 0,
"last_sync_at": "",
"last_error": "",
"last_release": {
"latest_version": APP_VERSION,
"download_url": "",
"release_notes": "",
"force_upgrade": False,
"updated_at": "",
},
}
@@ -94,6 +162,8 @@ def load_settings() -> dict[str, Any]:
if discovered_url != DEFAULT_SERVER_URL and _is_local_server_url(settings["server_url"]):
settings["server_url"] = discovered_url
settings["auto_sync"] = bool(settings.get("auto_sync", True))
if not isinstance(settings.get("last_release"), dict):
settings["last_release"] = default_settings()["last_release"]
try:
settings["sync_interval_seconds"] = max(
60, int(settings.get("sync_interval_seconds", 300))
@@ -159,6 +229,7 @@ def _request(
token: str = "",
payload: dict[str, Any] | None = None,
local_sync_token: str = "",
desktop_sync_key: str = "",
timeout: float = 10.0,
) -> tuple[int, dict[str, Any]]:
url = normalize_server_url(server_url) + path
@@ -171,6 +242,8 @@ def _request(
headers["Authorization"] = f"Bearer {token}"
if local_sync_token:
headers["X-Desktop-Sync-Token"] = local_sync_token
if desktop_sync_key:
headers["X-Desktop-Sync-Key"] = desktop_sync_key
request = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
@@ -257,7 +330,56 @@ def logout(*, revoke_remote: bool = True) -> None:
save_settings(settings)
def _apply_config_response(
response: dict[str, Any], settings: dict[str, Any]
) -> dict[str, Any]:
config = response.get("config")
if not isinstance(config, dict):
raise BackendError("后台没有返回有效的模型配置")
version = int(response.get("version", 0))
import ai_config
applied = ai_config.apply_settings(config, persist=True)
release = release_status(response.get("release"))
cached_release = {
key: release[key]
for key in (
"latest_version",
"download_url",
"release_notes",
"force_upgrade",
"updated_at",
)
}
settings.update(
{
"last_version": version,
"last_sync_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"last_error": "",
"last_release": cached_release,
}
)
save_settings(settings)
return {
"synced": True,
"version": version,
"applied_count": len(applied),
"updated_at": response.get("updated_at", ""),
"message": f"已同步云端配置 v{version}",
"release": release,
"local_app_version": APP_VERSION,
"update_available": release["update_available"],
"force_upgrade": release["force_upgrade"],
}
def cached_release_status() -> dict[str, Any]:
"""网络不可用时读取上次成功同步的升级策略。"""
return release_status(load_settings().get("last_release"))
def sync_config(*, force: bool = False, timeout: float = 10.0) -> dict[str, Any]:
"""保留账号登录和同机后台的兼容同步能力。"""
settings = load_settings()
if not is_configured(settings):
return {"synced": False, "reason": "not_configured", "message": "尚未登录后台"}
@@ -274,28 +396,33 @@ def sync_config(*, force: bool = False, timeout: float = 10.0) -> dict[str, Any]
),
timeout=timeout,
)
config = response.get("config")
if not isinstance(config, dict):
raise BackendError("后台没有返回有效的模型配置")
version = int(response.get("version", 0))
import ai_config
applied = ai_config.apply_settings(config, persist=True)
settings.update(
{
"last_version": version,
"last_sync_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"last_error": "",
}
)
return _apply_config_response(response, settings)
except Exception as exc:
settings["last_error"] = str(exc)
save_settings(settings)
return {
"synced": True,
"version": version,
"applied_count": len(applied),
"updated_at": response.get("updated_at", ""),
"message": f"已同步后台配置 v{version}",
raise
def sync_cloud_config(*, timeout: float = 10.0) -> dict[str, Any]:
"""无需用户登录,从固定云端读取桌面运行配置。"""
settings = load_settings()
settings.update(
{
"server_url": DEFAULT_SERVER_URL,
"username": "",
"access_token": "",
"auto_sync": True,
}
)
try:
_, response = _request(
"GET",
DEFAULT_SERVER_URL,
DESKTOP_CONFIG_PATH,
desktop_sync_key=DESKTOP_SYNC_KEY,
timeout=timeout,
)
return _apply_config_response(response, settings)
except Exception as exc:
settings["last_error"] = str(exc)
save_settings(settings)
@@ -303,11 +430,5 @@ def sync_config(*, force: bool = False, timeout: float = 10.0) -> dict[str, Any]
def startup_sync_config(*, timeout: float = 3.0) -> dict[str, Any]:
"""软件启动前快速检测后台,使首屏直接使用服务器配置。"""
if not is_configured():
return {
"synced": False,
"reason": "not_configured",
"message": "未发现可用的后台配置服务",
}
return sync_config(timeout=timeout)
"""软件启动前先请求固定云端,使首屏直接使用服务器配置。"""
return sync_cloud_config(timeout=timeout)