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 00000000..e4e8e222
Binary files /dev/null and b/ui/desktop/build/windows/icon.ico differ
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 @@
+
+