fix: add macOS portable runtime packaging
This commit is contained in:
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
|
||||
Regular → Executable
+163
-3
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user