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).
This commit is contained in:
viettranx committed 2026-03-27 10:15:15 +07:00
1 parent 30708ae79d
commit b9c1731e31
18 files changed
+1095 -3

No files matched your search

+161
View File
@@ -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 }}
+10
View File
@@ -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/
+20 -1
View File
@@ -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"
+52 -1
View File
@@ -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
<p align="center">
+397
View File
@@ -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)
}
}
+57
View File
@@ -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'"
+105
View File
@@ -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)"
+92
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

+15
View File
@@ -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"
}
}
}
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity version="0.1.0.0" processorArchitecture="*" name="com.goclaw.lite" type="win32"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
@@ -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 */}
<div className="floating-panel m-3 ml-2 flex-1 flex flex-col min-w-0">
<UpdateBanner />
{mainContent}
</div>
</div>
@@ -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<UpdateInfo | null>(null)
const [state, setState] = useState<BannerState>('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 (
<div className="flex items-center gap-3 px-4 py-2 bg-accent/10 border-b border-accent/20 text-xs shrink-0">
{state === 'available' && (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="text-accent shrink-0">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span className="text-text-primary">
{t('update.available', 'GoClaw Lite v{{version}} is available', { version: update.version })}
</span>
<button
onClick={handleUpdate}
className="font-medium text-accent hover:underline cursor-pointer"
>
{t('update.now', 'Update Now')}
</button>
<div className="flex-1" />
<button onClick={() => setDismissed(true)} className="text-text-muted hover:text-text-primary cursor-pointer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</>
)}
{state === 'downloading' && (
<>
<div className="w-3.5 h-3.5 border-2 border-accent border-t-transparent rounded-full animate-spin shrink-0" />
<span className="text-text-primary">{t('update.downloading', 'Downloading update...')}</span>
</>
)}
{state === 'done' && (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className="text-emerald-500 shrink-0">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span className="text-text-primary">{t('update.ready', 'Update installed! Restart to apply.')}</span>
<button
onClick={handleRestart}
className="font-medium text-accent hover:underline cursor-pointer"
>
{t('update.restart', 'Restart Now')}
</button>
</>
)}
{state === 'error' && (
<>
<span className="text-error">{t('update.failed', 'Update failed.')}</span>
<button onClick={handleUpdate} className="font-medium text-accent hover:underline cursor-pointer">
{t('update.retry', 'Retry')}
</button>
<div className="flex-1" />
<button onClick={() => setDismissed(true)} className="text-text-muted hover:text-text-primary cursor-pointer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</>
)}
</div>
)
}
@@ -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 (
<div className="space-y-6 max-w-lg">
<div className="flex items-center gap-4">
@@ -16,7 +24,7 @@ export function AboutTab() {
<div className="rounded-lg border border-border p-4 space-y-3">
<div className="flex justify-between text-xs">
<span className="text-text-muted">{t('about.version')}</span>
<span className="text-text-primary font-mono">0.1.0-beta</span>
<span className="text-text-primary font-mono">{version}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-text-muted">{t('about.edition')}</span>
@@ -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"
}
}
@@ -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"
}
}
@@ -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": "重试"
}
}
+19
View File
@@ -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<number>
IsGatewayReady(): Promise<boolean>
GetVersion(): Promise<string>
CheckForUpdate(): Promise<UpdateInfo>
ApplyUpdate(): Promise<void>
RestartApp(): Promise<void>
OpenFile(path: string): Promise<void>
SaveFile(srcPath: string): Promise<void>
DownloadURL(url: string, filename: string): Promise<void>
}
}
}
@@ -23,4 +37,9 @@ export const wails = {
getGatewayPort: (): Promise<number> => window.go.main.App.GetGatewayPort(),
isGatewayReady: (): Promise<boolean> => window.go.main.App.IsGatewayReady(),
getVersion: (): Promise<string> => window.go.main.App.GetVersion(),
checkForUpdate: (): Promise<UpdateInfo> => window.go.main.App.CheckForUpdate(),
applyUpdate: (): Promise<void> => window.go.main.App.ApplyUpdate(),
restartApp: (): Promise<void> => window.go.main.App.RestartApp(),
openFile: (path: string): Promise<void> => window.go.main.App.OpenFile(path),
saveFile: (srcPath: string): Promise<void> => window.go.main.App.SaveFile(srcPath),
}