jim800121chen 3f0175f1a9 feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI
從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑:
tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。
Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local),
雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。

Backend / Wails Go(AB1-AB13):
- internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped)
  + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event
- internal/auth:encrypted file token store(AES-GCM + scrypt + machineID
  fallback salt + 13 tests)
- internal/config:YAML validation + atomic write + 11 tests
- internal/log:ring buffer + ExportLog 升級 zip
- visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests
- 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage)
- end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護
  → tunnel drop failover)

Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎):
- AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab)
- ConnectionStatusBadge 5 種狀態
- TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁
- 設定頁 4 區塊(含重新配對 AlertDialog)
- agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests

Phase 0.7 review-driven fix(Round 2):
- A1 Session fixation 防護(RotateSessionID)
- A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log
- A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態)
- A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test
- F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL
  / F4 Settings draft 持久 + 未儲存 badge

驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 /
agent frontend pnpm test 119 tests 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:22:01 +08:00

304 lines
9.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handlers
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"visiona-agent/server/internal/api/ws"
"visiona-agent/server/internal/deps"
"github.com/gin-gonic/gin"
)
// shutdownNotifyBroadcaster 是 SystemHandler 呼叫 Hub.BroadcastToRoom 的抽象,
// 方便單元測試用 spy 注入。預設由 *ws.Hub 滿足。
type shutdownNotifyBroadcaster interface {
BroadcastToRoom(room string, data interface{})
}
// shutdownNotifySleepDuration 是 ShutdownNotify 廣播後等 client 收訊息的時間。
// 對應 TDD v2/server-lifecycle.md §2.3Minor 4的 "best-effort" 設計:
// 我們不等待實際送達 ACK只 sleep 固定時間,讓 write pump 有機會把 byte 推出去。
// 單元測試可 override 成 0 加速。
var shutdownNotifySleepDuration = 100 * time.Millisecond
type SystemHandler struct {
startTime time.Time
version string
buildTime string
pythonBin string // 由 main.go 傳入InstallDriver 會用到
shutdownFn func()
depsCache []deps.Dependency
bootID string // M8-4server 啟動時產生的 boot-id32 字元 hex
wsHub shutdownNotifyBroadcaster // MAJ-4 補丁:用於 shutdown-imminent 廣播
}
// newBootID 產生 32 字元 hex 字串16 bytes 隨機)。
// 對應 TDD v2/server-lifecycle.md §9.1:用純標準庫 crypto/rand不引 google/uuid。
func newBootID() string {
b := make([]byte, 16)
// crypto/rand.Read 在實務上不會失敗;即使失敗(回 zero bytes仍然可用
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func NewSystemHandler(version, buildTime, pythonBin string, shutdownFn func(), wsHub *ws.Hub) *SystemHandler {
var b shutdownNotifyBroadcaster
if wsHub != nil {
b = wsHub
}
return &SystemHandler{
startTime: time.Now(),
version: version,
buildTime: buildTime,
pythonBin: pythonBin,
shutdownFn: shutdownFn,
depsCache: deps.CheckAll(),
bootID: newBootID(),
wsHub: b,
}
}
func (h *SystemHandler) HealthCheck(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
}
// BootID 回傳此 server process 啟動時產生的 boot-id。
// 瀏覽器 tab 每 10 秒 poll 一次,用於偵測 server 重啟 → 觸發 window.location.reload()。
// 對應 TDD v2/server-lifecycle.md §9。
func (h *SystemHandler) BootID(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"bootId": h.bootID,
"startedAt": h.startTime.UnixMilli(),
},
})
}
func (h *SystemHandler) Info(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"version": h.version,
"platform": runtime.GOOS + "/" + runtime.GOARCH,
"uptime": time.Since(h.startTime).Seconds(),
"goVersion": runtime.Version(),
},
})
}
func (h *SystemHandler) Metrics(c *gin.Context) {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
c.JSON(200, gin.H{
"success": true,
"data": gin.H{
"version": h.version,
"buildTime": h.buildTime,
"platform": runtime.GOOS + "/" + runtime.GOARCH,
"goVersion": runtime.Version(),
"uptimeSeconds": time.Since(h.startTime).Seconds(),
"goroutines": runtime.NumGoroutine(),
"memHeapAllocMB": float64(ms.HeapAlloc) / 1024 / 1024,
"memSysMB": float64(ms.Sys) / 1024 / 1024,
"memHeapObjects": ms.HeapObjects,
"gcCycles": ms.NumGC,
"nextGcMB": float64(ms.NextGC) / 1024 / 1024,
},
})
}
func (h *SystemHandler) Deps(c *gin.Context) {
c.JSON(200, gin.H{
"success": true,
"data": gin.H{"deps": h.depsCache},
})
}
// InstallDriver 安裝 Kneron WinUSB driver僅 Windows 有意義)。
// 呼叫 platform-specific 實作(見 system_driver_windows.go / system_driver_other.go
func (h *SystemHandler) InstallDriver(c *gin.Context) {
if runtime.GOOS != "windows" {
c.JSON(400, gin.H{
"success": false,
"error": gin.H{
"code": "DRIVER_NOT_NEEDED",
"message": runtime.GOOS + " 不需要安裝 WinUSB driver",
},
})
return
}
pyBin := h.pythonBin
if pyBin == "" {
c.JSON(500, gin.H{
"success": false,
"error": gin.H{
"code": "PYTHON_NOT_AVAILABLE",
"message": "Python interpreter 未就緒,請確認 bundled Python runtime 初始化完成",
},
})
return
}
if err := installKneronWinUSBDriverHandler(pyBin); err != nil {
c.JSON(500, gin.H{
"success": false,
"error": gin.H{
"code": "DRIVER_INSTALL_FAILED",
"message": err.Error(),
},
})
return
}
c.JSON(200, gin.H{
"success": true,
"data": gin.H{"message": "WinUSB driver 安裝完成,請重新插拔裝置或直接點擊連線。"},
})
}
// ShutdownNotify 發送 server:shutdown-imminent 廣播到 /ws/system 的所有 client。
//
// 路由POST /api/system/shutdown-notify?reason=quit|restart
//
// 對應 TDD v2/server-lifecycle.md §2.3Minor 4與 v2/web-ui-offline-overlay.md §3.2a
// Wails 在 ctrl.Stop() SIGTERM 之前先呼叫這個 endpoint讓瀏覽器 tab 的 Offline Overlay
// 立即顯示,避免只靠 health polling 導致的 15 秒延遲 + race condition。
//
// 設計原則best-effort
// - 沒有 WebSocket client 也回 200失敗情境也視為正常
// - reason 非 quit / restart 時視為 "unknown"(仍 broadcast 讓前端決定怎麼處理)
// - broadcast 完固定 sleep 100 ms給 write pump 時間把 byte 真的推到 TCP socket
// (不等 ACKserver 馬上就要 SIGTERM 了)
//
// Request: POST /api/system/shutdown-notify?reason=quit
// Response 200: {"ok": true, "reason": "quit"}
func (h *SystemHandler) ShutdownNotify(c *gin.Context) {
reason := c.Query("reason")
switch reason {
case "quit", "restart":
// 正常路徑
default:
reason = "unknown"
}
if h.wsHub != nil {
payload := gin.H{
"type": "server:shutdown-imminent",
"reason": reason,
"ts": time.Now().UnixMilli(),
}
h.wsHub.BroadcastToRoom("system", payload)
// 等 client 有時間把訊息 flush 出去
if shutdownNotifySleepDuration > 0 {
time.Sleep(shutdownNotifySleepDuration)
}
}
c.JSON(http.StatusOK, gin.H{"ok": true, "reason": reason})
}
func (h *SystemHandler) Restart(c *gin.Context) {
c.JSON(200, gin.H{"success": true, "data": gin.H{"message": "restarting"}})
if f, ok := c.Writer.(http.Flusher); ok {
f.Flush()
}
go func() {
time.Sleep(200 * time.Millisecond)
// shutdownFn signals the main goroutine to perform exec after server shutdown
if h.shutdownFn != nil {
h.shutdownFn()
}
}()
}
// InstallUdevRule 在 Linux 上安裝 Kneron USB udev rule需要 pkexec 提權)。
// 非 Linux 平台直接回 successno-op
func (h *SystemHandler) InstallUdevRule(c *gin.Context) {
if runtime.GOOS != "linux" {
c.JSON(200, gin.H{"success": true, "data": gin.H{"message": "not linux, skipped"}})
return
}
// 已安裝 → 不重複安裝
if _, err := os.Stat("/etc/udev/rules.d/99-kneron.rules"); err == nil {
c.JSON(200, gin.H{"success": true, "data": gin.H{"message": "already installed"}})
return
}
// 找 bundle 裡的 99-kneron.rules
// AppImage: $VISIONA_BUNDLE_LIB_DIR/99-kneron.rules
// dev mode: installer/linux/99-kneron.rules相對 cwd
ruleSrc := ""
if libDir := os.Getenv("VISIONA_BUNDLE_LIB_DIR"); libDir != "" {
candidate := filepath.Join(libDir, "99-kneron.rules")
if _, err := os.Stat(candidate); err == nil {
ruleSrc = candidate
}
}
if ruleSrc == "" {
// dev mode fallback
candidates := []string{
"installer/linux/99-kneron.rules",
"../installer/linux/99-kneron.rules",
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
abs, _ := filepath.Abs(c)
ruleSrc = abs
break
}
}
}
if ruleSrc == "" {
c.JSON(500, gin.H{"success": false, "error": gin.H{
"code": "UDEV_RULE_NOT_FOUND",
"message": "99-kneron.rules not found in bundle",
}})
return
}
// AppImage FUSE mount 的檔案在 pkexec 提權後無法被 root 直接讀取
// FUSE allow_other 沒開),先 cp 到 /tmp 再 pkexec 從 /tmp 複製到 /etc。
tmpRule := "/tmp/visiona-agent-99-kneron.rules"
if data, err := os.ReadFile(ruleSrc); err == nil {
_ = os.WriteFile(tmpRule, data, 0o644)
} else {
c.JSON(500, gin.H{"success": false, "error": gin.H{
"code": "UDEV_READ_FAILED",
"message": fmt.Sprintf("cannot read rule file: %v", err),
}})
return
}
defer os.Remove(tmpRule)
// 用 pkexec 一次提權執行三個動作cp + reload-rules + trigger使用者只要輸入一次密碼。
// 用 sh -c 包起來而不是呼叫 3 次 pkexec原本每次都彈密碼框
// tmpRule 路徑和 /etc/udev/rules.d/99-kneron.rules 都是固定值,無 shell injection 風險。
script := fmt.Sprintf(
"cp %q /etc/udev/rules.d/99-kneron.rules && udevadm control --reload-rules && udevadm trigger",
tmpRule,
)
cpCmd := exec.Command("pkexec", "sh", "-c", script)
if out, err := cpCmd.CombinedOutput(); err != nil {
c.JSON(500, gin.H{"success": false, "error": gin.H{
"code": "UDEV_INSTALL_FAILED",
"message": fmt.Sprintf("pkexec install failed: %v (%s)", err, strings.TrimSpace(string(out))),
}})
return
}
c.JSON(200, gin.H{"success": true, "data": gin.H{
"message": "udev rule installed. Please unplug and replug your Kneron device.",
}})
}