diff --git a/.github/workflows/octop-desktop.yml b/.github/workflows/octop-desktop.yml new file mode 100644 index 00000000..aae3929b --- /dev/null +++ b/.github/workflows/octop-desktop.yml @@ -0,0 +1,301 @@ +name: Octop Desktop Package + +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 + release_tag: + description: "GitHub Release tag to attach to (e.g. v0.9.27). Defaults to the current v* tag ref." + required: false + default: "" + type: string + attach_from_run: + description: "Existing workflow run ID — attach its Octop-* artifacts and skip the 6-platform rebuild" + required: false + default: "" + type: string + +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 + if: github.event_name != 'workflow_dispatch' || github.event.inputs.attach_from_run == '' + 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 + 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: + 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 desktop/portable/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 desktop/portable/package.sh "${{ matrix.plat }}" + + - name: Smoke import (native host only) + if: steps.want.outputs.build == 'true' + run: | + set -euo pipefail + 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 + py="${staging}/runtime/python.exe" + else + echo "python missing under ${staging}/runtime" >&2 + exit 1 + fi + req_file="desktop/portable/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="desktop/portable/overrides-${{ matrix.plat }}.txt" + if [[ -f "$override_file" ]]; then + verify_args+=(--overrides "$override_file") + fi + PYTHONNOUSERSITE=1 \ + "$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=desktop/portable/_common.sh + source desktop/portable/_common.sh + 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. + # With archive:false, artifact name is the filename (name: is ignored). + - uses: actions/upload-artifact@v7 + if: steps.want.outputs.build == 'true' + with: + 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 + + release: + name: Attach zips to GitHub Release + needs: package + # always(): still run when package was skipped (attach_from_run rebuild skip). + if: >- + always() && + !cancelled() && + (needs.package.result == 'success' || needs.package.result == 'skipped') && + ( + (github.event_name == 'workflow_dispatch' && + github.event.inputs.attach_from_run != '') || + (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 + actions: read + steps: + - name: Resolve release tag + id: rel + run: | + set -euo pipefail + tag="${{ github.event.inputs.release_tag }}" + if [[ -z "$tag" && "${GITHUB_REF}" == refs/tags/v* ]]; then + tag="${GITHUB_REF_NAME}" + fi + if [[ "$tag" != v* ]]; then + echo "Need a v* tag (run on a version tag, or pass release_tag)." >&2 + exit 1 + fi + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + + # v8 required for archive:false artifacts. skip-decompress keeps the + # uploaded .zip / .tar / .dmg / .exe intact — default unzip turns portable + # zips into directories, so files: release-assets/* would skip them. + - uses: actions/download-artifact@v8 + with: + pattern: Octop-* + path: release-assets + merge-multiple: true + skip-decompress: true + repository: ${{ github.repository }} + run-id: ${{ github.event.inputs.attach_from_run || github.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: List release assets + run: | + set -euo pipefail + ls -lh release-assets/ + if find release-assets -mindepth 1 -maxdepth 1 -type d | grep -q .; then + echo "Download extracted archives into directories; refuse incomplete attach." >&2 + find release-assets -mindepth 1 -maxdepth 1 -print + exit 1 + fi + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.rel.outputs.tag }} + 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 3a4491df..779e5c26 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/dashboard/src/App.tsx b/dashboard/src/App.tsx index c368d618..5c686478 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -19,6 +19,7 @@ import { LayoutModeProvider } from "./context/LayoutModeContext"; import { VoiceOutputProvider } from "./context/VoiceOutputContext"; import { useIsMobile } from "./hooks/useIsMobile"; import { useUnauthorizedRedirect } from "./hooks/useUnauthorizedRedirect"; +import { installDesktopExternalLinks } from "./utils/desktopExternalLinks"; import { brandTokensFor } from "./styles/themePalettes"; import "./styles/theme-vars.css"; import "./styles/layout.css"; @@ -44,6 +45,8 @@ function ThemedApp() { useUnauthorizedRedirect(); + useEffect(() => installDesktopExternalLinks(), []); + // Set document title based on current language useEffect(() => { document.title = t("app.pageTitle"); diff --git a/dashboard/src/utils/desktopExternalLinks.ts b/dashboard/src/utils/desktopExternalLinks.ts new file mode 100644 index 00000000..88bdd469 --- /dev/null +++ b/dashboard/src/utils/desktopExternalLinks.ts @@ -0,0 +1,113 @@ +const INSTALL_FLAG = "__OCTOP_EXTERNAL_LINKS_INSTALLED__"; +const OPEN_URL_EVENT_PREFIX = "desktop:open-url:"; + +type DesktopWindow = Window & { + __OCTOP_EXTERNAL_LINKS_INSTALLED__?: boolean; + _wails?: { invoke?: (message: string) => void }; +}; + +export function isDesktopExternalURL( + raw: string, + base = window.location.href, +): boolean { + try { + const parsed = new URL(raw, base); + const scheme = parsed.protocol.replace(":", "").toLowerCase(); + if (scheme === "mailto") { + return Boolean(parsed.pathname || parsed.href.slice("mailto:".length)); + } + return scheme === "http" || scheme === "https"; + } catch { + return false; + } +} + +function wailsInvoke(): ((message: string) => void) | undefined { + const invoke = (window as DesktopWindow)._wails?.invoke; + return typeof invoke === "function" ? invoke : undefined; +} + +let lastUrl = ""; +let lastAt = 0; + +function openExternal(url: string): boolean { + const invoke = wailsInvoke(); + if (!invoke || !isDesktopExternalURL(url)) return false; + const now = Date.now(); + if (url === lastUrl && now - lastAt < 800) return true; + lastUrl = url; + lastAt = now; + invoke("wails:event:emit:" + OPEN_URL_EVENT_PREFIX + encodeURIComponent(url)); + return true; +} + +function linkFromEvent(event: Event): HTMLAnchorElement | null { + const raw = event.target; + const node = + raw instanceof Element + ? raw + : raw instanceof Node + ? raw.parentElement + : null; + if (!node) return null; + const link = node.closest("a[href][target]"); + if (!(link instanceof HTMLAnchorElement)) return null; + if (link.target.toLowerCase() !== "_blank") return null; + if (link.hasAttribute("download")) return null; + return link; +} + +function onActivate(event: Event): void { + if (event instanceof MouseEvent && event.button !== 0) return; + const link = linkFromEvent(event); + if (!link) return; + if (!openExternal(link.href)) return; + event.preventDefault(); +} + +function patchWindowOpen(): () => void { + const original = window.open.bind(window); + window.open = ((url?: string | URL, target?: string, features?: string) => { + const href = url == null ? "" : String(url); + const name = target == null ? "_blank" : String(target); + if (href && name.toLowerCase() === "_blank" && openExternal(href)) { + return null; + } + return original(url, target, features); + }) as typeof window.open; + return () => { + window.open = original; + }; +} + +export function tryInstallDesktopExternalLinks(): (() => void) | undefined { + const w = window as DesktopWindow; + if (!wailsInvoke() || w[INSTALL_FLAG]) return undefined; + w[INSTALL_FLAG] = true; + document.addEventListener("click", onActivate, true); + document.addEventListener("pointerdown", onActivate, true); + const restoreOpen = patchWindowOpen(); + return () => { + document.removeEventListener("click", onActivate, true); + document.removeEventListener("pointerdown", onActivate, true); + restoreOpen(); + w[INSTALL_FLAG] = false; + }; +} + +export function installDesktopExternalLinks(): () => void { + let cancelled = false; + let uninstall: (() => void) | undefined; + const tryInstall = () => { + if (cancelled || uninstall) return; + uninstall = tryInstallDesktopExternalLinks(); + }; + tryInstall(); + const timer = window.setInterval(tryInstall, 250); + window.setTimeout(() => window.clearInterval(timer), 12_000); + return () => { + cancelled = true; + window.clearInterval(timer); + uninstall?.(); + }; +} diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 00000000..b8f2873f --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,82 @@ +# 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: load bundled zip, spawn Octop, tray/settings | +| [`package-release.sh`](package-release.sh) | Native end-to-end portable + Wails release build | + +## 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` builds all six native platform/arch +variants. Each job first creates the green zip and then packages the matching +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 native package: + +```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`. + +```bash +go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13 +cd desktop/src +go mod tidy +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 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 valid, +otherwise extracts the matching zip shipped with the desktop package (embedded +in the Windows and Linux binaries, under `Contents/Resources` on macOS). The +Wails shell never downloads Octop. For local runtime debugging, set +`OCTOP_DESKTOP_PORTABLE_ZIP=/absolute/path/Octop-.zip`. + +Desktop outputs are native GUI packages: `Octop-Desktop-.dmg` (macOS), +`Octop-Desktop-.exe` (Windows), and `Octop-Desktop-.tar` (Linux). +The Linux tar contains only the GUI binary; it has no separate portable zip or +server terminal process. 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..7d3e99ea --- /dev/null +++ b/desktop/package-release.sh @@ -0,0 +1,116 @@ +#!/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-*) output="${REPO_ROOT}/desktop/src/bin/Octop-Desktop-${plat}.dmg" ;; + windows-*) output="${REPO_ROOT}/desktop/src/bin/Octop-Desktop-${plat}.exe" ;; + linux-*) output="${REPO_ROOT}/desktop/src/bin/Octop-Desktop-${plat}.tar" ;; +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 new file mode 100644 index 00000000..103f2ceb --- /dev/null +++ b/desktop/portable/AGENT_ELECTRON_INTEGRATION.md @@ -0,0 +1,75 @@ +# 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 desktop/portable/Makefile green` produces: + +``` +desktop/portable/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. Ship the matching zip with the desktop application. The Wails packages embed + it in the Windows `.exe`, place it under `Contents/Resources` (macOS), or + beside the executable (Linux); 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` + 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 ` + - 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/desktop/portable/Makefile b/desktop/portable/Makefile new file mode 100644 index 00000000..c7e9d537 --- /dev/null +++ b/desktop/portable/Makefile @@ -0,0 +1,55 @@ +# 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)/desktop/portable/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)/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/README.md b/desktop/portable/README.md new file mode 100644 index 00000000..b5027136 --- /dev/null +++ b/desktop/portable/README.md @@ -0,0 +1,116 @@ +# Octop 绿色便携包(多平台) + +解压即用:内置便携 CPython + Octop 及依赖,通过 `start.sh` / `start.bat` 启动。 +**不依赖**系统 Python,也**不包含** Wails / 桌面壳——用浏览器打开 Dashboard。 +首启走上游正常 setup wizard(本目录**不含** OOB / UI 裁剪)。 + +## 与上游解耦 + +本能力全部落在: + +- `desktop/portable/**`(脚本 / 模板 / 本目录 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 desktop/portable/Makefile green +``` + +依赖版本必须以仓库根目录 `uv.lock` 为准:`package.sh` 使用 +`uv export --frozen`,出包后跑 `desktop/portable/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 desktop/portable/Makefile green + +# 或分步: +make build-frontend # 上游已有目标 +bash desktop/portable/bootstrap-runtime.sh +bash desktop/portable/package.sh +``` + +本地一键重建(nvm 24): + +```bash +bash desktop/portable/rebuild.sh +``` + +交叉组装其它平台时,**带 C 扩展的包**必须在目标 ABI 上构建: + +| 目标 | 推荐方式 | +|------|----------| +| 当前主机 | `make -f desktop/portable/Makefile green` | +| Linux(从 macOS/Windows) | `make -f desktop/portable/Makefile green-linux` | +| Windows | 在 Windows / CI 上执行同上 `green` | + +### 离线包 + +```bash +bash desktop/portable/vendor-wheels.sh # 按当前 uv.lock 预取 wheel +OCTOP_GREEN_OFFLINE=1 bash desktop/portable/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/desktop/portable/_common.sh b/desktop/portable/_common.sh new file mode 100644 index 00000000..8106052f --- /dev/null +++ b/desktop/portable/_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}/desktop/portable}" +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/desktop/portable/bootstrap-runtime.sh b/desktop/portable/bootstrap-runtime.sh new file mode 100755 index 00000000..4c03e6ed --- /dev/null +++ b/desktop/portable/bootstrap-runtime.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# Download python-build-standalone into desktop/portable/runtimes/. +# +# Usage: +# 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=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_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/desktop/portable/package-linux-docker.sh b/desktop/portable/package-linux-docker.sh new file mode 100755 index 00000000..834b395a --- /dev/null +++ b/desktop/portable/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 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=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_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 desktop/portable/bootstrap-runtime.sh '${PLAT}' + bash desktop/portable/package.sh '${PLAT}' + " diff --git a/desktop/portable/package.sh b/desktop/portable/package.sh new file mode 100755 index 00000000..190a6136 --- /dev/null +++ b/desktop/portable/package.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# Assemble a relocatable green portable zip for one platform. +# +# Prerequisites: +# bash desktop/portable/bootstrap-runtime.sh +# make build-frontend # recommended (dashboard inside wheel) +# +# Usage: +# 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-/ +# 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=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_common.sh" + +TEMPLATES="${REPO_ROOT}/desktop/portable/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" "$REPO_ROOT" >&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 desktop/portable/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 --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="" + 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 + + # uv pip resolves against the host interpreter. macOS /usr/bin/python3 is + # often Xcode 3.9, which cannot satisfy octop's requires-python >=3.12. + if [[ "$plat" == "$host" ]]; then + local install_python + install_python="$(runtime_python "${staging}/runtime")" + if [[ ! -x "$install_python" && ! -f "$install_python" ]]; then + echo "[package] portable python missing: ${install_python}" >&2 + exit 1 + fi + echo "[package] ${plat}: resolving with ${install_python}" >&2 + common_args+=( --python "$install_python" ) + else + uv python install 3.12 >/dev/null + echo "[package] ${plat}: resolving with uv-managed CPython 3.12" >&2 + common_args+=( --python 3.12 ) + fi + + # Cross-platform: refuse compiling sdists on the host (wrong ABI). Prefer + # 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 + 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 desktop/portable/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 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 desktop/portable/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/desktop/portable/rebuild.sh b/desktop/portable/rebuild.sh new file mode 100755 index 00000000..bedaad54 --- /dev/null +++ b/desktop/portable/rebuild.sh @@ -0,0 +1,20 @@ +#!/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 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/templates/README.txt b/desktop/portable/templates/README.txt new file mode 100644 index 00000000..d3e86635 --- /dev/null +++ b/desktop/portable/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/desktop/portable/templates/launch.py b/desktop/portable/templates/launch.py new file mode 100644 index 00000000..5e172cb8 --- /dev/null +++ b/desktop/portable/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/desktop/portable/templates/start.bat b/desktop/portable/templates/start.bat new file mode 100644 index 00000000..bdf2d580 --- /dev/null +++ b/desktop/portable/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/desktop/portable/templates/start.sh b/desktop/portable/templates/start.sh new file mode 100755 index 00000000..8f4800d0 --- /dev/null +++ b/desktop/portable/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/desktop/portable/vendor-wheels.sh b/desktop/portable/vendor-wheels.sh new file mode 100755 index 00000000..c068fb09 --- /dev/null +++ b/desktop/portable/vendor-wheels.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Prefetch wheels for OCTOP_GREEN_OFFLINE=1 packaging. +# +# Usage: +# 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=desktop/portable/_common.sh +source "${REPO_ROOT}/desktop/portable/_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 + + # Host python3 may be <3.12 (e.g. Xcode 3.9). Download still needs a 3.12 + # interpreter so requires-python on octop/deps can resolve. + uv python install 3.12 >/dev/null + echo "[wheels] ${plat}: downloading → ${wheel_dir}" >&2 + uv pip download \ + --dest "$wheel_dir" \ + --python 3.12 \ + --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/desktop/portable/verify_imports.py b/desktop/portable/verify_imports.py new file mode 100755 index 00000000..f2c46f51 --- /dev/null +++ b/desktop/portable/verify_imports.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Smoke-check a green packages/ tree against frozen requirements. + +Usage: + python desktop/portable/verify_imports.py \\ + --packages desktop/portable/release/Octop-/packages \\ + --requirements desktop/portable/requirements-.txt \\ + [--overrides desktop/portable/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/desktop/src/.gitignore b/desktop/src/.gitignore new file mode 100644 index 00000000..734c8808 --- /dev/null +++ b/desktop/src/.gitignore @@ -0,0 +1,8 @@ +bin/ +.task/ +*.syso +bundled/portable.zip +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/assets/index.html b/desktop/src/assets/index.html new file mode 100644 index 00000000..48945299 --- /dev/null +++ b/desktop/src/assets/index.html @@ -0,0 +1,447 @@ + + + + + + Octop + + + + +
+ Octop + +

正在启动,请稍候…

+ 正在检查运行环境,可能需要几分钟。 +
+ + + + + diff --git a/desktop/src/assets/octop-mascot-peek.webp b/desktop/src/assets/octop-mascot-peek.webp new file mode 100644 index 00000000..a5461f96 Binary files /dev/null and b/desktop/src/assets/octop-mascot-peek.webp differ diff --git a/desktop/src/assets/octop-mascot-type.webp b/desktop/src/assets/octop-mascot-type.webp new file mode 100644 index 00000000..8dbe9954 Binary files /dev/null and b/desktop/src/assets/octop-mascot-type.webp differ diff --git a/desktop/src/assets/tray-icon.png b/desktop/src/assets/tray-icon.png new file mode 100644 index 00000000..8b84ff0e Binary files /dev/null and b/desktop/src/assets/tray-icon.png differ diff --git a/desktop/src/build/Taskfile.yml b/desktop/src/build/Taskfile.yml new file mode 100644 index 00000000..603de4af --- /dev/null +++ b/desktop/src/build/Taskfile.yml @@ -0,0 +1,50 @@ +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: Builds the embedded-assets development server + cmds: + - go build -o .task/devserver.exe ./cmd/devserver + + 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: Serves the embedded shell assets for Wails dev mode + deps: + - task: install:frontend:deps + cmds: + - ./.task/devserver.exe + + 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 00000000..06a52b88 Binary files /dev/null and b/desktop/src/build/appicon.png differ 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..8810ad0f --- /dev/null +++ b/desktop/src/build/darwin/Taskfile.yml @@ -0,0 +1,65 @@ +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 into an uncompressed DMG + deps: + - task: build + vars: + PRODUCTION: "true" + cmds: + - task: create:app:bundle + - rm -rf {{.DMG_ROOT}} {{.ARCHIVE}} {{.BIN_DIR}}/{{.APP_NAME}}-Desktop-darwin-{{.ARCH}}.zip + - mkdir -p {{.DMG_ROOT}} + - ditto {{.BIN_DIR}}/{{.APP_NAME}}.app {{.DMG_ROOT}}/{{.APP_NAME}}.app + - hdiutil create -volname {{.APP_NAME}} -srcfolder {{.DMG_ROOT}} -ov -format UDRO {{.ARCHIVE}} + - rm -rf {{.DMG_ROOT}} + vars: + ARCH: "{{.ARCH | default ARCH}}" + PORTABLE_ZIP: '{{.PORTABLE_ZIP | default (printf "../portable/release/Octop-darwin-%s.zip" .ARCH)}}' + DMG_ROOT: '{{.BIN_DIR}}/dmg-darwin-{{.ARCH}}' + ARCHIVE: '{{.BIN_DIR}}/{{.APP_NAME}}-Desktop-darwin-{{.ARCH}}.dmg' + + create:app:bundle: + summary: Creates an .app bundle + cmds: + - test -f {{.PORTABLE_ZIP}} + - mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/{MacOS,Resources} + - cp build/darwin/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources + - cp {{.PORTABLE_ZIP}} {{.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..b320de20 --- /dev/null +++ b/desktop/src/build/linux/Taskfile.yml @@ -0,0 +1,50 @@ +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: Packages one production binary with its portable runtime embedded + cmds: + - task: stage:portable + vars: + PORTABLE_ZIP: "{{.PORTABLE_ZIP}}" + - task: build + vars: + PRODUCTION: "true" + - rm -f {{.ARCHIVE}} bundled/portable.zip + - tar -C {{.BIN_DIR}} -cf {{.ARCHIVE}} {{.APP_NAME}} + vars: + ARCH: "{{.ARCH | default ARCH}}" + PORTABLE_ZIP: '{{.PORTABLE_ZIP | default (printf "../portable/release/Octop-linux-%s.zip" .ARCH)}}' + ARCHIVE: '{{.BIN_DIR}}/{{.APP_NAME}}-Desktop-linux-{{.ARCH}}.tar' + + stage:portable: + summary: Copies the matching green zip for go:embed + cmds: + - test -f {{.PORTABLE_ZIP}} + - mkdir -p bundled + - cp {{.PORTABLE_ZIP}} bundled/portable.zip + vars: + PORTABLE_ZIP: '{{.PORTABLE_ZIP | default (printf "../portable/release/Octop-linux-%s.zip" (.ARCH | default ARCH))}}' + + 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..47aec85f --- /dev/null +++ b/desktop/src/build/windows/Taskfile.yml @@ -0,0 +1,74 @@ +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"}}' + + stage:portable: + summary: Copies the green zip to bundled/portable.zip for go:embed + cmds: + - cmd: >- + powershell -NoProfile -Command + "if (-not (Test-Path '{{.PORTABLE_ZIP}}')) { throw 'portable zip not found: {{.PORTABLE_ZIP}}' }; + New-Item -ItemType Directory -Force bundled | Out-Null; + Copy-Item -Force '{{.PORTABLE_ZIP}}' 'bundled/portable.zip'" + vars: + PORTABLE_ZIP: '{{.PORTABLE_ZIP | default (printf "../portable/release/Octop-windows-%s.zip" (.ARCH | default ARCH))}}' + + package: + summary: Packages a single production exe with the portable runtime embedded + cmds: + - task: stage:portable + vars: + ARCH: "{{.ARCH}}" + PORTABLE_ZIP: "{{.PORTABLE_ZIP}}" + - task: build + vars: + PRODUCTION: "true" + ARCH: "{{.ARCH}}" + - cmd: >- + powershell -NoProfile -Command + "Remove-Item -Force '{{.ARCHIVE}}' -ErrorAction SilentlyContinue; + Remove-Item -Force '{{.BIN_DIR}}/{{.APP_NAME}}-Desktop-windows-{{.ARCH}}.zip' -ErrorAction SilentlyContinue; + Remove-Item -Recurse -Force '{{.BIN_DIR}}/{{.APP_NAME}}-Desktop-windows-{{.ARCH}}' -ErrorAction SilentlyContinue; + Copy-Item '{{.BIN_DIR}}/{{.APP_NAME}}.exe' '{{.ARCHIVE}}'; + Remove-Item -Force 'bundled/portable.zip' -ErrorAction SilentlyContinue" + vars: + ARCH: "{{.ARCH | default ARCH}}" + PORTABLE_ZIP: '{{.PORTABLE_ZIP | default (printf "../portable/release/Octop-windows-%s.zip" .ARCH)}}' + ARCHIVE: '{{.BIN_DIR}}/{{.APP_NAME}}-Desktop-windows-{{.ARCH}}.exe' + + 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 + + + + + + + + + + diff --git a/desktop/src/bundled/.gitkeep b/desktop/src/bundled/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/desktop/src/cmd/devserver/main.go b/desktop/src/cmd/devserver/main.go new file mode 100644 index 00000000..2fc6b6d5 --- /dev/null +++ b/desktop/src/cmd/devserver/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "log" + "net/http" + "os" +) + +func main() { + port := os.Getenv("WAILS_VITE_PORT") + if port == "" { + port = "9245" + } + + server := &http.Server{ + Addr: "localhost:" + port, + Handler: http.FileServer(http.Dir("assets")), + } + log.Printf("serving shell assets on http://%s", server.Addr) + log.Fatal(server.ListenAndServe()) +} diff --git a/desktop/src/download.go b/desktop/src/download.go new file mode 100644 index 00000000..f5117651 --- /dev/null +++ b/desktop/src/download.go @@ -0,0 +1,195 @@ +package main + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +func launchReady(root string) bool { + if _, err := os.Stat(filepath.Join(root, "launch.py")); err != nil { + return false + } + info, err := os.Stat(pythonExe(root)) + if err != nil || !info.Mode().IsRegular() || info.Size() < 1024 { + return false + } + return runtime.GOOS == "windows" || info.Mode().Perm()&0o111 != 0 +} + +func pythonExe(root string) string { + if runtime.GOOS == "windows" { + return filepath.Join(root, "runtime", "python.exe") + } + return filepath.Join(root, "runtime", "bin", "python3") +} + +func ensurePortable(status func(string)) error { + root := portableDir() + if launchReady(root) { + status("正在使用已有运行环境…") + return nil + } + status("首次启动,正在解压内置运行环境…") + if err := extractPortable(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 extractPortable(root string) error { + if os.Getenv("OCTOP_DESKTOP_PORTABLE_ZIP") != "" { + zipPath, err := bundledPortableZip() + if err != nil { + return err + } + return unzipGreen(zipPath, root) + } + if len(embeddedPortable) > 0 { + return unzipGreenBytes(embeddedPortable, root) + } + zipPath, err := bundledPortableZip() + if err != nil { + return err + } + return unzipGreen(zipPath, root) +} + +func bundledPortableZip() (string, error) { + name := fmt.Sprintf("Octop-%s.zip", greenPlat()) + if override := os.Getenv("OCTOP_DESKTOP_PORTABLE_ZIP"); override != "" { + if _, err := os.Stat(override); err != nil { + return "", fmt.Errorf("bundled portable package: %w", err) + } + return override, nil + } + exe, err := os.Executable() + if err != nil { + return "", err + } + dir := filepath.Dir(exe) + candidates := []string{ + filepath.Join(dir, name), + filepath.Join(dir, "..", "Resources", name), + } + for _, candidate := range candidates { + candidate = filepath.Clean(candidate) + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + return "", fmt.Errorf("bundled portable package %s not found beside application", name) +} + +func unzipGreen(zipPath, dest string) error { + r, err := zip.OpenReader(zipPath) + if err != nil { + return err + } + defer r.Close() + return unzipGreenFiles(r.File, dest) +} + +func unzipGreenBytes(data []byte, dest string) error { + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return err + } + return unzipGreenFiles(r.File, dest) +} + +func unzipGreenFiles(files []*zip.File, dest string) error { + _ = os.RemoveAll(dest) + if err := os.MkdirAll(dest, 0o755); err != nil { + return err + } + // Zip root is Octop-/… — strip that prefix. + for _, f := range files { + 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 f.Mode()&os.ModeSymlink != 0 { + rc, err := f.Open() + if err != nil { + return err + } + linkTarget, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + if err := os.Symlink(string(linkTarget), target); 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/download_test.go b/desktop/src/download_test.go new file mode 100644 index 00000000..952bc6b8 --- /dev/null +++ b/desktop/src/download_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "archive/zip" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestEnsurePortableUsesEmbeddedPackage(t *testing.T) { + home := t.TempDir() + t.Setenv("OCTOP_HOME", home) + t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", "") + + zipPath := filepath.Join(t.TempDir(), "embedded.zip") + writeTestGreenZip(t, zipPath) + data, err := os.ReadFile(zipPath) + if err != nil { + t.Fatal(err) + } + prev := embeddedPortable + embeddedPortable = data + t.Cleanup(func() { embeddedPortable = prev }) + + if err := ensurePortable(func(string) {}); err != nil { + t.Fatal(err) + } + if !launchReady(portableDir()) { + t.Fatal("embedded package was not extracted into the portable directory") + } +} + +func TestEnsurePortableUsesBundledPackage(t *testing.T) { + home := t.TempDir() + t.Setenv("OCTOP_HOME", home) + + zipPath := filepath.Join(t.TempDir(), "Octop-"+greenPlat()+".zip") + t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", zipPath) + writeTestGreenZip(t, zipPath) + + var statuses []string + err := ensurePortable(func(status string) { + statuses = append(statuses, status) + }) + if err != nil { + t.Fatal(err) + } + if !launchReady(portableDir()) { + t.Fatal("local package was not extracted into the portable directory") + } + if len(statuses) == 0 || statuses[0] != "首次启动,正在解压内置运行环境…" { + t.Fatalf("unexpected statuses: %v", statuses) + } + if _, err := os.Stat(zipPath); err != nil { + t.Fatalf("bundled package should be retained: %v", err) + } +} + +func TestBundledPortableZipRequiresMatchingPackage(t *testing.T) { + t.Setenv("OCTOP_DESKTOP_PORTABLE_ZIP", filepath.Join(t.TempDir(), "missing.zip")) + if _, err := bundledPortableZip(); err == nil { + t.Fatal("missing bundled package should fail") + } +} + +func TestLaunchReadyRejectsFlattenedPythonSymlink(t *testing.T) { + home := t.TempDir() + t.Setenv("OCTOP_HOME", home) + root := portableDir() + if err := os.MkdirAll(filepath.Join(root, "runtime", "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "launch.py"), []byte("test"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "runtime", "bin", "python3"), []byte("python3.12"), 0o755); err != nil { + t.Fatal(err) + } + if launchReady(root) { + t.Fatal("flattened Python symlink must not be treated as a ready runtime") + } +} + +func writeTestGreenZip(t *testing.T, path string) { + t.Helper() + f, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + w := zip.NewWriter(f) + files := []string{"Octop-test/launch.py"} + if runtime.GOOS == "windows" { + files = append(files, "Octop-test/runtime/python.exe") + } else { + files = append(files, + "Octop-test/runtime/bin/python3", + "Octop-test/runtime/bin/python3.12", + ) + } + for _, name := range files { + header := &zip.FileHeader{Name: name, Method: zip.Store} + content := []byte("test executable payload") + if strings.HasSuffix(name, "/python3") { + header.SetMode(os.ModeSymlink | 0o755) + content = []byte("python3.12") + } else { + header.SetMode(0o755) + if strings.HasSuffix(name, "/python3.12") || strings.HasSuffix(name, "/python.exe") { + content = make([]byte, 2048) + } + } + entry, err := w.CreateHeader(header) + if err != nil { + t.Fatal(err) + } + if _, err := entry.Write(content); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/desktop/src/embed_portable.go b/desktop/src/embed_portable.go new file mode 100644 index 00000000..fd3d7b0a --- /dev/null +++ b/desktop/src/embed_portable.go @@ -0,0 +1,7 @@ +//go:build !production || darwin + +package main + +// Development builds look for Octop-.zip beside the executable. macOS +// production keeps the zip in the signed .app Resources directory. +var embeddedPortable []byte diff --git a/desktop/src/embed_portable_embedded.go b/desktop/src/embed_portable_embedded.go new file mode 100644 index 00000000..16ec4623 --- /dev/null +++ b/desktop/src/embed_portable_embedded.go @@ -0,0 +1,11 @@ +//go:build production && !darwin + +package main + +import _ "embed" + +// Linux and Windows production builds embed their matching portable runtime, so +// their release archive contains only the desktop binary. +// +//go:embed bundled/portable.zip +var embeddedPortable []byte diff --git a/desktop/src/go.mod b/desktop/src/go.mod new file mode 100644 index 00000000..d4f280da --- /dev/null +++ b/desktop/src/go.mod @@ -0,0 +1,18 @@ +module octop.desktop + +go 1.25.0 + +require ( + github.com/godbus/dbus/v5 v5.2.2 + github.com/wailsapp/wails/v3 v3.0.0-beta.13 + golang.org/x/sys v0.46.0 +) + +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/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 +) 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..3a60c5c7 --- /dev/null +++ b/desktop/src/main.go @@ -0,0 +1,418 @@ +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 + +//go:embed assets/tray-icon.png +var trayIcon []byte + +const trayDoubleClick = 400 * time.Millisecond + +// 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 + + trayClickMu sync.Mutex + lastTrayClick time.Time + trayClickTimer *time.Timer +} + +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) GetSettingsStatus() (Settings, error) { + s := a.store.get() + if a.app == nil { + return s, nil + } + status, err := a.app.Autostart.Status() + if err != nil { + return s, fmt.Errorf("read autostart status: %w", err) + } + if s.Autostart != status.Enabled { + s.Autostart = status.Enabled + if err := a.store.save(s); err != nil { + return s, err + } + } + return s, nil +} + +func (a *App) SaveSettings(next Settings) (Settings, error) { + cur, err := a.GetSettingsStatus() + if err != nil { + return cur, err + } + autostart, err := a.setAutostart(next.Autostart) + if err != nil { + return cur, err + } + if err := a.sleep.set(next.PreventSleep); err != nil { + if _, rollbackErr := a.setAutostart(cur.Autostart); rollbackErr != nil { + log.Printf("rollback autostart after sleep prevention failure: %v", rollbackErr) + } + return cur, err + } + next.Autostart = autostart + if err := a.store.save(next); err != nil { + return cur, err + } + saved := a.store.get() + a.applyDashboardPrefs(saved) + return saved, nil +} + +func (a *App) ShowMain() { + a.showWindow() +} + +func (a *App) Quit() { + a.requestQuit() +} + +func (a *App) setAutostart(on bool) (bool, error) { + if a.app == nil { + return false, fmt.Errorf("autostart is unavailable before the application starts") + } + if on { + if err := a.app.Autostart.Enable(); err != nil { + return false, fmt.Errorf("enable autostart: %w", err) + } + } else if err := a.app.Autostart.Disable(); err != nil { + return false, fmt.Errorf("disable autostart: %w", err) + } + status, err := a.app.Autostart.Status() + if err != nil { + return false, fmt.Errorf("read autostart status: %w", err) + } + if status.Enabled != on { + return status.Enabled, fmt.Errorf("autostart state did not update") + } + return status.Enabled, nil +} + +func (a *App) applyDashboardPrefs(s Settings) { + if a.window == nil { + return + } + js := fmt.Sprintf( + `(function(){try{localStorage.setItem('octop:ui-locale',%s);}catch(e){}})();`, + jsonString(string(s.Locale)), + ) + 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("正在连接 Octop…") + if err := waitHealth(url, 60*time.Second); err != nil { + a.setStatus(err.Error()) + return + } + a.showDashboard(url) + return + } + s := a.store.get() + a.setStatus("正在检查运行环境…") + if err := ensurePortable(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("正在启动 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) + a.scheduleDragOverlay() + s := a.store.get() + go func() { + time.Sleep(800 * time.Millisecond) + a.applyDashboardPrefs(s) + }() + a.setStatus("Octop 已就绪") +} + +func (a *App) hideToTray() { + if a.window == nil { + return + } + a.window.Hide() +} + +func (a *App) showWindow() { + if a.window == nil { + return + } + if a.window.IsMinimised() { + a.window.UnMinimise() + } + a.window.Show() + a.window.Focus() +} + +func (a *App) toggleMainWindow() { + if a.window == nil { + return + } + if a.window.IsVisible() && !a.window.IsMinimised() { + a.hideToTray() + return + } + a.showWindow() +} + +func (a *App) onTrayLeftClick() { + a.trayClickMu.Lock() + defer a.trayClickMu.Unlock() + if a.trayClickTimer != nil { + a.trayClickTimer.Stop() + a.trayClickTimer = nil + } + now := time.Now() + if !a.lastTrayClick.IsZero() && now.Sub(a.lastTrayClick) < trayDoubleClick { + a.lastTrayClick = time.Time{} + go a.toggleMainWindow() + return + } + a.lastTrayClick = now + a.trayClickTimer = time.AfterFunc(trayDoubleClick, func() { + a.trayClickMu.Lock() + a.trayClickTimer = nil + a.trayClickMu.Unlock() + a.showWindow() + }) +} + +func (a *App) installDragOverlay() { + if a.window == nil { + return + } + a.window.ExecJS(`(function(){ + if (!document.body || !window._wails || typeof window._wails.invoke !== 'function') return; + var bar = document.getElementById('octop-window-drag-overlay'); + if (!bar) { + bar = document.createElement('div'); + bar.id = 'octop-window-drag-overlay'; + bar.setAttribute('aria-hidden', 'true'); + bar.style.cssText = 'position:fixed;top:0;left:0;right:0;height:32px;z-index:2147483647;background:transparent;user-select:none;'; + document.body.appendChild(bar); + } + if (bar.dataset.octopDragReady === '1') return; + bar.dataset.octopDragReady = '1'; + var armed = false, startX = 0, startY = 0; + bar.addEventListener('mousedown', function(event) { + if (event.button !== 0) return; + armed = true; + startX = event.screenX; + startY = event.screenY; + }, true); + window.addEventListener('mousemove', function(event) { + if (!armed) return; + if (Math.abs(event.screenX - startX) + Math.abs(event.screenY - startY) < 4) return; + armed = false; + window._wails.invoke('wails:drag'); + }, true); + window.addEventListener('mouseup', function() { armed = false; }, true); + bar.addEventListener('dblclick', function(event) { + event.preventDefault(); + event.stopPropagation(); + armed = false; + window._wails.invoke('wails:event:emit:desktop:toggle-maximise'); + }, true); + })();`) +} + +func (a *App) scheduleDragOverlay() { + go func() { + for range 40 { + time.Sleep(250 * time.Millisecond) + a.installDragOverlay() + a.installExternalLinks() + } + }() +} + +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, + }, + Linux: application.LinuxOptions{ + DisableQuitOnLastWindowClosed: true, + }, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: false, + }, + }) + api.app = app + attachOpenURLEventListener(app, api.OpenExternal) + app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(_ *application.ApplicationEvent) { + app.SetIcon(trayIcon) + }) + + win := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Octop", + Width: 1200, + Height: 800, + URL: "/", + Frameless: true, + AllowSimpleEventEmit: true, + BackgroundColour: application.NewRGB(247, 248, 250), + }) + api.window = win + app.Event.On("desktop:toggle-maximise", func(_ *application.CustomEvent) { + win.ToggleMaximise() + }) + installDragOverlay := func(_ *application.WindowEvent) { api.scheduleDragOverlay() } + win.OnWindowEvent(events.Mac.WebViewDidFinishNavigation, installDragOverlay) + win.OnWindowEvent(events.Windows.WebViewNavigationCompleted, installDragOverlay) + win.OnWindowEvent(events.Linux.WindowLoadFinished, installDragOverlay) + settingsWin := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Octop 设置", + Width: 400, + Height: 500, + URL: "/?settings=1", + Hidden: true, + Frameless: true, + AlwaysOnTop: true, + DisableResize: true, + BackgroundColour: application.NewRGB(255, 255, 255), + Windows: application.WindowsWindow{ + HiddenOnTaskbar: true, + }, + }) + + win.RegisterHook(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) { + if api.store.get().MinimizeToTray { + api.hideToTray() + } + }) + settingsWin.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { + e.Cancel() + settingsWin.Hide() + }) + settingsWin.OnWindowEvent(events.Common.WindowLostFocus, func(_ *application.WindowEvent) { + settingsWin.Hide() + }) + + tray := app.SystemTray.New() + tray.SetIcon(trayIcon) + tray.SetTooltip("Octop") + tray.AttachWindow(settingsWin).WindowOffset(6) + tray.OnClick(func() { api.onTrayLeftClick() }) + tray.OnRightClick(func() { tray.ShowWindow() }) + + if _, err := api.setAutostart(store.get().Autostart); err != nil { + log.Printf("sync autostart: %v", err) + } + if err := api.sleep.set(store.get().PreventSleep); err != nil { + log.Printf("enable sleep prevention: %v", err) + } + + api.scheduleDragOverlay() + go api.boot() + + if err := app.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/desktop/src/openurl.go b/desktop/src/openurl.go new file mode 100644 index 00000000..82618df0 --- /dev/null +++ b/desktop/src/openurl.go @@ -0,0 +1,157 @@ +package main + +import ( + "fmt" + "log" + "net/url" + "reflect" + "strings" + "sync" + "unsafe" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +const openURLEventPrefix = "desktop:open-url:" + +func (a *App) OpenExternal(raw string) error { + sanitized, err := validateOpenURL(raw) + if err != nil { + return err + } + if a.app == nil { + return fmt.Errorf("application is not ready") + } + return a.app.Browser.OpenURL(sanitized) +} + +type openURLEventListener struct { + open func(string) error +} + +func (l *openURLEventListener) DispatchWailsEvent(event *application.CustomEvent) { + if event == nil || l.open == nil { + return + } + raw, ok := parseOpenURLEvent(event.Name) + if !ok { + return + } + if err := l.open(raw); err != nil { + log.Printf("open external url: %v", err) + } +} + +func parseOpenURLEvent(name string) (string, bool) { + if !strings.HasPrefix(name, openURLEventPrefix) { + return "", false + } + raw, err := url.QueryUnescape(strings.TrimPrefix(name, openURLEventPrefix)) + if err != nil { + return "", false + } + if _, err := validateOpenURL(raw); err != nil { + return "", false + } + return raw, true +} + +func attachOpenURLEventListener(app *application.App, open func(string) error) { + if app == nil || open == nil { + return + } + root := reflect.ValueOf(app).Elem() + lockField := root.FieldByName("wailsEventListenerLock") + listField := root.FieldByName("wailsEventListeners") + if !lockField.IsValid() || !listField.IsValid() { + log.Printf("open-url: wails event listener field missing") + return + } + lock := reflect.NewAt(lockField.Type(), unsafe.Pointer(lockField.UnsafeAddr())).Interface().(*sync.Mutex) + lock.Lock() + defer lock.Unlock() + list := reflect.NewAt(listField.Type(), unsafe.Pointer(listField.UnsafeAddr())).Elem() + var listener application.WailsEventListener = &openURLEventListener{open: open} + list.Set(reflect.Append(list, reflect.ValueOf(listener))) +} + +func validateOpenURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" || strings.ContainsRune(raw, 0) { + return "", fmt.Errorf("url is not allowed") + } + parsed, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("url is not allowed") + } + switch strings.ToLower(parsed.Scheme) { + case "http", "https": + if parsed.Host == "" { + return "", fmt.Errorf("url is not allowed") + } + case "mailto": + if parsed.Opaque == "" && strings.Trim(parsed.Path, "/") == "" { + return "", fmt.Errorf("url is not allowed") + } + default: + return "", fmt.Errorf("url is not allowed") + } + return parsed.String(), nil +} + +func injectExternalLinksJS() string { + return `(function(){ + if (!window._wails || typeof window._wails.invoke !== "function") return; + if (window.__OCTOP_EXTERNAL_LINKS_INSTALLED__) return; + window.__OCTOP_EXTERNAL_LINKS_INSTALLED__ = true; + var lastUrl = "", lastAt = 0; + function isOpenable(url) { + try { + var parsed = new URL(String(url || ""), window.location.href); + var scheme = parsed.protocol.replace(":", "").toLowerCase(); + if (scheme === "mailto") return Boolean(parsed.pathname || parsed.href.slice("mailto:".length)); + return scheme === "http" || scheme === "https"; + } catch (e) { return false; } + } + function openExternal(url) { + if (!isOpenable(url)) return false; + var now = Date.now(); + if (url === lastUrl && now - lastAt < 800) return true; + lastUrl = url; + lastAt = now; + window._wails.invoke("wails:event:emit:desktop:open-url:" + encodeURIComponent(url)); + return true; + } + function linkFromEvent(event) { + var node = event.target; + if (node && node.nodeType === 3) node = node.parentNode; + if (!node || !node.closest) return null; + var link = node.closest("a[href][target]"); + if (!link || String(link.target).toLowerCase() !== "_blank") return null; + if (link.hasAttribute("download")) return null; + return link; + } + function onActivate(event) { + if (event.button != null && event.button !== 0) return; + var link = linkFromEvent(event); + if (!link) return; + if (!openExternal(link.href)) return; + event.preventDefault(); + } + document.addEventListener("click", onActivate, true); + document.addEventListener("pointerdown", onActivate, true); + var origOpen = window.open; + window.open = function(url, target) { + var name = target == null ? "_blank" : String(target); + if (url && name.toLowerCase() === "_blank" && openExternal(url)) return null; + return origOpen.apply(this, arguments); + }; +})();` +} + +func (a *App) installExternalLinks() { + if a.window == nil { + return + } + a.window.ExecJS(injectExternalLinksJS()) +} diff --git a/desktop/src/preventsleep.go b/desktop/src/preventsleep.go new file mode 100644 index 00000000..fb3cfac5 --- /dev/null +++ b/desktop/src/preventsleep.go @@ -0,0 +1,34 @@ +package main + +import "sync" + +type sleepGuard struct { + mu sync.Mutex + stopFn func() +} + +func (g *sleepGuard) set(enabled bool) error { + g.mu.Lock() + defer g.mu.Unlock() + + if enabled { + if g.stopFn != nil { + return nil + } + stop, err := startSleepInhibitor() + if err != nil { + return err + } + g.stopFn = stop + return nil + } + if g.stopFn != nil { + g.stopFn() + g.stopFn = nil + } + return nil +} + +func (g *sleepGuard) stop() { + _ = g.set(false) +} diff --git a/desktop/src/preventsleep_darwin.go b/desktop/src/preventsleep_darwin.go new file mode 100644 index 00000000..497decdf --- /dev/null +++ b/desktop/src/preventsleep_darwin.go @@ -0,0 +1,21 @@ +//go:build darwin + +package main + +import ( + "fmt" + "os/exec" +) + +func startSleepInhibitor() (func(), error) { + cmd := exec.Command("caffeinate", "-dimsu") + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("start macOS sleep inhibitor: %w", err) + } + return func() { + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }, nil +} diff --git a/desktop/src/preventsleep_linux.go b/desktop/src/preventsleep_linux.go new file mode 100644 index 00000000..82aa445c --- /dev/null +++ b/desktop/src/preventsleep_linux.go @@ -0,0 +1,47 @@ +//go:build linux + +package main + +import ( + "fmt" + "os" + + "github.com/godbus/dbus/v5" +) + +func startSleepInhibitor() (func(), error) { + conn, err := dbus.SystemBus() + if err != nil { + return nil, fmt.Errorf("connect to system D-Bus: %w", err) + } + call := conn.Object("org.freedesktop.login1", "/org/freedesktop/login1").Call( + "org.freedesktop.login1.Manager.Inhibit", + 0, + "idle:sleep", + "Octop", + "Octop desktop is running", + "block", + ) + if call.Err != nil { + _ = conn.Close() + return nil, fmt.Errorf("inhibit sleep through logind: %w", call.Err) + } + if len(call.Body) != 1 { + _ = conn.Close() + return nil, fmt.Errorf("inhibit sleep through logind: missing file descriptor") + } + fd, ok := call.Body[0].(dbus.UnixFD) + if !ok { + _ = conn.Close() + return nil, fmt.Errorf("inhibit sleep through logind: invalid file descriptor") + } + file := os.NewFile(uintptr(fd), "octop-sleep-inhibitor") + if file == nil { + _ = conn.Close() + return nil, fmt.Errorf("inhibit sleep through logind: open file descriptor") + } + return func() { + _ = file.Close() + _ = conn.Close() + }, nil +} diff --git a/desktop/src/preventsleep_other.go b/desktop/src/preventsleep_other.go new file mode 100644 index 00000000..1d84515e --- /dev/null +++ b/desktop/src/preventsleep_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !linux && !windows + +package main + +import "fmt" + +func startSleepInhibitor() (func(), error) { + return nil, fmt.Errorf("preventing system sleep is not supported on this platform") +} diff --git a/desktop/src/preventsleep_windows.go b/desktop/src/preventsleep_windows.go new file mode 100644 index 00000000..d131180c --- /dev/null +++ b/desktop/src/preventsleep_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package main + +import ( + "fmt" + + "golang.org/x/sys/windows" +) + +const ( + esContinuous = 0x80000000 + esSystemRequired = 0x00000001 + esDisplayRequired = 0x00000002 +) + +var setThreadExecutionState = windows.NewLazySystemDLL("kernel32.dll").NewProc("SetThreadExecutionState") + +func startSleepInhibitor() (func(), error) { + result, _, err := setThreadExecutionState.Call(esContinuous | esSystemRequired | esDisplayRequired) + if result == 0 { + return nil, fmt.Errorf("prevent sleep on Windows: %w", err) + } + return func() { + _, _, _ = setThreadExecutionState.Call(esContinuous) + }, nil +} diff --git a/desktop/src/process.go b/desktop/src/process.go new file mode 100644 index 00000000..70888e3b --- /dev/null +++ b/desktop/src/process.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" +) + +func mustEnv(cmd *exec.Cmd, extra map[string]string) { + cmd.Env = os.Environ() + for key, value := range extra { + cmd.Env = append(cmd.Env, key+"="+value) + } +} + +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) + if runtime.GOOS == "linux" { + // The Linux desktop release has no server terminal; the shell owns status + // presentation just like the Windows GUI executable. + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + } else if runtime.GOOS != "windows" { + 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..6a7ae8d2 --- /dev/null +++ b/desktop/src/process_windows.go @@ -0,0 +1,31 @@ +//go:build windows + +package main + +import ( + "io" + "os/exec" + "strconv" + "syscall" + + "golang.org/x/sys/windows" +) + +func hideConsole(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: windows.CREATE_NO_WINDOW, + } +} + +func configureProcGroup(cmd *exec.Cmd) { + hideConsole(cmd) + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard +} + +func killProcessTree(cmd *exec.Cmd) { + kill := exec.Command("taskkill", "/F", "/T", "/PID", strconv.Itoa(cmd.Process.Pid)) + hideConsole(kill) + _ = kill.Run() +} diff --git a/desktop/src/settings.go b/desktop/src/settings.go new file mode 100644 index 00000000..351d0720 --- /dev/null +++ b/desktop/src/settings.go @@ -0,0 +1,128 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "sync" +) + +type Locale string + +const ( + LocaleZH Locale = "zh" + LocaleEN Locale = "en" +) + +// Settings is persisted at ~/.octop/desktop-settings.json +type Settings struct { + Locale Locale `json:"locale"` + Autostart bool `json:"autostart"` + MinimizeToTray bool `json:"minimizeToTray"` + PreventSleep bool `json:"preventSleep"` + Port int `json:"port,omitempty"` +} + +func defaultSettings() Settings { + return Settings{ + Locale: LocaleZH, + Autostart: false, + MinimizeToTray: true, + PreventSleep: false, + Port: 8088, + } +} + +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) + var legacy struct { + PreventSleepMac bool `json:"preventSleepMac"` + } + _ = json.Unmarshal(data, &legacy) + if !s.PreventSleep { + s.PreventSleep = legacy.PreventSleepMac + } + if s.Port == 0 { + s.Port = 8088 + } + if s.Locale != LocaleEN { + s.Locale = LocaleZH + } + 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 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/desktop/src/settings_test.go b/desktop/src/settings_test.go new file mode 100644 index 00000000..a75ae740 --- /dev/null +++ b/desktop/src/settings_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadSettingsMigratesLegacyPreventSleepMac(t *testing.T) { + temp := t.TempDir() + t.Setenv("OCTOP_HOME", temp) + if err := os.WriteFile( + filepath.Join(temp, "desktop-settings.json"), + []byte(`{"preventSleepMac":true}`), + 0o644, + ); err != nil { + t.Fatal(err) + } + + if !loadSettings().PreventSleep { + t.Fatal("legacy preventSleepMac should migrate to preventSleep") + } +} 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 new file mode 100644 index 00000000..f8483615 --- /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] / "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) + 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