fix: add macOS portable runtime packaging

This commit is contained in:
apple committed 2026-06-18 18:33:03 +08:00
1 parent 549c8b2d0e
commit f399e62d0c
3 files changed
+399 -6

No files matched your search

@@ -0,0 +1,192 @@
name: Build macOS Desktop Portable Package
"on":
push:
branches:
- test
paths:
- ".github/workflows/macos-desktop-release-package.yml"
- "desktop_release/scripts/macos/install_macos.sh"
- "frontends/desktop/**"
workflow_dispatch:
inputs:
upload_release:
description: "Upload package to GitHub prerelease"
required: false
default: "true"
type: choice
options:
- "true"
- "false"
permissions:
contents: write
jobs:
build-macos:
name: Build macOS portable runtime package
runs-on: macos-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: frontends/desktop/package-lock.json
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Install frontend dependencies
working-directory: frontends/desktop
run: npm ci
- name: Build Tauri app
working-directory: frontends/desktop
run: npm run tauri build -- --bundles app
- name: Assemble portable package
shell: bash
run: |
set -euo pipefail
PKG_ROOT="artifacts/macos/GenericAgent-Desktop-macOS"
RUNTIME="$PKG_ROOT/runtime"
mkdir -p "$PKG_ROOT" "$RUNTIME"
APP_SRC="$(find frontends/desktop/src-tauri/target/release/bundle/macos -maxdepth 1 -name '*.app' -type d | head -n 1)"
test -n "$APP_SRC"
cp -R "$APP_SRC" "$PKG_ROOT/GenericAgent.app"
cp desktop_release/scripts/macos/install_macos.sh "$RUNTIME/install_macos.sh"
chmod +x "$RUNTIME/install_macos.sh"
# Embedded Python (python-build-standalone, macOS universal2 preferred, 3.12 install_only).
PBS_URL="$(python3 - <<'PY'
import json, urllib.request
api='https://api.github.com/repos/astral-sh/python-build-standalone/releases/latest'
data=json.load(urllib.request.urlopen(api, timeout=60))
assets=data.get('assets', [])
patterns=[
('aarch64-apple-darwin', 'install_only.tar.gz'),
('x86_64-apple-darwin', 'install_only.tar.gz'),
]
for arch, suffix in patterns:
for a in assets:
name=a.get('name','')
if 'cpython-3.12' in name and arch in name and name.endswith(suffix) and 'stripped' not in name:
print(a['browser_download_url'])
raise SystemExit
raise SystemExit('No suitable python-build-standalone macOS asset found')
PY
)"
echo "Using python-build-standalone: $PBS_URL"
curl -L --fail --retry 3 -o /tmp/pbs-macos.tar.gz "$PBS_URL"
mkdir -p "$RUNTIME/python"
tar -xzf /tmp/pbs-macos.tar.gz -C "$RUNTIME"
# python-build-standalone install_only archives contain python/ at archive root.
test -x "$RUNTIME/python/bin/python3"
PY="$RUNTIME/python/bin/python3"
"$PY" --version
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
mkdir -p "$RUNTIME/app"
tar \
--exclude='.git' \
--exclude='artifacts' \
--exclude='frontends/desktop/node_modules' \
--exclude='frontends/desktop/src-tauri/target' \
--exclude='**/__pycache__' \
--exclude='**/.venv' \
-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"
cat > "$PKG_ROOT/README_MACOS.md" <<'EOF'
# GenericAgent Desktop macOS Portable
这是自包含 macOS portable 包:`GenericAgent.app`、内置 Python、离线 wheels、运行源码都在同一个目录中。
## 使用
1. 解压后保持目录结构不变。
2. 双击 `GenericAgent.app` 启动。
3. 如果 macOS Gatekeeper 提示未知开发者,请右键 `GenericAgent.app` -> 打开,或在系统设置中允许打开。
## 目录结构
```text
GenericAgent-Desktop-macOS/
├── GenericAgent.app
├── README_MACOS.md
├── SHA256SUMS.txt
└── runtime/
├── python/ 内置 Python
├── wheels/ 离线依赖
├── install_macos.sh 首次准备脚本
└── app/ 运行所需源码(含 desktop_bridge.py)
```
`runtime/` 是内置运行环境与源码,正常使用无需改动。
EOF
sed -i '' 's/^ //' "$PKG_ROOT/README_MACOS.md"
(
cd "$PKG_ROOT"
shasum -a 256 README_MACOS.md > SHA256SUMS.txt
find GenericAgent.app runtime -type f -print0 | sort -z | xargs -0 shasum -a 256 >> SHA256SUMS.txt
)
mkdir -p artifacts/macos/out
(cd artifacts/macos && zip -qry out/GenericAgent-Desktop-macOS.zip GenericAgent-Desktop-macOS)
cp "$PKG_ROOT/SHA256SUMS.txt" artifacts/macos/out/SHA256SUMS-macOS.txt
echo "Package tree (top levels):"
find "$PKG_ROOT" -maxdepth 2 -print | sort | head -80
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
with:
name: GenericAgent-Desktop-macOS-Portable-${{ github.run_number }}
path: artifacts/macos/out/*
if-no-files-found: error
- name: Publish prerelease
if: ${{ github.event_name == 'push' || inputs.upload_release == 'true' }}
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
TAG_NAME="desktop-test-macos-latest"
TITLE="GenericAgent Desktop macOS Portable (test latest)"
NOTES=$(cat <<'EOF'
macOS self-contained desktop portable package built from the test branch.
Includes:
- GenericAgent.app
- runtime/python
- runtime/wheels
- runtime/app
- README_MACOS.md and SHA256SUMS-macOS.txt
EOF
)
if gh release view "$TAG_NAME" >/dev/null 2>&1; then
gh release upload "$TAG_NAME" artifacts/macos/out/* --clobber
else
gh release create "$TAG_NAME" artifacts/macos/out/* \
--target "${{ github.sha }}" \
--title "$TITLE" \
--notes "$NOTES" \
--prerelease
fi
+163 -3
View File
@@ -1,6 +1,166 @@
#!/usr/bin/env bash
set -euo pipefail
echo "TODO: implement macOS environment setup script."
echo "Expected future usage: ./install_macos.sh --project-dir /path/to/GenericAgent_Desktop --mode prepare-only"
exit 2
# GenericAgent Desktop macOS portable installer/preparer.
# Intended bundle layout:
# GenericAgent-Desktop-macOS/
# GenericAgent.app
# runtime/
# python/ wheels/ install_macos.sh app/
#
# Usage:
# ./install_macos.sh --python-path /path/to/python3 --project-dir /path/to/runtime/app --wheel-dir /path/to/wheels --mode PrepareOnly
# ./install_macos.sh --mode BridgeOnly
PROJECT_DIR=""
PYTHON_PATH=""
MODE="PrepareOnly"
NO_VENV=0
SKIP_PIP_INSTALL=0
WHEEL_DIR=""
EXTRA_PACKAGES=""
APP_PATH=""
log_step() { printf '\n==> %s\n' "$*" >&2; }
log_ok() { printf '[OK] %s\n' "$*" >&2; }
log_warn() { printf '[WARN] %s\n' "$*" >&2; }
fail() { printf '[ERROR] %s\n' "$*" >&2; exit 1; }
progress() { printf 'GAPROGRESS|%s\n' "$1"; }
usage() { sed -n '1,18p' "$0"; }
while [[ $# -gt 0 ]]; do
case "$1" in
--project-dir|-p) [[ $# -ge 2 ]] || fail "Missing value for $1"; PROJECT_DIR="$2"; shift 2 ;;
--python-path) [[ $# -ge 2 ]] || fail "Missing value for $1"; PYTHON_PATH="$2"; shift 2 ;;
--mode|-m) [[ $# -ge 2 ]] || fail "Missing value for $1"; MODE="$2"; shift 2 ;;
--app|-a) [[ $# -ge 2 ]] || fail "Missing value for $1"; APP_PATH="$2"; shift 2 ;;
--no-venv) 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 ;;
*) fail "Unknown argument: $1" ;;
esac
done
case "$MODE" in
PrepareOnly|Launch|BridgeOnly) ;;
*) fail "Unsupported --mode '$MODE'. Expected PrepareOnly, Launch, or BridgeOnly." ;;
esac
resolve_script_root() {
local src="${BASH_SOURCE[0]}"
while [[ -L "$src" ]]; do
local dir
dir="$(cd -P "$(dirname "$src")" && pwd)"
src="$(readlink "$src")"
[[ "$src" != /* ]] && src="$dir/$src"
done
cd -P "$(dirname "$src")" && pwd
}
abs_path() {
"$PYTHON_FOR_ABS" - "$1" <<'PY'
import os, sys
print(os.path.abspath(os.path.expanduser(sys.argv[1])))
PY
}
SCRIPT_ROOT="$(resolve_script_root)"
PYTHON_FOR_ABS="${PYTHON_PATH:-python3}"
if [[ -z "$PROJECT_DIR" ]]; then
for c in "$SCRIPT_ROOT/app" "$SCRIPT_ROOT/../runtime/app" "$SCRIPT_ROOT/.." "$PWD"; do
if [[ -f "$c/agentmain.py" ]]; then PROJECT_DIR="$c"; break; fi
done
fi
[[ -n "$PROJECT_DIR" ]] || fail "Cannot resolve project dir; pass --project-dir"
PROJECT_DIR="$(abs_path "$PROJECT_DIR")"
[[ -f "$PROJECT_DIR/agentmain.py" ]] || fail "Project dir does not contain agentmain.py: $PROJECT_DIR"
if [[ -z "$PYTHON_PATH" ]]; then
if [[ -x "$SCRIPT_ROOT/python/bin/python3" ]]; then PYTHON_PATH="$SCRIPT_ROOT/python/bin/python3"; else PYTHON_PATH="python3"; fi
fi
PYTHON_PATH="$(abs_path "$PYTHON_PATH")"
"$PYTHON_PATH" --version >&2 || fail "Python not runnable: $PYTHON_PATH"
if [[ -z "$WHEEL_DIR" && -d "$SCRIPT_ROOT/wheels" ]]; then WHEEL_DIR="$SCRIPT_ROOT/wheels"; fi
if [[ -n "$WHEEL_DIR" ]]; then WHEEL_DIR="$(abs_path "$WHEEL_DIR")"; fi
venv_python() {
printf '%s\n' "$PROJECT_DIR/.venv/bin/python"
}
ensure_venv() {
if [[ "$NO_VENV" == "1" ]]; then printf '%s\n' "$PYTHON_PATH"; return; fi
local vpy
vpy="$(venv_python)"
if [[ ! -x "$vpy" ]]; then
progress venv
log_step "Create venv: $PROJECT_DIR/.venv"
"$PYTHON_PATH" -m venv "$PROJECT_DIR/.venv"
fi
printf '%s\n' "$vpy"
}
install_deps() {
local py="$1"
[[ "$SKIP_PIP_INSTALL" == "1" ]] && return
progress deps
log_step "Install desktop bridge dependencies"
"$py" -m pip install --upgrade pip setuptools wheel
local pkgs=(
"requests>=2.28" "beautifulsoup4>=4.12" "bottle>=0.12" "simple-websocket-server>=0.4" "aiohttp>=3.9" psutil
)
if [[ -n "$EXTRA_PACKAGES" ]]; then
# shellcheck disable=SC2206
pkgs+=( $EXTRA_PACKAGES )
fi
if [[ -n "$WHEEL_DIR" && -d "$WHEEL_DIR" ]]; then
"$py" -m pip install --no-index --find-links "$WHEEL_DIR" "${pkgs[@]}"
else
log_warn "No wheel dir supplied; falling back to online pip install"
"$py" -m pip install "${pkgs[@]}"
fi
}
write_settings() {
local py="$1"
local settings_path="$HOME/.ga_desktop_settings.json"
"$py" - "$settings_path" "$py" "$PROJECT_DIR" <<'PY'
import json, pathlib, sys
settings_path, python_path, project_dir = sys.argv[1:4]
pathlib.Path(settings_path).write_text(json.dumps({"python_path": python_path, "project_dir": project_dir}, indent=2), encoding="utf-8")
PY
log_ok "Wrote desktop settings: $settings_path"
}
start_bridge() {
local py="$1"
local bridge="$PROJECT_DIR/frontends/desktop_bridge.py"
[[ -f "$bridge" ]] || fail "desktop_bridge.py not found: $bridge"
export PYTHONPATH="$PROJECT_DIR:$PROJECT_DIR/frontends:${PYTHONPATH:-}"
exec "$py" "$bridge"
}
launch_app() {
if [[ -n "$APP_PATH" ]]; then
open "$APP_PATH"
elif [[ -d "$SCRIPT_ROOT/../GenericAgent.app" ]]; then
open "$SCRIPT_ROOT/../GenericAgent.app"
else
log_warn "GenericAgent.app not found next to runtime; skip launch"
fi
}
RUNTIME_PY="$(ensure_venv)"
install_deps "$RUNTIME_PY"
write_settings "$RUNTIME_PY"
progress done
case "$MODE" in
PrepareOnly) log_ok "Prepare complete: $PROJECT_DIR" ;;
BridgeOnly) start_bridge "$RUNTIME_PY" ;;
Launch) launch_app ;;
esac
+44 -3
View File
@@ -33,6 +33,7 @@ fn find_bridge_script() -> PathBuf {
/// 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).
/// macOS portable package: the folder containing GenericAgent.app and runtime/.
fn bundle_anchor_dir() -> Option<PathBuf> {
#[cfg(not(windows))]
{
@@ -42,7 +43,27 @@ fn bundle_anchor_dir() -> Option<PathBuf> {
}
}
}
Some(std::env::current_exe().ok()?.parent()?.to_path_buf())
let exe = std::env::current_exe().ok()?;
#[cfg(target_os = "macos")]
{
// current_exe() inside a bundle is:
// <package>/GenericAgent.app/Contents/MacOS/GenericAgent
// The portable runtime sits next to the .app:
// <package>/runtime/app/agentmain.py
let mut d = exe.parent();
while let Some(dir) = d {
if dir.extension().and_then(|s| s.to_str()) == Some("app") {
if let Some(parent) = dir.parent() {
return Some(parent.to_path_buf());
}
}
d = dir.parent();
}
}
Some(exe.parent()?.to_path_buf())
}
/// Embedded interpreter inside the bundle's runtime/python (base python, before venv).
@@ -134,10 +155,25 @@ fn settings_path() -> PathBuf {
.join(".ga_desktop_settings.json")
}
/// Read config from settings file, or auto-discover and save
/// Read config from settings file, or auto-discover and save.
/// Self-contained bundles always prefer their own runtime/app over stale user settings,
/// otherwise an old ~/.ga_desktop_settings.json can silently point the UI at a different checkout.
pub fn get_or_discover_config() -> (String, String) {
let path = settings_path();
if bundle_root().is_some() {
let python = find_python();
let project = find_project_dir().unwrap_or_default();
if !python.is_empty() && !project.is_empty() {
let json = serde_json::json!({
"python_path": python,
"project_dir": project
});
let _ = std::fs::write(&path, serde_json::to_string_pretty(&json).unwrap());
return (python, project);
}
}
// Try reading existing settings
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
@@ -211,7 +247,12 @@ fn run_offline_prepare(project_dir: &str, report: &dyn Fn(i32, &str)) -> Result<
root.join("install_windows.ps1"),
root.join("python").join("python.exe"),
);
#[cfg(not(windows))]
#[cfg(target_os = "macos")]
let (script, py) = (
root.join("install_macos.sh"),
root.join("python").join("bin").join("python3"),
);
#[cfg(all(not(windows), not(target_os = "macos")))]
let (script, py) = (
root.join("install_linux.sh"),
root.join("python").join("bin").join("python3"),