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 @@ + + + + + + + + + + + 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), }