From 8112c2621b003a19b75c8156d2f486d79d5dbd8d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 10:35:21 +0000 Subject: [PATCH 01/18] feat: add multi-platform green portable packaging Ship relocatable Octop-.zip via scripts/green (portable CPython, frozen uv.lock deps, Windows pywin32 DLL fix) and octop-portable.yml. No OOB overlay or product trimming; first run uses upstream setup. Co-authored-by: Harry --- .cursor/skills/octop-electron-green/SKILL.md | 15 + .github/workflows/octop-portable.yml | 214 ++++++++++++++ .gitignore | 3 + CHANGELOG.md | 4 + scripts/green/AGENT_ELECTRON_INTEGRATION.md | 68 +++++ scripts/green/Makefile | 50 ++++ scripts/green/README.md | 116 ++++++++ scripts/green/_common.sh | 286 +++++++++++++++++++ scripts/green/bootstrap-runtime.sh | 89 ++++++ scripts/green/package-linux-docker.sh | 49 ++++ scripts/green/package.sh | 274 ++++++++++++++++++ scripts/green/rebuild.sh | 18 ++ scripts/green/templates/README.txt | 37 +++ scripts/green/templates/launch.py | 61 ++++ scripts/green/templates/start.bat | 88 ++++++ scripts/green/templates/start.sh | 79 +++++ scripts/green/vendor-wheels.sh | 59 ++++ scripts/green/verify_imports.py | 140 +++++++++ tests/unit/test_green_launch.py | 56 ++++ 19 files changed, 1706 insertions(+) create mode 100644 .cursor/skills/octop-electron-green/SKILL.md create mode 100644 .github/workflows/octop-portable.yml create mode 100644 scripts/green/AGENT_ELECTRON_INTEGRATION.md create mode 100644 scripts/green/Makefile create mode 100644 scripts/green/README.md create mode 100644 scripts/green/_common.sh create mode 100755 scripts/green/bootstrap-runtime.sh create mode 100755 scripts/green/package-linux-docker.sh create mode 100755 scripts/green/package.sh create mode 100755 scripts/green/rebuild.sh create mode 100644 scripts/green/templates/README.txt create mode 100644 scripts/green/templates/launch.py create mode 100644 scripts/green/templates/start.bat create mode 100755 scripts/green/templates/start.sh create mode 100755 scripts/green/vendor-wheels.sh create mode 100755 scripts/green/verify_imports.py create mode 100644 tests/unit/test_green_launch.py diff --git a/.cursor/skills/octop-electron-green/SKILL.md b/.cursor/skills/octop-electron-green/SKILL.md new file mode 100644 index 00000000..f8117266 --- /dev/null +++ b/.cursor/skills/octop-electron-green/SKILL.md @@ -0,0 +1,15 @@ +--- +name: octop-electron-green +description: >- + Integrate Octop green portable zip into Electron: download Octop-.zip, + extract, spawn launch.py, OCTOP_HOME, health check, quarantine, pywintypes. + Use when embedding Octop backend in Electron or another desktop shell. +--- + +# Octop Electron green package integration + +Follow the full task brief in this repository: + +**[scripts/green/AGENT_ELECTRON_INTEGRATION.md](../../../scripts/green/AGENT_ELECTRON_INTEGRATION.md)** + +When working in a **different** Electron repo, copy that file into the target project (or paste it into the agent prompt) and execute it step by step. Do not invent a parallel install path (no system Python, no `PYTHONPATH=packages`). diff --git a/.github/workflows/octop-portable.yml b/.github/workflows/octop-portable.yml new file mode 100644 index 00000000..8082b726 --- /dev/null +++ b/.github/workflows/octop-portable.yml @@ -0,0 +1,214 @@ +name: Octop portable + +on: + push: + tags: + - "*" + branches: + - "*" + workflow_dispatch: + inputs: + platforms: + description: "Comma-separated plats, or 'all'" + required: true + default: "all" + type: string + attach_release: + description: "If running on a v* tag, also upload zips to that GitHub Release" + required: false + default: true + type: boolean + +permissions: + contents: read + +concurrency: + group: green-portable-${{ github.ref }} + cancel-in-progress: true + +env: + # Prefer GitHub upstream on Actions runners (npmmirror is for CN local builds). + PBS_BASE_URL: https://github.com/astral-sh/python-build-standalone/releases/download/20251209 + PBS_TAG: "20251209" + PBS_PY: "3.12.12" + +jobs: + frontend: + name: Build dashboard + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v5 + with: + node-version: "24" + cache: npm + cache-dependency-path: dashboard/package-lock.json + + - name: Build frontend → src/octop/dashboard + run: make build-frontend + + - uses: actions/upload-artifact@v5 + with: + name: dashboard-dist + path: src/octop/dashboard/ + if-no-files-found: error + retention-days: 7 + + package: + name: "${{ matrix.plat }}" + needs: frontend + runs-on: ${{ matrix.os }} + # Matrix runners are native for each plat; pin host detection so x64 Git Bash + # on windows-11-arm does not mis-classify the job as windows-amd64 cross-build. + env: + GREEN_HOST_PLAT: ${{ matrix.plat }} + strategy: + fail-fast: false + matrix: + include: + - plat: linux-amd64 + os: ubuntu-latest + - plat: linux-arm64 + os: ubuntu-24.04-arm + - plat: darwin-arm64 + os: macos-14 + - plat: darwin-amd64 + os: macos-15-intel + - plat: windows-amd64 + os: windows-latest + - plat: windows-arm64 + os: windows-11-arm + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v5 + + - name: Decide whether to build this platform + id: want + run: | + set -euo pipefail + sel="${{ github.event.inputs.platforms || 'all' }}" + sel="$(echo "$sel" | tr '[:upper:]' '[:lower:]' | tr -d ' ')" + plat="${{ matrix.plat }}" + if [[ "$sel" == "all" || ",$sel," == *",$plat,"* ]]; then + echo "build=true" >> "$GITHUB_OUTPUT" + else + echo "build=false" >> "$GITHUB_OUTPUT" + echo "Skipping ${plat} (selection=${sel})" + fi + + - uses: actions/download-artifact@v5 + if: steps.want.outputs.build == 'true' + with: + name: dashboard-dist + path: src/octop/dashboard + + - uses: astral-sh/setup-uv@v6 + if: steps.want.outputs.build == 'true' + with: + enable-cache: true + python-version: "3.12" + + # GitHub Cache outages must not fail the build — PBS download is cheap enough. + - name: Cache python-build-standalone downloads + if: steps.want.outputs.build == 'true' + continue-on-error: true + uses: actions/cache@v5 + with: + path: green/.cache + key: pbs-${{ env.PBS_TAG }}-${{ env.PBS_PY }}-${{ matrix.plat }} + + - name: Bootstrap portable CPython + if: steps.want.outputs.build == 'true' + env: + GREEN_HOST_PLAT: ${{ matrix.plat }} + run: bash scripts/green/bootstrap-runtime.sh "${{ matrix.plat }}" + + - name: Assemble green zip + if: steps.want.outputs.build == 'true' + env: + GREEN_HOST_PLAT: ${{ matrix.plat }} + run: | + set -euo pipefail + echo "GREEN_HOST_PLAT=${GREEN_HOST_PLAT} RUNNER_ARCH=${RUNNER_ARCH:-} uname=$(uname -ms)" + bash scripts/green/package.sh "${{ matrix.plat }}" + + - name: Smoke import (native host only) + if: steps.want.outputs.build == 'true' + run: | + set -euo pipefail + staging="green/release/Octop-${{ matrix.plat }}" + if [[ -x "${staging}/runtime/bin/python3" ]]; then + py="${staging}/runtime/bin/python3" + elif [[ -f "${staging}/runtime/python.exe" ]]; then + py="${staging}/runtime/python.exe" + else + echo "python missing under ${staging}/runtime" >&2 + exit 1 + fi + req_file="green/requirements-${{ matrix.plat }}.txt" + if [[ ! -f "$req_file" ]]; then + echo "frozen requirements missing: ${req_file}" >&2 + exit 1 + fi + verify_args=( + --packages "${staging}/packages" + --requirements "$req_file" + ) + override_file="green/overrides-${{ matrix.plat }}.txt" + if [[ -f "$override_file" ]]; then + verify_args+=(--overrides "$override_file") + fi + PYTHONNOUSERSITE=1 \ + "$py" scripts/green/verify_imports.py \ + "${verify_args[@]}" + # macOS: fail if any native extension linked Homebrew/MacPorts paths. + if [[ "${{ matrix.plat }}" == darwin-* ]]; then + REPO_ROOT="$PWD" # shellcheck source=scripts/green/_common.sh + source scripts/green/_common.sh + verify_no_homebrew_dylibs "${staging}/packages" "${{ matrix.plat }}" + fi + + # archive: false — upload the prebuilt zip as-is. Default archive=true would + # wrap it again, so Actions UI / "Download artifact" becomes zip-in-zip + # (Octop-.zip containing another Octop-.zip). Affects every plat. + # With archive:false, artifact name is the filename (name: is ignored). + - uses: actions/upload-artifact@v7 + if: steps.want.outputs.build == 'true' + with: + path: green/release/Octop-${{ matrix.plat }}.zip + archive: false + if-no-files-found: error + retention-days: 14 + + release: + name: Attach zips to GitHub Release + needs: package + if: >- + startsWith(github.ref, 'refs/tags/v') && + (github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && + github.event.inputs.attach_release == 'true')) + runs-on: ubuntu-latest + permissions: + contents: write + steps: + # v8 required to download archive:false artifacts from upload-artifact@v7 + - uses: actions/download-artifact@v8 + with: + pattern: Octop-*.zip + path: green-zips + merge-multiple: true + + - name: List zips + run: ls -lh green-zips/ + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: green-zips/*.zip + fail_on_unmatched_files: true + # Upsert: works whether Release workflow already created the release. + generate_release_notes: false diff --git a/.gitignore b/.gitignore index 3a4491df..3565fe3e 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,9 @@ src/octop/dashboard/assets/* *.log logs/ +# green portable +/green + # Local monorepo dev helpers (not for upstream) scripts/dev-local-link.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f812c6a..f875f71b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ## [Unreleased] +### 新增 + +- 绿色便携包:`scripts/green/` 用 python-build-standalone + 锁定依赖打出 `Octop-.zip`(六平台 Actions),解压后经 `launch.py` 启动;不含 OOB/UI 裁剪,首启走上游 setup wizard + ## [0.9.26] - 2026-08-23 ### 新增 diff --git a/scripts/green/AGENT_ELECTRON_INTEGRATION.md b/scripts/green/AGENT_ELECTRON_INTEGRATION.md new file mode 100644 index 00000000..12196068 --- /dev/null +++ b/scripts/green/AGENT_ELECTRON_INTEGRATION.md @@ -0,0 +1,68 @@ +# Octop Electron green-package integration + +This document is the contract for embedding Octop's **green portable zip** +in an Electron (or other) desktop shell. Do not invent a parallel install +path (no system Python, no `PYTHONPATH=packages`). + +## Artifact + +CI / `make -f scripts/green/Makefile green` produces: + +``` +green/release/Octop-.zip +``` + +Platforms: `darwin-arm64` `darwin-amd64` `linux-amd64` `linux-arm64` +`windows-amd64` `windows-arm64`. + +Layout after extract (outside asar): + +``` +Octop-/ + runtime/ portable CPython + packages/ site-packages + launch.py + start.sh / start.bat +``` + +## Spawn + +1. Download the zip for the current OS/arch. Verify checksum if you ship one. +2. Extract to a writable user directory **outside** `app.asar`. +3. On macOS, after checksum: `xattr -dr com.apple.quarantine `. +4. Set `OCTOP_HOME` to a persistent data dir (not inside the zip if you + replace the zip on upgrade). +5. Set `PYTHONNOUSERSITE=1`. **Do not set `PYTHONPATH`.** +6. Spawn: + + - macOS / Linux: `/runtime/bin/python3 /launch.py run --host 127.0.0.1 --port ` + - Windows: `/runtime/python.exe /launch.py run --host 127.0.0.1 --port ` + +7. Poll `http://127.0.0.1:/api/health` until ready, then load the + Dashboard (`http://127.0.0.1:/`). +8. First run uses the **normal Octop setup wizard** (create admin). The + green zip does not skip setup or mint loopback sessions. +9. On quit, kill the process **tree** (Windows: taskkill `/T`; POSIX: process group). + +## Windows / pywintypes + +`No module named pywintypes` means: + +1. The process did not start via `launch.py` (plain `PYTHONPATH=packages` + skips `.pth` processing). +2. `packages\pywin32_system32\pywintypes*.dll` is missing; a current + package copies those DLLs into `runtime\`. +3. Workaround for an old zip only: `pip install --target packages pywin32` + then copy DLLs next to `python.exe`. Do not use `%CD%` in PowerShell. + +## Health + +`GET /api/health` is the ready signal. Do not scrape stdout for “ready”. + +## What not to do + +- Do not bundle the zip inside asar (native libs and the interpreter must + be real files). +- Do not use the system Python or `uv run`. +- Do not enable desktop OOB / auto-login overlays; this package tracks + upstream auth and setup. diff --git a/scripts/green/Makefile b/scripts/green/Makefile new file mode 100644 index 00000000..7f7477d8 --- /dev/null +++ b/scripts/green/Makefile @@ -0,0 +1,50 @@ +# Standalone Makefile for green portable packaging. +# Does not modify the repo-root Makefile — merge-friendly with upstream. +# +# Usage (from repo root): +# make -f scripts/green/Makefile green +# make -f scripts/green/Makefile green GREEN_PLAT=linux-amd64 +# make -f scripts/green/Makefile green-linux +# make -f scripts/green/Makefile clean + +SHELL := /bin/bash +REPO_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../..) +GREEN_PLAT ?= + +.DEFAULT_GOAL := help + +.PHONY: help +help: + @echo "Green portable (offline zip)" + @echo " make -f scripts/green/Makefile green # host plat zip" + @echo " make -f scripts/green/Makefile green GREEN_PLAT=…" + @echo " make -f scripts/green/Makefile bootstrap|wheels|package|green-linux|clean" + +.PHONY: bootstrap +bootstrap: + bash $(REPO_ROOT)/scripts/green/bootstrap-runtime.sh $(GREEN_PLAT) + +.PHONY: wheels +wheels: + bash $(REPO_ROOT)/scripts/green/vendor-wheels.sh $(GREEN_PLAT) + +.PHONY: package +package: + bash $(REPO_ROOT)/scripts/green/package.sh $(GREEN_PLAT) + +.PHONY: green +green: + $(MAKE) -C $(REPO_ROOT) build-frontend + bash $(REPO_ROOT)/scripts/green/bootstrap-runtime.sh $(GREEN_PLAT) + bash $(REPO_ROOT)/scripts/green/package.sh $(GREEN_PLAT) + @echo "[green] Done. Zips under $(REPO_ROOT)/green/release/" + +.PHONY: green-linux +green-linux: + $(MAKE) -C $(REPO_ROOT) build-frontend + bash $(REPO_ROOT)/scripts/green/package-linux-docker.sh $(if $(GREEN_PLAT),$(GREEN_PLAT),linux-amd64) + +.PHONY: clean +clean: + rm -rf $(REPO_ROOT)/green + @echo "[green] removed $(REPO_ROOT)/green" diff --git a/scripts/green/README.md b/scripts/green/README.md new file mode 100644 index 00000000..af9053c1 --- /dev/null +++ b/scripts/green/README.md @@ -0,0 +1,116 @@ +# Octop 绿色便携包(多平台) + +解压即用:内置便携 CPython + Octop 及依赖,通过 `start.sh` / `start.bat` 启动。 +**不依赖**系统 Python,也**不包含** Wails / 桌面壳——用浏览器打开 Dashboard。 +首启走上游正常 setup wizard(本目录**不含** OOB / UI 裁剪)。 + +## 与上游解耦 + +本能力全部落在: + +- `scripts/green/**`(脚本 / 模板 / 本目录 Makefile) +- `.github/workflows/octop-portable.yml`(多平台 CI) +- 根 `.gitignore` 一行 `/green`(忽略构建产物) +- `tests/unit/test_green_launch.py`(launch.py PATH / addsitedir) + +**不修改** `src/`、`dashboard/`、`pyproject.toml`、`uv.lock`、根 `Makefile`。 +合并上游时只需留意上述路径;日常用: + +```bash +make -f scripts/green/Makefile green +``` + +依赖版本必须以仓库根目录 `uv.lock` 为准:`package.sh` 使用 +`uv export --frozen`,出包后跑 `scripts/green/verify_imports.py` +校验关键包 pin 与 import(含 `langchain-openai` / `langchain-core` 配对), +避免「同安装、不同环境」因版本错配或原生扩展加载失败而偶发报错。 +平台 overrides(`darwin-amd64` / `windows-arm64` 的 `cryptography==46.x`) +会一并传入校验,避免与 lock 中的 49.x 误报不一致。 + +## 产物布局 + +``` +Octop-/ + runtime/ # python-build-standalone + packages/ # Octop + 依赖(site-packages,可搬迁) + launch.py # 启动引导(site.addsitedir / Windows pywin32) + start.sh # macOS / Linux + start.bat # Windows + README.txt + data/ # 首次运行自动创建(用户数据) +``` + +支持平台:`darwin-arm64` `darwin-amd64` `linux-amd64` `linux-arm64` `windows-amd64` `windows-arm64`。 + +## 构建(在仓库根目录) + +需已安装:`uv`、`curl`、`zip`(可选)、Node(编前端)。 + +```bash +# 一键:当前主机平台(前端 + 便携 CPython + zip) +make -f scripts/green/Makefile green + +# 或分步: +make build-frontend # 上游已有目标 +bash scripts/green/bootstrap-runtime.sh +bash scripts/green/package.sh +``` + +本地一键重建(nvm 24): + +```bash +bash scripts/green/rebuild.sh +``` + +交叉组装其它平台时,**带 C 扩展的包**必须在目标 ABI 上构建: + +| 目标 | 推荐方式 | +|------|----------| +| 当前主机 | `make -f scripts/green/Makefile green` | +| Linux(从 macOS/Windows) | `make -f scripts/green/Makefile green-linux` | +| Windows | 在 Windows / CI 上执行同上 `green` | + +### 离线包 + +```bash +bash scripts/green/vendor-wheels.sh # 按当前 uv.lock 预取 wheel +OCTOP_GREEN_OFFLINE=1 bash scripts/green/package.sh +``` + +离线缓存必须来自**当前分支**的 `uv.lock`,不要复用旧分叉的 wheel 目录。 + +### macOS Intel(`darwin-amd64`)注意 + +锁定的 `cryptography` 49.x **不再发布** macOS x86_64 / universal2 wheel。若允许从 sdist 编译,会链到构建机 Homebrew 的 `/usr/local/opt/openssl@3`,用户机缺库即启动失败。 + +绿包脚本已做: + +1. `darwin-amd64` 覆盖钉死 `cryptography==46.0.3`(仍有 `macosx_*_universal2` wheel) +2. 全平台 `--only-binary cryptography`,禁止源码编译 +3. 打包后 `otool` 检查,拒绝 Homebrew/MacPorts 绝对路径 +4. smoke import 覆盖 `cryptography.fernet` + +### Windows / pywin32 + +`mcp` / `docker` 在 win32 上传递依赖 `pywin32`。打包脚本会: + +1. 若 `packages/pywin32_system32` 缺失则显式 `uv pip install pywin32` +2. 把 `pywintypes*.dll` / `pythoncom*.dll` 拷到 `runtime/` +3. `launch.py` 用 `site.addsitedir` 处理 `.pth`,并 `os.add_dll_directory` + +**不要**设置 `PYTHONPATH=packages`(会跳过 `.pth`,导致 `No module named pywintypes`)。 + +`windows-arm64` 另排除无 wheel 的 `psycopg-binary` / `sqlite-vec`,并把 `cryptography` 钉到 `46.0.0`(仅该版本提供 `win_arm64` wheel)。 + +## CI + +[`.github/workflows/octop-portable.yml`](../../.github/workflows/octop-portable.yml) 在 6 个 runner 上出 zip: + +`linux-amd64` `linux-arm64` `darwin-arm64` `darwin-amd64` `windows-amd64` `windows-arm64` + +Actions 使用 GitHub 上游 PBS(`PBS_BASE_URL`),本机构建默认 npmmirror。产物以 `archive: false` 上传,避免 zip-in-zip。 + +## Electron 壳 + +壳只消费 `Octop-.zip`,不要把绿包编进 asar。步骤见 +[`AGENT_ELECTRON_INTEGRATION.md`](AGENT_ELECTRON_INTEGRATION.md)。 diff --git a/scripts/green/_common.sh b/scripts/green/_common.sh new file mode 100644 index 00000000..35187db0 --- /dev/null +++ b/scripts/green/_common.sh @@ -0,0 +1,286 @@ +# Shared helpers for green portable packaging scripts. +# shellcheck shell=bash +# Sourced by bootstrap-runtime.sh / vendor-wheels.sh / package.sh + +GREEN_ROOT="${GREEN_ROOT:-${REPO_ROOT}/green}" +GREEN_RUNTIMES="${GREEN_ROOT}/runtimes" +GREEN_WHEELS="${GREEN_ROOT}/wheels" +GREEN_CACHE="${GREEN_ROOT}/.cache" +GREEN_RELEASE="${GREEN_ROOT}/release" + +# Pin a known-good python-build-standalone release (override with PBS_TAG / PBS_PY). +PBS_TAG="${PBS_TAG:-20251209}" +PBS_PY="${PBS_PY:-3.12.12}" +# Prefer npmmirror mirror; override with PBS_BASE_URL for GitHub upstream. +PBS_BASE_URL="${PBS_BASE_URL:-https://registry.npmmirror.com/-/binary/python-build-standalone/${PBS_TAG}}" + +ALL_PLATS=( + darwin-arm64 + darwin-amd64 + linux-amd64 + linux-arm64 + windows-amd64 + windows-arm64 +) + +# Map GitHub Actions RUNNER_ARCH (X64/ARM64/...) → green arch suffix. +runner_arch_to_green() { + case "$(printf '%s' "${1:-}" | tr '[:lower:]' '[:upper:]')" in + ARM64|ARM) echo arm64 ;; + X64|AMD64) echo amd64 ;; + *) return 1 ;; + esac +} + +# Probe python machine() — setup-uv on windows-11-arm provides arm64 CPython +# even when Git Bash itself is an x64 process. +python_machine_arch() { + local py m + for py in python python3; do + if command -v "$py" >/dev/null 2>&1; then + m="$("$py" -c 'import platform; print(platform.machine())' 2>/dev/null | tr -d '\r\n')" + case "$(printf '%s' "$m" | tr '[:lower:]' '[:upper:]')" in + ARM64|AARCH64) echo arm64; return 0 ;; + AMD64|X86_64|X64) echo amd64; return 0 ;; + esac + fi + done + return 1 +} + +host_plat() { + # CI / operators can pin this when shell arch detection is wrong + # (common on windows-11-arm + x64 Git Bash). + if [[ -n "${GREEN_HOST_PLAT:-}" ]]; then + if ! is_known_plat "$GREEN_HOST_PLAT"; then + echo "GREEN_HOST_PLAT=${GREEN_HOST_PLAT} is not a known plat" >&2 + return 1 + fi + echo "$GREEN_HOST_PLAT" + return 0 + fi + + local os arch + case "$(uname -s)" in + Darwin) os=darwin ;; + Linux) os=linux ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) os=windows ;; + *) + echo "unsupported host OS: $(uname -s)" >&2 + return 1 + ;; + esac + + # GitHub Actions: RUNNER_ARCH is the VM architecture (not the shell binary). + if arch="$(runner_arch_to_green "${RUNNER_ARCH:-}")"; then + echo "${os}-${arch}" + return 0 + fi + + # Windows: detect OS / interpreter arch, not the current process arch. + # Git Bash / MSYS on windows-11-arm often is an x64 binary, so both + # `uname -m` and PROCESSOR_ARCHITECTURE can look like amd64 while the + # machine is ARM64. That falsely enables cross-assemble + --only-binary + # and breaks sdist-only pure-Python deps (e.g. aliyun-python-sdk-core). + if [[ "$os" == windows ]]; then + if arch="$(python_machine_arch)"; then + echo "${os}-${arch}" + return 0 + fi + + local os_arch="" pa="${PROCESSOR_ARCHITECTURE:-}" pa32="${PROCESSOR_ARCHITEW6432:-}" + if command -v powershell.exe >/dev/null 2>&1; then + os_arch="$( + powershell.exe -NoProfile -Command \ + '[System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()' \ + 2>/dev/null | tr -d '\r\n' + )" + fi + # Fallback: CIM OS architecture (more reliable on some runner images). + if [[ -z "$os_arch" ]] && command -v powershell.exe >/dev/null 2>&1; then + os_arch="$( + powershell.exe -NoProfile -Command \ + '(Get-CimInstance -ClassName Win32_OperatingSystem).OSArchitecture' \ + 2>/dev/null | tr -d '\r\n' + )" + fi + case "$(printf '%s' "$os_arch" | tr '[:lower:]' '[:upper:]')" in + ARM64|*ARM64*|ARM\ 64*) arch=arm64 ;; + X64|AMD64|*X64*) arch=amd64 ;; + *) + case "$(printf '%s' "${pa32:-$pa}" | tr '[:lower:]' '[:upper:]')" in + ARM64) arch=arm64 ;; + AMD64|X86_64) arch=amd64 ;; + *) + case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=amd64 ;; + *) + echo "unsupported arch: OSArchitecture=${os_arch} uname=$(uname -m) PROCESSOR_ARCHITECTURE=${pa} PROCESSOR_ARCHITEW6432=${pa32} RUNNER_ARCH=${RUNNER_ARCH:-}" >&2 + return 1 + ;; + esac + ;; + esac + ;; + esac + else + case "$(uname -m)" in + arm64|aarch64) arch=arm64 ;; + x86_64|amd64) arch=amd64 ;; + *) + echo "unsupported arch: $(uname -m)" >&2 + return 1 + ;; + esac + fi + echo "${os}-${arch}" +} + +# python-build-standalone triple +plat_triple() { + case "$1" in + darwin-arm64) echo "aarch64-apple-darwin" ;; + darwin-amd64) echo "x86_64-apple-darwin" ;; + linux-amd64) echo "x86_64-unknown-linux-gnu" ;; + linux-arm64) echo "aarch64-unknown-linux-gnu" ;; + windows-amd64) echo "x86_64-pc-windows-msvc" ;; + windows-arm64) echo "aarch64-pc-windows-msvc" ;; + *) return 1 ;; + esac +} + +# uv --python-platform values +uv_platform() { + case "$1" in + darwin-arm64) echo "aarch64-apple-darwin" ;; + darwin-amd64) echo "x86_64-apple-darwin" ;; + linux-amd64) echo "x86_64-unknown-linux-gnu" ;; + linux-arm64) echo "aarch64-unknown-linux-gnu" ;; + windows-amd64) echo "x86_64-pc-windows-msvc" ;; + windows-arm64) echo "aarch64-pc-windows-msvc" ;; + *) return 1 ;; + esac +} + +# pip --platform tags (for optional offline wheel prefetch) +pip_platform() { + case "$1" in + darwin-arm64) echo "macosx_11_0_arm64" ;; + darwin-amd64) echo "macosx_11_0_x86_64" ;; + linux-amd64) echo "manylinux2014_x86_64" ;; + linux-arm64) echo "manylinux2014_aarch64" ;; + windows-amd64) echo "win_amd64" ;; + windows-arm64) echo "win_arm64" ;; + *) return 1 ;; + esac +} + +is_known_plat() { + local p + for p in "${ALL_PLATS[@]}"; do + [[ "$p" == "$1" ]] && return 0 + done + return 1 +} + +runtime_python() { + # Print path to python inside a runtime root (may not exist yet). + local runtime_dir="$1" + if [[ -x "${runtime_dir}/bin/python3" ]]; then + echo "${runtime_dir}/bin/python3" + elif [[ -f "${runtime_dir}/python.exe" ]]; then + echo "${runtime_dir}/python.exe" + elif [[ -x "${runtime_dir}/install/bin/python3" ]]; then + echo "${runtime_dir}/install/bin/python3" + else + echo "${runtime_dir}/bin/python3" + fi +} + +require_uv() { + if ! command -v uv >/dev/null 2>&1; then + echo "uv is required (https://docs.astral.sh/uv/)" >&2 + exit 1 + fi +} + +pbs_archive_name() { + local plat="$1" + local triple + triple="$(plat_triple "$plat")" || return 1 + echo "cpython-${PBS_PY}+${PBS_TAG}-${triple}-install_only_stripped.tar.gz" +} + +# Write platform-specific uv/pip overrides (empty file = none). +# Prints the override file path when overrides exist; otherwise prints nothing. +# +# cryptography: locked 49.x dropped macOS Intel / universal2 wheels. Native +# darwin-amd64 builds then compile from sdist and link Homebrew OpenSSL +# (/usr/local/opt/openssl@3), which is missing on end-user machines. +# Pin to the last release that still ships macosx_*_universal2 wheels. +write_green_overrides() { + local plat="$1" + local out="${2:-${GREEN_ROOT}/overrides-${plat}.txt}" + mkdir -p "$(dirname "$out")" + case "$plat" in + windows-arm64) + { + echo "psycopg-binary ; sys_platform == 'octop-unsupported'" + echo "sqlite-vec ; sys_platform == 'octop-unsupported'" + # 46.0.0 is the only release shipping a win_arm64 wheel; newer ones would + # fall back to a Rust + OpenSSL source build that fails on the runner. + echo "cryptography==46.0.0" + } > "$out" + echo "$out" + ;; + darwin-amd64) + { + # 46.0.3 still publishes cp*-abi3-macosx_10_9_universal2 (covers x86_64). + # cryptography>=47 only ships macos arm64 wheels → sdist + brew openssl. + echo "cryptography==46.0.3" + } > "$out" + echo "$out" + ;; + *) + rm -f "$out" + ;; + esac +} + +# Fail if any Mach-O under packages/ links Homebrew / MacPorts absolute paths. +# Catches relocatable-package regressions (e.g. cryptography built against brew openssl). +verify_no_homebrew_dylibs() { + local staging_packages="$1" + local plat="$2" + + case "$plat" in + darwin-*) ;; + *) return 0 ;; + esac + if ! command -v otool >/dev/null 2>&1; then + echo "[green] WARNING: otool missing — skip Homebrew dylib check" >&2 + return 0 + fi + + local bad=0 + local f deps + # Limit to native extension shared objects (skip .dylibs already vendored beside wheels). + while IFS= read -r -d '' f; do + deps="$(otool -L "$f" 2>/dev/null || true)" + if printf '%s\n' "$deps" | grep -E -q '/usr/local/opt/|/opt/homebrew/|/opt/local/'; then + echo "[green] ERROR: non-portable dylib link in ${f}" >&2 + printf '%s\n' "$deps" | grep -E '/usr/local/opt/|/opt/homebrew/|/opt/local/' >&2 || true + bad=1 + fi + done < <(find "$staging_packages" \( -name '*.so' -o -name '*.dylib' \) -print0 2>/dev/null) + + if [[ "$bad" -ne 0 ]]; then + echo "[green] ERROR: green zip must not depend on Homebrew/MacPorts libraries." >&2 + echo " Tip: pin cryptography to a release with macos universal2 wheels (see write_green_overrides)," >&2 + echo " and keep --only-binary cryptography so sdist builds cannot slip through." >&2 + return 1 + fi + echo "[green] dylib check OK (no Homebrew/MacPorts absolute paths)" + return 0 +} diff --git a/scripts/green/bootstrap-runtime.sh b/scripts/green/bootstrap-runtime.sh new file mode 100755 index 00000000..041ff133 --- /dev/null +++ b/scripts/green/bootstrap-runtime.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Download python-build-standalone into green/runtimes/. +# +# Usage: +# bash scripts/green/bootstrap-runtime.sh # host platform +# bash scripts/green/bootstrap-runtime.sh darwin-arm64 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=scripts/green/_common.sh +source "${REPO_ROOT}/scripts/green/_common.sh" + +download() { + local url="$1" + local dest="$2" + mkdir -p "$(dirname "$dest")" + if [[ -f "$dest" && -s "$dest" ]]; then + echo "[bootstrap] cache hit ${dest}" >&2 + return 0 + fi + echo "[bootstrap] downloading ${url}" >&2 + curl -fL --retry 3 --retry-delay 2 -o "${dest}.partial" "$url" + mv "${dest}.partial" "$dest" +} + +flatten_pbs_install() { + local extract_dir="$1" + local dest="$2" + rm -rf "$dest" + mkdir -p "$(dirname "$dest")" + if [[ -d "${extract_dir}/python" ]]; then + mv "${extract_dir}/python" "$dest" + elif [[ -d "${extract_dir}/install" ]]; then + mv "${extract_dir}/install" "$dest" + else + echo "[bootstrap] unexpected PBS layout under ${extract_dir}" >&2 + find "$extract_dir" -maxdepth 2 -print >&2 || true + exit 1 + fi +} + +bootstrap_one() { + local plat="$1" + local triple archive url cache dest + triple="$(plat_triple "$plat")" || { + echo "unknown platform: $plat" >&2 + exit 1 + } + archive="$(pbs_archive_name "$plat")" + url="${PBS_BASE_URL}/${archive}" + cache="${GREEN_CACHE}/${archive}" + dest="${GREEN_RUNTIMES}/${plat}" + + mkdir -p "$GREEN_RUNTIMES" "$GREEN_CACHE" + if [[ -x "$(runtime_python "$dest")" || -f "${dest}/python.exe" ]]; then + echo "[bootstrap] ${plat}: runtime already present → ${dest}" >&2 + return 0 + fi + + download "$url" "$cache" + local tmp + tmp="$(mktemp -d "${GREEN_CACHE}/extract.XXXXXX")" + tar -xzf "$cache" -C "$tmp" + flatten_pbs_install "$tmp" "$dest" + rm -rf "$tmp" + + local py + py="$(runtime_python "$dest")" + if [[ ! -x "$py" && ! -f "$py" ]]; then + echo "[bootstrap] python missing after extract: ${py}" >&2 + exit 1 + fi + echo "[bootstrap] ${plat}: ${py}" >&2 + "$py" -c "import sys; print(sys.version)" >&2 || true +} + +main() { + local plat="${1:-}" + if [[ -z "$plat" ]]; then + plat="$(host_plat)" + fi + if ! is_known_plat "$plat"; then + echo "unknown platform: $plat (known: ${ALL_PLATS[*]})" >&2 + exit 1 + fi + bootstrap_one "$plat" +} + +main "${1:-}" diff --git a/scripts/green/package-linux-docker.sh b/scripts/green/package-linux-docker.sh new file mode 100755 index 00000000..b1ff1016 --- /dev/null +++ b/scripts/green/package-linux-docker.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Cross-build a Linux green zip inside Docker (native ABI for C extensions). +# +# Usage: +# bash scripts/green/package-linux-docker.sh +# bash scripts/green/package-linux-docker.sh linux-arm64 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=scripts/green/_common.sh +source "${REPO_ROOT}/scripts/green/_common.sh" + +PLAT="${1:-linux-amd64}" +case "$PLAT" in + linux-amd64|linux-arm64) ;; + *) + echo "package-linux-docker.sh: expected linux-amd64 or linux-arm64, got ${PLAT}" >&2 + exit 1 + ;; +esac + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required for Linux cross-builds" >&2 + exit 1 +fi + +IMAGE="${GREEN_LINUX_IMAGE:-python:3.12-bookworm}" +echo "[docker] image=${IMAGE} plat=${PLAT}" >&2 + +docker run --rm \ + -e GREEN_HOST_PLAT="$PLAT" \ + -e PBS_TAG="$PBS_TAG" \ + -e PBS_PY="$PBS_PY" \ + -e PBS_BASE_URL="${PBS_BASE_URL}" \ + -e OCTOP_GREEN_OFFLINE="${OCTOP_GREEN_OFFLINE:-0}" \ + -v "${REPO_ROOT}:/src" \ + -w /src \ + "$IMAGE" \ + bash -lc " + set -euo pipefail + apt-get update -qq + apt-get install -y -qq curl zip ca-certificates >/dev/null + if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH=\"\$HOME/.local/bin:\$PATH\" + fi + bash scripts/green/bootstrap-runtime.sh '${PLAT}' + bash scripts/green/package.sh '${PLAT}' + " diff --git a/scripts/green/package.sh b/scripts/green/package.sh new file mode 100755 index 00000000..f368e716 --- /dev/null +++ b/scripts/green/package.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# Assemble a relocatable green portable zip for one platform. +# +# Prerequisites: +# bash scripts/green/bootstrap-runtime.sh +# make build-frontend # recommended (dashboard inside wheel) +# +# Usage: +# bash scripts/green/package.sh # host platform (online install) +# bash scripts/green/package.sh darwin-arm64 +# OCTOP_GREEN_OFFLINE=1 bash scripts/green/package.sh # require local wheels +# +# Layout of each zip: +# Octop-/ +# runtime/ portable CPython +# packages/ site-packages (uv --target, relocatable) +# start.sh / start.bat +# README.txt +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=scripts/green/_common.sh +source "${REPO_ROOT}/scripts/green/_common.sh" + +TEMPLATES="${REPO_ROOT}/scripts/green/templates" +require_uv + +build_octop_wheel() { + local wheel_dir="$1" + mkdir -p "$wheel_dir" + # Drop previous local octop wheels so we pick the fresh build. + find "$wheel_dir" -maxdepth 1 -type f -name 'octop-*.whl' -delete 2>/dev/null || true + if [[ ! -f "${REPO_ROOT}/src/octop/dashboard/index.html" ]]; then + echo "[package] WARNING: dashboard not built (src/octop/dashboard/index.html missing)." >&2 + echo " Run: make build-frontend" >&2 + fi + echo "[package] building octop wheel → ${wheel_dir}" >&2 + uv build --wheel --out-dir "$wheel_dir" . >&2 + local whl + whl="$(ls -1 "${wheel_dir}"/octop-*.whl 2>/dev/null | sort | tail -1 || true)" + if [[ -z "$whl" ]]; then + echo "[package] failed to build octop-*.whl" >&2 + exit 1 + fi + # stdout: wheel path only (captured by caller) + echo "$whl" +} + +wheel_cache_usable() { + local wheel_dir="$1" + local count + count="$(find "$wheel_dir" -maxdepth 1 -type f -name '*.whl' 2>/dev/null | wc -l | tr -d ' ')" + # Need octop + a reasonable set of deps. + [[ "${count:-0}" -ge 10 ]] +} + +# Copy pywin32 DLLs beside portable python.exe so ``import pywintypes`` works. +fix_windows_pywin32() { + local staging="$1" + local dll_src="${staging}/packages/pywin32_system32" + local runtime="${staging}/runtime" + if [[ ! -d "$dll_src" ]]; then + echo "[package] pywin32_system32 missing under packages/ — was pywin32 installed?" >&2 + return 1 + fi + local f + local copied=0 + for f in "$dll_src"/pywintypes*.dll "$dll_src"/pythoncom*.dll; do + [[ -f "$f" ]] || continue + cp -f "$f" "$runtime/" + copied=$((copied + 1)) + done + if [[ "$copied" -eq 0 ]]; then + echo "[package] no pywintypes/pythoncom DLLs found in ${dll_src}" >&2 + return 1 + fi + echo "[package] copied ${copied} pywin32 DLL(s) → runtime/" + return 0 +} + +assemble_one() { + local plat="$1" + local runtime="${GREEN_RUNTIMES}/${plat}" + local wheel_dir="${GREEN_WHEELS}/${plat}" + local staging="${GREEN_RELEASE}/Octop-${plat}" + local zip_path="${GREEN_RELEASE}/Octop-${plat}.zip" + local pyplat + + if ! pyplat="$(uv_platform "$plat")"; then + echo "unknown platform: $plat" >&2 + exit 1 + fi + + if [[ ! -d "$runtime" ]]; then + echo "[package] missing runtime ${runtime}" >&2 + echo " Run: bash scripts/green/bootstrap-runtime.sh ${plat}" >&2 + exit 1 + fi + + mkdir -p "$wheel_dir" "$GREEN_RELEASE" "$GREEN_ROOT" + local octop_whl + octop_whl="$(build_octop_wheel "$wheel_dir")" + + local req_file="${GREEN_ROOT}/requirements-${plat}.txt" + echo "[package] ${plat}: exporting frozen deps → ${req_file}" >&2 + uv export --frozen --no-dev --no-emit-project --no-hashes -o "$req_file" >/dev/null + + # Platform-specific pins / exclusions (see write_green_overrides in _common.sh). + local override_file="" + override_file="$(write_green_overrides "$plat" || true)" + if [[ -n "$override_file" ]]; then + echo "[package] ${plat}: using overrides → ${override_file}" >&2 + cat "$override_file" >&2 || true + fi + + echo "[package] ${plat}: staging → ${staging}" + rm -rf "$staging" + mkdir -p "${staging}/packages" + cp -a "$runtime" "${staging}/runtime" + + local common_args=( + --target "${staging}/packages" + --python-platform "$pyplat" + --python-version 3.12 + # Never compile cryptography from sdist: on Intel macOS that links + # Homebrew OpenSSL and breaks portable zips on machines without brew. + --only-binary cryptography + ) + if [[ -n "$override_file" ]]; then + common_args+=( --overrides "$override_file" ) + fi + local offline_args=() + local host + host="$(host_plat)" + echo "[package] ${plat}: host=${host} RUNNER_ARCH=${RUNNER_ARCH:-} GREEN_HOST_PLAT=${GREEN_HOST_PLAT:-}" >&2 + + # Last-resort correction: packaging windows-arm64 while shell looks amd64, but + # Actions/toolcache clearly provides ARM64 (matches the failing CI symptom). + if [[ "$plat" == "windows-arm64" && "$host" == "windows-amd64" ]]; then + local py_arch="" + py_arch="$(python_machine_arch 2>/dev/null || true)" + if [[ "${RUNNER_ARCH:-}" == "ARM64" || "$py_arch" == "arm64" ]]; then + echo "[package] ${plat}: overriding host windows-amd64 → windows-arm64 (RUNNER_ARCH=${RUNNER_ARCH:-} python=${py_arch:-unknown})" >&2 + host=windows-arm64 + fi + fi + + # Cross-platform: refuse compiling sdists on the host (wrong ABI). Prefer + # binary wheels only; for Linux use scripts/green/package-linux-docker.sh. + if [[ "$plat" != "$host" ]]; then + common_args+=( --only-binary ":all:" ) + echo "[package] ${plat}: cross-assemble from ${host} (binary wheels only)" >&2 + fi + + if [[ "${OCTOP_GREEN_OFFLINE:-0}" == "1" ]]; then + if ! wheel_cache_usable "$wheel_dir"; then + echo "[package] OCTOP_GREEN_OFFLINE=1 but wheels cache incomplete: ${wheel_dir}" >&2 + echo " Run: bash scripts/green/vendor-wheels.sh ${plat}" >&2 + exit 1 + fi + echo "[package] ${plat}: offline install from ${wheel_dir}" + offline_args+=( --no-index --find-links "$wheel_dir" ) + elif wheel_cache_usable "$wheel_dir"; then + echo "[package] ${plat}: install preferring local wheels (+ PyPI fallback)" + offline_args+=( --find-links "$wheel_dir" ) + else + echo "[package] ${plat}: online install from locked requirements" + fi + + # Install locked deps first, then the local octop wheel without re-resolving deps. + # bash 3.2 + set -u: empty array expansion is unbound — branch explicitly. + set +e + if [[ ${#offline_args[@]} -gt 0 ]]; then + uv pip install "${common_args[@]}" "${offline_args[@]}" -r "$req_file" + status=$? + if [[ $status -eq 0 ]]; then + uv pip install "${common_args[@]}" "${offline_args[@]}" --no-deps "$octop_whl" + status=$? + fi + else + uv pip install "${common_args[@]}" -r "$req_file" + status=$? + if [[ $status -eq 0 ]]; then + uv pip install "${common_args[@]}" --no-deps "$octop_whl" + status=$? + fi + fi + # Windows markers: ensure pywin32 landed (mcp needs pywintypes). + if [[ $status -eq 0 && "$plat" == windows-* ]]; then + if [[ ! -d "${staging}/packages/pywin32_system32" ]]; then + echo "[package] ${plat}: pywin32 missing — installing explicitly" >&2 + if [[ ${#offline_args[@]} -gt 0 ]]; then + uv pip install "${common_args[@]}" "${offline_args[@]}" pywin32 + else + uv pip install "${common_args[@]}" pywin32 + fi + status=$? + fi + fi + set -e + if [[ $status -ne 0 ]]; then + echo "[package] install failed for ${plat} (exit ${status})" >&2 + if [[ "$plat" != "$host" && "$plat" == linux-* ]]; then + echo " Tip: build Linux zips in Docker:" >&2 + echo " bash scripts/green/package-linux-docker.sh ${plat}" >&2 + elif [[ "$plat" != "$host" && "$plat" == windows-* ]]; then + echo " Tip: build Windows zips on a Windows host (or CI):" >&2 + echo " make -f scripts/green/Makefile green GREEN_PLAT=${plat}" >&2 + fi + exit "$status" + fi + + cp "${TEMPLATES}/start.sh" "${staging}/start.sh" + cp "${TEMPLATES}/start.bat" "${staging}/start.bat" + cp "${TEMPLATES}/launch.py" "${staging}/launch.py" + cp "${TEMPLATES}/README.txt" "${staging}/README.txt" + chmod +x "${staging}/start.sh" + + # Windows: pywin32 DLLs must be findable next to python.exe (or on PATH). + # --target installs leave them under packages/pywin32_system32 only. + if [[ "$plat" == windows-* ]]; then + fix_windows_pywin32 "$staging" || { + echo "[package] WARNING: pywin32 DLL fix failed — Windows runtime may miss pywintypes" >&2 + } + fi + + # macOS: reject Homebrew-linked native extensions before zipping. + verify_no_homebrew_dylibs "${staging}/packages" "$plat" + + { + echo "platform=${plat}" + echo "python=${PBS_PY}" + echo "pbs_tag=${PBS_TAG}" + sed -n 's/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/octop_version=\1/p' \ + "${REPO_ROOT}/pyproject.toml" | head -1 + } > "${staging}/VERSION.txt" + + rm -f "$zip_path" + echo "[package] ${plat}: zipping → ${zip_path}" + ( + cd "$GREEN_RELEASE" + if command -v zip >/dev/null 2>&1; then + zip -qry "Octop-${plat}.zip" "Octop-${plat}" + else + # Windows runners often have `python` but not `python3` / `zip`. + py="" + if command -v python3 >/dev/null 2>&1; then + py=python3 + elif command -v python >/dev/null 2>&1; then + py=python + fi + if [[ -z "$py" ]]; then + echo "[package] need zip or python to create archive" >&2 + exit 1 + fi + "$py" -c "import shutil; shutil.make_archive('Octop-${plat}', 'zip', '.', 'Octop-${plat}')" + fi + ) + echo "[package] wrote ${zip_path}" +} + +main() { + local plat="${1:-}" + if [[ -z "$plat" ]]; then + plat="$(host_plat)" + fi + if ! is_known_plat "$plat"; then + echo "unknown platform: $plat (known: ${ALL_PLATS[*]})" >&2 + exit 1 + fi + assemble_one "$plat" +} + +main "${1:-}" diff --git a/scripts/green/rebuild.sh b/scripts/green/rebuild.sh new file mode 100755 index 00000000..bffc2a1b --- /dev/null +++ b/scripts/green/rebuild.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Local one-shot: rebuild dashboard and the host-platform green zip. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$REPO_ROOT" + +if [[ -f "${HOME}/.nvm/nvm.sh" ]]; then + # shellcheck disable=SC1091 + source "${HOME}/.nvm/nvm.sh" + nvm use 24 +fi + +rm -rf src/octop/dashboard +rm -rf green/ + +make build-frontend +make -f scripts/green/Makefile green diff --git a/scripts/green/templates/README.txt b/scripts/green/templates/README.txt new file mode 100644 index 00000000..d3e86635 --- /dev/null +++ b/scripts/green/templates/README.txt @@ -0,0 +1,37 @@ +Octop green portable package +============================ + +Extract this zip anywhere. It includes a portable CPython runtime and Octop +dependencies. No system Python install is required. + +Start +----- + macOS / Linux: ./start.sh + Windows: start.bat + +Defaults: http://127.0.0.1:8088 data dir = ./data (OCTOP_HOME) + + ./start.sh --home /path/to/data --host 127.0.0.1 --port 8088 + +First launch follows the normal Octop setup wizard (create admin password). + +Layout +------ + runtime/ portable CPython + packages/ Octop + locked dependencies (site-packages) + launch.py entry bootstrap (loads packages/ + Windows pywin32 DLLs) + start.sh / start.bat + README.txt + VERSION.txt + +Notes +----- + Do not set PYTHONPATH=packages. Always start via start.sh / start.bat / + launch.py so .pth files (pywin32) are processed. + + macOS: if Gatekeeper quarantines the unzipped folder: + + xattr -dr com.apple.quarantine . + +Windows: if import pywintypes fails, rebuild from a current + green package (launch.py + pywin32 DLL copy). Do not set PYTHONPATH manually. diff --git a/scripts/green/templates/launch.py b/scripts/green/templates/launch.py new file mode 100644 index 00000000..5e172cb8 --- /dev/null +++ b/scripts/green/templates/launch.py @@ -0,0 +1,61 @@ +"""Green portable entry: wire packages/ onto sys.path then run ``octop``. + +Using plain ``PYTHONPATH=packages`` skips ``.pth`` processing (pywin32 etc.). +``site.addsitedir`` loads those hooks. On Windows we also expose +``pywin32_system32`` DLLs so ``import pywintypes`` works. +""" +from __future__ import annotations + +import os +import runpy +import site +import sys +from pathlib import Path + + +def _bootstrap() -> Path: + root = Path(__file__).resolve().parent + # The bundled interpreter is launched by absolute path, so its bin dir is + # rarely on PATH. Prepend it so inherited envs (agent shells, subprocesses) + # resolve ``python3`` to the same interpreter Octop runs with. + interpreter_dir = os.path.dirname(sys.executable) + if interpreter_dir: + _path = os.environ.get("PATH", "") + if interpreter_dir not in _path.split(os.pathsep): + os.environ["PATH"] = ( + f"{interpreter_dir}{os.pathsep}{_path}" if _path else interpreter_dir + ) + packages = root / "packages" + if not packages.is_dir(): + sys.stderr.write(f"launch.py: packages/ missing next to {root}\n") + raise SystemExit(1) + + # Process .pth files (required for pywin32 layout). + site.addsitedir(str(packages)) + + if sys.platform == "win32": + dll_dir = packages / "pywin32_system32" + if dll_dir.is_dir(): + dll_s = str(dll_dir) + path = os.environ.get("PATH", "") + if dll_s.lower() not in path.lower(): + os.environ["PATH"] = dll_s + os.pathsep + path + # Python 3.8+: prefer explicit DLL search path. + add_dll = getattr(os, "add_dll_directory", None) + if add_dll is not None: + try: + add_dll(dll_s) + except OSError: + pass + return root + + +def main() -> None: + _bootstrap() + # ``python launch.py …`` → same as ``python -m octop …`` + sys.argv = [sys.argv[0], *sys.argv[1:]] + runpy.run_module("octop", run_name="__main__", alter_sys=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/green/templates/start.bat b/scripts/green/templates/start.bat new file mode 100644 index 00000000..bdf2d580 --- /dev/null +++ b/scripts/green/templates/start.bat @@ -0,0 +1,88 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +rem Octop green portable launcher (Windows) +rem Usage: +rem start.bat +rem start.bat --home D:\octop-data +rem start.bat --home .\data --host 0.0.0.0 --port 8088 + +set "ROOT=%~dp0" +if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%" + +if not defined OCTOP_HOME set "OCTOP_HOME=%ROOT%\data" +set "HOST=127.0.0.1" +set "PORT=8088" +set "EXTRA=" + +:parse +if "%~1"=="" goto run +if /I "%~1"=="--home" ( + if "%~2"=="" ( + echo start.bat: --home requires a path + exit /b 1 + ) + set "OCTOP_HOME=%~2" + shift + shift + goto parse +) +if /I "%~1"=="--host" ( + if "%~2"=="" ( + echo start.bat: --host requires a value + exit /b 1 + ) + set "HOST=%~2" + shift + shift + goto parse +) +if /I "%~1"=="--port" ( + if "%~2"=="" ( + echo start.bat: --port requires a value + exit /b 1 + ) + set "PORT=%~2" + shift + shift + goto parse +) +if /I "%~1"=="-h" goto help +if /I "%~1"=="--help" goto help +set "EXTRA=!EXTRA! %~1" +shift +goto parse + +:help +echo Octop green portable launcher +echo. +echo Usage: start.bat [--home DIR] [--host HOST] [--port PORT] [octop run args...] +echo. +echo Defaults: +echo OCTOP_HOME / --home %%ROOT%%\data +echo --host 127.0.0.1 +echo --port 8088 +exit /b 0 + +:run +if not exist "%OCTOP_HOME%" mkdir "%OCTOP_HOME%" + +set "PY=%ROOT%\runtime\python.exe" +if not exist "%PY%" ( + echo start.bat: portable Python not found at %PY% + exit /b 1 +) + +if not exist "%ROOT%\launch.py" ( + echo start.bat: launch.py missing — rebuild the green package + exit /b 1 +) + +rem Prefer launch.py (site.addsitedir + pywin32 DLL path). Do not set PYTHONPATH. +set "PYTHONNOUSERSITE=1" +set "PYTHONPATH=" + +echo [octop] home=%OCTOP_HOME% +echo [octop] http://%HOST%:%PORT% +"%PY%" "%ROOT%\launch.py" run --host %HOST% --port %PORT% %EXTRA% +exit /b %ERRORLEVEL% diff --git a/scripts/green/templates/start.sh b/scripts/green/templates/start.sh new file mode 100755 index 00000000..8f4800d0 --- /dev/null +++ b/scripts/green/templates/start.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Octop green portable launcher (macOS / Linux). +# Usage: +# ./start.sh +# ./start.sh --home /path/to/data +# ./start.sh --home ./data --host 0.0.0.0 --port 8088 +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export OCTOP_HOME="${OCTOP_HOME:-${ROOT}/data}" + +HOST="127.0.0.1" +PORT="8088" +EXTRA=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --home) + [[ $# -ge 2 ]] || { echo "start.sh: --home requires a path" >&2; exit 1; } + OCTOP_HOME="$2" + shift 2 + ;; + --host) + [[ $# -ge 2 ]] || { echo "start.sh: --host requires a value" >&2; exit 1; } + HOST="$2" + shift 2 + ;; + --port) + [[ $# -ge 2 ]] || { echo "start.sh: --port requires a value" >&2; exit 1; } + PORT="$2" + shift 2 + ;; + -h|--help) + cat <&2 + exit 1 +fi + +# launch.py adds packages/ via site.addsitedir (honours .pth / pywin32). +export PYTHONNOUSERSITE=1 +unset PYTHONPATH || true + +echo "[octop] home=${OCTOP_HOME}" +echo "[octop] http://${HOST}:${PORT}" +if [[ ${#EXTRA[@]} -gt 0 ]]; then + exec "$PY" "${ROOT}/launch.py" run --host "$HOST" --port "$PORT" "${EXTRA[@]}" +else + exec "$PY" "${ROOT}/launch.py" run --host "$HOST" --port "$PORT" +fi diff --git a/scripts/green/vendor-wheels.sh b/scripts/green/vendor-wheels.sh new file mode 100755 index 00000000..c1102d6c --- /dev/null +++ b/scripts/green/vendor-wheels.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Prefetch wheels for OCTOP_GREEN_OFFLINE=1 packaging. +# +# Usage: +# bash scripts/green/vendor-wheels.sh # host platform +# bash scripts/green/vendor-wheels.sh windows-amd64 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# shellcheck source=scripts/green/_common.sh +source "${REPO_ROOT}/scripts/green/_common.sh" + +require_uv + +vendor_one() { + local plat="$1" + local pyplat wheel_dir req_file override_file + pyplat="$(uv_platform "$plat")" || { + echo "unknown platform: $plat" >&2 + exit 1 + } + wheel_dir="${GREEN_WHEELS}/${plat}" + mkdir -p "$wheel_dir" "$GREEN_ROOT" + + req_file="${GREEN_ROOT}/requirements-${plat}.txt" + echo "[wheels] ${plat}: exporting frozen deps → ${req_file}" >&2 + uv export --frozen --no-dev --no-emit-project --no-hashes -o "$req_file" >/dev/null + + override_file="$(write_green_overrides "$plat" || true)" + local extra=() + if [[ -n "$override_file" ]]; then + extra+=( --overrides "$override_file" ) + fi + + echo "[wheels] ${plat}: downloading → ${wheel_dir}" >&2 + uv pip download \ + --dest "$wheel_dir" \ + --python-platform "$pyplat" \ + --python-version 3.12 \ + --only-binary cryptography \ + "${extra[@]}" \ + -r "$req_file" + + echo "[wheels] ${plat}: done ($(find "$wheel_dir" -maxdepth 1 -type f -name '*.whl' | wc -l | tr -d ' ') wheels)" +} + +main() { + local plat="${1:-}" + if [[ -z "$plat" ]]; then + plat="$(host_plat)" + fi + if ! is_known_plat "$plat"; then + echo "unknown platform: $plat (known: ${ALL_PLATS[*]})" >&2 + exit 1 + fi + vendor_one "$plat" +} + +main "${1:-}" diff --git a/scripts/green/verify_imports.py b/scripts/green/verify_imports.py new file mode 100755 index 00000000..60c1f11c --- /dev/null +++ b/scripts/green/verify_imports.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Smoke-check a green packages/ tree against frozen requirements. + +Usage: + python scripts/green/verify_imports.py \\ + --packages green/release/Octop-/packages \\ + --requirements green/requirements-.txt \\ + [--overrides green/overrides-.txt] +""" +from __future__ import annotations + +import argparse +import ast +import importlib +import re +import site +import sys +from pathlib import Path + +_REQ_RE = re.compile( + r"^(?P[A-Za-z0-9][A-Za-z0-9._-]*)(?:\s*==\s*(?P[^\s;#]+))?" +) + +# Import names that have historically drifted across lock/install environments. +_SMOKE_IMPORTS = ( + "octop", + "fastapi", + "cryptography.fernet", + "langchain_core", + "langchain_openai", +) + + +def _parse_pins(path: Path) -> dict[str, str]: + pins: dict[str, str] = {} + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith(("#", "-")): + continue + match = _REQ_RE.match(line) + if match is None: + continue + name = match.group("name").replace("_", "-").lower() + ver = match.group("ver") + if ver: + pins[name] = ver + return pins + + +def _parse_override_pins(path: Path | None) -> dict[str, str]: + if path is None or not path.is_file(): + return {} + pins: dict[str, str] = {} + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or ";" in line: + continue + match = _REQ_RE.match(line) + if match is None or not match.group("ver"): + continue + pins[match.group("name").replace("_", "-").lower()] = match.group("ver") + return pins + + +def _installed_version(mod_name: str) -> str | None: + try: + mod = importlib.import_module(mod_name) + except Exception: + return None + ver = getattr(mod, "__version__", None) + if isinstance(ver, str): + return ver + return None + + +def _dist_version(dist_name: str) -> str | None: + try: + from importlib.metadata import version + except ImportError: + return None + try: + return version(dist_name) + except Exception: + return None + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--packages", required=True, type=Path) + parser.add_argument("--requirements", required=True, type=Path) + parser.add_argument("--overrides", type=Path, default=None) + args = parser.parse_args() + + packages = args.packages.resolve() + if not packages.is_dir(): + print(f"packages/ missing: {packages}", file=sys.stderr) + return 1 + site.addsitedir(str(packages)) + + req_pins = _parse_pins(args.requirements) + override_pins = _parse_override_pins(args.overrides) + pins = {**req_pins, **override_pins} + + errors: list[str] = [] + for dist in ("langchain-core", "langchain-openai", "cryptography"): + expected = pins.get(dist) + if expected is None: + continue + got = _dist_version(dist) + if got is None: + errors.append(f"{dist}: not installed (want {expected})") + elif got != expected: + errors.append(f"{dist}: installed {got}, want {expected}") + + for mod in _SMOKE_IMPORTS: + try: + importlib.import_module(mod) + except Exception as exc: + errors.append(f"import {mod}: {type(exc).__name__}: {exc}") + + # Sanity: launch.py must stay importable as a file (no compile errors). + try: + ast.parse(Path(__file__).read_text(encoding="utf-8")) + except SyntaxError as exc: + errors.append(f"self-parse: {exc}") + + if errors: + print("verify_imports FAILED:", file=sys.stderr) + for item in errors: + print(f" - {item}", file=sys.stderr) + return 1 + print("verify_imports OK") + langchain_core_ver = _installed_version("langchain_core") + if langchain_core_ver: + print(f"langchain_core={langchain_core_ver}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_green_launch.py b/tests/unit/test_green_launch.py new file mode 100644 index 00000000..5c494b60 --- /dev/null +++ b/tests/unit/test_green_launch.py @@ -0,0 +1,56 @@ +"""Tests for the green portable launch bootstrap.""" + +from __future__ import annotations + +import importlib.util +import inspect +import os +import sys +from pathlib import Path +from types import ModuleType + +import pytest + + +def _load_launch_module() -> ModuleType: + path = Path(__file__).resolve().parents[2] / "scripts" / "green" / "templates" / "launch.py" + spec = importlib.util.spec_from_file_location("green_portable_launch", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("parent_path", ["/system/bin", None]) +def test_bootstrap_prepends_interpreter_dir_to_path( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + parent_path: str | None, +) -> None: + (tmp_path / "packages").mkdir() + runtime_bin = tmp_path / "runtime" / "bin" + runtime_bin.mkdir(parents=True) + executable = runtime_bin / "python3" + + module = _load_launch_module() + module.__file__ = str(tmp_path / "launch.py") + monkeypatch.setattr(sys, "executable", str(executable)) + if parent_path is None: + monkeypatch.delenv("PATH", raising=False) + else: + monkeypatch.setenv("PATH", parent_path) + + module._bootstrap() + + interpreter_dir = str(runtime_bin) + if parent_path is None: + assert os.environ["PATH"] == interpreter_dir + else: + assert os.environ["PATH"] == interpreter_dir + os.pathsep + parent_path + + +def test_launch_has_no_provider_seed() -> None: + source = inspect.getsource(_load_launch_module()) + assert "_seed_initial_provider" not in source + assert "nexusapi" not in source + assert "OCTOP_DESKTOP_OOB" not in source From 6c06b01e15c5ab3826957b065d234fe072354372 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 02:20:32 +0000 Subject: [PATCH 02/18] feat(desktop): Wails v3 shell with green portable under ~/.octop Move green packaging to desktop/portable, add a Wails v3 client that downloads Octop-.zip, spawns launch.py with OCTOP_HOME=~/.octop, and exposes locale/theme/autostart/tray/macOS caffeinate settings. Green upgrades install into packages via OCTOP_GREEN_PACKAGES. Co-authored-by: Harry --- .cursor/skills/octop-electron-green/SKILL.md | 2 +- .github/workflows/octop-portable.yml | 10 +- .gitignore | 1 + CHANGELOG.md | 3 +- desktop/README.md | 48 ++++ .../portable}/AGENT_ELECTRON_INTEGRATION.md | 13 +- desktop/portable/Makefile | 50 ++++ {scripts/green => desktop/portable}/README.md | 22 +- .../green => desktop/portable}/_common.sh | 0 .../portable}/bootstrap-runtime.sh | 8 +- .../portable}/package-linux-docker.sh | 12 +- .../green => desktop/portable}/package.sh | 24 +- .../green => desktop/portable}/rebuild.sh | 2 +- .../portable}/templates/README.txt | 0 .../portable}/templates/launch.py | 0 .../portable}/templates/start.bat | 0 .../portable}/templates/start.sh | 0 .../portable}/vendor-wheels.sh | 8 +- .../portable}/verify_imports.py | 2 +- desktop/src/.gitignore | 5 + desktop/src/assets/index.html | 154 ++++++++++ desktop/src/download.go | 207 +++++++++++++ desktop/src/go.mod | 16 ++ desktop/src/go.sum | 35 +++ desktop/src/main.go | 272 ++++++++++++++++++ desktop/src/preventsleep.go | 52 ++++ desktop/src/process.go | 40 +++ desktop/src/process_unix.go | 17 ++ desktop/src/process_windows.go | 15 + desktop/src/settings.go | 148 ++++++++++ scripts/green/Makefile | 50 ---- src/octop/infra/setup/self_update.py | 25 +- tests/unit/cli/test_update_cmd.py | 30 ++ tests/unit/test_green_launch.py | 2 +- 34 files changed, 1169 insertions(+), 104 deletions(-) create mode 100644 desktop/README.md rename {scripts/green => desktop/portable}/AGENT_ELECTRON_INTEGRATION.md (81%) create mode 100644 desktop/portable/Makefile rename {scripts/green => desktop/portable}/README.md (86%) rename {scripts/green => desktop/portable}/_common.sh (100%) rename {scripts/green => desktop/portable}/bootstrap-runtime.sh (90%) rename {scripts/green => desktop/portable}/package-linux-docker.sh (78%) rename {scripts/green => desktop/portable}/package.sh (91%) rename {scripts/green => desktop/portable}/rebuild.sh (90%) rename {scripts/green => desktop/portable}/templates/README.txt (100%) rename {scripts/green => desktop/portable}/templates/launch.py (100%) rename {scripts/green => desktop/portable}/templates/start.bat (100%) rename {scripts/green => desktop/portable}/templates/start.sh (100%) rename {scripts/green => desktop/portable}/vendor-wheels.sh (86%) rename {scripts/green => desktop/portable}/verify_imports.py (98%) create mode 100644 desktop/src/.gitignore create mode 100644 desktop/src/assets/index.html create mode 100644 desktop/src/download.go create mode 100644 desktop/src/go.mod create mode 100644 desktop/src/go.sum create mode 100644 desktop/src/main.go create mode 100644 desktop/src/preventsleep.go create mode 100644 desktop/src/process.go create mode 100644 desktop/src/process_unix.go create mode 100644 desktop/src/process_windows.go create mode 100644 desktop/src/settings.go delete mode 100644 scripts/green/Makefile diff --git a/.cursor/skills/octop-electron-green/SKILL.md b/.cursor/skills/octop-electron-green/SKILL.md index f8117266..55f19dd2 100644 --- a/.cursor/skills/octop-electron-green/SKILL.md +++ b/.cursor/skills/octop-electron-green/SKILL.md @@ -10,6 +10,6 @@ description: >- Follow the full task brief in this repository: -**[scripts/green/AGENT_ELECTRON_INTEGRATION.md](../../../scripts/green/AGENT_ELECTRON_INTEGRATION.md)** +**[desktop/portable/AGENT_ELECTRON_INTEGRATION.md](../../../desktop/portable/AGENT_ELECTRON_INTEGRATION.md)** When working in a **different** Electron repo, copy that file into the target project (or paste it into the agent prompt) and execute it step by step. Do not invent a parallel install path (no system Python, no `PYTHONPATH=packages`). diff --git a/.github/workflows/octop-portable.yml b/.github/workflows/octop-portable.yml index 8082b726..499a64d1 100644 --- a/.github/workflows/octop-portable.yml +++ b/.github/workflows/octop-portable.yml @@ -124,7 +124,7 @@ jobs: if: steps.want.outputs.build == 'true' env: GREEN_HOST_PLAT: ${{ matrix.plat }} - run: bash scripts/green/bootstrap-runtime.sh "${{ matrix.plat }}" + run: bash desktop/portable/bootstrap-runtime.sh "${{ matrix.plat }}" - name: Assemble green zip if: steps.want.outputs.build == 'true' @@ -133,7 +133,7 @@ jobs: run: | set -euo pipefail echo "GREEN_HOST_PLAT=${GREEN_HOST_PLAT} RUNNER_ARCH=${RUNNER_ARCH:-} uname=$(uname -ms)" - bash scripts/green/package.sh "${{ matrix.plat }}" + bash desktop/portable/package.sh "${{ matrix.plat }}" - name: Smoke import (native host only) if: steps.want.outputs.build == 'true' @@ -162,12 +162,12 @@ jobs: verify_args+=(--overrides "$override_file") fi PYTHONNOUSERSITE=1 \ - "$py" scripts/green/verify_imports.py \ + "$py" desktop/portable/verify_imports.py \ "${verify_args[@]}" # macOS: fail if any native extension linked Homebrew/MacPorts paths. if [[ "${{ matrix.plat }}" == darwin-* ]]; then - REPO_ROOT="$PWD" # shellcheck source=scripts/green/_common.sh - source scripts/green/_common.sh + REPO_ROOT="$PWD" # shellcheck source=desktop/portable/_common.sh + source desktop/portable/_common.sh verify_no_homebrew_dylibs "${staging}/packages" "${{ matrix.plat }}" fi diff --git a/.gitignore b/.gitignore index 3565fe3e..7de1784b 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,7 @@ logs/ # green portable /green + # Local monorepo dev helpers (not for upstream) scripts/dev-local-link.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index f875f71b..14756468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ ### 新增 -- 绿色便携包:`scripts/green/` 用 python-build-standalone + 锁定依赖打出 `Octop-.zip`(六平台 Actions),解压后经 `launch.py` 启动;不含 OOB/UI 裁剪,首启走上游 setup wizard +- 绿色便携包:`desktop/portable/` 用 python-build-standalone + 锁定依赖打出 `Octop-.zip`(六平台 Actions),解压后经 `launch.py` 启动;不含 OOB/UI 裁剪,首启走上游 setup wizard +- 桌面壳(Wails v3):`desktop/src/` 从 GitHub Release 下载绿包并启动 Octop;`OCTOP_HOME` 与默认一致(`~/.octop`);内置升级对绿包使用 `--target packages` ## [0.9.26] - 2026-08-23 diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 00000000..a1f8010a --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,48 @@ +# Octop desktop (Wails v3 + green portable) + +All desktop-client code lives here. This is **not** `src/octop/infra/desktop` +(remote desktop streaming). + +| Path | Role | +|------|------| +| [`portable/`](portable/) | Green zip packaging (was `scripts/green/`) | +| [`src/`](src/) | Wails v3 shell: download zip, spawn Octop, tray/settings | + +## Data directory + +Same as the Octop CLI/server default: + +- `OCTOP_HOME` → `~/.octop` (or the existing `OCTOP_HOME` env) +- Green runtime extract → `~/.octop/portable/` +- Shell prefs → `~/.octop/desktop-settings.json` + +## Build green zip + +From repo root: + +```bash +make -f desktop/portable/Makefile green +``` + +CI: `.github/workflows/octop-portable.yml` (unchanged location). + +## Build the Wails shell + +Requires **Go 1.25+** and [Wails v3](https://v3.wails.io/) (`wails3`). + +```bash +cd desktop/src +go mod tidy +# generate bindings if you have the CLI: +# wails3 generate +wails3 build +``` + +Dev against an already-running Octop: + +```bash +OCTOP_DESKTOP_URL=http://127.0.0.1:8088 wails3 dev +``` + +First launch without that env downloads `Octop-.zip` from the latest +GitHub Release (`OCTOP_DESKTOP_GITHUB_REPO`, default `forcemeter/Octop-Agent`). diff --git a/scripts/green/AGENT_ELECTRON_INTEGRATION.md b/desktop/portable/AGENT_ELECTRON_INTEGRATION.md similarity index 81% rename from scripts/green/AGENT_ELECTRON_INTEGRATION.md rename to desktop/portable/AGENT_ELECTRON_INTEGRATION.md index 12196068..052551dc 100644 --- a/scripts/green/AGENT_ELECTRON_INTEGRATION.md +++ b/desktop/portable/AGENT_ELECTRON_INTEGRATION.md @@ -6,7 +6,7 @@ path (no system Python, no `PYTHONPATH=packages`). ## Artifact -CI / `make -f scripts/green/Makefile green` produces: +CI / `make -f desktop/portable/Makefile green` produces: ``` green/release/Octop-.zip @@ -30,9 +30,14 @@ Octop-/ 1. Download the zip for the current OS/arch. Verify checksum if you ship one. 2. Extract to a writable user directory **outside** `app.asar`. 3. On macOS, after checksum: `xattr -dr com.apple.quarantine `. -4. Set `OCTOP_HOME` to a persistent data dir (not inside the zip if you - replace the zip on upgrade). -5. Set `PYTHONNOUSERSITE=1`. **Do not set `PYTHONPATH`.** +4. Set `OCTOP_HOME` to Octop's default data dir (`~/.octop`, or `$OCTOP_HOME` + if already set). Do **not** use the zip's `./data` folder when launching + from the Wails desktop shell (`desktop/src`). + Extract the zip under `~/.octop/portable/` so runtime files stay next to + user data without overwriting `octop.db`. +5. Set `PYTHONNOUSERSITE=1` and `OCTOP_GREEN_PACKAGES=/packages`. + **Do not set `PYTHONPATH`.** + 6. Spawn: - macOS / Linux: `/runtime/bin/python3 /launch.py run --host 127.0.0.1 --port ` diff --git a/desktop/portable/Makefile b/desktop/portable/Makefile new file mode 100644 index 00000000..23d43f2c --- /dev/null +++ b/desktop/portable/Makefile @@ -0,0 +1,50 @@ +# Standalone Makefile for green portable packaging. +# Does not modify the repo-root Makefile — merge-friendly with upstream. +# +# Usage (from repo root): +# make -f desktop/portable/Makefile green +# make -f desktop/portable/Makefile green GREEN_PLAT=linux-amd64 +# make -f desktop/portable/Makefile green-linux +# make -f desktop/portable/Makefile clean + +SHELL := /bin/bash +REPO_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../..) +GREEN_PLAT ?= + +.DEFAULT_GOAL := help + +.PHONY: help +help: + @echo "Green portable (offline zip)" + @echo " make -f desktop/portable/Makefile green # host plat zip" + @echo " make -f desktop/portable/Makefile green GREEN_PLAT=…" + @echo " make -f desktop/portable/Makefile bootstrap|wheels|package|green-linux|clean" + +.PHONY: bootstrap +bootstrap: + bash $(REPO_ROOT)/desktop/portable/bootstrap-runtime.sh $(GREEN_PLAT) + +.PHONY: wheels +wheels: + bash $(REPO_ROOT)/desktop/portable/vendor-wheels.sh $(GREEN_PLAT) + +.PHONY: package +package: + bash $(REPO_ROOT)/desktop/portable/package.sh $(GREEN_PLAT) + +.PHONY: green +green: + $(MAKE) -C $(REPO_ROOT) build-frontend + bash $(REPO_ROOT)/desktop/portable/bootstrap-runtime.sh $(GREEN_PLAT) + bash $(REPO_ROOT)/desktop/portable/package.sh $(GREEN_PLAT) + @echo "[green] Done. Zips under $(REPO_ROOT)/green/release/" + +.PHONY: green-linux +green-linux: + $(MAKE) -C $(REPO_ROOT) build-frontend + bash $(REPO_ROOT)/desktop/portable/package-linux-docker.sh $(if $(GREEN_PLAT),$(GREEN_PLAT),linux-amd64) + +.PHONY: clean +clean: + rm -rf $(REPO_ROOT)/green + @echo "[green] removed $(REPO_ROOT)/green" diff --git a/scripts/green/README.md b/desktop/portable/README.md similarity index 86% rename from scripts/green/README.md rename to desktop/portable/README.md index af9053c1..b5027136 100644 --- a/scripts/green/README.md +++ b/desktop/portable/README.md @@ -8,7 +8,7 @@ 本能力全部落在: -- `scripts/green/**`(脚本 / 模板 / 本目录 Makefile) +- `desktop/portable/**`(脚本 / 模板 / 本目录 Makefile) - `.github/workflows/octop-portable.yml`(多平台 CI) - 根 `.gitignore` 一行 `/green`(忽略构建产物) - `tests/unit/test_green_launch.py`(launch.py PATH / addsitedir) @@ -17,11 +17,11 @@ 合并上游时只需留意上述路径;日常用: ```bash -make -f scripts/green/Makefile green +make -f desktop/portable/Makefile green ``` 依赖版本必须以仓库根目录 `uv.lock` 为准:`package.sh` 使用 -`uv export --frozen`,出包后跑 `scripts/green/verify_imports.py` +`uv export --frozen`,出包后跑 `desktop/portable/verify_imports.py` 校验关键包 pin 与 import(含 `langchain-openai` / `langchain-core` 配对), 避免「同安装、不同环境」因版本错配或原生扩展加载失败而偶发报错。 平台 overrides(`darwin-amd64` / `windows-arm64` 的 `cryptography==46.x`) @@ -48,33 +48,33 @@ Octop-/ ```bash # 一键:当前主机平台(前端 + 便携 CPython + zip) -make -f scripts/green/Makefile green +make -f desktop/portable/Makefile green # 或分步: make build-frontend # 上游已有目标 -bash scripts/green/bootstrap-runtime.sh -bash scripts/green/package.sh +bash desktop/portable/bootstrap-runtime.sh +bash desktop/portable/package.sh ``` 本地一键重建(nvm 24): ```bash -bash scripts/green/rebuild.sh +bash desktop/portable/rebuild.sh ``` 交叉组装其它平台时,**带 C 扩展的包**必须在目标 ABI 上构建: | 目标 | 推荐方式 | |------|----------| -| 当前主机 | `make -f scripts/green/Makefile green` | -| Linux(从 macOS/Windows) | `make -f scripts/green/Makefile green-linux` | +| 当前主机 | `make -f desktop/portable/Makefile green` | +| Linux(从 macOS/Windows) | `make -f desktop/portable/Makefile green-linux` | | Windows | 在 Windows / CI 上执行同上 `green` | ### 离线包 ```bash -bash scripts/green/vendor-wheels.sh # 按当前 uv.lock 预取 wheel -OCTOP_GREEN_OFFLINE=1 bash scripts/green/package.sh +bash desktop/portable/vendor-wheels.sh # 按当前 uv.lock 预取 wheel +OCTOP_GREEN_OFFLINE=1 bash desktop/portable/package.sh ``` 离线缓存必须来自**当前分支**的 `uv.lock`,不要复用旧分叉的 wheel 目录。 diff --git a/scripts/green/_common.sh b/desktop/portable/_common.sh similarity index 100% rename from scripts/green/_common.sh rename to desktop/portable/_common.sh diff --git a/scripts/green/bootstrap-runtime.sh b/desktop/portable/bootstrap-runtime.sh similarity index 90% rename from scripts/green/bootstrap-runtime.sh rename to desktop/portable/bootstrap-runtime.sh index 041ff133..cdbc682e 100755 --- a/scripts/green/bootstrap-runtime.sh +++ b/desktop/portable/bootstrap-runtime.sh @@ -2,13 +2,13 @@ # Download python-build-standalone into green/runtimes/. # # Usage: -# bash scripts/green/bootstrap-runtime.sh # host platform -# bash scripts/green/bootstrap-runtime.sh darwin-arm64 +# bash desktop/portable/bootstrap-runtime.sh # host platform +# bash desktop/portable/bootstrap-runtime.sh darwin-arm64 set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -# shellcheck source=scripts/green/_common.sh -source "${REPO_ROOT}/scripts/green/_common.sh" +# shellcheck source=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_common.sh" download() { local url="$1" diff --git a/scripts/green/package-linux-docker.sh b/desktop/portable/package-linux-docker.sh similarity index 78% rename from scripts/green/package-linux-docker.sh rename to desktop/portable/package-linux-docker.sh index b1ff1016..834b395a 100755 --- a/scripts/green/package-linux-docker.sh +++ b/desktop/portable/package-linux-docker.sh @@ -2,13 +2,13 @@ # Cross-build a Linux green zip inside Docker (native ABI for C extensions). # # Usage: -# bash scripts/green/package-linux-docker.sh -# bash scripts/green/package-linux-docker.sh linux-arm64 +# bash desktop/portable/package-linux-docker.sh +# bash desktop/portable/package-linux-docker.sh linux-arm64 set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -# shellcheck source=scripts/green/_common.sh -source "${REPO_ROOT}/scripts/green/_common.sh" +# shellcheck source=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_common.sh" PLAT="${1:-linux-amd64}" case "$PLAT" in @@ -44,6 +44,6 @@ docker run --rm \ curl -LsSf https://astral.sh/uv/install.sh | sh export PATH=\"\$HOME/.local/bin:\$PATH\" fi - bash scripts/green/bootstrap-runtime.sh '${PLAT}' - bash scripts/green/package.sh '${PLAT}' + bash desktop/portable/bootstrap-runtime.sh '${PLAT}' + bash desktop/portable/package.sh '${PLAT}' " diff --git a/scripts/green/package.sh b/desktop/portable/package.sh similarity index 91% rename from scripts/green/package.sh rename to desktop/portable/package.sh index f368e716..522500e2 100755 --- a/scripts/green/package.sh +++ b/desktop/portable/package.sh @@ -2,13 +2,13 @@ # Assemble a relocatable green portable zip for one platform. # # Prerequisites: -# bash scripts/green/bootstrap-runtime.sh +# bash desktop/portable/bootstrap-runtime.sh # make build-frontend # recommended (dashboard inside wheel) # # Usage: -# bash scripts/green/package.sh # host platform (online install) -# bash scripts/green/package.sh darwin-arm64 -# OCTOP_GREEN_OFFLINE=1 bash scripts/green/package.sh # require local wheels +# bash desktop/portable/package.sh # host platform (online install) +# bash desktop/portable/package.sh darwin-arm64 +# OCTOP_GREEN_OFFLINE=1 bash desktop/portable/package.sh # require local wheels # # Layout of each zip: # Octop-/ @@ -19,10 +19,10 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -# shellcheck source=scripts/green/_common.sh -source "${REPO_ROOT}/scripts/green/_common.sh" +# shellcheck source=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_common.sh" -TEMPLATES="${REPO_ROOT}/scripts/green/templates" +TEMPLATES="${REPO_ROOT}/desktop/portable/templates" require_uv build_octop_wheel() { @@ -93,7 +93,7 @@ assemble_one() { if [[ ! -d "$runtime" ]]; then echo "[package] missing runtime ${runtime}" >&2 - echo " Run: bash scripts/green/bootstrap-runtime.sh ${plat}" >&2 + echo " Run: bash desktop/portable/bootstrap-runtime.sh ${plat}" >&2 exit 1 fi @@ -146,7 +146,7 @@ assemble_one() { fi # Cross-platform: refuse compiling sdists on the host (wrong ABI). Prefer - # binary wheels only; for Linux use scripts/green/package-linux-docker.sh. + # binary wheels only; for Linux use desktop/portable/package-linux-docker.sh. if [[ "$plat" != "$host" ]]; then common_args+=( --only-binary ":all:" ) echo "[package] ${plat}: cross-assemble from ${host} (binary wheels only)" >&2 @@ -155,7 +155,7 @@ assemble_one() { if [[ "${OCTOP_GREEN_OFFLINE:-0}" == "1" ]]; then if ! wheel_cache_usable "$wheel_dir"; then echo "[package] OCTOP_GREEN_OFFLINE=1 but wheels cache incomplete: ${wheel_dir}" >&2 - echo " Run: bash scripts/green/vendor-wheels.sh ${plat}" >&2 + echo " Run: bash desktop/portable/vendor-wheels.sh ${plat}" >&2 exit 1 fi echo "[package] ${plat}: offline install from ${wheel_dir}" @@ -202,10 +202,10 @@ assemble_one() { echo "[package] install failed for ${plat} (exit ${status})" >&2 if [[ "$plat" != "$host" && "$plat" == linux-* ]]; then echo " Tip: build Linux zips in Docker:" >&2 - echo " bash scripts/green/package-linux-docker.sh ${plat}" >&2 + echo " bash desktop/portable/package-linux-docker.sh ${plat}" >&2 elif [[ "$plat" != "$host" && "$plat" == windows-* ]]; then echo " Tip: build Windows zips on a Windows host (or CI):" >&2 - echo " make -f scripts/green/Makefile green GREEN_PLAT=${plat}" >&2 + echo " make -f desktop/portable/Makefile green GREEN_PLAT=${plat}" >&2 fi exit "$status" fi diff --git a/scripts/green/rebuild.sh b/desktop/portable/rebuild.sh similarity index 90% rename from scripts/green/rebuild.sh rename to desktop/portable/rebuild.sh index bffc2a1b..f07d21d3 100755 --- a/scripts/green/rebuild.sh +++ b/desktop/portable/rebuild.sh @@ -15,4 +15,4 @@ rm -rf src/octop/dashboard rm -rf green/ make build-frontend -make -f scripts/green/Makefile green +make -f desktop/portable/Makefile green diff --git a/scripts/green/templates/README.txt b/desktop/portable/templates/README.txt similarity index 100% rename from scripts/green/templates/README.txt rename to desktop/portable/templates/README.txt diff --git a/scripts/green/templates/launch.py b/desktop/portable/templates/launch.py similarity index 100% rename from scripts/green/templates/launch.py rename to desktop/portable/templates/launch.py diff --git a/scripts/green/templates/start.bat b/desktop/portable/templates/start.bat similarity index 100% rename from scripts/green/templates/start.bat rename to desktop/portable/templates/start.bat diff --git a/scripts/green/templates/start.sh b/desktop/portable/templates/start.sh similarity index 100% rename from scripts/green/templates/start.sh rename to desktop/portable/templates/start.sh diff --git a/scripts/green/vendor-wheels.sh b/desktop/portable/vendor-wheels.sh similarity index 86% rename from scripts/green/vendor-wheels.sh rename to desktop/portable/vendor-wheels.sh index c1102d6c..9016da0c 100755 --- a/scripts/green/vendor-wheels.sh +++ b/desktop/portable/vendor-wheels.sh @@ -2,13 +2,13 @@ # Prefetch wheels for OCTOP_GREEN_OFFLINE=1 packaging. # # Usage: -# bash scripts/green/vendor-wheels.sh # host platform -# bash scripts/green/vendor-wheels.sh windows-amd64 +# bash desktop/portable/vendor-wheels.sh # host platform +# bash desktop/portable/vendor-wheels.sh windows-amd64 set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -# shellcheck source=scripts/green/_common.sh -source "${REPO_ROOT}/scripts/green/_common.sh" +# shellcheck source=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_common.sh" require_uv diff --git a/scripts/green/verify_imports.py b/desktop/portable/verify_imports.py similarity index 98% rename from scripts/green/verify_imports.py rename to desktop/portable/verify_imports.py index 60c1f11c..7c8ed499 100755 --- a/scripts/green/verify_imports.py +++ b/desktop/portable/verify_imports.py @@ -2,7 +2,7 @@ """Smoke-check a green packages/ tree against frozen requirements. Usage: - python scripts/green/verify_imports.py \\ + python desktop/portable/verify_imports.py \\ --packages green/release/Octop-/packages \\ --requirements green/requirements-.txt \\ [--overrides green/overrides-.txt] diff --git a/desktop/src/.gitignore b/desktop/src/.gitignore new file mode 100644 index 00000000..7e996a3e --- /dev/null +++ b/desktop/src/.gitignore @@ -0,0 +1,5 @@ +bin/ +build/bin/ +frontend/dist/ +frontend/node_modules/ +*.exe diff --git a/desktop/src/assets/index.html b/desktop/src/assets/index.html new file mode 100644 index 00000000..27ebfd5b --- /dev/null +++ b/desktop/src/assets/index.html @@ -0,0 +1,154 @@ + + + + + + Octop + + + + +
+

Octop

+

+ +
+
+
语言 / Language
+ 选择偏好语言 / Choose your preferred language +
+ +
+
+
+
深色主题
+ 切换明暗主题 +
+ +
+
+
+
开机启动
+ 登录系统后自动启动 +
+ +
+
+
+
最小化到托盘
+ 点击最小化时隐藏窗口并保留在系统托盘(关闭窗口仍会进入托盘) +
+ +
+
+
+
防止系统休眠(仅 macOS)
+ 启用后将保持系统唤醒(等效 caffeinate -dimsu) +
+ +
+
+ + + diff --git a/desktop/src/download.go b/desktop/src/download.go new file mode 100644 index 00000000..3a58c910 --- /dev/null +++ b/desktop/src/download.go @@ -0,0 +1,207 @@ +package main + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +type ghRelease struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` +} + +func launchReady(root string) bool { + if _, err := os.Stat(filepath.Join(root, "launch.py")); err != nil { + return false + } + if runtime.GOOS == "windows" { + _, err := os.Stat(filepath.Join(root, "runtime", "python.exe")) + return err == nil + } + _, err := os.Stat(filepath.Join(root, "runtime", "bin", "python3")) + return err == nil +} + +func pythonExe(root string) string { + if runtime.GOOS == "windows" { + return filepath.Join(root, "runtime", "python.exe") + } + return filepath.Join(root, "runtime", "bin", "python3") +} + +func ensureGreenZip(repo string, status func(string)) error { + root := portableDir() + if launchReady(root) { + status("using existing portable runtime") + return nil + } + plat := greenPlat() + name := fmt.Sprintf("Octop-%s.zip", plat) + status("fetching GitHub release " + name) + url, err := latestAssetURL(repo, name) + if err != nil { + return err + } + zipPath := filepath.Join(octopHome(), name) + if err := os.MkdirAll(octopHome(), 0o755); err != nil { + return err + } + status("downloading " + url) + if err := downloadFile(url, zipPath); err != nil { + return err + } + defer os.Remove(zipPath) + status("extracting") + if err := unzipGreen(zipPath, root); err != nil { + return err + } + if runtime.GOOS == "darwin" { + _ = exec.Command("xattr", "-dr", "com.apple.quarantine", root).Run() + } + if !launchReady(root) { + return fmt.Errorf("portable extract missing launch.py or python under %s", root) + } + return nil +} + +func latestAssetURL(repo, assetName string) (string, error) { + api := "https://api.github.com/repos/" + repo + "/releases/latest" + req, err := http.NewRequest(http.MethodGet, api, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "octop-desktop") + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return "", fmt.Errorf("github releases: HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + var rel ghRelease + if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil { + return "", err + } + for _, a := range rel.Assets { + if a.Name == assetName { + return a.BrowserDownloadURL, nil + } + } + return "", fmt.Errorf("release %s has no asset %s", rel.TagName, assetName) +} + +func downloadFile(url, dest string) error { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "octop-desktop") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download HTTP %d", resp.StatusCode) + } + tmp := dest + ".partial" + out, err := os.Create(tmp) + if err != nil { + return err + } + _, err = io.Copy(out, resp.Body) + closeErr := out.Close() + if err != nil { + _ = os.Remove(tmp) + return err + } + if closeErr != nil { + _ = os.Remove(tmp) + return closeErr + } + return os.Rename(tmp, dest) +} + +func unzipGreen(zipPath, dest string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + _ = os.RemoveAll(dest) + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + // Zip root is Octop-/… — strip that prefix. + for _, f := range r.File { + name := f.Name + parts := strings.SplitN(name, "/", 2) + if len(parts) < 2 { + continue + } + rel := parts[1] + if rel == "" { + continue + } + target := filepath.Join(dest, filepath.FromSlash(rel)) + if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) && target != filepath.Clean(dest) { + return fmt.Errorf("illegal zip path %s", name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + rc.Close() + return err + } + _, err = io.Copy(out, rc) + out.Close() + rc.Close() + if err != nil { + return err + } + } + return nil +} + +func waitHealth(base string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + url := strings.TrimRight(base, "/") + "/api/health" + for time.Now().Before(deadline) { + resp, err := http.Get(url) + if err == nil { + resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 500 { + return nil + } + } + time.Sleep(400 * time.Millisecond) + } + return fmt.Errorf("octop did not become healthy at %s", url) +} diff --git a/desktop/src/go.mod b/desktop/src/go.mod new file mode 100644 index 00000000..c0e80da2 --- /dev/null +++ b/desktop/src/go.mod @@ -0,0 +1,16 @@ +module octop.desktop + +go 1.25.0 + +require github.com/wailsapp/wails/v3 v3.0.0-beta.13 + +require ( + github.com/adrg/xdg v0.5.3 // indirect + github.com/coder/websocket v1.8.14 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/sys v0.46.0 // indirect +) diff --git a/desktop/src/go.sum b/desktop/src/go.sum new file mode 100644 index 00000000..79651db9 --- /dev/null +++ b/desktop/src/go.sum @@ -0,0 +1,35 @@ +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= +github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= +github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wailsapp/wails/v3 v3.0.0-beta.13 h1:BgShB3PNtGS1U7YyyigTCVAdkuczbBzT0Q/aODjuP04= +github.com/wailsapp/wails/v3 v3.0.0-beta.13/go.mod h1:zKZYhB3WjrN5LhJWbnOAVMN0Xf8qTozbw2nf5micKl4= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/desktop/src/main.go b/desktop/src/main.go new file mode 100644 index 00000000..5cea34db --- /dev/null +++ b/desktop/src/main.go @@ -0,0 +1,272 @@ +package main + +import ( + "context" + "embed" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "sync" + "time" + + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +//go:embed assets/* +var assets embed.FS + +// App is the Wails service bound to the shell UI. +type App struct { + app *application.App + window *application.WebviewWindow + store *settingsStore + sleep *sleepGuard + cmd *exec.Cmd + mu sync.Mutex + quitting bool +} + +func (a *App) ServiceName() string { return "desktop" } + +func (a *App) ServiceStartup(context.Context, application.ServiceOptions) error { return nil } + +func (a *App) ServiceShutdown() error { + a.sleep.stop() + a.mu.Lock() + cmd := a.cmd + a.cmd = nil + a.mu.Unlock() + stopOctop(cmd) + return nil +} + +func (a *App) GetSettings() Settings { + return a.store.get() +} + +func (a *App) Platform() map[string]any { + return map[string]any{ + "darwin": isDarwin(), + "plat": greenPlat(), + "home": octopHome(), + } +} + +func (a *App) SaveSettings(next Settings) (Settings, error) { + cur := a.store.get() + if err := a.store.save(next); err != nil { + return cur, err + } + saved := a.store.get() + a.applyAutostart(saved.Autostart) + a.sleep.set(saved.PreventSleepMac) + a.applyDashboardPrefs(saved) + return saved, nil +} + +func (a *App) applyAutostart(on bool) { + if a.app == nil { + return + } + if on { + _ = a.app.Autostart.Enable() + return + } + _ = a.app.Autostart.Disable() +} + +func (a *App) applyDashboardPrefs(s Settings) { + if a.window == nil { + return + } + pref := "light" + if s.Theme == ThemeDark { + pref = "dark" + } + js := fmt.Sprintf( + `(function(){try{localStorage.setItem('octop:ui-locale',%s);var t={};try{t=JSON.parse(localStorage.getItem('theme')||'{}')||{}}catch(e){t={}}t.preference=%s;localStorage.setItem('theme',JSON.stringify(t));}catch(e){}})();`, + jsonString(string(s.Locale)), + jsonString(pref), + ) + a.window.ExecJS(js) +} + +func jsonString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + +func (a *App) setStatus(msg string) { + if a.app == nil { + return + } + a.app.Event.Emit("desktop:status", msg) +} + +func (a *App) boot() { + if url := os.Getenv("OCTOP_DESKTOP_URL"); url != "" { + a.setStatus("connecting " + url) + if err := waitHealth(url, 60*time.Second); err != nil { + a.setStatus(err.Error()) + return + } + a.showDashboard(url) + return + } + s := a.store.get() + a.setStatus("preparing portable runtime") + if err := ensureGreenZip(s.GitHubRepo, a.setStatus); err != nil { + a.setStatus(err.Error()) + return + } + root := portableDir() + a.mu.Lock() + stopOctop(a.cmd) + cmd, err := startOctop(root, s.Port) + a.cmd = cmd + a.mu.Unlock() + if err != nil { + a.setStatus(err.Error()) + return + } + base := dashboardURL(s.Port) + a.setStatus("waiting for Octop") + if err := waitHealth(base, 2*time.Minute); err != nil { + a.setStatus(err.Error()) + return + } + a.showDashboard(base) +} + +func (a *App) showDashboard(base string) { + if a.window == nil { + return + } + a.window.SetURL(base) + s := a.store.get() + go func() { + time.Sleep(800 * time.Millisecond) + a.applyDashboardPrefs(s) + }() + a.setStatus("ready") +} + +func (a *App) hideToTray() { + if a.window == nil { + return + } + if a.store.get().MinimizeToTray { + a.window.Hide() + } +} + +func (a *App) showWindow() { + if a.window == nil { + return + } + a.window.Show() + a.window.Focus() +} + +func (a *App) requestQuit() { + a.mu.Lock() + a.quitting = true + a.mu.Unlock() + if a.app != nil { + a.app.Quit() + } +} + +func main() { + store := &settingsStore{cur: loadSettings()} + api := &App{ + store: store, + sleep: &sleepGuard{}, + } + + app := application.New(application.Options{ + Name: "Octop", + Description: "Octop desktop", + Services: []application.Service{ + application.NewService(api), + }, + Assets: application.AssetOptions{ + Handler: application.BundledAssetFileServer(assets), + }, + Windows: application.WindowsOptions{ + DisableQuitOnLastWindowClosed: true, + }, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: false, + }, + }) + api.app = app + + win := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Octop", + Width: 1200, + Height: 800, + URL: "/", + BackgroundColour: application.NewRGB(15, 17, 21), + }) + api.window = win + + settingsWin := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Octop", + Width: 560, + Height: 640, + URL: "/", + Hidden: true, + BackgroundColour: application.NewRGB(15, 17, 21), + }) + + win.OnWindowEvent(events.Common.WindowClosing, func(e *application.WindowEvent) { + api.mu.Lock() + quit := api.quitting + api.mu.Unlock() + if quit { + return + } + e.Cancel() + api.hideToTray() + }) + win.OnWindowEvent(events.Common.WindowMinimise, func(_ *application.WindowEvent) { + api.hideToTray() + }) + + settingsWin.OnWindowEvent(events.Common.WindowClosing, func(e *application.WindowEvent) { + api.mu.Lock() + quit := api.quitting + api.mu.Unlock() + if quit { + return + } + e.Cancel() + settingsWin.Hide() + }) + + tray := app.SystemTray.New() + menu := app.NewMenu() + menu.Add("Show Octop").OnClick(func(*application.Context) { api.showWindow() }) + menu.Add("Settings").OnClick(func(*application.Context) { + settingsWin.Show() + settingsWin.Focus() + }) + menu.AddSeparator() + menu.Add("Quit").OnClick(func(*application.Context) { api.requestQuit() }) + tray.SetMenu(menu) + tray.SetTooltip("Octop") + tray.OnClick(func() { api.showWindow() }) + + api.applyAutostart(store.get().Autostart) + api.sleep.set(store.get().PreventSleepMac) + + go api.boot() + + if err := app.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/desktop/src/preventsleep.go b/desktop/src/preventsleep.go new file mode 100644 index 00000000..6001f917 --- /dev/null +++ b/desktop/src/preventsleep.go @@ -0,0 +1,52 @@ +package main + +import ( + "os" + "os/exec" + "runtime" + "sync" +) + +type sleepGuard struct { + mu sync.Mutex + cmd *exec.Cmd +} + +func (g *sleepGuard) set(enabled bool) { + if runtime.GOOS != "darwin" { + return + } + g.mu.Lock() + defer g.mu.Unlock() + if enabled { + if g.cmd != nil && g.cmd.Process != nil { + return + } + cmd := exec.Command("caffeinate", "-dimsu") + if err := cmd.Start(); err != nil { + return + } + g.cmd = cmd + return + } + if g.cmd != nil && g.cmd.Process != nil { + _ = g.cmd.Process.Kill() + _, _ = g.cmd.Process.Wait() + } + g.cmd = nil +} + +func (g *sleepGuard) stop() { + g.set(false) +} + +func isDarwin() bool { + return runtime.GOOS == "darwin" +} + +func mustEnv(cmd *exec.Cmd, extra map[string]string) { + cmd.Env = os.Environ() + for k, v := range extra { + cmd.Env = append(cmd.Env, k+"="+v) + } +} diff --git a/desktop/src/process.go b/desktop/src/process.go new file mode 100644 index 00000000..6a86f372 --- /dev/null +++ b/desktop/src/process.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" +) + +func startOctop(root string, port int) (*exec.Cmd, error) { + py := pythonExe(root) + launch := filepath.Join(root, "launch.py") + cmd := exec.Command(py, launch, "run", "--host", "127.0.0.1", "--port", strconv.Itoa(port)) + cmd.Dir = root + mustEnv(cmd, map[string]string{ + "OCTOP_HOME": octopHome(), + "OCTOP_GREEN_PACKAGES": filepath.Join(root, "packages"), + "PYTHONNOUSERSITE": "1", + "PYTHONPATH": "", + }) + configureProcGroup(cmd) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return nil, err + } + return cmd, nil +} + +func stopOctop(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + killProcessTree(cmd) +} + +func dashboardURL(port int) string { + return fmt.Sprintf("http://127.0.0.1:%d/", port) +} diff --git a/desktop/src/process_unix.go b/desktop/src/process_unix.go new file mode 100644 index 00000000..68d45c53 --- /dev/null +++ b/desktop/src/process_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package main + +import ( + "os/exec" + "syscall" +) + +func configureProcGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +func killProcessTree(cmd *exec.Cmd) { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) + _ = cmd.Wait() +} diff --git a/desktop/src/process_windows.go b/desktop/src/process_windows.go new file mode 100644 index 00000000..367e17ca --- /dev/null +++ b/desktop/src/process_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package main + +import ( + "os/exec" + "strconv" +) + +func configureProcGroup(cmd *exec.Cmd) {} + +func killProcessTree(cmd *exec.Cmd) { + pid := cmd.Process.Pid + _ = exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(pid)).Run() +} diff --git a/desktop/src/settings.go b/desktop/src/settings.go new file mode 100644 index 00000000..a762ae2c --- /dev/null +++ b/desktop/src/settings.go @@ -0,0 +1,148 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "sync" +) + +type Locale string + +const ( + LocaleZH Locale = "zh" + LocaleEN Locale = "en" +) + +type ThemeMode string + +const ( + ThemeLight ThemeMode = "light" + ThemeDark ThemeMode = "dark" +) + +// Settings is persisted at ~/.octop/desktop-settings.json +type Settings struct { + Locale Locale `json:"locale"` + Theme ThemeMode `json:"theme"` + Autostart bool `json:"autostart"` + MinimizeToTray bool `json:"minimizeToTray"` + PreventSleepMac bool `json:"preventSleepMac"` + GitHubRepo string `json:"githubRepo,omitempty"` + Port int `json:"port,omitempty"` +} + +func defaultSettings() Settings { + return Settings{ + Locale: LocaleZH, + Theme: ThemeLight, + Autostart: false, + MinimizeToTray: true, + PreventSleepMac: false, + GitHubRepo: defaultGitHubRepo(), + Port: 8088, + } +} + +func defaultGitHubRepo() string { + if v := os.Getenv("OCTOP_DESKTOP_GITHUB_REPO"); v != "" { + return v + } + return "forcemeter/Octop-Agent" +} + +func octopHome() string { + if v := os.Getenv("OCTOP_HOME"); v != "" { + return v + } + home, err := os.UserHomeDir() + if err != nil { + return ".octop" + } + return filepath.Join(home, ".octop") +} + +func portableDir() string { + return filepath.Join(octopHome(), "portable") +} + +func settingsPath() string { + return filepath.Join(octopHome(), "desktop-settings.json") +} + +type settingsStore struct { + mu sync.Mutex + cur Settings +} + +func loadSettings() Settings { + s := defaultSettings() + data, err := os.ReadFile(settingsPath()) + if err != nil { + return s + } + _ = json.Unmarshal(data, &s) + if s.Port == 0 { + s.Port = 8088 + } + if s.GitHubRepo == "" { + s.GitHubRepo = defaultGitHubRepo() + } + if s.Locale != LocaleEN { + s.Locale = LocaleZH + } + if s.Theme != ThemeDark { + s.Theme = ThemeLight + } + return s +} + +func (st *settingsStore) get() Settings { + st.mu.Lock() + defer st.mu.Unlock() + return st.cur +} + +func (st *settingsStore) save(next Settings) error { + st.mu.Lock() + defer st.mu.Unlock() + if next.Port == 0 { + next.Port = 8088 + } + if next.GitHubRepo == "" { + next.GitHubRepo = defaultGitHubRepo() + } + if err := os.MkdirAll(octopHome(), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(next, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(settingsPath(), data, 0o644); err != nil { + return err + } + st.cur = next + return nil +} + +func greenPlat() string { + osName := runtime.GOOS + arch := runtime.GOARCH + switch osName { + case "darwin": + osName = "darwin" + case "windows": + osName = "windows" + default: + osName = "linux" + } + switch arch { + case "arm64": + arch = "arm64" + default: + arch = "amd64" + } + return osName + "-" + arch +} diff --git a/scripts/green/Makefile b/scripts/green/Makefile deleted file mode 100644 index 7f7477d8..00000000 --- a/scripts/green/Makefile +++ /dev/null @@ -1,50 +0,0 @@ -# Standalone Makefile for green portable packaging. -# Does not modify the repo-root Makefile — merge-friendly with upstream. -# -# Usage (from repo root): -# make -f scripts/green/Makefile green -# make -f scripts/green/Makefile green GREEN_PLAT=linux-amd64 -# make -f scripts/green/Makefile green-linux -# make -f scripts/green/Makefile clean - -SHELL := /bin/bash -REPO_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../..) -GREEN_PLAT ?= - -.DEFAULT_GOAL := help - -.PHONY: help -help: - @echo "Green portable (offline zip)" - @echo " make -f scripts/green/Makefile green # host plat zip" - @echo " make -f scripts/green/Makefile green GREEN_PLAT=…" - @echo " make -f scripts/green/Makefile bootstrap|wheels|package|green-linux|clean" - -.PHONY: bootstrap -bootstrap: - bash $(REPO_ROOT)/scripts/green/bootstrap-runtime.sh $(GREEN_PLAT) - -.PHONY: wheels -wheels: - bash $(REPO_ROOT)/scripts/green/vendor-wheels.sh $(GREEN_PLAT) - -.PHONY: package -package: - bash $(REPO_ROOT)/scripts/green/package.sh $(GREEN_PLAT) - -.PHONY: green -green: - $(MAKE) -C $(REPO_ROOT) build-frontend - bash $(REPO_ROOT)/scripts/green/bootstrap-runtime.sh $(GREEN_PLAT) - bash $(REPO_ROOT)/scripts/green/package.sh $(GREEN_PLAT) - @echo "[green] Done. Zips under $(REPO_ROOT)/green/release/" - -.PHONY: green-linux -green-linux: - $(MAKE) -C $(REPO_ROOT) build-frontend - bash $(REPO_ROOT)/scripts/green/package-linux-docker.sh $(if $(GREEN_PLAT),$(GREEN_PLAT),linux-amd64) - -.PHONY: clean -clean: - rm -rf $(REPO_ROOT)/green - @echo "[green] removed $(REPO_ROOT)/green" diff --git a/src/octop/infra/setup/self_update.py b/src/octop/infra/setup/self_update.py index 2b014fc0..6d760d60 100644 --- a/src/octop/infra/setup/self_update.py +++ b/src/octop/infra/setup/self_update.py @@ -21,6 +21,7 @@ _PACKAGE_NAME = "octop" _PYPI_URL = f"https://pypi.org/pypi/{_PACKAGE_NAME}/json" +_GREEN_PACKAGES_ENV = "OCTOP_GREEN_PACKAGES" _MIRRORS = [ "https://mirrors.cloud.tencent.com/pypi/simple", @@ -46,8 +47,20 @@ class UpgradeResult: mirror_errors: list[str] = field(default_factory=list) +def green_packages_dir() -> Path | None: + """Return ``--target`` dir for green portable installs, if configured.""" + raw = (os.environ.get(_GREEN_PACKAGES_ENV) or "").strip() + if not raw: + return None + return Path(raw).expanduser() + + def resolve_venv_python() -> str: """Return the Python executable for the managed ~/.octop/venv install.""" + # Green portable: always the interpreter that launched launch.py, never ~/.octop/venv. + if green_packages_dir() is not None: + return sys.executable + base_prefix = getattr(sys, "base_prefix", sys.prefix) if sys.prefix != base_prefix: return sys.executable @@ -205,6 +218,11 @@ def build_upgrade_command( *, index_url: str = "", ) -> list[str] | None: + target = green_packages_dir() + target_args: list[str] = [] + if target is not None: + target_args = ["--target", str(target)] + if installer == "uv": uv_exe = find_uv_executable() cmd = [ @@ -213,6 +231,7 @@ def build_upgrade_command( "install", "--python", venv_python, + *target_args, "--upgrade-package", _PACKAGE_NAME, ] @@ -223,16 +242,16 @@ def build_upgrade_command( upgrade_flags = ["--upgrade", "--upgrade-strategy", "only-if-needed"] if has_pip(venv_python): - cmd = [venv_python, "-m", "pip", "install", *upgrade_flags] + cmd = [venv_python, "-m", "pip", "install", *upgrade_flags, *target_args] else: venv_pip = find_pip_in_venv(venv_python) if venv_pip: - cmd = [venv_pip, "install", *upgrade_flags] + cmd = [venv_pip, "install", *upgrade_flags, *target_args] else: standalone = shutil.which("pip3") or shutil.which("pip") if not standalone: return None - cmd = [standalone, "install", *upgrade_flags] + cmd = [standalone, "install", *upgrade_flags, *target_args] if index_url: cmd.extend(["-i", index_url]) cmd.append(_PACKAGE_NAME) diff --git a/tests/unit/cli/test_update_cmd.py b/tests/unit/cli/test_update_cmd.py index 43e2e428..954ae6b0 100644 --- a/tests/unit/cli/test_update_cmd.py +++ b/tests/unit/cli/test_update_cmd.py @@ -80,6 +80,36 @@ def test_build_upgrade_command_uses_managed_venv_python() -> None: assert "--upgrade-package" in cmd assert "octop" in cmd assert "https://mirror.example/simple" in cmd + assert "--target" not in cmd + + +def test_build_upgrade_command_green_packages_target( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + packages = tmp_path / "packages" + packages.mkdir() + monkeypatch.setenv("OCTOP_GREEN_PACKAGES", str(packages)) + python = "/opt/octop/runtime/bin/python3" + cmd = self_update.build_upgrade_command("uv", python) + assert cmd is not None + assert "--target" in cmd + assert str(packages) in cmd + + +def test_resolve_venv_python_green_uses_current_interpreter( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + packages = tmp_path / "packages" + packages.mkdir() + venv_python = tmp_path / "venv" / "bin" / "python" + venv_python.parent.mkdir(parents=True) + venv_python.write_text("#!/bin/sh\n", encoding="utf-8") + monkeypatch.setenv("OCTOP_HOME", str(tmp_path)) + monkeypatch.setenv("OCTOP_GREEN_PACKAGES", str(packages)) + monkeypatch.setattr(self_update.sys, "executable", "/bundled/python3") + monkeypatch.setattr(self_update.sys, "prefix", "/usr") + monkeypatch.setattr(self_update.sys, "base_prefix", "/usr") + assert self_update.resolve_venv_python() == "/bundled/python3" def test_resolve_venv_python_prefers_octop_home( diff --git a/tests/unit/test_green_launch.py b/tests/unit/test_green_launch.py index 5c494b60..f8483615 100644 --- a/tests/unit/test_green_launch.py +++ b/tests/unit/test_green_launch.py @@ -13,7 +13,7 @@ def _load_launch_module() -> ModuleType: - path = Path(__file__).resolve().parents[2] / "scripts" / "green" / "templates" / "launch.py" + path = Path(__file__).resolve().parents[2] / "desktop" / "portable" / "templates" / "launch.py" spec = importlib.util.spec_from_file_location("green_portable_launch", path) assert spec is not None and spec.loader is not None module = importlib.util.module_from_spec(spec) From ce438f0f6a71cded30aa2218f50ae3314df7edf5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 02:54:54 +0000 Subject: [PATCH 03/18] fix(desktop): add Wails v3 Taskfile and build/config.yml wails3 build/dev look for Taskfile.yml and build/config.yml in desktop/src. Un-ignore that tree from the repo-wide build/ gitignore. Co-authored-by: Harry --- .gitignore | 2 + CHANGELOG.md | 2 +- desktop/README.md | 20 ++++--- desktop/src/.gitignore | 9 ++-- desktop/src/Taskfile.yml | 33 ++++++++++++ desktop/src/build/Taskfile.yml | 51 ++++++++++++++++++ desktop/src/build/appicon.png | Bin 0 -> 1000 bytes desktop/src/build/config.yml | 45 ++++++++++++++++ desktop/src/build/darwin/Info.dev.plist | 35 ++++++++++++ desktop/src/build/darwin/Info.plist | 33 ++++++++++++ desktop/src/build/darwin/Taskfile.yml | 53 +++++++++++++++++++ desktop/src/build/linux/Taskfile.yml | 32 +++++++++++ desktop/src/build/windows/Taskfile.yml | 47 ++++++++++++++++ desktop/src/build/windows/info.json | 15 ++++++ desktop/src/build/windows/wails.exe.manifest | 22 ++++++++ 15 files changed, 387 insertions(+), 12 deletions(-) create mode 100644 desktop/src/Taskfile.yml create mode 100644 desktop/src/build/Taskfile.yml create mode 100644 desktop/src/build/appicon.png create mode 100644 desktop/src/build/config.yml create mode 100644 desktop/src/build/darwin/Info.dev.plist create mode 100644 desktop/src/build/darwin/Info.plist create mode 100644 desktop/src/build/darwin/Taskfile.yml create mode 100644 desktop/src/build/linux/Taskfile.yml create mode 100644 desktop/src/build/windows/Taskfile.yml create mode 100644 desktop/src/build/windows/info.json create mode 100644 desktop/src/build/windows/wails.exe.manifest diff --git a/.gitignore b/.gitignore index 7de1784b..2162b8a4 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,8 @@ __pycache__/ *.so .Python build/ +!desktop/src/build/ +!desktop/src/build/** dist/ *.egg-info/ *.egg diff --git a/CHANGELOG.md b/CHANGELOG.md index 14756468..3c87aca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ ### 新增 - 绿色便携包:`desktop/portable/` 用 python-build-standalone + 锁定依赖打出 `Octop-.zip`(六平台 Actions),解压后经 `launch.py` 启动;不含 OOB/UI 裁剪,首启走上游 setup wizard -- 桌面壳(Wails v3):`desktop/src/` 从 GitHub Release 下载绿包并启动 Octop;`OCTOP_HOME` 与默认一致(`~/.octop`);内置升级对绿包使用 `--target packages` +- 桌面壳(Wails v3):`desktop/src/` 从 GitHub Release 下载绿包并启动 Octop;`OCTOP_HOME` 与默认一致(`~/.octop`);内置升级对绿包使用 `--target packages`;补齐 `Taskfile.yml` / `build/config.yml`,可在该目录执行 `wails3 dev` / `wails3 build` ## [0.9.26] - 2026-08-23 diff --git a/desktop/README.md b/desktop/README.md index a1f8010a..b35929c1 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -28,21 +28,27 @@ CI: `.github/workflows/octop-portable.yml` (unchanged location). ## Build the Wails shell -Requires **Go 1.25+** and [Wails v3](https://v3.wails.io/) (`wails3`). +Run these from **`desktop/src`** (that directory contains `Taskfile.yml` and +`build/config.yml`). Requires **Go 1.25+**, [Wails v3](https://v3.wails.io/) +`v3.0.0-beta.13`, and [Task](https://taskfile.dev/) (`task` on `PATH`). ```bash +go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13 cd desktop/src go mod tidy -# generate bindings if you have the CLI: -# wails3 generate -wails3 build +wails3 generate icons # once: appicon.png → .icns / .ico +wails3 build # native binary under desktop/src/bin/ ``` -Dev against an already-running Octop: +Dev against an already-running Octop (skips downloading the green zip): ```bash +cd desktop/src OCTOP_DESKTOP_URL=http://127.0.0.1:8088 wails3 dev ``` -First launch without that env downloads `Octop-.zip` from the latest -GitHub Release (`OCTOP_DESKTOP_GITHUB_REPO`, default `forcemeter/Octop-Agent`). +Without `OCTOP_DESKTOP_URL`, first launch uses `~/.octop/portable/` if present, +otherwise downloads `Octop-.zip` from the latest GitHub Release +(`OCTOP_DESKTOP_GITHUB_REPO`, default `forcemeter/Octop-Agent`). + +Linux also needs GTK4 + WebKitGTK 6 to link. macOS 12+. diff --git a/desktop/src/.gitignore b/desktop/src/.gitignore index 7e996a3e..b4372fbd 100644 --- a/desktop/src/.gitignore +++ b/desktop/src/.gitignore @@ -1,5 +1,6 @@ bin/ -build/bin/ -frontend/dist/ -frontend/node_modules/ -*.exe +*.syso +build/windows/*.syso +build/darwin/icons.icns +build/windows/icon.ico +build/linux/icon.png diff --git a/desktop/src/Taskfile.yml b/desktop/src/Taskfile.yml new file mode 100644 index 00000000..7eae6780 --- /dev/null +++ b/desktop/src/Taskfile.yml @@ -0,0 +1,33 @@ +version: "3" + +includes: + common: ./build/Taskfile.yml + windows: ./build/windows/Taskfile.yml + darwin: ./build/darwin/Taskfile.yml + linux: ./build/linux/Taskfile.yml + +vars: + APP_NAME: "Octop" + BIN_DIR: "bin" + VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}' + +tasks: + build: + summary: Builds the Octop desktop shell + cmds: + - task: "{{OS}}:build" + + package: + summary: Packages a production build + cmds: + - task: "{{OS}}:package" + + run: + summary: Runs the built binary + cmds: + - task: "{{OS}}:run" + + dev: + summary: Dev mode (watches Go, rebuilds) + cmds: + - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}} diff --git a/desktop/src/build/Taskfile.yml b/desktop/src/build/Taskfile.yml new file mode 100644 index 00000000..db583474 --- /dev/null +++ b/desktop/src/build/Taskfile.yml @@ -0,0 +1,51 @@ +version: "3" + +# Shell UI is Go-embedded HTML (desktop/src/assets). No Vite/npm frontend. + +tasks: + go:mod:tidy: + summary: Runs go mod tidy + internal: true + run: once + cmds: + - go mod tidy + + install:frontend:deps: + summary: No npm frontend + cmds: + - echo "embedded assets; skip npm" + + build:frontend: + summary: No Vite build (assets embedded) + cmds: + - echo "embedded assets; skip vite" + + generate:bindings: + summary: Bindings unused (Call.ByName) + cmds: + - echo "skip generate bindings" + + generate:icons: + summary: Generates .ico / .icns from appicon.png + dir: build + sources: + - appicon.png + generates: + - darwin/icons.icns + - windows/icon.ico + cmds: + - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico + + dev:frontend: + summary: Keep a process so wails3 dev background slot stays up + cmds: + - cmd: sleep 86400 + platforms: [linux, darwin] + - cmd: timeout /t 86400 /nobreak + platforms: [windows] + + update:build-assets: + summary: Updates generated build assets from config.yml + dir: build + cmds: + - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir . diff --git a/desktop/src/build/appicon.png b/desktop/src/build/appicon.png new file mode 100644 index 0000000000000000000000000000000000000000..aa7ad883b025d5b8ff66e4fdf4dd94c75b8d0787 GIT binary patch literal 1000 zcma)5Uq};i9RL3AZtncEd57gf&h0iqq7b!$%^G@}XG=)2u%4tAnd~9h$cjCTaXM{^ zw$W1)p-6j>9?F-X2;BK6_A(y?dkLzW1L?uy_9FMNpx@?5$)bKQzwhVw`SbgJ`Q1(S z^?D2ybrk?$=)NFc20*w(K$qcd;LgGj0Qst0boB>6e%nL_NvNWh>ts8jR-WLWpL;xw zo_wF4`NR6*OWphIS8uJ?THXkWw)>Y_hIi(?+h5E+7NnRyd=I@(MP#jaJQxg{ns z@2N^>8mMiRC5AP6q%AZia~(FX(PC|h#+gLcLltR_MMXL+%3QnK8?&%2sW_cj_0Z{D z1F?mroOPW6vvGx0eT+fUM*@_yRwR{YJeE)d zY!b>yp(L^T7*@|q0Iwm);PR$`jRZE*JP1u-23+P`g%>%6)C|7DQcuBxPS!^QROmK? zJH^zlUXDJ%cyJ%<3wWS}DLjP$(iNoMC?p)+!`S#gMY~a=1x?cdCF{!$Sgw`{6d%eH zKq#FN00Qn3Ji)Yw5g>KB*zsA!Ali}y2{g6O@GFJH)4qiX7>i%WT{)?Ka?d9PL4=i5 zGw@(8dr|TVPOw2HYr+#$>pqlRyct+Y%~itM?^dR|cuf~<;hYlC!PZ_FE#&bShX9wH zgjk^!5x6A&4ooOen2IM9t!w|phBcI&n*v9eg;evu$Z?4JZBo|G%DGuaQG2LBzL9c- zY7mz!N5(HAr2KAa)oztDv$Uf1&;hMT6WmgPUIR~&Zk3-7x)pVOP2QL-AC1^+NRPj< nBKGOx5$@g0L(+cw@H)AJ#HL5BQ@e}3_z#Beu3qt_+dKLbZ2mWi literal 0 HcmV?d00001 diff --git a/desktop/src/build/config.yml b/desktop/src/build/config.yml new file mode 100644 index 00000000..b328236e --- /dev/null +++ b/desktop/src/build/config.yml @@ -0,0 +1,45 @@ +# This file contains the configuration for this project. +# When you update `info`, run `wails3 task common:update:build-assets` to refresh generated assets. +version: "3" + +info: + companyName: "Octop" + productName: "Octop" + productIdentifier: "com.octop.desktop" + description: "Octop desktop shell" + copyright: "(c) 2026, Octop" + comments: "Wails v3 + green portable" + version: "0.9.26" + +dev_mode: + root_path: . + log_level: warn + debounce: 1000 + ignore: + dir: + - .git + - node_modules + - frontend + - bin + - assets + file: + - .DS_Store + - .gitignore + - .gitkeep + - "*_test.go" + watched_extension: + - "*.go" + git_ignore: true + executes: + - cmd: wails3 task common:install:frontend:deps + type: once + - cmd: wails3 task common:dev:frontend + type: background + - cmd: go mod tidy + type: blocking + - cmd: wails3 task build + type: blocking + - cmd: wails3 task run + type: primary + +fileAssociations: [] diff --git a/desktop/src/build/darwin/Info.dev.plist b/desktop/src/build/darwin/Info.dev.plist new file mode 100644 index 00000000..1b2364b5 --- /dev/null +++ b/desktop/src/build/darwin/Info.dev.plist @@ -0,0 +1,35 @@ + + + + + CFBundlePackageType + APPL + CFBundleName + Octop + CFBundleExecutable + Octop + CFBundleIdentifier + com.octop.desktop + CFBundleVersion + 0.9.26.dev + CFBundleGetInfoString + Octop desktop shell (dev) + CFBundleShortVersionString + 0.9.26.dev + CFBundleIconFile + icons + LSMinimumSystemVersion + 12.0.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + Copyright © 2026 + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + + diff --git a/desktop/src/build/darwin/Info.plist b/desktop/src/build/darwin/Info.plist new file mode 100644 index 00000000..8a5ceba3 --- /dev/null +++ b/desktop/src/build/darwin/Info.plist @@ -0,0 +1,33 @@ + + + + + CFBundlePackageType + APPL + CFBundleName + Octop + CFBundleExecutable + Octop + CFBundleIdentifier + com.octop.desktop + CFBundleVersion + 0.9.26 + CFBundleGetInfoString + Octop desktop shell + CFBundleShortVersionString + 0.9.26 + CFBundleIconFile + icons + LSMinimumSystemVersion + 12.0.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + Copyright © 2026 + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/desktop/src/build/darwin/Taskfile.yml b/desktop/src/build/darwin/Taskfile.yml new file mode 100644 index 00000000..a873bd4a --- /dev/null +++ b/desktop/src/build/darwin/Taskfile.yml @@ -0,0 +1,53 @@ +version: "3" + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Creates a production build of the application + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + - task: common:generate:icons + cmds: + - go build {{.BUILD_FLAGS}} -o {{.OUTPUT}} + vars: + BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + DEFAULT_OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}" + OUTPUT: "{{ .OUTPUT | default .DEFAULT_OUTPUT }}" + env: + GOOS: darwin + CGO_ENABLED: 1 + GOARCH: "{{.ARCH | default ARCH}}" + CGO_CFLAGS: "-mmacosx-version-min=12.0" + CGO_LDFLAGS: "-mmacosx-version-min=12.0" + MACOSX_DEPLOYMENT_TARGET: "12.0" + PRODUCTION: '{{.PRODUCTION | default "false"}}' + + package: + summary: Packages a production .app bundle + deps: + - task: build + vars: + PRODUCTION: "true" + cmds: + - task: create:app:bundle + + create:app:bundle: + summary: Creates an .app bundle + cmds: + - mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/{MacOS,Resources} + - cp build/darwin/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources + - cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS + - cp build/darwin/Info.plist {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents + - codesign --force --deep --sign - {{.BIN_DIR}}/{{.APP_NAME}}.app + + run: + cmds: + - mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/{MacOS,Resources} + - cp build/darwin/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources + - cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS + - cp build/darwin/Info.dev.plist {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist + - codesign --force --deep --sign - {{.BIN_DIR}}/{{.APP_NAME}}.dev.app + - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}" diff --git a/desktop/src/build/linux/Taskfile.yml b/desktop/src/build/linux/Taskfile.yml new file mode 100644 index 00000000..937369b2 --- /dev/null +++ b/desktop/src/build/linux/Taskfile.yml @@ -0,0 +1,32 @@ +version: "3" + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Builds the application for Linux + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + - task: common:generate:icons + cmds: + - go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}} + vars: + BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + env: + GOOS: linux + CGO_ENABLED: 1 + GOARCH: "{{.ARCH | default ARCH}}" + PRODUCTION: '{{.PRODUCTION | default "false"}}' + + package: + summary: Production binary + deps: + - task: build + vars: + PRODUCTION: "true" + + run: + cmds: + - "{{.BIN_DIR}}/{{.APP_NAME}}" diff --git a/desktop/src/build/windows/Taskfile.yml b/desktop/src/build/windows/Taskfile.yml new file mode 100644 index 00000000..992aae61 --- /dev/null +++ b/desktop/src/build/windows/Taskfile.yml @@ -0,0 +1,47 @@ +version: "3" + +includes: + common: ../Taskfile.yml + +tasks: + build: + summary: Builds the Windows binary + deps: + - task: common:go:mod:tidy + - task: common:build:frontend + - task: common:generate:icons + cmds: + - task: generate:syso + vars: + ARCH: "{{.ARCH}}" + - go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}.exe" + - cmd: powershell Remove-item *.syso + platforms: [windows] + - cmd: rm -f *.syso + platforms: [linux, darwin] + vars: + BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}' + env: + GOOS: windows + CGO_ENABLED: "0" + GOARCH: "{{.ARCH | default ARCH}}" + PRODUCTION: '{{.PRODUCTION | default "false"}}' + + package: + summary: Production Windows exe + deps: + - task: build + vars: + PRODUCTION: "true" + + run: + cmds: + - "{{.BIN_DIR}}/{{.APP_NAME}}.exe" + + generate:syso: + summary: Generates Windows .syso (linked from module root) + dir: build + cmds: + - wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso + vars: + ARCH: "{{.ARCH | default ARCH}}" diff --git a/desktop/src/build/windows/info.json b/desktop/src/build/windows/info.json new file mode 100644 index 00000000..640d8282 --- /dev/null +++ b/desktop/src/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "0.9.26" + }, + "info": { + "0000": { + "ProductVersion": "0.9.26", + "CompanyName": "Octop", + "FileDescription": "Octop desktop", + "LegalCopyright": "Copyright © 2026", + "ProductName": "Octop", + "Comments": "Octop desktop shell" + } + } +} diff --git a/desktop/src/build/windows/wails.exe.manifest b/desktop/src/build/windows/wails.exe.manifest new file mode 100644 index 00000000..3ff7dde8 --- /dev/null +++ b/desktop/src/build/windows/wails.exe.manifest @@ -0,0 +1,22 @@ + + + + + + + + + + + true/pm + permonitorv2,permonitor + + + + + + + + + + From c8f70e87c74826c77a35fa877ed8d4e96cf12c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=A0=91=E6=B5=B7?= Date: Wed, 26 Aug 2026 14:17:05 +0800 Subject: [PATCH 04/18] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/octop-portable.yml | 57 ++++++-- .gitignore | 4 +- desktop/README.md | 43 ++++-- desktop/package-release.sh | 115 ++++++++++++++++ desktop/portable/.gitignore | 6 + .../portable/AGENT_ELECTRON_INTEGRATION.md | 6 +- desktop/portable/Makefile | 11 +- desktop/portable/_common.sh | 2 +- desktop/portable/bootstrap-runtime.sh | 2 +- desktop/portable/package.sh | 4 +- desktop/portable/rebuild.sh | 4 +- desktop/portable/verify_imports.py | 6 +- desktop/src/.gitignore | 1 + desktop/src/assets/index.html | 66 ++++++--- desktop/src/assets/tray-icon.png | Bin 0 -> 12269 bytes desktop/src/build/Taskfile.yml | 13 +- desktop/src/build/appicon.png | Bin 1000 -> 238751 bytes desktop/src/build/darwin/Taskfile.yml | 10 +- desktop/src/build/linux/Taskfile.yml | 14 +- desktop/src/build/windows/Taskfile.yml | 17 ++- desktop/src/cmd/devserver/main.go | 21 +++ desktop/src/download.go | 125 ++++++------------ desktop/src/download_test.go | 105 +++++++++++++++ desktop/src/main.go | 59 +++++---- desktop/src/settings.go | 15 --- 25 files changed, 525 insertions(+), 181 deletions(-) create mode 100755 desktop/package-release.sh create mode 100644 desktop/portable/.gitignore create mode 100644 desktop/src/assets/tray-icon.png create mode 100644 desktop/src/cmd/devserver/main.go create mode 100644 desktop/src/download_test.go diff --git a/.github/workflows/octop-portable.yml b/.github/workflows/octop-portable.yml index 499a64d1..d16c7513 100644 --- a/.github/workflows/octop-portable.yml +++ b/.github/workflows/octop-portable.yml @@ -68,16 +68,22 @@ jobs: matrix: include: - plat: linux-amd64 + arch: amd64 os: ubuntu-latest - plat: linux-arm64 + arch: arm64 os: ubuntu-24.04-arm - plat: darwin-arm64 + arch: arm64 os: macos-14 - plat: darwin-amd64 + arch: amd64 os: macos-15-intel - plat: windows-amd64 + arch: amd64 os: windows-latest - plat: windows-arm64 + arch: arm64 os: windows-11-arm defaults: run: @@ -139,7 +145,7 @@ jobs: if: steps.want.outputs.build == 'true' run: | set -euo pipefail - staging="green/release/Octop-${{ matrix.plat }}" + staging="desktop/portable/release/Octop-${{ matrix.plat }}" if [[ -x "${staging}/runtime/bin/python3" ]]; then py="${staging}/runtime/bin/python3" elif [[ -f "${staging}/runtime/python.exe" ]]; then @@ -148,7 +154,7 @@ jobs: echo "python missing under ${staging}/runtime" >&2 exit 1 fi - req_file="green/requirements-${{ matrix.plat }}.txt" + req_file="desktop/portable/requirements-${{ matrix.plat }}.txt" if [[ ! -f "$req_file" ]]; then echo "frozen requirements missing: ${req_file}" >&2 exit 1 @@ -157,7 +163,7 @@ jobs: --packages "${staging}/packages" --requirements "$req_file" ) - override_file="green/overrides-${{ matrix.plat }}.txt" + override_file="desktop/portable/overrides-${{ matrix.plat }}.txt" if [[ -f "$override_file" ]]; then verify_args+=(--overrides "$override_file") fi @@ -171,6 +177,30 @@ jobs: verify_no_homebrew_dylibs "${staging}/packages" "${{ matrix.plat }}" fi + - uses: actions/setup-go@v6 + if: steps.want.outputs.build == 'true' + with: + go-version: "1.25.x" + cache-dependency-path: desktop/src/go.sum + + - name: Install Linux desktop build dependencies + if: steps.want.outputs.build == 'true' && runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-4-dev libwebkitgtk-6.0-dev + + - name: Install Wails v3 CLI + if: steps.want.outputs.build == 'true' + run: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13 + + - name: Package desktop app with bundled portable runtime + if: steps.want.outputs.build == 'true' + working-directory: desktop/src + run: >- + wails3 task package + ARCH=${{ matrix.arch }} + PORTABLE_ZIP=../portable/release/Octop-${{ matrix.plat }}.zip + # archive: false — upload the prebuilt zip as-is. Default archive=true would # wrap it again, so Actions UI / "Download artifact" becomes zip-in-zip # (Octop-.zip containing another Octop-.zip). Affects every plat. @@ -178,7 +208,16 @@ jobs: - uses: actions/upload-artifact@v7 if: steps.want.outputs.build == 'true' with: - path: green/release/Octop-${{ matrix.plat }}.zip + path: desktop/portable/release/Octop-${{ matrix.plat }}.zip + archive: false + if-no-files-found: error + retention-days: 14 + + - name: Upload bundled desktop package + if: steps.want.outputs.build == 'true' + uses: actions/upload-artifact@v7 + with: + path: desktop/src/bin/Octop-Desktop-${{ matrix.plat }}.* archive: false if-no-files-found: error retention-days: 14 @@ -198,17 +237,17 @@ jobs: # v8 required to download archive:false artifacts from upload-artifact@v7 - uses: actions/download-artifact@v8 with: - pattern: Octop-*.zip - path: green-zips + pattern: Octop-* + path: release-assets merge-multiple: true - - name: List zips - run: ls -lh green-zips/ + - name: List release assets + run: ls -lh release-assets/ - name: Upload to GitHub Release uses: softprops/action-gh-release@v2 with: - files: green-zips/*.zip + files: release-assets/* fail_on_unmatched_files: true # Upsert: works whether Release workflow already created the release. generate_release_notes: false diff --git a/.gitignore b/.gitignore index 2162b8a4..e88a79d8 100644 --- a/.gitignore +++ b/.gitignore @@ -91,8 +91,8 @@ src/octop/dashboard/assets/* *.log logs/ -# green portable -/green +# Legacy green portable workspace +/green/ # Local monorepo dev helpers (not for upstream) diff --git a/desktop/README.md b/desktop/README.md index b35929c1..3bfe2543 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -6,7 +6,8 @@ All desktop-client code lives here. This is **not** `src/octop/infra/desktop` | Path | Role | |------|------| | [`portable/`](portable/) | Green zip packaging (was `scripts/green/`) | -| [`src/`](src/) | Wails v3 shell: download zip, spawn Octop, tray/settings | +| [`src/`](src/) | Wails v3 shell: load bundled zip, spawn Octop, tray/settings | +| [`package-release.sh`](package-release.sh) | Native end-to-end portable + Wails release build | ## Data directory @@ -24,31 +25,55 @@ From repo root: make -f desktop/portable/Makefile green ``` -CI: `.github/workflows/octop-portable.yml` (unchanged location). +CI: `.github/workflows/octop-portable.yml` builds all six native platform/arch +variants. Each job first creates the green zip and then packages the matching +zip with its Wails application. + +## Build a complete desktop release + +Run the end-to-end script on the matching native host. It builds the Dashboard, +creates and verifies the portable runtime, embeds it into Wails, and produces +the final archive: + +```bash +desktop/package-release.sh +# Reuse an existing desktop/portable/release/Octop-.zip: +desktop/package-release.sh darwin-arm64 --reuse-portable +``` + +Wails requires native packaging, so all six variants are produced by the CI +matrix on macOS, Linux, and Windows runners rather than cross-compiled locally. ## Build the Wails shell Run these from **`desktop/src`** (that directory contains `Taskfile.yml` and `build/config.yml`). Requires **Go 1.25+**, [Wails v3](https://v3.wails.io/) -`v3.0.0-beta.13`, and [Task](https://taskfile.dev/) (`task` on `PATH`). +`v3.0.0-beta.13`. ```bash go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13 cd desktop/src go mod tidy -wails3 generate icons # once: appicon.png → .icns / .ico -wails3 build # native binary under desktop/src/bin/ +wails3 build # development binary under desktop/src/bin/ +wails3 task package ARCH=arm64 \ + PORTABLE_ZIP=../portable/release/Octop-darwin-arm64.zip ``` -Dev against an already-running Octop (skips downloading the green zip): +Dev against an already-running Octop (skips the bundled green zip): ```bash cd desktop/src OCTOP_DESKTOP_URL=http://127.0.0.1:8088 wails3 dev ``` -Without `OCTOP_DESKTOP_URL`, first launch uses `~/.octop/portable/` if present, -otherwise downloads `Octop-.zip` from the latest GitHub Release -(`OCTOP_DESKTOP_GITHUB_REPO`, default `forcemeter/Octop-Agent`). +Without `OCTOP_DESKTOP_URL`, first launch uses `~/.octop/portable/` if valid, +otherwise extracts the matching zip shipped with the desktop package. The Wails +shell never downloads Octop. For local runtime debugging, set +`OCTOP_DESKTOP_PORTABLE_ZIP=/absolute/path/Octop-.zip`. + +Desktop outputs are named `Octop-Desktop-.zip` on macOS/Windows and +`Octop-Desktop-.tar.gz` on Linux. Runtime upgrades remain owned by Octop: +the shell sets `OCTOP_GREEN_PACKAGES`, so `octop update` upgrades the extracted +`packages/` directory through Octop's existing `--target` logic. Linux also needs GTK4 + WebKitGTK 6 to link. macOS 12+. diff --git a/desktop/package-release.sh b/desktop/package-release.sh new file mode 100755 index 00000000..c3af0b4a --- /dev/null +++ b/desktop/package-release.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Build one native Octop desktop release: Dashboard → portable runtime → Wails package. +# Run once per native platform; the GitHub Actions matrix runs all six variants. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_common.sh" + +if ! command -v npm >/dev/null 2>&1 && [[ -s "${HOME}/.nvm/nvm.sh" ]]; then + # shellcheck disable=SC1091 + source "${HOME}/.nvm/nvm.sh" + nvm use 24 >/dev/null +fi + +usage() { + cat <<'EOF' +Usage: desktop/package-release.sh [platform] [--reuse-portable] + +Platforms: + darwin-arm64 darwin-amd64 linux-arm64 linux-amd64 + windows-arm64 windows-amd64 + +The platform defaults to the current native host. --reuse-portable skips +rebuilding desktop/portable/release/Octop-.zip when it already exists. +EOF +} + +plat="" +reuse_portable=0 +for arg in "$@"; do + case "$arg" in + --reuse-portable) reuse_portable=1 ;; + -h|--help) usage; exit 0 ;; + -*) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;; + *) + if [[ -n "$plat" ]]; then + echo "only one platform may be specified" >&2 + exit 2 + fi + plat="$arg" + ;; + esac +done + +if [[ -z "$plat" ]]; then + plat="$(host_plat)" +fi +if ! is_known_plat "$plat"; then + echo "unknown platform: $plat" >&2 + exit 2 +fi +host="$(host_plat)" +if [[ "$plat" != "$host" ]]; then + echo "Wails release packages require a native runner: requested=${plat}, host=${host}" >&2 + exit 2 +fi +if ! command -v wails3 >/dev/null 2>&1; then + echo "wails3 is required: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13" >&2 + exit 1 +fi + +arch="${plat##*-}" +portable_zip="${REPO_ROOT}/desktop/portable/release/Octop-${plat}.zip" + +echo "[desktop-release] platform=${plat}" +echo "[desktop-release] building Dashboard" +make -C "$REPO_ROOT" build-frontend + +if [[ "$reuse_portable" == 1 && -f "$portable_zip" ]]; then + echo "[desktop-release] reusing ${portable_zip}" +else + echo "[desktop-release] building portable runtime" + bash "${REPO_ROOT}/desktop/portable/bootstrap-runtime.sh" "$plat" + bash "${REPO_ROOT}/desktop/portable/package.sh" "$plat" +fi + +staging="${REPO_ROOT}/desktop/portable/release/Octop-${plat}" +requirements="${REPO_ROOT}/desktop/portable/requirements-${plat}.txt" +if [[ "$plat" == windows-* ]]; then + portable_python="${staging}/runtime/python.exe" +else + portable_python="${staging}/runtime/bin/python3" +fi +if [[ ! -f "$portable_python" || ! -f "$requirements" ]]; then + echo "portable staging is incomplete for ${plat}" >&2 + exit 1 +fi + +verify_args=(--packages "${staging}/packages" --requirements "$requirements") +override_file="${REPO_ROOT}/desktop/portable/overrides-${plat}.txt" +if [[ -f "$override_file" ]]; then + verify_args+=(--overrides "$override_file") +fi +echo "[desktop-release] verifying portable imports" +PYTHONNOUSERSITE=1 "$portable_python" \ + "${REPO_ROOT}/desktop/portable/verify_imports.py" "${verify_args[@]}" + +echo "[desktop-release] packaging Wails application" +( + cd "${REPO_ROOT}/desktop/src" + wails3 task package "ARCH=${arch}" "PORTABLE_ZIP=${portable_zip}" +) + +case "$plat" in + darwin-*|windows-*) output="${REPO_ROOT}/desktop/src/bin/Octop-Desktop-${plat}.zip" ;; + linux-*) output="${REPO_ROOT}/desktop/src/bin/Octop-Desktop-${plat}.tar.gz" ;; +esac +if [[ ! -s "$output" ]]; then + echo "desktop release was not created: ${output}" >&2 + exit 1 +fi + +echo "[desktop-release] complete" +echo "$output" diff --git a/desktop/portable/.gitignore b/desktop/portable/.gitignore new file mode 100644 index 00000000..9829673b --- /dev/null +++ b/desktop/portable/.gitignore @@ -0,0 +1,6 @@ +/.cache/ +/release/ +/runtimes/ +/wheels/ +/requirements-*.txt +/overrides-*.txt diff --git a/desktop/portable/AGENT_ELECTRON_INTEGRATION.md b/desktop/portable/AGENT_ELECTRON_INTEGRATION.md index 052551dc..2eb8eefc 100644 --- a/desktop/portable/AGENT_ELECTRON_INTEGRATION.md +++ b/desktop/portable/AGENT_ELECTRON_INTEGRATION.md @@ -9,7 +9,7 @@ path (no system Python, no `PYTHONPATH=packages`). CI / `make -f desktop/portable/Makefile green` produces: ``` -green/release/Octop-.zip +desktop/portable/release/Octop-.zip ``` Platforms: `darwin-arm64` `darwin-amd64` `linux-amd64` `linux-arm64` @@ -27,7 +27,9 @@ Octop-/ ## Spawn -1. Download the zip for the current OS/arch. Verify checksum if you ship one. +1. Ship the matching zip with the desktop application. The Wails packages place + it beside the executable (Windows/Linux) or under `Contents/Resources` + (macOS); the desktop shell does not download it at runtime. 2. Extract to a writable user directory **outside** `app.asar`. 3. On macOS, after checksum: `xattr -dr com.apple.quarantine `. 4. Set `OCTOP_HOME` to Octop's default data dir (`~/.octop`, or `$OCTOP_HOME` diff --git a/desktop/portable/Makefile b/desktop/portable/Makefile index 23d43f2c..c7e9d537 100644 --- a/desktop/portable/Makefile +++ b/desktop/portable/Makefile @@ -37,7 +37,7 @@ green: $(MAKE) -C $(REPO_ROOT) build-frontend bash $(REPO_ROOT)/desktop/portable/bootstrap-runtime.sh $(GREEN_PLAT) bash $(REPO_ROOT)/desktop/portable/package.sh $(GREEN_PLAT) - @echo "[green] Done. Zips under $(REPO_ROOT)/green/release/" + @echo "[green] Done. Zips under $(REPO_ROOT)/desktop/portable/release/" .PHONY: green-linux green-linux: @@ -46,5 +46,10 @@ green-linux: .PHONY: clean clean: - rm -rf $(REPO_ROOT)/green - @echo "[green] removed $(REPO_ROOT)/green" + rm -rf $(REPO_ROOT)/desktop/portable/runtimes \ + $(REPO_ROOT)/desktop/portable/wheels \ + $(REPO_ROOT)/desktop/portable/.cache \ + $(REPO_ROOT)/desktop/portable/release + rm -f $(REPO_ROOT)/desktop/portable/requirements-*.txt \ + $(REPO_ROOT)/desktop/portable/overrides-*.txt + @echo "[green] removed generated files under $(REPO_ROOT)/desktop/portable" diff --git a/desktop/portable/_common.sh b/desktop/portable/_common.sh index 35187db0..8106052f 100644 --- a/desktop/portable/_common.sh +++ b/desktop/portable/_common.sh @@ -2,7 +2,7 @@ # shellcheck shell=bash # Sourced by bootstrap-runtime.sh / vendor-wheels.sh / package.sh -GREEN_ROOT="${GREEN_ROOT:-${REPO_ROOT}/green}" +GREEN_ROOT="${GREEN_ROOT:-${REPO_ROOT}/desktop/portable}" GREEN_RUNTIMES="${GREEN_ROOT}/runtimes" GREEN_WHEELS="${GREEN_ROOT}/wheels" GREEN_CACHE="${GREEN_ROOT}/.cache" diff --git a/desktop/portable/bootstrap-runtime.sh b/desktop/portable/bootstrap-runtime.sh index cdbc682e..4c03e6ed 100755 --- a/desktop/portable/bootstrap-runtime.sh +++ b/desktop/portable/bootstrap-runtime.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Download python-build-standalone into green/runtimes/. +# Download python-build-standalone into desktop/portable/runtimes/. # # Usage: # bash desktop/portable/bootstrap-runtime.sh # host platform diff --git a/desktop/portable/package.sh b/desktop/portable/package.sh index 522500e2..97f0bf2b 100755 --- a/desktop/portable/package.sh +++ b/desktop/portable/package.sh @@ -35,7 +35,7 @@ build_octop_wheel() { echo " Run: make build-frontend" >&2 fi echo "[package] building octop wheel → ${wheel_dir}" >&2 - uv build --wheel --out-dir "$wheel_dir" . >&2 + uv build --wheel --out-dir "$wheel_dir" "$REPO_ROOT" >&2 local whl whl="$(ls -1 "${wheel_dir}"/octop-*.whl 2>/dev/null | sort | tail -1 || true)" if [[ -z "$whl" ]]; then @@ -103,7 +103,7 @@ assemble_one() { local req_file="${GREEN_ROOT}/requirements-${plat}.txt" echo "[package] ${plat}: exporting frozen deps → ${req_file}" >&2 - uv export --frozen --no-dev --no-emit-project --no-hashes -o "$req_file" >/dev/null + uv export --project "$REPO_ROOT" --frozen --no-dev --no-emit-project --no-hashes -o "$req_file" >/dev/null # Platform-specific pins / exclusions (see write_green_overrides in _common.sh). local override_file="" diff --git a/desktop/portable/rebuild.sh b/desktop/portable/rebuild.sh index f07d21d3..bedaad54 100755 --- a/desktop/portable/rebuild.sh +++ b/desktop/portable/rebuild.sh @@ -12,7 +12,9 @@ if [[ -f "${HOME}/.nvm/nvm.sh" ]]; then fi rm -rf src/octop/dashboard -rm -rf green/ +rm -rf desktop/portable/runtimes desktop/portable/wheels \ + desktop/portable/.cache desktop/portable/release +rm -f desktop/portable/requirements-*.txt desktop/portable/overrides-*.txt make build-frontend make -f desktop/portable/Makefile green diff --git a/desktop/portable/verify_imports.py b/desktop/portable/verify_imports.py index 7c8ed499..f2c46f51 100755 --- a/desktop/portable/verify_imports.py +++ b/desktop/portable/verify_imports.py @@ -3,9 +3,9 @@ Usage: python desktop/portable/verify_imports.py \\ - --packages green/release/Octop-/packages \\ - --requirements green/requirements-.txt \\ - [--overrides green/overrides-.txt] + --packages desktop/portable/release/Octop-/packages \\ + --requirements desktop/portable/requirements-.txt \\ + [--overrides desktop/portable/overrides-.txt] """ from __future__ import annotations diff --git a/desktop/src/.gitignore b/desktop/src/.gitignore index b4372fbd..b64cab47 100644 --- a/desktop/src/.gitignore +++ b/desktop/src/.gitignore @@ -1,4 +1,5 @@ bin/ +.task/ *.syso build/windows/*.syso build/darwin/icons.icns diff --git a/desktop/src/assets/index.html b/desktop/src/assets/index.html index 27ebfd5b..4dbfc7b5 100644 --- a/desktop/src/assets/index.html +++ b/desktop/src/assets/index.html @@ -9,20 +9,35 @@ :root { color-scheme: light dark; } body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; background: #0f1115; color: #e8eaed; } body.light { background: #f6f7f9; color: #1a1d23; } - .wrap { max-width: 560px; margin: 48px auto; padding: 0 24px; } + .wrap { max-width: 560px; margin: 20px auto; padding: 0 22px; } h1 { font-size: 1.4rem; font-weight: 600; } .status { opacity: .8; min-height: 1.4em; margin: 12px 0 28px; } .row { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 14px 0; border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent); } .row small { display: block; opacity: .65; margin-top: 4px; } select, button { font: inherit; } - .mac-only.hidden { display: none; } + .actions { display: flex; gap: 10px; margin-top: 20px; } + .actions button { flex: 1; padding: 9px 12px; border: 0; border-radius: 8px; cursor: pointer; } + .actions .primary { background: #1677ff; color: white; } + .loading { min-height: 420px; display: grid; place-content: center; justify-items: center; text-align: center; } + .loading img { width: 78px; height: 78px; border-radius: 18px; box-shadow: 0 14px 38px rgba(255, 45, 55, .25); } + .spinner { width: 28px; height: 28px; margin-top: 24px; border: 3px solid color-mix(in srgb, currentColor 18%, transparent); border-top-color: #ff3545; border-radius: 50%; animation: spin .8s linear infinite; } + .loading .status { margin: 14px 0 0; } + .loading small { max-width: 320px; margin-top: 8px; opacity: .55; line-height: 1.5; } + @keyframes spin { to { transform: rotate(360deg); } } + .hidden { display: none; }
-

Octop

-

+
+ Octop + +

正在启动,请稍候…

+ 正在检查运行环境,可能需要几分钟。 +
+
- - - -
-
- Octop - -

正在启动,请稍候…

- 正在检查运行环境,可能需要几分钟。 -
- -