Files
GenericAgent/.github/workflows/desktop-release-package.yml
T
YI QI LIMandabraxas914 efb3bc6ad1 feat(desktop): compiled React Desktop 2.0 v0.2.1 renderer update (#787)
* fix(desktop): expose explicit backup capabilities

* fix(desktop): align titlebar controls with native metrics

* release(desktop): prepare portable v0.2.1

* build(desktop): rebuild compiled dist from source 4a67226

Rebuild the compiled-only React Desktop 2.0 renderer from abraxas914/GenericAgent
main @4a67226, carrying the v0.2.1 iterations that PR #784 was closed to await:
live turn-timer persistence across session switch, Mixin image-to-path fallback,
folder drag-drop, per-turn duration, and image-thumbnail repair.

- dist/**: regenerated 98 manifest assets + entry HTML
- build-provenance.json: sourceCommit -> 4a67226, manifest sha256 recomputed
- verify-compiled-dist.mjs: expectedSourceCommit -> 4a67226
- dist/README.md: provenance source commit updated
- desktop_bridge.py, test_bridge_origin_security.py: synced to 4a67226
- core (agentmain.py, llmcore*, src/**, public/**) remains zero-diff vs upstream

---------

Co-authored-by: abraxas914 <abraxas345589144233@outlook.com>
2026-08-30 14:21:51 +08:00

888 lines
46 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
name: Build Desktop Portable Packages
# Triggers:
# - workflow_dispatch -> build CI artifacts for testing (choose a target platform)
# - push tag desktop-portable-* -> build all three platforms and publish them
# together into ONE GitHub Release (tag = the pushed tag)
# Plain pushes to branches do NOT build (avoids 3 heavy Rust builds per commit).
"on":
push:
tags:
- "desktop-portable-*"
workflow_dispatch:
inputs:
target:
description: "Which platform(s) to build (artifacts only; release happens on tag push)"
required: false
default: "all"
type: choice
options:
- "all"
- "windows"
- "linux"
- "macos"
permissions:
contents: read
env:
NODE_VERSION: "22.23.2"
RUST_TOOLCHAIN: "1.95.0"
PBS_RELEASE: "20260814"
PBS_PYTHON_VERSION: "3.12.14"
MACOS_PACKAGING_PYTHON_VERSION: "3.12.10"
PYTHONDONTWRITEBYTECODE: "1"
jobs:
# ----------------------------------------------------------------------------
build-windows:
name: Windows portable
if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'windows')) }}
runs-on: windows-2025
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Set up Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Cache Rust build
uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2
with:
workspaces: frontends/desktop/src-tauri -> target
- name: Install desktop dependencies
working-directory: frontends/desktop
run: |
npm install --package-lock=false
node -e "const v=require('./node_modules/@tauri-apps/cli/package.json').version; if(v!=='2.11.4') throw new Error('unexpected Tauri CLI '+v)"
test ! -e package-lock.json
- name: Verify tracked compiled renderer bytes
working-directory: frontends/desktop
run: npm run test:dist
- name: Build Windows desktop exe
working-directory: frontends/desktop
run: npm run tauri build -- --bundles nsis
- name: Assemble self-contained portable bundle
shell: bash
run: |
set -euo pipefail
purge_runtime_bytecode() {
local runtime_root="$1"
find "$runtime_root" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
if find "$runtime_root" -type d -name '__pycache__' -print -quit | grep -q .; then
echo "Python bytecode cache directory remains in packaged runtime: $runtime_root" >&2
return 1
fi
if find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -print -quit | grep -q .; then
echo "Python bytecode remains in packaged runtime: $runtime_root" >&2
return 1
fi
}
EXE_SRC="$(find frontends/desktop/src-tauri/target/release -maxdepth 1 -type f -name 'ga-desktop.exe' | head -n 1)"
[[ -n "$EXE_SRC" ]] || { echo "ga-desktop.exe not found" >&2; exit 1; }
PKG="artifacts/windows/GenericAgent-Desktop-Windows-Portable"
RUNTIME="$PKG/runtime"
mkdir -p "$RUNTIME"
# exe at the top level, named GenericAgent (typical portable form)
cp "$EXE_SRC" "$PKG/GenericAgent.exe"
cp frontends/desktop/packaging/scripts/windows/install_windows.ps1 "$RUNTIME/install_windows.ps1"
cp frontends/desktop/packaging/scripts/merge_desktop_settings.py "$RUNTIME/merge_desktop_settings.py"
# Uninstaller: double-click entry at top level, worker script under runtime/.
cp frontends/desktop/packaging/scripts/windows/uninstall.bat "$PKG/uninstall.bat"
cp frontends/desktop/packaging/scripts/windows/uninstall_windows.ps1 "$RUNTIME/uninstall_windows.ps1"
# Fixed python-build-standalone Windows x86_64 archive.
PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-x86_64-pc-windows-msvc-install_only.tar.gz"
PBS_SHA256="7330282b47cd43a66b702d39078d2e5a88e580cee351d82f95045f21f5ee042a"
command -v cygpath >/dev/null 2>&1 || { echo "cygpath not found on Windows runner" >&2; exit 1; }
RUNNER_TEMP_POSIX="$(cygpath -u "$RUNNER_TEMP")"
[[ "$RUNNER_TEMP_POSIX" == /* ]] || { echo "cygpath did not return an absolute POSIX path" >&2; exit 1; }
PBS_ARCHIVE="${RUNNER_TEMP_POSIX}/pbs-windows-x86_64.tar.gz"
echo "Python build-standalone: $PBS_URL"
curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL"
printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | sha256sum --check --strict -
tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME" # extracts a 'python/' dir (python.exe at top)
PY="$RUNTIME/python/python.exe"
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine().lower() in ("amd64", "x86_64"); print(sys.version)'
# App-local UCRT: python-build-standalone links the Universal CRT dynamically
# and does NOT bundle it. Win10/11 ship UCRT in-box, but stripped/older images
# may not -> python.exe fails to load (missing api-ms-win-crt-*.dll). Copy the
# redistributable UCRT DLLs next to python.exe (Microsoft-supported app-local
# deployment). Pure SDK source, no System32 fallback, hard-fail if absent so we
# never ship a bundle from an unexpected source.
UCRT_SRC="$(ls -d "/c/Program Files (x86)/Windows Kits/10/Redist/"*/ucrt/DLLs/x64 2>/dev/null | sort -V | tail -1)"
[[ -n "$UCRT_SRC" ]] || { echo "Windows SDK UCRT redist dir not found on runner" >&2; exit 1; }
echo "UCRT source: $UCRT_SRC"
cp "$UCRT_SRC"/api-ms-win-crt-*.dll "$RUNTIME/python/"
cp "$UCRT_SRC"/ucrtbase.dll "$RUNTIME/python/"
test -f "$RUNTIME/python/api-ms-win-crt-runtime-l1-1-0.dll" \
|| { echo "UCRT DLLs missing after copy" >&2; exit 1; }
# Offline binary-only wheels. requirements.txt pins direct and transitive versions.
mkdir -p "$RUNTIME/wheels"
cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME/wheels/requirements.txt"
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME/wheels" \
--requirement "$RUNTIME/wheels/requirements.txt"
# Runtime source (project root minus heavy/dev/build dirs)
mkdir -p "$RUNTIME/app"
tar \
--exclude='./.git' --exclude='./.github' \
--exclude='./frontends/tests' \
--exclude='./frontends/desktop/src-tauri' \
--exclude='./frontends/desktop/src' \
--exclude='./frontends/desktop/public' \
--exclude='./frontends/desktop/scripts' \
--exclude='./frontends/desktop/release_qualification' \
--exclude='./frontends/desktop/tests' \
--exclude='./frontends/desktop/testing' \
--exclude='./frontends/desktop/spec' \
--exclude='./frontends/desktop/node_modules' \
--exclude='./frontends/desktop/dist' \
--exclude='./frontends/desktop/DESIGN.md' \
--exclude='./frontends/desktop/package.json' \
--exclude='./frontends/desktop/package-lock.json' \
--exclude='./frontends/desktop/.npmrc' \
--exclude='./frontends/desktop/index.html' \
--exclude='./frontends/desktop/loading.html' \
--exclude='./frontends/desktop/setup.html' \
--exclude='./frontends/desktop/tsconfig*.json' \
--exclude='./frontends/desktop/vite.config.ts' \
--exclude='./frontends/desktop/packaging' --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"
test ! -e "$RUNTIME/app/frontends/desktop/dist"
test ! -e "$RUNTIME/app/frontends/desktop/src"
test ! -e "$RUNTIME/app/frontends/desktop/public"
test ! -e "$RUNTIME/app/frontends/desktop/package-lock.json"
test ! -e "$RUNTIME/app/frontends/desktop/node_modules"
# Drop python debug symbols (.pdb) to slim the package (~80MB)
find "$RUNTIME/python" -name '*.pdb' -delete 2>/dev/null || true
purge_runtime_bytecode "$RUNTIME"
cat > "$PKG/readme.txt" <<'EOF'
================ 中文 ================
GenericAgent Desktop — Windows 便携版(自包含)
无需安装 Python、无需联网装依赖、无需源码仓库——全部已内置。
前置条件
- Windows 10/11 x64
- Microsoft Edge WebView2 运行时(Win11 一般自带;缺失时首次运行会提示安装)
使用
1. 解压到任意目录(路径建议不含特殊字符)。
2. 双击 GenericAgent.exe。
3. 首次启动会离线校验并补齐随包 Python 运行时依赖,界面显示进度,完成后进入主界面。
4. 之后启动直接秒进。
说明
- 想真正对话,仍需在程序里配置模型 / API Key。
- 如果 Windows Defender 防火墙拦截 `127.0.0.1` / `localhost` 回环连接,程序可能无法启动或连接本机服务。请允许 `GenericAgent.exe` 和 `runtime\python\python.exe` 通过防火墙,然后重启桌面端。
- 本便携版尚未使用 Windows 代码签名证书;SmartScreen 可能显示警告。运行前请用随包发布的 SHA256SUMS-windows.txt 核对 ZIP。
- 可整体移动本文件夹;重启后会自动刷新路径。若需强制重新准备,请删除 runtime\.prepared 后重启。
- runtime\ 是内置运行环境与源码,正常使用无需改动。
================ English ================
GenericAgent Desktop — Windows Portable (self-contained)
No Python install, no internet for dependencies, no source checkout — everything is bundled.
Requirements
- Windows 10/11 x64
- Microsoft Edge WebView2 runtime (usually preinstalled on Win11; you are prompted if missing)
Usage
1. Extract anywhere (a path without special characters is recommended).
2. Double-click GenericAgent.exe.
3. The first launch verifies and completes the bundled Python runtime offline with a
progress UI, then opens the main window.
4. Subsequent launches start instantly.
Notes
- To actually chat, configure a model / API key inside the app.
- If Windows Defender Firewall blocks the `127.0.0.1` / `localhost` loopback connection, the app may fail to start or reach its local service. Allow `GenericAgent.exe` and `runtime\python\python.exe` through the firewall, then restart the desktop app.
- This portable build is not Windows code-signed, so SmartScreen may warn. Verify the ZIP against the published SHA256SUMS-windows.txt before running it.
- You may move the whole folder; relaunching refreshes its saved paths. To force a new
preparation pass, delete runtime\.prepared and relaunch.
- runtime\ holds the bundled runtime and source; no need to touch it.
EOF
sed -i 's/^ //' "$PKG/readme.txt"
echo "Package tree (top levels):"
find "$PKG" -maxdepth 2 -printf '%y %p\n' | sort | head -40
- name: Zip portable package
shell: pwsh
run: |
New-Item -ItemType Directory -Force -Path artifacts/windows/out | Out-Null
Compress-Archive -Path artifacts/windows/GenericAgent-Desktop-Windows-Portable `
-DestinationPath artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip -Force
$h = Get-FileHash artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip -Algorithm SHA256
"{0} GenericAgent-Desktop-Windows-Portable.zip" -f $h.Hash.ToLowerInvariant() |
Set-Content -Encoding ASCII artifacts/windows/out/SHA256SUMS-windows.txt
Get-ChildItem artifacts/windows/out | Format-Table Name, Length
- name: Upload workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: GenericAgent-Desktop-Windows-Portable-${{ github.run_number }}
path: |
artifacts/windows/out/GenericAgent-Desktop-Windows-Portable.zip
artifacts/windows/out/SHA256SUMS-windows.txt
if-no-files-found: error
# ----------------------------------------------------------------------------
build-linux:
name: Linux portable
if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'linux')) }}
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Set up Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Cache Rust build
uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2
with:
# Keep registry/git downloads, but never restore binaries built on a newer glibc image.
prefix-key: "v1-rust-release-ubuntu-22.04-glibc-2.35"
workspaces: frontends/desktop/src-tauri -> target
cache-targets: "false"
cache-bin: "false"
- name: Install Tauri Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
patchelf \
binutils \
file \
xvfb
- name: Install desktop dependencies
working-directory: frontends/desktop
run: |
npm install --package-lock=false
node -e "const v=require('./node_modules/@tauri-apps/cli/package.json').version; if(v!=='2.11.4') throw new Error('unexpected Tauri CLI '+v)"
test ! -e package-lock.json
- name: Verify tracked compiled renderer bytes
working-directory: frontends/desktop
run: npm run test:dist
- name: Build Linux AppImage
working-directory: frontends/desktop
run: npm run tauri build -- --bundles appimage
- name: Assemble self-contained portable bundle
shell: bash
run: |
set -euo pipefail
purge_runtime_bytecode() {
local runtime_root="$1"
find "$runtime_root" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
if find "$runtime_root" -type d -name '__pycache__' -print -quit | grep -q .; then
echo "Python bytecode cache directory remains in packaged runtime: $runtime_root" >&2
return 1
fi
if find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -print -quit | grep -q .; then
echo "Python bytecode remains in packaged runtime: $runtime_root" >&2
return 1
fi
}
APPIMAGE_SRC="$(find frontends/desktop/src-tauri/target/release/bundle/appimage -maxdepth 1 -type f -name '*.AppImage' | head -n 1)"
[[ -n "$APPIMAGE_SRC" ]] || { echo "No AppImage found" >&2; exit 1; }
PKG="artifacts/linux/GenericAgent-Desktop-Linux-Portable"
RUNTIME="$PKG/runtime"
mkdir -p "$RUNTIME"
# Stable internal name so the generated .desktop Exec= never changes on upgrades.
cp "$APPIMAGE_SRC" "$PKG/GenericAgent.AppImage"
chmod +x "$PKG/GenericAgent.AppImage"
# Icon for the generated .desktop launcher (Icon= needs a real image file on Linux).
cp frontends/desktop/src-tauri/icons/icon.png "$PKG/GenericAgent.png"
cp frontends/desktop/packaging/scripts/linux/install_linux.sh "$RUNTIME/install_linux.sh"
cp frontends/desktop/packaging/scripts/merge_desktop_settings.py "$RUNTIME/merge_desktop_settings.py"
chmod +x "$RUNTIME/install_linux.sh"
# Uninstaller: top-level entry next to the AppImage.
cp frontends/desktop/packaging/scripts/linux/uninstall.sh "$PKG/uninstall.sh"
chmod +x "$PKG/uninstall.sh"
# Fixed python-build-standalone Linux x86_64 archive.
PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-x86_64-unknown-linux-gnu-install_only.tar.gz"
PBS_SHA256="3297691ae34f75fed81ac424e040145fccb0bafe8e581cd5cadbddfa1c0766c0"
PBS_ARCHIVE="${RUNNER_TEMP}/pbs-linux-x86_64.tar.gz"
echo "Python build-standalone: $PBS_URL"
curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL"
printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | sha256sum --check --strict -
tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME" # extracts a 'python/' dir
PY="$RUNTIME/python/bin/python3"
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine() == "x86_64"; print(sys.version)'
# Offline binary-only wheels. requirements.txt pins direct and transitive versions.
mkdir -p "$RUNTIME/wheels"
cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME/wheels/requirements.txt"
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME/wheels" \
--requirement "$RUNTIME/wheels/requirements.txt"
# Runtime source (project root minus heavy/dev/build dirs)
mkdir -p "$RUNTIME/app"
tar \
--exclude='./.git' --exclude='./.github' \
--exclude='./frontends/tests' \
--exclude='./frontends/desktop/src-tauri' \
--exclude='./frontends/desktop/src' \
--exclude='./frontends/desktop/public' \
--exclude='./frontends/desktop/scripts' \
--exclude='./frontends/desktop/release_qualification' \
--exclude='./frontends/desktop/tests' \
--exclude='./frontends/desktop/testing' \
--exclude='./frontends/desktop/spec' \
--exclude='./frontends/desktop/node_modules' \
--exclude='./frontends/desktop/dist' \
--exclude='./frontends/desktop/DESIGN.md' \
--exclude='./frontends/desktop/package.json' \
--exclude='./frontends/desktop/package-lock.json' \
--exclude='./frontends/desktop/.npmrc' \
--exclude='./frontends/desktop/index.html' \
--exclude='./frontends/desktop/loading.html' \
--exclude='./frontends/desktop/setup.html' \
--exclude='./frontends/desktop/tsconfig*.json' \
--exclude='./frontends/desktop/vite.config.ts' \
--exclude='./frontends/desktop/packaging' --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"
test ! -e "$RUNTIME/app/frontends/desktop/dist"
test ! -e "$RUNTIME/app/frontends/desktop/src"
test ! -e "$RUNTIME/app/frontends/desktop/public"
test ! -e "$RUNTIME/app/frontends/desktop/package-lock.json"
test ! -e "$RUNTIME/app/frontends/desktop/node_modules"
purge_runtime_bytecode "$RUNTIME"
cat > "$PKG/readme.txt" <<'EOF'
================ 中文 ================
GenericAgent Desktop — Linux 便携版(自包含)
无需安装 Python、无需联网装依赖、无需源码仓库——全部已内置。
前置条件
- Linux x86_64,glibc 2.35 或更高版本(如 Ubuntu 22.04、Debian 12)
- 桌面环境 + FUSE(运行 AppImage 所需;多数发行版自带)
使用
1. 解压到任意目录(路径建议不含特殊字符)。
2. 给 AppImage 执行权限并运行:
chmod +x GenericAgent.AppImage
./GenericAgent.AppImage
3. 首次启动会离线校验并补齐随包 Python 运行时依赖,界面显示进度,完成后进入主界面。
4. 之后启动直接秒进。
说明
- 想真正对话,仍需在程序里配置模型 / API Key。
- 可整体移动本文件夹;重启后会自动刷新路径。若需强制重新准备,请删除 runtime/.prepared 后重启。
- runtime/ 是内置运行环境与源码,正常使用无需改动。
================ English ================
GenericAgent Desktop — Linux Portable (self-contained)
No Python install, no internet for dependencies, no source checkout — everything is bundled.
Requirements
- Linux x86_64 with glibc 2.35 or newer (for example Ubuntu 22.04 or Debian 12)
- A desktop environment + FUSE (required to run the AppImage; preinstalled on most distros)
Usage
1. Extract anywhere (a path without special characters is recommended).
2. Make the AppImage executable and run it:
chmod +x GenericAgent.AppImage
./GenericAgent.AppImage
3. The first launch verifies and completes the bundled Python runtime offline with a
progress UI, then opens the main window.
4. Subsequent launches start instantly.
Notes
- To actually chat, configure a model / API key inside the app.
- You may move the whole folder; relaunching refreshes its saved paths. To force a new
preparation pass, delete runtime/.prepared and relaunch.
- runtime/ holds the bundled runtime and source; no need to touch it.
EOF
sed -i 's/^ //' "$PKG/readme.txt"
mkdir -p artifacts/linux/out
tar -C artifacts/linux -czf artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz GenericAgent-Desktop-Linux-Portable
( cd artifacts/linux/out && sha256sum GenericAgent-Desktop-Linux-Portable.tar.gz > SHA256SUMS-linux.txt )
tar -tzf artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz > /tmp/pkglist.txt
echo "Package contents (first 40 of $(wc -l < /tmp/pkglist.txt) entries):"
head -40 /tmp/pkglist.txt
ls -lh artifacts/linux/out
# Exercise the exact archived candidate on the oldest supported builder.
SMOKE_ROOT="${RUNNER_TEMP}/ga-linux-package-smoke"
mkdir -p "$SMOKE_ROOT"
tar -xzf artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz -C "$SMOKE_ROOT"
SMOKE_PACKAGE="$SMOKE_ROOT/GenericAgent-Desktop-Linux-Portable"
APPIMAGE_ABS="$(realpath "$SMOKE_PACKAGE/GenericAgent.AppImage")"
APPIMAGE_SCAN="$SMOKE_ROOT/appimage-scan"
mkdir -p "$APPIMAGE_SCAN"
(cd "$APPIMAGE_SCAN" && "$APPIMAGE_ABS" --appimage-extract >/dev/null)
MAX_GLIBC=""
ELF_COUNT=0
while IFS= read -r -d '' candidate; do
file -b "$candidate" | grep -q '^ELF' || continue
ELF_COUNT=$((ELF_COUNT + 1))
candidate_max="$(readelf --version-info "$candidate" 2>/dev/null \
| sed -n 's/.*Name: GLIBC_\([0-9][0-9.]*\).*/\1/p' \
| sort -V | tail -n 1)"
[[ -n "$candidate_max" ]] || continue
if [[ -z "$MAX_GLIBC" \
|| "$(printf '%s\n%s\n' "$MAX_GLIBC" "$candidate_max" | sort -V | tail -n 1)" == "$candidate_max" ]]; then
MAX_GLIBC="$candidate_max"
fi
done < <(find "$SMOKE_PACKAGE" "$APPIMAGE_SCAN/squashfs-root" -type f -print0)
[[ "$ELF_COUNT" -gt 0 && -n "$MAX_GLIBC" ]] \
|| { echo "No GLIBC symbol versions found in final Linux package ELFs" >&2; exit 1; }
[[ "$(printf '%s\n%s\n' "$MAX_GLIBC" '2.35' | sort -V | tail -n 1)" == "2.35" ]] \
|| { echo "Final package requires GLIBC_$MAX_GLIBC; maximum allowed is GLIBC_2.35" >&2; exit 1; }
echo "Scanned $ELF_COUNT final package ELFs; maximum symbol is GLIBC_$MAX_GLIBC"
APPIMAGE_EXTRACT_AND_RUN=1 WEBKIT_DISABLE_COMPOSITING_MODE=1 \
xvfb-run -a "$SMOKE_PACKAGE/GenericAgent.AppImage" >"$SMOKE_ROOT/app.log" 2>&1 &
SMOKE_PID=$!
cleanup_smoke() {
kill "$SMOKE_PID" 2>/dev/null || true
wait "$SMOKE_PID" 2>/dev/null || true
}
trap cleanup_smoke EXIT
IDENTITY=""
for _ in $(seq 1 120); do
IDENTITY="$(curl --fail --silent --max-time 1 http://127.0.0.1:14168/services/identity || true)"
[[ -n "$IDENTITY" ]] && break
sleep 1
done
[[ -n "$IDENTITY" ]] || { cat "$SMOKE_ROOT/app.log" >&2; exit 1; }
EXPECTED_APP_DIR="$SMOKE_PACKAGE/runtime/app/frontends" \
IDENTITY="$IDENTITY" python3 - <<'PY'
import json, os, pathlib
identity = json.loads(os.environ["IDENTITY"])
actual = pathlib.Path(identity["app_dir"]).resolve()
expected = pathlib.Path(os.environ["EXPECTED_APP_DIR"]).resolve()
assert actual == expected, (actual, expected)
PY
curl --fail --silent --max-time 3 -X POST http://127.0.0.1:14168/services/bridge/exit >/dev/null
cleanup_smoke
trap - EXIT
- name: Upload workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: GenericAgent-Desktop-Linux-Portable-${{ github.run_number }}
path: |
artifacts/linux/out/GenericAgent-Desktop-Linux-Portable.tar.gz
artifacts/linux/out/SHA256SUMS-linux.txt
if-no-files-found: error
# ----------------------------------------------------------------------------
build-macos:
name: macOS arm64 DMG
if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-')) || (github.event_name == 'workflow_dispatch' && (github.event.inputs.target == 'all' || github.event.inputs.target == 'macos')) }}
runs-on: macos-26
permissions:
contents: read
env:
DEVELOPER_DIR: /Applications/Xcode_26.5.app/Contents/Developer
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
- name: Verify pinned macOS build environment
run: |
test "$(uname -m)" = arm64
test "$(xcodebuild -version | sed -n '1p')" = "Xcode 26.5"
test "$(xcodebuild -version | sed -n '2p')" = "Build version 17F42"
test "$(xcrun --sdk macosx --show-sdk-version)" = "26.5"
- name: Set up Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Set up Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable action snapshot
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
- name: Cache Rust build
uses: Swatinem/rust-cache@63fed3e2fecf6f7b51dc6f043341b79ef82a9ae7 # v2.9.2
with:
workspaces: frontends/desktop/src-tauri -> target
- name: Install desktop dependencies
working-directory: frontends/desktop
run: |
npm install --package-lock=false
node -e "const v=require('./node_modules/@tauri-apps/cli/package.json').version; if(v!=='2.11.4') throw new Error('unexpected Tauri CLI '+v)"
test ! -e package-lock.json
- name: Set up Python DMG tooling runtime
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.MACOS_PACKAGING_PYTHON_VERSION }}
- name: Install Python packaging tools
run: >-
python3 -m pip install --require-hashes --only-binary=:all:
--requirement frontends/desktop/packaging/dmg-build-requirements.txt
- name: Verify tracked compiled renderer bytes
working-directory: frontends/desktop
run: npm run test:dist
- name: Build macOS .app
working-directory: frontends/desktop
run: npm run tauri build -- --bundles app
- name: Assemble self-contained DMG app
shell: bash
run: |
set -euo pipefail
purge_runtime_bytecode() {
local runtime_root="$1"
find "$runtime_root" -type d -name '__pycache__' -prune -exec rm -rf {} +
find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
if find "$runtime_root" -type d -name '__pycache__' -print -quit | grep -q .; then
echo "Python bytecode cache directory remains in packaged runtime: $runtime_root" >&2
return 1
fi
if find "$runtime_root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -print -quit | grep -q .; then
echo "Python bytecode remains in packaged runtime: $runtime_root" >&2
return 1
fi
}
APP_SRC="$(find frontends/desktop/src-tauri/target/release/bundle/macos -maxdepth 1 -name '*.app' -type d | head -n 1)"
[[ -n "$APP_SRC" ]] || { echo "No .app found" >&2; exit 1; }
# Build one runtime tree and embed it only in the final DMG application.
RUNTIME_SRC="artifacts/macos/runtime-src"
mkdir -p "$RUNTIME_SRC"
cp frontends/desktop/packaging/scripts/macos/install_macos.sh "$RUNTIME_SRC/install_macos.sh"
cp frontends/desktop/packaging/scripts/merge_desktop_settings.py "$RUNTIME_SRC/merge_desktop_settings.py"
chmod +x "$RUNTIME_SRC/install_macos.sh"
# Fixed python-build-standalone macOS arm64 archive.
PBS_URL="https://github.com/astral-sh/python-build-standalone/releases/download/${PBS_RELEASE}/cpython-${PBS_PYTHON_VERSION}%2B${PBS_RELEASE}-aarch64-apple-darwin-install_only.tar.gz"
PBS_SHA256="4572133a5542f306b9bdb155da5800f9e38950cd0a98d469b832ce256fe299ea"
PBS_ARCHIVE="${RUNNER_TEMP}/pbs-macos-aarch64.tar.gz"
echo "Python build-standalone: $PBS_URL"
curl --proto '=https' --tlsv1.2 --fail --location --retry 3 --output "$PBS_ARCHIVE" "$PBS_URL"
printf '%s %s\n' "$PBS_SHA256" "$PBS_ARCHIVE" | shasum -a 256 --check -
tar -xzf "$PBS_ARCHIVE" -C "$RUNTIME_SRC" # extracts a 'python/' dir
PY="$RUNTIME_SRC/python/bin/python3"
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import platform, sys; assert sys.version_info[:3] == (3, 12, 14); assert platform.machine() == "arm64"; print(sys.version)'
# Offline binary-only wheels. requirements.txt pins direct and transitive versions.
mkdir -p "$RUNTIME_SRC/wheels"
cp frontends/desktop/packaging/python-runtime-requirements.txt "$RUNTIME_SRC/wheels/requirements.txt"
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip download --only-binary=:all: --dest "$RUNTIME_SRC/wheels" \
--requirement "$RUNTIME_SRC/wheels/requirements.txt"
# macOS applications must be immutable at first launch. Install from the downloaded
# wheelhouse before the runtime enters the signed .app, then carry a durable marker.
# The binary-only offline repair path needs neither setuptools nor wheel. Remove any
# copies shipped in the base interpreter; bytecode writes are disabled at runtime too.
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip install --no-compile --no-index --find-links "$RUNTIME_SRC/wheels" \
--requirement "$RUNTIME_SRC/wheels/requirements.txt"
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip uninstall --yes setuptools wheel
PYTHONDONTWRITEBYTECODE=1 "$PY" -m pip check
PYTHONDONTWRITEBYTECODE=1 "$PY" -c 'import aiohttp, fastapi, importlib.util, pydantic, uvicorn, websockets; assert all(importlib.util.find_spec(name) is None for name in ("setuptools", "wheel", "pkg_resources"))'
purge_runtime_bytecode "$RUNTIME_SRC"
printf 'ok\n' > "$RUNTIME_SRC/.prepared"
test -s "$RUNTIME_SRC/.prepared"
# Runtime source (project root minus heavy/dev/build dirs) — bsdtar exclude syntax.
mkdir -p "$RUNTIME_SRC/app"
tar \
--exclude='.git' --exclude='.github' \
--exclude='frontends/tests' \
--exclude='frontends/desktop/src-tauri' \
--exclude='frontends/desktop/src' \
--exclude='frontends/desktop/public' \
--exclude='frontends/desktop/scripts' \
--exclude='frontends/desktop/release_qualification' \
--exclude='frontends/desktop/tests' \
--exclude='frontends/desktop/testing' \
--exclude='frontends/desktop/spec' \
--exclude='frontends/desktop/node_modules' \
--exclude='frontends/desktop/dist' \
--exclude='frontends/desktop/DESIGN.md' \
--exclude='frontends/desktop/package.json' \
--exclude='frontends/desktop/package-lock.json' \
--exclude='frontends/desktop/.npmrc' \
--exclude='frontends/desktop/index.html' \
--exclude='frontends/desktop/loading.html' \
--exclude='frontends/desktop/setup.html' \
--exclude='frontends/desktop/tsconfig*.json' \
--exclude='frontends/desktop/vite.config.ts' \
--exclude='frontends/desktop/packaging' --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_SRC/app"
test -f "$RUNTIME_SRC/app/agentmain.py"
test -f "$RUNTIME_SRC/app/frontends/desktop_bridge.py"
test -f "$RUNTIME_SRC/app/frontends/desktop/static/index.html"
test ! -e "$RUNTIME_SRC/app/frontends/desktop/dist"
test ! -e "$RUNTIME_SRC/app/frontends/desktop/src"
test ! -e "$RUNTIME_SRC/app/frontends/desktop/public"
test ! -e "$RUNTIME_SRC/app/frontends/desktop/package-lock.json"
test ! -e "$RUNTIME_SRC/app/frontends/desktop/node_modules"
DMG_STAGE="artifacts/macos/dmg-stage"
DMG_APP="$DMG_STAGE/GenericAgent.app"
mkdir -p "$DMG_STAGE"
# Standard DMG: GenericAgent.app + Applications alias. The post-build
# repackager below writes the curated two-icon Finder layout.
ditto "$APP_SRC" "$DMG_APP"
mkdir -p "$DMG_APP/Contents/Resources"
ditto "$RUNTIME_SRC" "$DMG_APP/Contents/Resources/runtime"
test -s "$DMG_APP/Contents/Resources/runtime/.prepared"
DMG_RUNTIME="$DMG_APP/Contents/Resources/runtime"
PYTHONDONTWRITEBYTECODE=1 "$DMG_RUNTIME/python/bin/python3" \
-c 'import aiohttp, fastapi, pydantic, uvicorn, websockets'
purge_runtime_bytecode "$DMG_RUNTIME"
# Ad-hoc signing only: this is not Developer ID signing or notarization.
codesign --force --deep --sign - "$DMG_APP"
codesign --verify --deep --strict "$DMG_APP"
ln -s /Applications "$DMG_STAGE/Applications"
mkdir -p artifacts/macos/out
hdiutil create \
-volname "GenericAgent Desktop" \
-srcfolder "$DMG_STAGE" \
-ov \
-format UDZO \
"artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg"
# Restore the intentionally minimal Finder presentation: only the app
# and Applications shortcut, with the established window/icon layout.
bash frontends/desktop/scripts/post-dmg.sh "artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg"
(
cd artifacts/macos/out
shasum -a 256 "GenericAgent-Desktop-macOS-aarch64.dmg" \
> "GenericAgent-Desktop-macOS-aarch64.dmg.sha256"
)
echo "DMG stage tree:"
find "$DMG_STAGE" -maxdepth 2 -print | sort
- name: Upload workflow artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: GenericAgent-Desktop-macOS-aarch64-DMG-${{ github.run_number }}
path: |
artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg
artifacts/macos/out/GenericAgent-Desktop-macOS-aarch64.dmg.sha256
if-no-files-found: error
# ----------------------------------------------------------------------------
publish-release:
name: Publish one atomic prerelease
needs: [build-windows, build-linux, build-macos]
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/desktop-portable-') && needs.build-windows.result == 'success' && needs.build-linux.result == 'success' && needs.build-macos.result == 'success' }}
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Download Windows candidate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: GenericAgent-Desktop-Windows-Portable-${{ github.run_number }}
path: ${{ runner.temp }}/release-assets
- name: Download Linux candidate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: GenericAgent-Desktop-Linux-Portable-${{ github.run_number }}
path: ${{ runner.temp }}/release-assets
- name: Download macOS arm64 candidate
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: GenericAgent-Desktop-macOS-aarch64-DMG-${{ github.run_number }}
path: ${{ runner.temp }}/release-assets
- name: Validate six assets and publish prerelease
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG_NAME: ${{ github.ref_name }}
TARGET_SHA: ${{ github.sha }}
shell: bash
run: |
set -euo pipefail
ASSET_DIR="${RUNNER_TEMP}/release-assets"
EXPECTED=(
GenericAgent-Desktop-Linux-Portable.tar.gz
GenericAgent-Desktop-Windows-Portable.zip
GenericAgent-Desktop-macOS-aarch64.dmg
GenericAgent-Desktop-macOS-aarch64.dmg.sha256
SHA256SUMS-linux.txt
SHA256SUMS-windows.txt
)
if find "$ASSET_DIR" -mindepth 1 -maxdepth 1 ! -type f -print -quit | grep -q .; then
echo "Release staging contains a non-regular entry" >&2
find "$ASSET_DIR" -mindepth 1 -maxdepth 1 -print >&2
exit 1
fi
mapfile -t ACTUAL < <(find "$ASSET_DIR" -mindepth 1 -maxdepth 1 -type f -printf '%f\n' | LC_ALL=C sort)
if ! diff -u <(printf '%s\n' "${EXPECTED[@]}") <(printf '%s\n' "${ACTUAL[@]}"); then
echo "Release staging must contain exactly the six expected files" >&2
exit 1
fi
for filename in "${EXPECTED[@]}"; do
test -s "$ASSET_DIR/$filename" || { echo "Missing or empty release asset: $filename" >&2; exit 1; }
done
verify_checksum_manifest() {
local manifest="$1"
local payload="$2"
local normalized="${RUNNER_TEMP}/${manifest}.normalized"
tr -d '\r' < "$ASSET_DIR/$manifest" > "$normalized"
awk -v expected="$payload" '
NF == 2 && $1 ~ /^[0-9a-fA-F]{64}$/ && $2 == expected { valid += 1 }
END { exit !(NR == 1 && valid == 1) }
' "$normalized"
(cd "$ASSET_DIR" && sha256sum --check --strict "$normalized")
}
verify_checksum_manifest SHA256SUMS-windows.txt GenericAgent-Desktop-Windows-Portable.zip
verify_checksum_manifest SHA256SUMS-linux.txt GenericAgent-Desktop-Linux-Portable.tar.gz
verify_checksum_manifest GenericAgent-Desktop-macOS-aarch64.dmg.sha256 GenericAgent-Desktop-macOS-aarch64.dmg
[[ "$TAG_NAME" =~ ^desktop-portable-[A-Za-z0-9._-]+$ ]] \
|| { echo "Invalid release tag: $TAG_NAME" >&2; exit 1; }
[[ "$TARGET_SHA" =~ ^[0-9a-f]{40}$ ]] \
|| { echo "Invalid release target SHA" >&2; exit 1; }
TITLE="GenericAgent Desktop ${TAG_NAME}"
NOTES_FILE="${RUNNER_TEMP}/ga-release-notes.md"
cat > "$NOTES_FILE" <<'EOF'
GenericAgent Desktop 桌面版 / Desktop
三个平台产物来自同一提交,并在公开发布前统一验证。All three platform artifacts come from one commit and are validated before publication.
## 安装方法 / Installation
- Windows x64:下载 `GenericAgent-Desktop-Windows-Portable.zip`,解压后双击 `GenericAgent.exe`。卸载时运行 `uninstall.bat`,再删除解压目录。
Windows x64: extract `GenericAgent-Desktop-Windows-Portable.zip`, then launch `GenericAgent.exe`. Run `uninstall.bat` before deleting the extracted directory.
- Linux x86_64:下载并解压 `GenericAgent-Desktop-Linux-Portable.tar.gz`,执行 `chmod +x GenericAgent.AppImage` 后运行它。卸载时运行 `uninstall.sh`,再删除解压目录。
Linux x86_64: extract `GenericAgent-Desktop-Linux-Portable.tar.gz`, run `chmod +x GenericAgent.AppImage`, then launch it. Run `uninstall.sh` before deleting the extracted directory.
- macOS Apple silicon (arm64):打开 `GenericAgent-Desktop-macOS-aarch64.dmg`,把 `GenericAgent.app` 拖入 Applications。
macOS Apple silicon (arm64): open `GenericAgent-Desktop-macOS-aarch64.dmg` and drag `GenericAgent.app` to Applications.
## 签名状态 / Signing status
Windows 便携版尚未使用代码签名证书,SmartScreen 可能显示警告;运行前请核对 `SHA256SUMS-windows.txt`。
The Windows portable build is not code-signed, so SmartScreen may warn; verify `SHA256SUMS-windows.txt` before running it.
macOS 应用仅使用 ad-hoc 签名,未使用 Apple Developer ID,也未 notarize。首次打开可能需要右键选择“打开”或在“隐私与安全性”中允许。
The macOS app is ad-hoc signed only. It is neither Developer ID signed nor notarized; first launch may require Open from the context menu or approval in Privacy & Security.
SHA-256 清单与各产物一同发布。SHA-256 manifests are included with the artifacts.
EOF
sed -i 's/^ //' "$NOTES_FILE"
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
echo "A release already exists for $TAG_NAME; refusing to overwrite it" >&2
exit 1
fi
ASSETS=()
for filename in "${EXPECTED[@]}"; do
ASSETS+=("$ASSET_DIR/$filename")
done
# Keep the release invisible while all assets upload; only the final edit publishes it.
gh release create "$TAG_NAME" "${ASSETS[@]}" \
--target "$TARGET_SHA" \
--title "$TITLE" \
--notes-file "$NOTES_FILE" \
--draft \
--prerelease
mapfile -t REMOTE_ASSETS < <(gh release view "$TAG_NAME" --json assets --jq '.assets[].name' | LC_ALL=C sort)
diff -u <(printf '%s\n' "${EXPECTED[@]}") <(printf '%s\n' "${REMOTE_ASSETS[@]}")
test "$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft')" = true
test "$(gh release view "$TAG_NAME" --json isPrerelease --jq '.isPrerelease')" = true
gh release edit "$TAG_NAME" --draft=false --prerelease
test "$(gh release view "$TAG_NAME" --json isDraft --jq '.isDraft')" = false
test "$(gh release view "$TAG_NAME" --json isPrerelease --jq '.isPrerelease')" = true