mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 18:15:34 +00:00
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:
1 parent
30708ae79d
commit
b9c1731e31
18 files changed
+1095
-3
No files matched your search
@@ -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
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'"
|
||||
Executable
+105
@@ -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)"
|
||||
@@ -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 |
@@ -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": "重试"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
Reference in new issue
Block a user