From b9c1731e31ae689a9ce96b2146d72f75afb2d48e Mon Sep 17 00:00:00 2001 From: viettranx Date: Fri, 27 Mar 2026 10:15:15 +0700 Subject: [PATCH] feat(desktop): packaging, auto-update, CI/CD, and install scripts - GitHub Actions: release-desktop.yaml builds macOS (arm64+amd64) + Windows on lite-v* tag push, creates DMG + tar.gz + zip GitHub Release assets - Install scripts: install-lite.sh (macOS curl) + install-lite.ps1 (Windows PowerShell) - Auto-update: internal/updater checks GitHub Releases, downloads + atomic app swap with path traversal guards, size limits, symlink handling - UpdateBanner: thin notification bar with download progress + restart - Wails bindings: CheckForUpdate, ApplyUpdate (server-cached, no URL from JS), RestartApp (graceful gateway shutdown before exit) - AboutTab: dynamic version from backend via GetVersion() - Windows build assets: icon.ico, info.json, wails.exe.manifest - Makefile: desktop-dev, desktop-build, desktop-dmg targets - README: Desktop Edition section with install commands + feature comparison - .gitignore: desktop packaging artifacts, SQLite DB files, update backups Security: HTTPS-only downloads, io.LimitReader on all extractions, zip/tar path traversal validation, symlink target guard, no untrusted URL from frontend (ApplyUpdate uses server-cached info). --- .github/workflows/release-desktop.yaml | 161 +++++++ .gitignore | 10 + Makefile | 21 +- README.md | 53 ++- internal/updater/updater.go | 397 ++++++++++++++++++ scripts/install-lite.ps1 | 57 +++ scripts/install-lite.sh | 105 +++++ ui/desktop/app.go | 92 ++++ ui/desktop/build/windows/icon.ico | Bin 0 -> 370070 bytes ui/desktop/build/windows/info.json | 15 + ui/desktop/build/windows/wails.exe.manifest | 22 + .../src/components/layout/AppShell.tsx | 2 + .../src/components/layout/UpdateBanner.tsx | 107 +++++ .../src/components/settings/AboutTab.tsx | 10 +- .../frontend/src/i18n/locales/en/desktop.json | 9 + .../frontend/src/i18n/locales/vi/desktop.json | 9 + .../frontend/src/i18n/locales/zh/desktop.json | 9 + ui/desktop/frontend/src/lib/wails.ts | 19 + 18 files changed, 1095 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/release-desktop.yaml create mode 100644 internal/updater/updater.go create mode 100644 scripts/install-lite.ps1 create mode 100755 scripts/install-lite.sh create mode 100644 ui/desktop/build/windows/icon.ico create mode 100644 ui/desktop/build/windows/info.json create mode 100644 ui/desktop/build/windows/wails.exe.manifest create mode 100644 ui/desktop/frontend/src/components/layout/UpdateBanner.tsx diff --git a/.github/workflows/release-desktop.yaml b/.github/workflows/release-desktop.yaml new file mode 100644 index 00000000..25ec0da5 --- /dev/null +++ b/.github/workflows/release-desktop.yaml @@ -0,0 +1,161 @@ +name: Release Desktop + +on: + push: + tags: ['lite-v*'] + +permissions: + contents: write + +env: + GITHUB_REPO: ${{ github.repository }} + +jobs: + # ── macOS builds (native runners required for Wails/WebKit) ── + build-macos: + strategy: + matrix: + include: + - runner: macos-14 # Apple Silicon (arm64) + arch: arm64 + - runner: macos-13 # Intel (amd64) + arch: amd64 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Setup pnpm + run: corepack enable && corepack prepare pnpm@latest --activate + + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@latest + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#lite-v}" >> "$GITHUB_OUTPUT" + + - name: Patch wails.json version + working-directory: ui/desktop + run: | + jq --arg v "${{ steps.version.outputs.version }}" \ + '.info.productVersion = $v' wails.json > wails.json.tmp \ + && mv wails.json.tmp wails.json + + - name: Build desktop app + working-directory: ui/desktop + run: | + wails build -tags sqliteonly \ + -ldflags "-s -w -X github.com/nextlevelbuilder/goclaw/cmd.Version=${{ steps.version.outputs.version }}" + + - name: Create DMG + run: | + mkdir -p dmg-staging + cp -R ui/desktop/build/bin/goclaw-lite.app dmg-staging/ + ln -s /Applications dmg-staging/Applications + hdiutil create -volname "GoClaw Lite ${{ steps.version.outputs.version }}" \ + -srcfolder dmg-staging -ov -format UDZO \ + "goclaw-lite-${{ steps.version.outputs.version }}-darwin-${{ matrix.arch }}.dmg" + + - name: Create tar.gz (for auto-update) + run: | + cd ui/desktop/build/bin + tar czf "../../../../goclaw-lite-${{ steps.version.outputs.version }}-darwin-${{ matrix.arch }}.tar.gz" \ + goclaw-lite.app + + # TODO: Add code signing when Apple Developer cert is available + # - name: Sign app + # run: codesign --deep --force --sign "${{ secrets.APPLE_SIGNING_IDENTITY }}" ... + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-${{ matrix.arch }} + path: | + goclaw-lite-*.dmg + goclaw-lite-*.tar.gz + + # ── Windows build ── + build-windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Setup pnpm + run: corepack enable && corepack prepare pnpm@latest --activate + + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@latest + + - name: Extract version from tag + id: version + shell: bash + run: echo "version=${GITHUB_REF_NAME#lite-v}" >> "$GITHUB_OUTPUT" + + - name: Patch wails.json version + working-directory: ui/desktop + shell: pwsh + run: | + $json = Get-Content wails.json | ConvertFrom-Json + $json.info.productVersion = "${{ steps.version.outputs.version }}" + $json | ConvertTo-Json -Depth 10 | Set-Content wails.json + + - name: Build desktop app + working-directory: ui/desktop + shell: bash + run: | + wails build -tags sqliteonly \ + -ldflags "-s -w -X github.com/nextlevelbuilder/goclaw/cmd.Version=${{ steps.version.outputs.version }}" + + - name: Create zip + shell: pwsh + run: | + Compress-Archive -Path "ui/desktop/build/bin/goclaw-lite.exe" ` + -DestinationPath "goclaw-lite-${{ steps.version.outputs.version }}-windows-amd64.zip" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-amd64 + path: goclaw-lite-*.zip + + # ── Create GitHub Release ── + create-release: + needs: [build-macos, build-windows] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract version from tag + id: version + run: echo "version=${GITHUB_REF_NAME#lite-v}" >> "$GITHUB_OUTPUT" + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: List artifacts + run: ls -la artifacts/ + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: "GoClaw Lite v${{ steps.version.outputs.version }}" + draft: false + prerelease: false + generate_release_notes: true + files: artifacts/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index beb8b117..32834ac8 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ ui/desktop/frontend/package.json.md5 ui/desktop/build/bin/ ui/desktop/desktop goclaw-lite +goclaw-lite-*.dmg +goclaw-lite-*.tar.gz +goclaw-lite-*.zip /desktop .mcp.json @@ -60,6 +63,13 @@ k8s-*/* tests/**/creds.json ui/simple-saas *.tar +# SQLite database files +goclaw.db +goclaw.db-wal +goclaw.db-shm +# Update backup files +*.app.bak +*.exe.bak # AI tool config directories (user-specific) .gemini/ .opencode/ diff --git a/Makefile b/Makefile index 50470ba5..ab8d0148 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ VERSION ?= $(shell git describe --tags --abbrev=0 2>/dev/null || echo dev) LDFLAGS = -s -w -X github.com/nextlevelbuilder/goclaw/cmd.Version=$(VERSION) BINARY = goclaw -.PHONY: build run clean version net up down logs reset test vet check-web dev migrate setup ci +.PHONY: build run clean version net up down logs reset test vet check-web dev migrate setup ci desktop-dev desktop-build desktop-dmg build: CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o $(BINARY) . @@ -76,3 +76,22 @@ setup: cd ui/web && pnpm install --frozen-lockfile ci: build test vet check-web + +# ── Desktop (Wails + SQLite) ── + +desktop-dev: + cd ui/desktop && wails dev -tags sqliteonly + +desktop-build: + cd ui/desktop && wails build -tags sqliteonly -ldflags="-s -w -X github.com/nextlevelbuilder/goclaw/cmd.Version=$(VERSION)" + +desktop-dmg: desktop-build + @echo "Creating DMG..." + rm -rf /tmp/goclaw-dmg-staging + mkdir -p /tmp/goclaw-dmg-staging + cp -R ui/desktop/build/bin/goclaw-lite.app /tmp/goclaw-dmg-staging/ + ln -s /Applications /tmp/goclaw-dmg-staging/Applications + hdiutil create -volname "GoClaw Lite $(VERSION)" -srcfolder /tmp/goclaw-dmg-staging \ + -ov -format UDZO "goclaw-lite-$(VERSION)-darwin-$$(uname -m | sed 's/x86_64/amd64/').dmg" + rm -rf /tmp/goclaw-dmg-staging + @echo "DMG created: goclaw-lite-$(VERSION)-darwin-$$(uname -m | sed 's/x86_64/amd64/').dmg" diff --git a/README.md b/README.md index 24087a0c..64eec4ce 100644 --- a/README.md +++ b/README.md @@ -96,12 +96,63 @@ A Go port of [OpenClaw](https://github.com/openclaw/openclaw) with enhanced secu | Skill system | ✅ Embeddings/semantic | ✅ SKILL.md + TOML | ✅ Basic | ✅ BM25 + pgvector hybrid | | Lane-based scheduler | ✅ | Bounded concurrency | — | ✅ (main/subagent/team/cron) | | Messaging channels | 37+ | 15+ | 10+ | 7+ | -| Companion apps | macOS, iOS, Android | Python SDK | — | Web dashboard | +| Companion apps | macOS, iOS, Android | Python SDK | — | Web dashboard + **Desktop app** | | Live Canvas / Voice | ✅ (A2UI + TTS/STT) | — | Voice transcription | TTS (4 providers) | | LLM providers | 10+ | 8 native + 29 compat | 13+ | **20+** | | Per-user workspaces | ✅ (file-based) | — | — | ✅ (PostgreSQL) | | Encrypted secrets | — (env vars only) | ✅ ChaCha20-Poly1305 | — (plaintext JSON) | ✅ AES-256-GCM in DB | +## Desktop Edition (GoClaw Lite) + +A native desktop app for local AI agents — no Docker, no PostgreSQL, no infrastructure. + +**macOS:** +```bash +curl -fsSL https://raw.githubusercontent.com/nextlevelbuilder/goclaw/main/scripts/install-lite.sh | bash +``` + +**Windows (PowerShell):** +```powershell +irm https://raw.githubusercontent.com/nextlevelbuilder/goclaw/main/scripts/install-lite.ps1 | iex +``` + +### What's Included +- Single native app (Wails v2 + React), ~30 MB +- SQLite database (zero setup) +- Chat with agents (streaming, tools, media, file attachments) +- Agent management (max 5), provider config, MCP servers, skills, cron +- Team tasks with Kanban board and real-time updates +- Auto-update from GitHub Releases + +### Lite vs Standard + +| Feature | Lite (Desktop) | Standard (Server) | +|---------|---------------|-------------------| +| Agents | Max 5 | Unlimited | +| Teams | Max 1 (5 members) | Unlimited | +| Database | SQLite (local) | PostgreSQL | +| Memory | FTS5 text search | pgvector semantic | +| Channels | — | Telegram, Discord, Slack, Zalo, Feishu, WhatsApp | +| Knowledge Graph | — | Full | +| RBAC / Multi-tenant | — | Full | +| Auto-update | GitHub Releases | Docker / binary | + +### Building from Source +```bash +# Prerequisites: Go 1.26+, pnpm, Wails CLI (go install github.com/wailsapp/wails/v2/cmd/wails@latest) +make desktop-build # Build .app (macOS) or .exe (Windows) +make desktop-dmg VERSION=0.1.0 # Create .dmg installer (macOS only) +make desktop-dev # Dev mode with hot reload +``` + +### Desktop Releases +Desktop uses independent versioning with `lite-v*` tags: +```bash +git tag lite-v0.1.0 && git push origin lite-v0.1.0 +# → GitHub Actions builds macOS (.dmg + .tar.gz) + Windows (.zip) +# → Creates GitHub Release with all assets +``` + ## Architecture

diff --git a/internal/updater/updater.go b/internal/updater/updater.go new file mode 100644 index 00000000..026ca9bb --- /dev/null +++ b/internal/updater/updater.go @@ -0,0 +1,397 @@ +// Package updater checks GitHub Releases for newer desktop (lite) versions +// and applies updates by swapping the .app bundle (macOS) or .exe (Windows). +package updater + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "time" +) + +const ( + githubRepo = "nextlevelbuilder/goclaw" + tagPrefix = "lite-v" + // maxFileSize limits individual extracted files to 500 MB (decompression bomb guard). + maxFileSize = 500 << 20 +) + +// httpClient with timeouts — never use http.DefaultClient for external calls. +var httpClient = &http.Client{Timeout: 60 * time.Second} + +// UpdateInfo describes an available update. +type UpdateInfo struct { + Available bool `json:"available"` + Version string `json:"version"` // e.g. "0.2.0" + DownloadURL string `json:"download_url"` // asset URL for current OS/arch + ReleaseURL string `json:"release_url"` // GitHub release page + ReleaseNotes string `json:"release_notes"` // release body markdown +} + +// githubRelease is a minimal GitHub Release API response. +type githubRelease struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Body string `json:"body"` + Assets []githubAsset `json:"assets"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` +} + +type githubAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// CheckForUpdate queries GitHub Releases for a newer lite-v* release. +// currentVersion should be a semver string like "0.1.0" (no "v" prefix). +func CheckForUpdate(currentVersion string) (*UpdateInfo, error) { + if currentVersion == "" || currentVersion == "dev" { + return &UpdateInfo{Available: false}, nil + } + + url := fmt.Sprintf("https://api.github.com/repos/%s/releases", githubRepo) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch releases: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("github api: %s", resp.Status) + } + + var releases []githubRelease + if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&releases); err != nil { + return nil, fmt.Errorf("decode releases: %w", err) + } + + for _, rel := range releases { + if rel.Draft || rel.Prerelease { + continue + } + if !strings.HasPrefix(rel.TagName, tagPrefix) { + continue + } + relVersion := strings.TrimPrefix(rel.TagName, tagPrefix) + if !isNewer(relVersion, currentVersion) { + continue + } + + assetURL := findAsset(rel.Assets, runtime.GOOS, runtime.GOARCH) + if assetURL == "" { + continue + } + + return &UpdateInfo{ + Available: true, + Version: relVersion, + DownloadURL: assetURL, + ReleaseURL: rel.HTMLURL, + ReleaseNotes: rel.Body, + }, nil + } + + return &UpdateInfo{Available: false}, nil +} + +// findAsset returns the download URL for the .tar.gz (macOS) or .zip (Windows) asset. +func findAsset(assets []githubAsset, goos, goarch string) string { + var ext string + switch goos { + case "darwin": + ext = ".tar.gz" + case "windows": + ext = ".zip" + default: + return "" + } + + suffix := fmt.Sprintf("-%s-%s%s", goos, goarch, ext) + for _, a := range assets { + if strings.HasSuffix(a.Name, suffix) { + return a.BrowserDownloadURL + } + } + return "" +} + +// DownloadAndApply downloads the update asset and replaces the current app. +// appPath is the path to the current .app bundle (macOS) or .exe (Windows). +func DownloadAndApply(info *UpdateInfo, appPath string) error { + if info == nil || info.DownloadURL == "" { + return fmt.Errorf("no download URL") + } + + // Enforce HTTPS for download URLs. + if !strings.HasPrefix(info.DownloadURL, "https://") { + return fmt.Errorf("download URL must use HTTPS") + } + + slog.Info("updater: downloading", "url", info.DownloadURL) + dlClient := &http.Client{Timeout: 10 * time.Minute} // large file download + resp, err := dlClient.Get(info.DownloadURL) + if err != nil { + return fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download: %s", resp.Status) + } + + tmpDir, err := os.MkdirTemp("", "goclaw-update-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + switch runtime.GOOS { + case "darwin": + return applyMacOS(resp.Body, tmpDir, appPath) + case "windows": + return applyWindows(resp.Body, tmpDir, appPath) + default: + return fmt.Errorf("unsupported OS: %s", runtime.GOOS) + } +} + +// applyMacOS extracts .tar.gz and atomically swaps the .app bundle. +func applyMacOS(r io.Reader, tmpDir, appPath string) error { + gz, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("gzip: %w", err) + } + defer gz.Close() + + cleanTmpDir := filepath.Clean(tmpDir) + string(filepath.Separator) + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("tar: %w", err) + } + + target := filepath.Join(tmpDir, hdr.Name) + // Path traversal guard + if !strings.HasPrefix(filepath.Clean(target)+string(filepath.Separator), cleanTmpDir) && + filepath.Clean(target) != filepath.Clean(tmpDir) { + slog.Warn("updater: skipping path-traversal entry", "name", hdr.Name) + continue + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, os.FileMode(hdr.Mode)); err != nil { + return fmt.Errorf("mkdir %s: %w", hdr.Name, err) + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return fmt.Errorf("mkdir parent %s: %w", hdr.Name, err) + } + if err := extractFile(target, tr, os.FileMode(hdr.Mode)); err != nil { + return fmt.Errorf("extract %s: %w", hdr.Name, err) + } + case tar.TypeSymlink: + // Validate symlink target doesn't escape tmpDir + linkTarget := filepath.Join(filepath.Dir(target), hdr.Linkname) + if !strings.HasPrefix(filepath.Clean(linkTarget), filepath.Clean(tmpDir)) { + slog.Warn("updater: skipping symlink escaping tmpDir", "name", hdr.Name, "target", hdr.Linkname) + continue + } + os.Remove(target) // remove existing if any + if err := os.Symlink(hdr.Linkname, target); err != nil { + return fmt.Errorf("symlink %s: %w", hdr.Name, err) + } + } + } + + // Find extracted .app + newApp := filepath.Join(tmpDir, "goclaw-lite.app") + if _, err := os.Stat(newApp); err != nil { + return fmt.Errorf("extracted app not found: %w", err) + } + + // Atomic swap: rename current → .bak, rename new → current, remove .bak + bakPath := appPath + ".bak" + os.RemoveAll(bakPath) + if err := os.Rename(appPath, bakPath); err != nil { + return fmt.Errorf("backup current app: %w", err) + } + if err := os.Rename(newApp, appPath); err != nil { + // Rollback + os.Rename(bakPath, appPath) + return fmt.Errorf("install new app: %w", err) + } + os.RemoveAll(bakPath) + + // Remove quarantine attribute (unsigned app on macOS) + removeQuarantine(appPath) + + slog.Info("updater: macOS app updated", "path", appPath) + return nil +} + +// applyWindows extracts .zip and replaces the .exe via temp rename. +func applyWindows(r io.Reader, tmpDir, exePath string) error { + // Write zip to temp file (zip needs random access) + tmpZip := filepath.Join(tmpDir, "update.zip") + f, err := os.Create(tmpZip) + if err != nil { + return err + } + if _, err := io.Copy(f, io.LimitReader(r, maxFileSize)); err != nil { + f.Close() + return fmt.Errorf("write zip: %w", err) + } + f.Close() + + zr, err := zip.OpenReader(tmpZip) + if err != nil { + return fmt.Errorf("open zip: %w", err) + } + defer zr.Close() + + // Find the .exe in the zip (validate no path traversal) + var exeFile *zip.File + for _, zf := range zr.File { + name := filepath.Base(zf.Name) // use only basename to prevent traversal + if strings.HasSuffix(name, ".exe") && !strings.Contains(zf.Name, "..") { + exeFile = zf + break + } + } + if exeFile == nil { + return fmt.Errorf("no .exe found in zip") + } + + // Extract to temp + newExe := filepath.Join(tmpDir, "goclaw-lite.exe") + src, err := exeFile.Open() + if err != nil { + return err + } + defer src.Close() + + if err := extractFile(newExe, src, 0o755); err != nil { + return fmt.Errorf("extract exe: %w", err) + } + + // Windows: can't delete running exe, but can rename it + bakPath := exePath + ".bak" + os.Remove(bakPath) + if err := os.Rename(exePath, bakPath); err != nil { + return fmt.Errorf("backup current exe: %w", err) + } + if err := os.Rename(newExe, exePath); err != nil { + os.Rename(bakPath, exePath) + return fmt.Errorf("install new exe: %w", err) + } + // .bak will be cleaned up on next launch + slog.Info("updater: windows exe updated", "path", exePath) + return nil +} + +// extractFile writes src to a file at path with size limit and proper error handling. +func extractFile(path string, src io.Reader, mode os.FileMode) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + _, err = io.Copy(f, io.LimitReader(src, maxFileSize)) + closeErr := f.Close() + if err != nil { + return err + } + return closeErr +} + +// removeQuarantine strips the macOS quarantine xattr from an app bundle. +func removeQuarantine(appPath string) { + // xattr -rd com.apple.quarantine /path/to/app + if runtime.GOOS == "darwin" { + exec := filepath.Join("/usr/bin", "xattr") + if _, err := os.Stat(exec); err == nil { + cmd := &os.ProcAttr{Files: []*os.File{nil, nil, nil}} + p, err := os.StartProcess(exec, []string{"xattr", "-rd", "com.apple.quarantine", appPath}, cmd) + if err == nil { + p.Wait() + } + } + } +} + +// isNewer returns true if version a is newer than b (simple semver compare). +// Pre-release suffixes (e.g. "0.2.0-rc1") are stripped — only major.minor.patch compared. +func isNewer(a, b string) bool { + pa := parseSemver(a) + pb := parseSemver(b) + for i := 0; i < 3; i++ { + if pa[i] > pb[i] { + return true + } + if pa[i] < pb[i] { + return false + } + } + return false +} + +func parseSemver(s string) [3]int { + s = strings.TrimPrefix(s, "v") + // Strip pre-release suffix: "0.2.0-rc1" → "0.2.0" + if idx := strings.IndexByte(s, '-'); idx >= 0 { + s = s[:idx] + } + var parts [3]int + for i, p := range strings.SplitN(s, ".", 3) { + if i >= 3 { + break + } + fmt.Sscanf(p, "%d", &parts[i]) + } + return parts +} + +// ResolveAppPath returns the path to the current .app bundle (macOS) or .exe (Windows) +// by walking up from the running executable path. +func ResolveAppPath() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", err + } + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return "", err + } + + switch runtime.GOOS { + case "darwin": + // exe: /path/to/GoClaw Lite.app/Contents/MacOS/goclaw-lite + // app: /path/to/GoClaw Lite.app + dir := filepath.Dir(filepath.Dir(filepath.Dir(exe))) + if strings.HasSuffix(dir, ".app") { + return dir, nil + } + return "", fmt.Errorf("not running from .app bundle: %s", exe) + case "windows": + return exe, nil + default: + return "", fmt.Errorf("unsupported OS: %s", runtime.GOOS) + } +} diff --git a/scripts/install-lite.ps1 b/scripts/install-lite.ps1 new file mode 100644 index 00000000..6a8296c0 --- /dev/null +++ b/scripts/install-lite.ps1 @@ -0,0 +1,57 @@ +# GoClaw Lite (Desktop) installer for Windows +# Usage: +# irm https://raw.githubusercontent.com/nextlevelbuilder/goclaw/main/scripts/install-lite.ps1 | iex +# .\install-lite.ps1 -Version lite-v0.1.0 + +param([string]$Version = "") + +$ErrorActionPreference = "Stop" +$Repo = "nextlevelbuilder/goclaw" +$InstallDir = "$env:LOCALAPPDATA\GoClaw Lite" + +# Resolve latest version +if (-not $Version) { + Write-Host "-> Fetching latest desktop release..." + $releases = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases" + $latest = $releases | Where-Object { $_.tag_name -like "lite-v*" -and -not $_.prerelease -and -not $_.draft } | Select-Object -First 1 + if (-not $latest) { + Write-Error "No desktop release found. Check https://github.com/$Repo/releases" + exit 1 + } + $Version = $latest.tag_name +} + +$Semver = $Version -replace "^lite-v", "" +Write-Host "-> Installing GoClaw Lite v$Semver..." + +# Download +$Asset = "goclaw-lite-$Semver-windows-amd64.zip" +$Url = "https://github.com/$Repo/releases/download/$Version/$Asset" +$TmpZip = Join-Path $env:TEMP $Asset + +Write-Host "-> Downloading $Url..." +Invoke-WebRequest -Uri $Url -OutFile $TmpZip -UseBasicParsing + +# Extract +Write-Host "-> Installing to $InstallDir..." +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +Expand-Archive -Path $TmpZip -DestinationPath $InstallDir -Force +Remove-Item $TmpZip -Force + +# Create Start Menu shortcut +$ExePath = Join-Path $InstallDir "goclaw-lite.exe" +if (Test-Path $ExePath) { + $ShortcutPath = Join-Path ([Environment]::GetFolderPath("StartMenu")) "Programs\GoClaw Lite.lnk" + $Shell = New-Object -ComObject WScript.Shell + $Shortcut = $Shell.CreateShortcut($ShortcutPath) + $Shortcut.TargetPath = $ExePath + $Shortcut.WorkingDirectory = $InstallDir + $Shortcut.Save() + Write-Host "-> Start Menu shortcut created" +} + +Write-Host "" +Write-Host "GoClaw Lite v$Semver installed to $InstallDir" -ForegroundColor Green +Write-Host "" +Write-Host "To launch: Start Menu -> GoClaw Lite" +Write-Host "Or run: & '$ExePath'" diff --git a/scripts/install-lite.sh b/scripts/install-lite.sh new file mode 100755 index 00000000..3f61c826 --- /dev/null +++ b/scripts/install-lite.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# GoClaw Lite (Desktop) installer — downloads the latest .app from GitHub Releases. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/nextlevelbuilder/goclaw/main/scripts/install-lite.sh | bash +# curl -fsSL ... | bash -s -- --version lite-v0.1.0 +# +# macOS only. Windows users: download .zip from GitHub Releases. + +set -euo pipefail + +REPO="nextlevelbuilder/goclaw" +INSTALL_DIR="/Applications" +VERSION="" + +# ── Parse args ── +while [[ $# -gt 0 ]]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --help|-h) + echo "Usage: install-lite.sh [--version lite-v1.0.0]" + echo " Downloads and installs GoClaw Lite desktop app to /Applications/" + exit 0 + ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# ── Detect OS/arch ── +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) echo "❌ Unsupported architecture: $ARCH"; exit 1 ;; +esac + +if [[ "$OS" != "darwin" ]]; then + echo "❌ This installer is for macOS only." + echo "" + echo "For Windows: download .zip from https://github.com/$REPO/releases" + echo "For Linux: not yet supported" + exit 1 +fi + +# ── Resolve version ── +if [[ -z "$VERSION" ]]; then + echo "→ Fetching latest desktop release..." + VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases" \ + | grep '"tag_name": "lite-v' \ + | head -1 \ + | sed 's/.*"tag_name": "\(lite-v[^"]*\)".*/\1/') + + if [[ -z "$VERSION" ]]; then + echo "❌ No desktop release found. Check https://github.com/$REPO/releases" + exit 1 + fi +fi + +SEMVER="${VERSION#lite-v}" +echo "→ Installing GoClaw Lite v${SEMVER} (${ARCH})..." + +# ── Download ── +ASSET="goclaw-lite-${SEMVER}-darwin-${ARCH}.tar.gz" +URL="https://github.com/$REPO/releases/download/$VERSION/$ASSET" +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +echo "→ Downloading $URL..." +if ! curl -fSL --progress-bar "$URL" -o "$TMPDIR/$ASSET"; then + echo "❌ Download failed. Check the version and try again." + echo " Available releases: https://github.com/$REPO/releases" + exit 1 +fi + +# ── Extract ── +echo "→ Extracting..." +tar xzf "$TMPDIR/$ASSET" -C "$TMPDIR" + +if [[ ! -d "$TMPDIR/goclaw-lite.app" ]]; then + echo "❌ Archive does not contain goclaw-lite.app" + exit 1 +fi + +# ── Install ── +TARGET="$INSTALL_DIR/goclaw-lite.app" +if [[ -d "$TARGET" ]]; then + echo "→ Removing existing installation..." + rm -rf "$TARGET" +fi + +echo "→ Installing to $TARGET..." +cp -R "$TMPDIR/goclaw-lite.app" "$TARGET" + +# Remove quarantine attribute (unsigned app) +xattr -rd com.apple.quarantine "$TARGET" 2>/dev/null || true + +echo "" +echo "✅ GoClaw Lite v${SEMVER} installed to $TARGET" +echo "" +echo "To launch:" +echo " open /Applications/goclaw-lite.app" +echo "" +echo "If macOS blocks the app:" +echo " Right-click → Open → Open (first launch only)" diff --git a/ui/desktop/app.go b/ui/desktop/app.go index a0cbc2e1..420fab36 100644 --- a/ui/desktop/app.go +++ b/ui/desktop/app.go @@ -16,6 +16,7 @@ import ( "time" "github.com/nextlevelbuilder/goclaw/cmd" + "github.com/nextlevelbuilder/goclaw/internal/updater" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" ) @@ -25,6 +26,7 @@ type App struct { cancelGw context.CancelFunc gatewayToken string gatewayPort int + lastUpdate *updater.UpdateInfo // cached update info from last check } // NewApp creates a new App instance with default port. @@ -87,6 +89,9 @@ func (a *App) startup(ctx context.Context) { }() a.waitForGateway() + + // Check for updates after gateway is ready, then every 6 hours. + go a.updateLoop() } // waitForGateway polls the health endpoint until the gateway is ready or times out. @@ -180,6 +185,93 @@ func (a *App) SaveFile(srcPath string) error { return err } +// CheckForUpdate queries GitHub for a newer desktop release. +func (a *App) CheckForUpdate() (*updater.UpdateInfo, error) { + info, err := updater.CheckForUpdate(cmd.Version) + if err != nil { + return nil, err + } + if info.Available { + a.lastUpdate = info // cache for ApplyUpdate + } + return info, nil +} + +// ApplyUpdate downloads and installs the cached update. +// Uses server-side cached UpdateInfo — does NOT accept URL from frontend (security). +func (a *App) ApplyUpdate() error { + if a.lastUpdate == nil || !a.lastUpdate.Available { + return fmt.Errorf("no update available") + } + appPath, err := updater.ResolveAppPath() + if err != nil { + return fmt.Errorf("resolve app path: %w", err) + } + if err := updater.DownloadAndApply(a.lastUpdate, appPath); err != nil { + return fmt.Errorf("apply update: %w", err) + } + return nil +} + +// RestartApp gracefully shuts down the gateway, relaunches the app, and exits. +func (a *App) RestartApp() error { + appPath, err := updater.ResolveAppPath() + if err != nil { + return err + } + // Graceful gateway shutdown before exit + if a.cancelGw != nil { + a.cancelGw() + time.Sleep(500 * time.Millisecond) // brief wait for WAL checkpoint + } + switch runtime.GOOS { + case "darwin": + exec.Command("open", "-n", appPath).Start() + case "windows": + exec.Command(appPath).Start() + } + os.Exit(0) + return nil +} + +// updateLoop checks for updates on startup (5s delay) then every 6 hours. +// Respects app context for clean shutdown. +func (a *App) updateLoop() { + timer := time.NewTimer(5 * time.Second) + defer timer.Stop() + + select { + case <-timer.C: + a.checkAndEmitUpdate() + case <-a.ctx.Done(): + return + } + + ticker := time.NewTicker(6 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ticker.C: + a.checkAndEmitUpdate() + case <-a.ctx.Done(): + return + } + } +} + +func (a *App) checkAndEmitUpdate() { + info, err := updater.CheckForUpdate(cmd.Version) + if err != nil { + slog.Debug("update check failed", "error", err) + return + } + if info.Available { + a.lastUpdate = info + slog.Info("update available", "version", info.Version) + wailsRuntime.EventsEmit(a.ctx, "update:available", info) + } +} + // DownloadURL fetches a URL with Bearer auth and opens a Save As dialog. func (a *App) DownloadURL(url, defaultFilename string) error { req, err := http.NewRequest("GET", url, nil) diff --git a/ui/desktop/build/windows/icon.ico b/ui/desktop/build/windows/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e4e8e222f44bbe30bc761c04875cf0859a9c1eb6 GIT binary patch literal 370070 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 x| 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 F{||V{ycYlf literal 0 HcmV?d00001 diff --git a/ui/desktop/build/windows/info.json b/ui/desktop/build/windows/info.json new file mode 100644 index 00000000..fa011fe2 --- /dev/null +++ b/ui/desktop/build/windows/info.json @@ -0,0 +1,15 @@ +{ + "fixed": { + "file_version": "0.1.0.0" + }, + "info": { + "0409": { + "ProductVersion": "0.1.0.0", + "CompanyName": "GoClaw", + "FileDescription": "GoClaw Lite Desktop — AI Agent Gateway", + "LegalCopyright": "Copyright (c) 2025-2026 GoClaw", + "ProductName": "GoClaw Lite", + "Comments": "Local AI agent desktop app powered by SQLite" + } + } +} diff --git a/ui/desktop/build/windows/wails.exe.manifest b/ui/desktop/build/windows/wails.exe.manifest new file mode 100644 index 00000000..588864a8 --- /dev/null +++ b/ui/desktop/build/windows/wails.exe.manifest @@ -0,0 +1,22 @@ + + + + + + + + + + + true/pm + PerMonitorV2, PerMonitor + + + + + + + + + + diff --git a/ui/desktop/frontend/src/components/layout/AppShell.tsx b/ui/desktop/frontend/src/components/layout/AppShell.tsx index 7392c431..67456fc8 100644 --- a/ui/desktop/frontend/src/components/layout/AppShell.tsx +++ b/ui/desktop/frontend/src/components/layout/AppShell.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from 'react' import { useUiStore } from '../../stores/ui-store' import { Sidebar } from './Sidebar' +import { UpdateBanner } from './UpdateBanner' import { SettingsView } from '../settings/SettingsView' const TeamBoard = lazy(() => import('../teams/TeamBoard').then((m) => ({ default: m.TeamBoard }))) @@ -39,6 +40,7 @@ export function AppShell({ children }: AppShellProps) { {/* Main panel — chat, settings, or team board */}

+ {mainContent}
diff --git a/ui/desktop/frontend/src/components/layout/UpdateBanner.tsx b/ui/desktop/frontend/src/components/layout/UpdateBanner.tsx new file mode 100644 index 00000000..c49e0ece --- /dev/null +++ b/ui/desktop/frontend/src/components/layout/UpdateBanner.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { EventsOn } from '../../../wailsjs/runtime/runtime' +import { wails, type UpdateInfo } from '../../lib/wails' + +type BannerState = 'idle' | 'available' | 'downloading' | 'done' | 'error' + +export function UpdateBanner() { + const { t } = useTranslation('desktop') + const [update, setUpdate] = useState(null) + const [state, setState] = useState('idle') + const [dismissed, setDismissed] = useState(false) + + useEffect(() => { + const cancel = EventsOn('update:available', (info: UpdateInfo) => { + if (info?.available) { + setUpdate(info) + setState('available') + setDismissed(false) + } + }) + return cancel + }, []) + + const handleUpdate = async () => { + if (!update) return + setState('downloading') + try { + await wails.applyUpdate() + setState('done') + } catch { + setState('error') + } + } + + const handleRestart = () => { + wails.restartApp() + } + + if (state === 'idle' || dismissed || !update) return null + + return ( +
+ {state === 'available' && ( + <> + + + + + + + {t('update.available', 'GoClaw Lite v{{version}} is available', { version: update.version })} + + +
+ + + )} + + {state === 'downloading' && ( + <> +
+ {t('update.downloading', 'Downloading update...')} + + )} + + {state === 'done' && ( + <> + + + + {t('update.ready', 'Update installed! Restart to apply.')} + + + )} + + {state === 'error' && ( + <> + {t('update.failed', 'Update failed.')} + +
+ + + )} +
+ ) +} diff --git a/ui/desktop/frontend/src/components/settings/AboutTab.tsx b/ui/desktop/frontend/src/components/settings/AboutTab.tsx index efc0fe6a..ff89092f 100644 --- a/ui/desktop/frontend/src/components/settings/AboutTab.tsx +++ b/ui/desktop/frontend/src/components/settings/AboutTab.tsx @@ -1,8 +1,16 @@ +import { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { BrowserOpenURL } from '../../../wailsjs/runtime/runtime' +import { wails } from '../../lib/wails' export function AboutTab() { const { t } = useTranslation('desktop') + const [version, setVersion] = useState('...') + + useEffect(() => { + wails.getVersion().then(setVersion).catch(() => setVersion('unknown')) + }, []) + return (
@@ -16,7 +24,7 @@ export function AboutTab() {
{t('about.version')} - 0.1.0-beta + {version}
{t('about.edition')} diff --git a/ui/desktop/frontend/src/i18n/locales/en/desktop.json b/ui/desktop/frontend/src/i18n/locales/en/desktop.json index c0c51c6e..a8412845 100644 --- a/ui/desktop/frontend/src/i18n/locales/en/desktop.json +++ b/ui/desktop/frontend/src/i18n/locales/en/desktop.json @@ -134,5 +134,14 @@ "saveFailed": "Failed to save", "deleteFailed": "Failed to delete", "createFailed": "Failed to create" + }, + "update": { + "available": "GoClaw Lite v{{version}} is available", + "now": "Update Now", + "downloading": "Downloading update...", + "ready": "Update installed! Restart to apply.", + "restart": "Restart Now", + "failed": "Update failed.", + "retry": "Retry" } } diff --git a/ui/desktop/frontend/src/i18n/locales/vi/desktop.json b/ui/desktop/frontend/src/i18n/locales/vi/desktop.json index 90aaac16..ee75ab51 100644 --- a/ui/desktop/frontend/src/i18n/locales/vi/desktop.json +++ b/ui/desktop/frontend/src/i18n/locales/vi/desktop.json @@ -138,5 +138,14 @@ "saveFailed": "Failed to save", "deleteFailed": "Failed to delete", "createFailed": "Failed to create" + }, + "update": { + "available": "GoClaw Lite v{{version}} đã có bản mới", + "now": "Cập nhật ngay", + "downloading": "Đang tải bản cập nhật...", + "ready": "Đã cài đặt! Khởi động lại để áp dụng.", + "restart": "Khởi động lại", + "failed": "Cập nhật thất bại.", + "retry": "Thử lại" } } diff --git a/ui/desktop/frontend/src/i18n/locales/zh/desktop.json b/ui/desktop/frontend/src/i18n/locales/zh/desktop.json index 40cdbc99..45e5dc45 100644 --- a/ui/desktop/frontend/src/i18n/locales/zh/desktop.json +++ b/ui/desktop/frontend/src/i18n/locales/zh/desktop.json @@ -138,5 +138,14 @@ "saveFailed": "Failed to save", "deleteFailed": "Failed to delete", "createFailed": "Failed to create" + }, + "update": { + "available": "GoClaw Lite v{{version}} 新版本可用", + "now": "立即更新", + "downloading": "正在下载更新...", + "ready": "更新已安装!重启以应用。", + "restart": "立即重启", + "failed": "更新失败。", + "retry": "重试" } } diff --git a/ui/desktop/frontend/src/lib/wails.ts b/ui/desktop/frontend/src/lib/wails.ts index 1487709b..c55d34e9 100644 --- a/ui/desktop/frontend/src/lib/wails.ts +++ b/ui/desktop/frontend/src/lib/wails.ts @@ -1,6 +1,14 @@ // Type-safe wrappers for Wails Go runtime bindings // Wails injects window.go at startup with bound Go methods +export interface UpdateInfo { + available: boolean + version: string + download_url: string + release_url: string + release_notes: string +} + declare global { interface Window { go: { @@ -11,6 +19,12 @@ declare global { GetGatewayPort(): Promise IsGatewayReady(): Promise GetVersion(): Promise + CheckForUpdate(): Promise + ApplyUpdate(): Promise + RestartApp(): Promise + OpenFile(path: string): Promise + SaveFile(srcPath: string): Promise + DownloadURL(url: string, filename: string): Promise } } } @@ -23,4 +37,9 @@ export const wails = { getGatewayPort: (): Promise => window.go.main.App.GetGatewayPort(), isGatewayReady: (): Promise => window.go.main.App.IsGatewayReady(), getVersion: (): Promise => window.go.main.App.GetVersion(), + checkForUpdate: (): Promise => window.go.main.App.CheckForUpdate(), + applyUpdate: (): Promise => window.go.main.App.ApplyUpdate(), + restartApp: (): Promise => window.go.main.App.RestartApp(), + openFile: (path: string): Promise => window.go.main.App.OpenFile(path), + saveFile: (srcPath: string): Promise => window.go.main.App.SaveFile(srcPath), }