feat(desktop): self-contained portable bundle (Windows + Linux)

- lib.rs: 跨平台首次自准备——检测自包含包(runtime/)无 venv 时,后台离线
  建 venv+装依赖再起 bridge;加载窗显示进度条;Linux 用 $APPIMAGE 定位 runtime/
- loading.html: 加 window.gaProgress 进度条 + 状态文字
- install_windows.ps1 / install_linux.sh: 加 --wheel-dir 离线 + --extra-packages
  (conductor 依赖) + GAPROGRESS 进度标记;镜像源;Linux 离线模式跳过 AppImage/图标
- install.bat: 回填 release 版(自动定位项目根 + 错误处理)
- linux workflow: 组装完整自包含便携包(内置 python + 离线 wheels + 源码 + AppImage)
This commit is contained in:
yanghang committed 2026-06-18 16:42:56 +08:00
1 parent 8c7d0082d0
commit 07d34d5fbe
6 files changed
+461 -108

No files matched your search

@@ -83,49 +83,95 @@ jobs:
cp "$APPIMAGE_SRC" "$PKG_ROOT/GenericAgent.AppImage"
chmod +x "$PKG_ROOT/GenericAgent.AppImage"
cp desktop_release/scripts/linux/install_linux.sh "$PKG_ROOT/install_linux.sh"
chmod +x "$PKG_ROOT/install_linux.sh"
# ---- Self-contained portable runtime: embedded python + offline wheels + source ----
RUNTIME="$PKG_ROOT/runtime"
mkdir -p "$RUNTIME"
cp desktop_release/scripts/linux/install_linux.sh "$RUNTIME/install_linux.sh"
chmod +x "$RUNTIME/install_linux.sh"
# Embedded Python (python-build-standalone, linux x86_64, 3.12)
PBS_URL="$(curl -fsSL https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest \
| grep -o '"browser_download_url": *"[^"]*"' \
| grep 'cpython-3\.12\.[0-9]*+[0-9]*-x86_64-unknown-linux-gnu-install_only\.tar\.gz' \
| head -1 | sed -E 's/.*"(https[^"]+)"/\1/')"
echo "Python build-standalone: $PBS_URL"
curl -fsSL -o /tmp/pbs.tar.gz "$PBS_URL"
tar -xzf /tmp/pbs.tar.gz -C "$RUNTIME" # extracts a 'python/' dir
PY="$RUNTIME/python/bin/python3"
"$PY" --version
# Offline wheels (native linux wheels for the embedded python)
mkdir -p "$RUNTIME/wheels"
"$PY" -m pip download --dest "$RUNTIME/wheels" \
"requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil \
fastapi uvicorn websockets pydantic setuptools wheel
# Runtime source (project root minus heavy/dev/build dirs)
mkdir -p "$RUNTIME/app"
tar \
--exclude='./.git' --exclude='./.github' \
--exclude='./frontends/desktop/src-tauri' \
--exclude='./frontends/desktop/node_modules' \
--exclude='./desktop_release' --exclude='./docs' \
--exclude='./assets/demo' --exclude='./assets/images' \
--exclude='./assets/GenericAgent_Technical_Report.pdf' \
--exclude='./artifacts' \
--exclude='*/node_modules' --exclude='*/target' \
--exclude='*/.venv' --exclude='./.venv' \
--exclude='*/__pycache__' --exclude='*.pyc' \
-cf - . | tar -xf - -C "$RUNTIME/app"
test -f "$RUNTIME/app/agentmain.py"
test -f "$RUNTIME/app/frontends/desktop_bridge.py"
test -f "$RUNTIME/app/frontends/desktop/static/index.html"
# Drop python debug/cache bits to slim the package
find "$RUNTIME/python" -name '__pycache__' -type d -prune -exec rm -rf {} + 2>/dev/null || true
cat > "$PKG_ROOT/README_LINUX.md" <<'EOF'
# GenericAgent Desktop Linux
# GenericAgent Desktop — Linux 便携版(自包含)
## Install
无需安装 Python、无需联网装依赖、无需源码仓库——全部已内置。
This package contains the Linux desktop shell and installer script.
Recommended project layout:
```text
GenericAgent/
├── agentmain.py
└── frontends/
├── desktop_bridge.py
├── GenericAgent.AppImage
└── install_linux.sh
```
Copy `GenericAgent.AppImage` and `install_linux.sh` into your `GenericAgent/frontends/` directory, then run:
## 前置条件
- Linux x86_64(glibc,常见发行版即可)
- 桌面环境 + FUSE(运行 AppImage 所需;多数发行版自带)
## 使用
1. 解压本压缩包到任意目录(路径建议不含特殊字符)。
2. 给 AppImage 执行权限并运行:
```bash
cd /path/to/GenericAgent/frontends
chmod +x install_linux.sh
./install_linux.sh
chmod +x GenericAgent.AppImage
./GenericAgent.AppImage
```
3. 首次启动会自动离线准备环境(建虚拟环境、装依赖),界面显示进度,完成后进入主界面。
4. 之后启动直接秒进。
The script creates/updates:
> 想真正对话,仍需在程序里配置模型 / API Key。
- `~/.ga_desktop_settings.json`
- `~/.local/share/applications/genericagent-desktop.desktop`
- `~/Desktop/GenericAgent Desktop.desktop` or `~/桌面/GenericAgent Desktop.desktop`
## 功能
- 核心聊天(内置 bridge,端口 14168)。
- 指挥家 / 协作(conductor,端口 8900):随程序自动启动,已内置 fastapi/uvicorn 等依赖。
Start GenericAgent Desktop from the desktop icon or the application menu.
## 说明
- 首次准备完成后不要移动本文件夹;若已移动,删除 `runtime/app/.venv` 后重新启动即可重建。
- `runtime/` 是内置运行环境与源码,正常使用无需改动。
## 目录结构
```text
GenericAgent-Desktop-Linux/
├── GenericAgent.AppImage ← 运行这个
├── README_LINUX.md
└── runtime/
├── python/ wheels/ install_linux.sh
└── app/ 运行所需源码(含 conductor)
```
EOF
# Remove indentation added by YAML heredoc context if any shell preserves it.
sed -i 's/^ //' "$PKG_ROOT/README_LINUX.md"
(
cd "$PKG_ROOT"
sha256sum GenericAgent.AppImage install_linux.sh README_LINUX.md > SHA256SUMS.txt
sha256sum GenericAgent.AppImage README_LINUX.md > SHA256SUMS.txt
)
mkdir -p artifacts/linux/out
+39 -9
View File
@@ -26,6 +26,8 @@ MODE="PrepareOnly"
NO_VENV=0
SKIP_PIP_INSTALL=0
APPIMAGE_PATH=""
WHEEL_DIR=""
EXTRA_PACKAGES=""
log_step() { printf '\n==> %s\n' "$*" >&2; }
log_ok() { printf '[OK] %s\n' "$*" >&2; }
@@ -54,6 +56,12 @@ while [[ $# -gt 0 ]]; do
NO_VENV=1; shift ;;
--skip-pip-install)
SKIP_PIP_INSTALL=1; shift ;;
--wheel-dir)
[[ $# -ge 2 ]] || fail "Missing value for $1"
WHEEL_DIR="$2"; shift 2 ;;
--extra-packages)
[[ $# -ge 2 ]] || fail "Missing value for $1"
EXTRA_PACKAGES="$2"; shift 2 ;;
--help|-h)
usage; exit 0 ;;
*)
@@ -164,6 +172,7 @@ ensure_venv() {
local venv="$root/.venv"
local vpy="$venv/bin/python"
if [[ ! -x "$vpy" ]]; then
printf 'GAPROGRESS|venv\n'
log_step "Create virtual environment: $venv"
"$base_py" -m venv "$venv" || fail "Failed to create venv. On Debian/Ubuntu install python3-venv."
fi
@@ -179,9 +188,20 @@ install_dependencies() {
return
fi
log_step "Install/refresh minimal Python dependencies"
"$py" -m pip install --upgrade pip setuptools wheel || fail "pip bootstrap failed."
"$py" -m pip install -e "$root" psutil || fail "pip install failed."
printf 'GAPROGRESS|deps\n'
if [[ -n "$WHEEL_DIR" ]]; then
# Offline (portable bundle): install from local wheels only, no network, no pip self-upgrade.
log_step "Install dependencies offline from $WHEEL_DIR"
# shellcheck disable=SC2086
"$py" -m pip install --no-index --find-links "$WHEEL_DIR" -e "$root" psutil $EXTRA_PACKAGES \
|| fail "Offline pip install failed (check wheel dir)."
else
log_step "Install/refresh minimal Python dependencies"
"$py" -m pip install --upgrade pip setuptools wheel || fail "pip bootstrap failed."
# shellcheck disable=SC2086
"$py" -m pip install -e "$root" psutil $EXTRA_PACKAGES || fail "pip install failed."
fi
printf 'GAPROGRESS|done\n'
}
ensure_mykey() {
@@ -353,11 +373,19 @@ log_step "Resolve project root"
ROOT="$(find_project_root "$SCRIPT_ROOT")"
log_ok "Project root: $ROOT"
log_step "Resolve AppImage"
APPIMAGE="$(find_appimage "$ROOT" "$SCRIPT_ROOT")"
[[ -n "$APPIMAGE" ]] || fail "GenericAgent.AppImage not found. Put it next to install_linux.sh in GenericAgent/frontends/."
chmod +x "$APPIMAGE"
log_ok "AppImage: $APPIMAGE"
# Portable self-prepare (offline, --wheel-dir set): the AppImage is already running and
# manages itself, so skip AppImage discovery and desktop-launcher creation.
PORTABLE_PREPARE=0
[[ -n "$WHEEL_DIR" ]] && PORTABLE_PREPARE=1
APPIMAGE=""
if [[ "$PORTABLE_PREPARE" -eq 0 ]]; then
log_step "Resolve AppImage"
APPIMAGE="$(find_appimage "$ROOT" "$SCRIPT_ROOT")"
[[ -n "$APPIMAGE" ]] || fail "GenericAgent.AppImage not found. Put it next to install_linux.sh in GenericAgent/frontends/."
chmod +x "$APPIMAGE"
log_ok "AppImage: $APPIMAGE"
fi
log_step "Resolve Python"
BASE_PY="$(find_python)"
@@ -369,7 +397,9 @@ log_ok "Runtime Python: $PY"
install_dependencies "$ROOT" "$PY"
ensure_mykey "$ROOT"
write_desktop_settings "$ROOT" "$PY"
install_desktop_launchers "$ROOT" "$APPIMAGE"
if [[ "$PORTABLE_PREPARE" -eq 0 ]]; then
install_desktop_launchers "$ROOT" "$APPIMAGE"
fi
case "$MODE" in
PrepareOnly)
+17 -5
View File
@@ -1,9 +1,21 @@
@echo off
setlocal
cd /d "%~dp0"
set "SCRIPT_DIR=%~dp0"
for %%I in ("%SCRIPT_DIR%..\..\..") do set "PROJECT_DIR=%%~fI"
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%install_windows.ps1" -ProjectDir "%PROJECT_DIR%" -Mode PrepareOnly -SkipNpmInstall %*
echo GenericAgent Desktop Windows setup
echo.
echo If the installer failed, copy the error above and send it to the developer.
pause
echo Recommended location: put this whole folder under GenericAgent\frontends\
echo Example: GenericAgent\frontends\GenericAgent-Desktop-Windows\install.bat
echo This script will prepare Python/.venv, install minimal dependencies, and write %%USERPROFILE%%\.ga_desktop_settings.json.
echo.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0install_windows.ps1" -Mode PrepareOnly -SkipNpmInstall %*
if errorlevel 1 (
echo.
echo Setup failed. If the project root was not found, run:
echo powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -ProjectDir D:\path\to\GenericAgent -Mode PrepareOnly -SkipNpmInstall
echo.
pause
exit /b 1
)
echo.
echo Setup finished. You can now run start_windows.bat.
pause
@@ -9,6 +9,14 @@ Usage examples:
powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -Mode DesktopBuildOnly
powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -Mode DesktopDevOnly
Mirror examples (default to China mirrors for faster downloads):
# use the built-in defaults (Tsinghua PyPI + npmmirror)
powershell -ExecutionPolicy Bypass -File .\install_windows.ps1
# use a different pip mirror
powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -PipIndexUrl https://mirrors.aliyun.com/pypi/simple/
# disable mirrors, fall back to official PyPI / npm registry
powershell -ExecutionPolicy Bypass -File .\install_windows.ps1 -PipIndexUrl "" -NpmRegistry ""
What this script does:
1. Locate GenericAgent project dir, which must contain agentmain.py.
2. Locate a supported Python, or create/use .venv.
@@ -33,7 +41,14 @@ param(
[switch]$NoVenv,
[switch]$SkipPipInstall,
[switch]$SkipNpmInstall,
[switch]$SkipWebView2Check
[switch]$SkipWebView2Check,
# Package mirrors. Default to China mirrors for speed; pass "" to use the official source.
[string]$PipIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple",
[string]$NpmRegistry = "https://registry.npmmirror.com",
# Offline install: when set, pip installs from local wheels only (no network). Used by the portable bundle.
[string]$WheelDir = "",
# Extra packages to install beyond the core deps (e.g. "fastapi uvicorn websockets" for the conductor service).
[string]$ExtraPipPackages = ""
)
$ErrorActionPreference = "Stop"
@@ -125,6 +140,7 @@ function Ensure-Venv([string]$root, [string]$basePython) {
$venvDir = Join-Path $root ".venv"
$venvPy = Join-Path $venvDir "Scripts\python.exe"
if (-not (Test-Path $venvPy)) {
Write-Host "GAPROGRESS|venv"
Write-Step "Create virtual environment: $venvDir"
& $basePython -m venv $venvDir
if ($LASTEXITCODE -ne 0) { Fail "Failed to create venv." }
@@ -135,14 +151,41 @@ function Ensure-Venv([string]$root, [string]$basePython) {
function Install-Dependencies([string]$root, [string]$py) {
if ($SkipPipInstall) { Write-Warn "SkipPipInstall is set; dependencies are not installed."; return }
# Extra packages (e.g. conductor service deps) appended to the core install.
$extra = @()
if ($ExtraPipPackages) { $extra = $ExtraPipPackages.Split(" ", [StringSplitOptions]::RemoveEmptyEntries) }
# Offline mode (portable bundle): install from local wheels only, no network, no pip self-upgrade.
if ($WheelDir) {
$wd = (Resolve-Path $WheelDir -ErrorAction Stop).Path
Write-Ok "offline wheels: $wd"
if ($extra.Count) { Write-Ok "extra packages: $($extra -join ', ')" }
Write-Host "GAPROGRESS|deps"
Write-Step "Install GenericAgent minimal package and desktop bridge extras (offline)"
# setuptools/wheel are included in the wheel dir so the editable build resolves offline.
& $py -m pip install --no-index --find-links $wd -e $root psutil @extra
if ($LASTEXITCODE -ne 0) { Fail "offline pip install failed (check wheels dir)." }
Write-Host "GAPROGRESS|done"
return
}
# Online mode: use a pip index mirror when -PipIndexUrl is set (default: Tsinghua). Pass -PipIndexUrl "" for official PyPI.
$pipIndexArgs = @()
if ($PipIndexUrl) {
$pipIndexArgs = @("-i", $PipIndexUrl)
Write-Ok "pip index mirror: $PipIndexUrl"
} else {
Write-Warn "No pip mirror set; using official PyPI."
}
Write-Step "Upgrade pip"
& $py -m pip install --upgrade pip
& $py -m pip install @pipIndexArgs --upgrade pip
if ($LASTEXITCODE -ne 0) { Fail "pip upgrade failed." }
Write-Step "Install GenericAgent minimal package and desktop bridge extras"
# pyproject.toml already includes: requests, beautifulsoup4, bottle, simple-websocket-server, aiohttp.
# desktop_bridge.py additionally imports psutil.
& $py -m pip install -e $root psutil
& $py -m pip install @pipIndexArgs -e $root psutil @extra
if ($LASTEXITCODE -ne 0) { Fail "pip install failed." }
}
@@ -214,10 +257,16 @@ function Install-DesktopNpm([string]$root) {
if ($SkipNpmInstall) { Write-Warn "SkipNpmInstall is set; desktop npm dependencies are not installed."; return }
$desktop = Find-DesktopDir $root
Ensure-NodeAndNpm
# Use an npm registry mirror when -NpmRegistry is set (default: npmmirror). Pass -NpmRegistry "" for official registry.
$npmRegArgs = @()
if ($NpmRegistry) {
$npmRegArgs = @("--registry", $NpmRegistry)
Write-Ok "npm registry mirror: $NpmRegistry"
}
Write-Step "Install Tauri desktop npm dependencies: $desktop"
Push-Location $desktop
try {
& cmd /c npm install
& cmd /c npm install @npmRegArgs
if ($LASTEXITCODE -ne 0) { Fail "npm install failed in $desktop" }
& cmd /c npx tauri --version
if ($LASTEXITCODE -ne 0) { Fail "npx tauri --version failed after npm install" }
+244 -59
View File
@@ -1,9 +1,10 @@
use std::process::{Command, Child};
use std::process::{Command, Child, Stdio};
use std::io::{BufRead, BufReader};
use std::sync::Mutex;
use std::net::TcpStream;
use std::time::{Duration, Instant};
use std::thread;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tauri::Manager;
#[cfg(windows)]
@@ -29,10 +30,39 @@ fn find_bridge_script() -> PathBuf {
.join("desktop_bridge.py")
}
/// Directory next to which a self-contained bundle keeps its runtime/ folder.
/// Windows: the exe's folder. Linux: the .AppImage's folder ($APPIMAGE) when launched as an
/// AppImage (current_exe would otherwise point inside the read-only squashfs mount).
fn bundle_anchor_dir() -> Option<PathBuf> {
#[cfg(not(windows))]
{
if let Some(p) = std::env::var_os("APPIMAGE") {
if let Some(d) = PathBuf::from(p).parent() {
return Some(d.to_path_buf());
}
}
}
Some(std::env::current_exe().ok()?.parent()?.to_path_buf())
}
/// Embedded interpreter inside the bundle's runtime/python (base python, before venv).
fn bundle_python() -> Option<PathBuf> {
let root = bundle_root()?;
#[cfg(windows)]
let p = root.join("python").join("python.exe");
#[cfg(not(windows))]
let p = root.join("python").join("bin").join("python3");
if p.exists() { Some(p) } else { None }
}
/// Find python executable:
/// 1. .portable/uv-python/ 下找 python.exe (Windows) 或 python3 (Unix)
/// 2. Fallback to system PATH
/// 1. The embedded bundle python (runtime/python).
/// 2. .portable/uv-python/ 下找 python.exe (Windows) 或 python3 (Unix)
/// 3. Fallback to system PATH
fn find_python() -> String {
if let Some(p) = bundle_python() {
return p.to_string_lossy().to_string();
}
let root = project_root();
let portable_python_dir = root.join(".portable").join("uv-python");
@@ -69,11 +99,20 @@ fn find_python() -> String {
{ "python3".to_string() }
}
/// Find project directory by searching upward from exe for agentmain.py
/// Find the project directory (folder containing agentmain.py).
/// Bundle layout: <exe dir>/runtime/app/agentmain.py. Dev layout: walk up from the exe.
fn find_project_dir() -> Option<String> {
// Bundle layout: source tucked under <anchor>/runtime/app/
if let Some(anchor) = bundle_anchor_dir() {
let app = anchor.join("runtime").join("app");
if app.join("agentmain.py").exists() {
return Some(app.to_string_lossy().to_string());
}
}
// Dev/source layout: walk up to 8 levels from the exe location.
let exe = std::env::current_exe().ok()?;
let mut dir = exe.parent();
// Walk up to 8 levels from exe location
let mut dir = Some(exe.parent()?);
for _ in 0..8 {
match dir {
Some(d) => {
@@ -134,6 +173,105 @@ pub fn get_or_discover_config() -> (String, String) {
(python, project)
}
/// Self-contained bundle support dir: holds python/, wheels/, install_windows.ps1 and app/.
/// Typical portable layout keeps only the exe (+README) at the top level and tucks everything
/// else under <exe dir>/runtime/. Returns None when this is not a bundle (e.g. dev build).
fn bundle_root() -> Option<PathBuf> {
let runtime = bundle_anchor_dir()?.join("runtime");
if runtime.join("app").join("agentmain.py").exists() {
return Some(runtime);
}
None
}
/// venv python created by the offline prepare step.
fn venv_python(project_dir: &Path) -> PathBuf {
#[cfg(windows)]
{ project_dir.join(".venv").join("Scripts").join("python.exe") }
#[cfg(not(windows))]
{ project_dir.join(".venv").join("bin").join("python") }
}
/// True when this is a self-contained bundle whose python env has not been prepared yet
/// (embedded python present but app/.venv missing). project_dir must be the app/ folder.
fn needs_first_run_prepare(project_dir: &str) -> bool {
if project_dir.is_empty() { return false; }
bundle_python().is_some() && !venv_python(Path::new(project_dir)).exists()
}
/// Run the offline prepare (install_windows.ps1 -Mode PrepareOnly) using bundled python + wheels.
/// Streams the script's stdout and forwards GAPROGRESS markers to `report(pct, message)`.
/// Blocking; intended to run on a background thread. Writes ~/.ga_desktop_settings.json.
fn run_offline_prepare(project_dir: &str, report: &dyn Fn(i32, &str)) -> Result<(), String> {
let root = bundle_root().ok_or("cannot locate bundle root")?;
let wheels = root.join("wheels");
#[cfg(windows)]
let (script, py) = (
root.join("install_windows.ps1"),
root.join("python").join("python.exe"),
);
#[cfg(not(windows))]
let (script, py) = (
root.join("install_linux.sh"),
root.join("python").join("bin").join("python3"),
);
if !script.exists() || !py.exists() || !wheels.exists() {
return Err(format!("prepare resources missing under {:?}", root));
}
#[cfg(windows)]
let mut cmd = {
let mut c = Command::new("powershell.exe");
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
.arg(&script)
.arg("-PythonPath").arg(&py)
.arg("-ProjectDir").arg(project_dir)
.arg("-WheelDir").arg(&wheels)
.arg("-ExtraPipPackages").arg("fastapi uvicorn websockets")
.args(["-Mode", "PrepareOnly", "-SkipNpmInstall"]);
c
};
#[cfg(not(windows))]
let mut cmd = {
let mut c = Command::new("bash");
c.arg(&script)
.arg("--python-path").arg(&py)
.arg("--project-dir").arg(project_dir)
.arg("--wheel-dir").arg(&wheels)
.arg("--extra-packages").arg("fastapi uvicorn websockets")
.args(["--mode", "PrepareOnly"]);
c
};
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
#[cfg(windows)]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
let mut child = cmd.spawn().map_err(|e| format!("failed to launch prepare: {}", e))?;
// Map the script's ASCII progress keys to a percentage + human message (Chinese kept here
// to avoid console-encoding issues when the key crosses the process boundary).
if let Some(out) = child.stdout.take() {
for line in BufReader::new(out).lines().flatten() {
if let Some(key) = line.trim().strip_prefix("GAPROGRESS|") {
match key.trim() {
"venv" => report(15, "正在创建运行环境…"),
"deps" => report(45, "正在安装依赖…"),
"done" => report(90, "依赖安装完成"),
_ => {}
}
}
}
}
let status = child.wait().map_err(|e| format!("prepare wait failed: {}", e))?;
if !status.success() {
return Err(format!("prepare exited with status {:?}", status.code()));
}
Ok(())
}
fn is_bridge_running() -> bool {
TcpStream::connect(("127.0.0.1", 14168)).is_ok()
}
@@ -149,15 +287,6 @@ fn wait_for_port(port: u16, timeout: Duration) -> bool {
false
}
fn sanitize_python_command(cmd: &mut Command) {
// AppImage runtimes may set Python-specific environment variables that point
// inside the mounted AppImage (for example /tmp/.mount_*/usr). If those are
// inherited by the project venv Python, Python can look for its stdlib in the
// wrong place and fail during startup with "No module named 'encodings'".
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
fn start_bridge() {
let script = find_bridge_script();
if !script.exists() {
@@ -171,7 +300,6 @@ fn start_bridge() {
let show_console = std::env::args().any(|a| a == "--console");
let mut cmd = Command::new(&python);
sanitize_python_command(&mut cmd);
cmd.arg(&script)
.current_dir(script.parent().unwrap());
@@ -223,7 +351,6 @@ fn start_bridge_with_config(app_handle: tauri::AppHandle, python_path: String, p
}
let mut cmd = Command::new(&py);
sanitize_python_command(&mut cmd);
cmd.arg(&script).current_dir(&dir);
#[cfg(windows)]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
@@ -261,16 +388,21 @@ pub fn run() {
let no_autostart = args.iter().any(|a| a == "--no-autostart");
let dev_mode = args.iter().any(|a| a == "--dev");
// Self-contained bundle: detect whether the first-run offline prepare is still needed.
let project_dir = find_project_dir().unwrap_or_default();
let needs_prepare = needs_first_run_prepare(&project_dir);
let bridge_ok = is_bridge_running();
let mut spawned_bridge = false;
if !bridge_ok && !no_autostart {
// Skip the early spawn when a first-run prepare is required (no venv yet);
// the setup thread prepares the env first and then starts the bridge.
if !bridge_ok && !no_autostart && !needs_prepare {
// Try to start bridge with saved/discovered config
let (py_str, dir_str) = get_or_discover_config();
let dir = PathBuf::from(&dir_str);
let script = dir.join("frontends").join("desktop_bridge.py");
if script.exists() {
let mut cmd = Command::new(&py_str);
sanitize_python_command(&mut cmd);
cmd.arg(&script).current_dir(&dir);
#[cfg(windows)]
cmd.creation_flags(0x08000000);
@@ -291,46 +423,99 @@ pub fn run() {
}))
.invoke_handler(tauri::generate_handler![start_bridge_with_config, get_config])
.setup(move |app| {
let bridge_wait = if spawned_bridge {
Duration::from_secs(20)
} else {
Duration::from_secs(2)
};
let bridge_ready = wait_for_port(14168, bridge_wait);
if bridge_ready {
// Navigate to bridge HTTP only after it is ready; the window starts on loading.html
// so WebView never caches an early "connection refused" error page.
if let Some(w) = app.get_webview_window("main") {
let url = tauri::Url::parse("http://127.0.0.1:14168/").unwrap();
let _ = w.navigate(url);
if dev_mode {
w.open_devtools();
} else {
// Disable F5/F12/Ctrl+R/right-click in production
let _ = w.eval(r#"
document.addEventListener('keydown', function(e) {
if (e.key === 'F12' || e.key === 'F5' ||
(e.ctrlKey && e.key === 'r') ||
(e.ctrlKey && e.shiftKey && e.key === 'I')) {
e.preventDefault();
}
});
document.addEventListener('contextmenu', function(e) {
e.preventDefault();
});
"#);
}
let _ = w.show();
}
} else {
// Show setup window
if let Some(w) = app.get_webview_window("setup") {
if dev_mode {
w.open_devtools();
}
let _ = w.show();
}
// Show the loading window immediately so the first-run prepare isn't a blank screen.
// The window starts on loading.html (a local page), so no "connection refused" flash.
if let Some(w) = app.get_webview_window("main") {
let _ = w.show();
}
let handle = app.handle().clone();
let project_dir = project_dir.clone();
thread::spawn(move || {
// Progress reporter: push status into the loading window (window.gaProgress).
let main_win = handle.get_webview_window("main");
let report = |pct: i32, msg: &str| {
if let Some(w) = &main_win {
let js = format!(
"window.gaProgress && window.gaProgress({}, {})",
pct,
serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".to_string())
);
let _ = w.eval(&js);
}
};
// First-run (self-contained bundle): prepare the embedded python env offline,
// then start the bridge with the freshly created venv.
if needs_prepare {
report(5, "首次启动,正在准备运行环境…");
if let Err(e) = run_offline_prepare(&project_dir, &report) {
eprintln!("[tauri] first-run prepare failed: {}", e);
if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.show(); }
if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); }
return;
}
report(95, "正在启动服务…");
if !is_bridge_running() {
let (py_str, dir_str) = get_or_discover_config();
let dir = PathBuf::from(&dir_str);
let script = dir.join("frontends").join("desktop_bridge.py");
if script.exists() {
let mut cmd = Command::new(&py_str);
cmd.arg(&script).current_dir(&dir);
#[cfg(windows)]
cmd.creation_flags(0x08000000);
if let Ok(child) = cmd.spawn() {
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
}
}
}
}
// First run (prepare) and cold bridge start may take a while; allow up to 60s.
let wait = if needs_prepare || spawned_bridge {
Duration::from_secs(60)
} else {
Duration::from_secs(2)
};
let bridge_ready = wait_for_port(14168, wait);
if bridge_ready {
// Navigate to the bridge HTTP only after it is ready.
if let Some(w) = handle.get_webview_window("main") {
if let Ok(url) = tauri::Url::parse("http://127.0.0.1:14168/") {
let _ = w.navigate(url);
}
if dev_mode {
w.open_devtools();
} else {
// Disable F5/F12/Ctrl+R/right-click in production
let _ = w.eval(r#"
document.addEventListener('keydown', function(e) {
if (e.key === 'F12' || e.key === 'F5' ||
(e.ctrlKey && e.key === 'r') ||
(e.ctrlKey && e.shiftKey && e.key === 'I')) {
e.preventDefault();
}
});
document.addEventListener('contextmenu', function(e) {
e.preventDefault();
});
"#);
}
let _ = w.show();
let _ = w.set_focus();
}
if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.hide(); }
} else {
// Bridge never came up -> let the user fix paths in the setup window.
if let Some(sw) = handle.get_webview_window("setup") {
if dev_mode { sw.open_devtools(); }
let _ = sw.show();
}
if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); }
}
});
Ok(())
})
.on_window_event(|window, event| {
+35 -4
View File
@@ -1,14 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>GenericAgent</title>
<style>
:root{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Helvetica Neue",Helvetica,Arial,sans-serif;background:#fff;color:#111}
:root{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI","Microsoft YaHei UI","Helvetica Neue",Helvetica,Arial,sans-serif;background:#fff;color:#111}
@media(prefers-color-scheme:dark){:root{background:#1a1a1a;color:#eee}}
html,body{height:100%;margin:0;display:flex;align-items:center;justify-content:center}
.card{text-align:center;opacity:.78}.spinner{width:28px;height:28px;margin:0 auto 14px;border:3px solid #9994;border-top-color:currentColor;border-radius:50%;animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}
.card{text-align:center;opacity:.85;width:280px}
.spinner{width:28px;height:28px;margin:0 auto 14px;border:3px solid #9994;border-top-color:currentColor;border-radius:50%;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
#ga-status{font-size:13px;line-height:1.5}
.bar{display:none;width:240px;height:6px;margin:14px auto 0;background:#9993;border-radius:99px;overflow:hidden}
.bar.show{display:block}
#ga-bar{height:100%;width:0;background:currentColor;border-radius:99px;transition:width .3s ease}
#ga-bar.indet{width:35%;animation:indet 1.1s ease-in-out infinite}
@keyframes indet{0%{margin-left:-35%}100%{margin-left:100%}}
#ga-sub{margin-top:8px;font-size:11px;opacity:.6}
</style>
<script>
window.gaProgress = function(pct, msg){
var s = document.getElementById('ga-status');
if (s && msg) s.textContent = msg;
var bar = document.querySelector('.bar');
var fill = document.getElementById('ga-bar');
if (bar) bar.classList.add('show');
if (fill){
if (pct < 0){ fill.classList.add('indet'); }
else { fill.classList.remove('indet'); fill.style.width = Math.max(0, Math.min(100, pct)) + '%'; }
}
var sub = document.getElementById('ga-sub');
if (sub) sub.textContent = '首次启动正在准备运行环境,仅此一次';
};
</script>
</head>
<body><div class="card"><div class="spinner"></div><div>Starting GenericAgent…</div></div></body>
<body>
<div class="card">
<div class="spinner"></div>
<div id="ga-status">正在启动 GenericAgent…</div>
<div class="bar"><div id="ga-bar"></div></div>
<div id="ga-sub"></div>
</div>
</body>
</html>