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:
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user