從 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>
357 lines
13 KiB
Go
357 lines
13 KiB
Go
package main
|
||
|
||
// agent_bindings.go — visionA Agent 專屬 Wails bindings
|
||
//
|
||
// AB2+AB3 建立 stub;AB5 補上 GetConnectionStatus / Pair / Unpair 的真正實作
|
||
// (委派 tunnelManager);AB7-AB10 補齊 Settings / Log / TestConnection / ResetAllSettings。
|
||
//
|
||
// 型別佈局:
|
||
// 本檔 `ConnectionState` / `ConnectionStatus` 是 Wails binding 對外合約
|
||
// (frontend `src/types/agent.ts` 使用者看到的版本),與 tunnel package 內部
|
||
// 的 `tunnel.ConnectionState` / `tunnel.ConnectionStatus` 互相對應,由
|
||
// `snapshotToDTO` 做轉換。兩層分開:
|
||
// - tunnel package 內部可隨時重構;
|
||
// - binding 對外契約必須穩定(frontend 依賴的型別只在這個檔案動)。
|
||
//
|
||
// Design spec 對應章節:`.autoflow/03-design/visiona-agent-spec.md` §4 / §5 / §6。
|
||
// TDD 對應章節:`.autoflow/04-architecture/visiona-agent-tdd.md` §6.1 / §6.4 / §10。
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"visiona-agent/internal/agentconfig"
|
||
"visiona-agent/internal/tunnel"
|
||
|
||
"github.com/gorilla/websocket"
|
||
)
|
||
|
||
// ErrNotImplemented 表示該 binding 對應的功能尚未在 Phase 0 雛形完成。
|
||
// 前端應捕捉此錯誤並以「尚未啟用」的提示呈現,而非崩潰。
|
||
var ErrNotImplemented = errors.New("visiona-agent: not implemented yet (pending AB4-AB10)")
|
||
|
||
// ErrAgentNotReady 表示 Agent 尚未完成啟動(例如 config store / tunnel manager
|
||
// 未建)。前端應提示使用者稍候。
|
||
var ErrAgentNotReady = errors.New("visiona-agent: agent not ready")
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 狀態頁(Design spec §4)
|
||
// -----------------------------------------------------------------------
|
||
|
||
// ConnectionState 對應 TDD §6.4 connstate.State。
|
||
type ConnectionState string
|
||
|
||
const (
|
||
ConnStateNotPaired ConnectionState = "notPaired"
|
||
ConnStateConnecting ConnectionState = "connecting"
|
||
ConnStateOnline ConnectionState = "online"
|
||
ConnStateReconnecting ConnectionState = "reconnecting"
|
||
ConnStateOffline ConnectionState = "offline"
|
||
ConnStateError ConnectionState = "error"
|
||
)
|
||
|
||
// ConnectionStatus 是狀態頁主要資料來源(對齊 TDD §6.4 Snapshot)。
|
||
// 前端訂閱 Wails event "connection:status" 取得即時變化,啟動時一次性呼叫
|
||
// GetConnectionStatus() 取 initial snapshot。
|
||
type ConnectionStatus struct {
|
||
State ConnectionState `json:"state"`
|
||
Error string `json:"error,omitempty"`
|
||
AttemptNo int `json:"attemptNo,omitempty"`
|
||
RelayURL string `json:"relayUrl"`
|
||
Account string `json:"account,omitempty"`
|
||
ConnectedSinceMs int64 `json:"connectedSince,omitempty"` // Unix ms,0 代表尚未連線
|
||
LastSeenMs int64 `json:"lastSeenAt,omitempty"` // Unix ms,最後一次收到 yamux pong 的時間
|
||
SessionTokenPreview string `json:"sessionTokenPreview,omitempty"` // "vAs_a1b2c3d4 ··· e7f8"
|
||
}
|
||
|
||
// snapshotToDTO 把 tunnel package 的 snapshot 轉成 Wails binding 對外的 DTO。
|
||
// 負責兩件事:
|
||
// 1. 時間欄位 *time.Time → Unix millis(frontend 接 number,不處理 Go time.Time 序列化)
|
||
// 2. 兜底 nil Manager:回未配對狀態(例:VISIONA_RELAY_URL 未設 → tunnelManager == nil)
|
||
func snapshotToDTO(s tunnel.ConnectionStatus) ConnectionStatus {
|
||
dto := ConnectionStatus{
|
||
State: ConnectionState(s.State),
|
||
Error: s.LastError,
|
||
AttemptNo: s.AttemptNo,
|
||
RelayURL: s.RelayURL,
|
||
Account: s.Account,
|
||
SessionTokenPreview: s.SessionTokenPreview,
|
||
}
|
||
if s.ConnectedSince != nil {
|
||
dto.ConnectedSinceMs = s.ConnectedSince.UnixMilli()
|
||
}
|
||
if s.LastSeenAt != nil {
|
||
dto.LastSeenMs = s.LastSeenAt.UnixMilli()
|
||
}
|
||
return dto
|
||
}
|
||
|
||
// GetConnectionStatus 回傳當前 tunnel 連線狀態 snapshot。
|
||
//
|
||
// AB5:已接入 tunnelManager。若 tunnelManager 為 nil(啟動時未設 relay URL),
|
||
// 回傳 notPaired 狀態但不回錯——狀態頁的「未配對」空狀態就是合法 UI 狀態。
|
||
func (a *App) GetConnectionStatus() (ConnectionStatus, error) {
|
||
if a.tunnelManager == nil {
|
||
return ConnectionStatus{State: ConnStateNotPaired}, nil
|
||
}
|
||
return snapshotToDTO(a.tunnelManager.Status()), nil
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 配對頁(Design spec §5)
|
||
// -----------------------------------------------------------------------
|
||
|
||
// Pair 送出 Pairing Token 向雲端換 Session Token。
|
||
// 成功後 Agent 會自動啟動 tunnel client、並透過 "connection:status" event 推進狀態。
|
||
//
|
||
// AB5:委派給 tunnelManager。前端應捕捉 tunnel.ErrInvalidTokenFormat /
|
||
// ErrTokenInvalid / ErrTokenExpired / ErrTokenUsed / ErrTokenRevoked 對應 spec §5.4 表格。
|
||
// 若 tunnelManager 為 nil(啟動時 server port 未拿到),回 ErrNotImplemented 讓
|
||
// 前端顯示「Agent 還沒 ready」的提示。
|
||
func (a *App) Pair(pairingToken string) error {
|
||
if a.tunnelManager == nil {
|
||
return ErrAgentNotReady
|
||
}
|
||
ctx := a.ctx
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
return a.tunnelManager.Pair(ctx, pairingToken)
|
||
}
|
||
|
||
// Unpair 清除本地 Session Token 並斷開 tunnel。
|
||
//
|
||
// AB5:委派給 tunnelManager。
|
||
func (a *App) Unpair() error {
|
||
if a.tunnelManager == nil {
|
||
return nil // 未配對狀態,Unpair 視為 no-op
|
||
}
|
||
return a.tunnelManager.Unpair()
|
||
}
|
||
|
||
// Reconnect 觸發手動重連。對應 spec §4.5「立即重試」按鈕。
|
||
// AB5 新增。
|
||
func (a *App) Reconnect() error {
|
||
if a.tunnelManager == nil {
|
||
return ErrAgentNotReady
|
||
}
|
||
ctx := a.ctx
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
return a.tunnelManager.Reconnect(ctx)
|
||
}
|
||
|
||
// Disconnect 主動斷開 tunnel(保留 token)。對應 spec §4.2 (C) 「斷開連線」按鈕。
|
||
// AB5 新增。
|
||
func (a *App) Disconnect() error {
|
||
if a.tunnelManager == nil {
|
||
return nil
|
||
}
|
||
return a.tunnelManager.Stop()
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 設定頁(Design spec §6)
|
||
// -----------------------------------------------------------------------
|
||
//
|
||
// ⚠️ 對齊前端 `src/types/agent.ts`:
|
||
//
|
||
// export interface AgentSettings {
|
||
// relayUrl: string;
|
||
// autoStart: boolean;
|
||
// reconnectStrategy: "auto" | "manual";
|
||
// logLevel: "debug" | "info" | "warn" | "error";
|
||
// }
|
||
//
|
||
// 後端 struct 的 json tag 必須完全對應。改欄位名 = 壞前端。
|
||
|
||
// ReconnectStrategy 對齊 design spec §6.2.2 RadioGroup 選項(frontend `ReconnectStrategy`)。
|
||
type ReconnectStrategy string
|
||
|
||
const (
|
||
ReconnectStrategyAuto ReconnectStrategy = "auto"
|
||
ReconnectStrategyManual ReconnectStrategy = "manual"
|
||
)
|
||
|
||
// AgentSettings 是設定頁的資料結構(對齊 TDD §10 agent config YAML + frontend types)。
|
||
//
|
||
// 對應 agentconfig.Config 的扁平版(UI 不需要 version / relay.{url,reconnect}
|
||
// 的 nested 結構;前端習慣單層扁平 object)。內部用 agentconfigToSettings /
|
||
// settingsToAgentConfig 互轉。
|
||
type AgentSettings struct {
|
||
RelayURL string `json:"relayUrl"`
|
||
AutoStart bool `json:"autoStart"`
|
||
ReconnectStrategy ReconnectStrategy `json:"reconnectStrategy"`
|
||
LogLevel string `json:"logLevel"` // "debug" | "info" | "warn" | "error"
|
||
}
|
||
|
||
// GetAgentSettings 讀取 Agent 層級設定。
|
||
//
|
||
// AB9 落地:從 `agentconfig.Store` 讀;store 尚未建立時回預設值 + 不 error
|
||
// (讓前端能先渲染設定頁),不走 ErrAgentNotReady 的原因是:讀操作沒有副作用,
|
||
// 沒有 store 也能回預設值。
|
||
func (a *App) GetAgentSettings() (AgentSettings, error) {
|
||
if a.configStore == nil {
|
||
return agentConfigToSettings(agentconfig.Default()), nil
|
||
}
|
||
return agentConfigToSettings(a.configStore.Get()), nil
|
||
}
|
||
|
||
// SaveAgentSettings 驗證、寫 config.yaml、並套用到 Manager。
|
||
//
|
||
// 套用規則:
|
||
// - RelayURL / ReconnectStrategy → 呼叫 Manager.ApplyRelaySettings()
|
||
// 若 URL 改變且 Manager 在 running,自動 Stop+Start
|
||
// - AutoStart → TODO(AB11+:寫入 OS autostart entry;雛形只存 config)
|
||
// - LogLevel → TODO(Phase 1:套用到 logger;雛形只存 config)
|
||
//
|
||
// 驗證失敗不改內部狀態。
|
||
func (a *App) SaveAgentSettings(settings AgentSettings) error {
|
||
if a.configStore == nil {
|
||
return ErrAgentNotReady
|
||
}
|
||
cfg := settingsToAgentConfig(settings)
|
||
if err := a.configStore.Save(cfg); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 套用到 Manager
|
||
if a.tunnelManager != nil {
|
||
reconnectMode := tunnel.ReconnectAuto
|
||
if settings.ReconnectStrategy == ReconnectStrategyManual {
|
||
reconnectMode = tunnel.ReconnectManual
|
||
}
|
||
if _, err := a.tunnelManager.ApplyRelaySettings(a.ctx, settings.RelayURL, reconnectMode); err != nil {
|
||
// apply 失敗不回 error:設定已存檔;只在 log 記一筆
|
||
a.appLog("SaveAgentSettings: ApplyRelaySettings failed: %v", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// TestResult 是 TestConnection binding 的回傳(對齊 frontend `TestRelayResult`)。
|
||
type TestResult struct {
|
||
OK bool `json:"ok"`
|
||
LatencyMs int64 `json:"latencyMs,omitempty"`
|
||
Reason string `json:"reason,omitempty"`
|
||
}
|
||
|
||
// TestConnection 驗證 Relay URL 的 reachability。
|
||
//
|
||
// 行為(對齊 Design spec §6.2.1):
|
||
// - 嘗試 WebSocket upgrade(不帶 token;只測網路 + TLS 可達)
|
||
// - 建立後立即 close,不發任何 frame
|
||
// - 失敗時回對應的本地化錯誤訊息(reason)
|
||
//
|
||
// Timeout 5 秒(使用者等得夠久就算失敗了)。
|
||
func (a *App) TestConnection(relayURL string) TestResult {
|
||
relayURL = strings.TrimSpace(relayURL)
|
||
if relayURL == "" {
|
||
return TestResult{OK: false, Reason: "URL is empty"}
|
||
}
|
||
if !strings.HasPrefix(relayURL, "ws://") && !strings.HasPrefix(relayURL, "wss://") {
|
||
return TestResult{OK: false, Reason: fmt.Sprintf("URL must start with ws:// or wss:// (got %q)", relayURL)}
|
||
}
|
||
if _, err := url.Parse(relayURL); err != nil {
|
||
return TestResult{OK: false, Reason: fmt.Sprintf("invalid URL: %v", err)}
|
||
}
|
||
|
||
dialer := websocket.Dialer{
|
||
HandshakeTimeout: 5 * time.Second,
|
||
}
|
||
headers := http.Header{}
|
||
start := time.Now()
|
||
conn, resp, err := dialer.Dial(relayURL, headers)
|
||
latency := time.Since(start).Milliseconds()
|
||
if err != nil {
|
||
reason := err.Error()
|
||
if resp != nil {
|
||
reason = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, reason)
|
||
}
|
||
return TestResult{OK: false, Reason: reason}
|
||
}
|
||
_ = conn.Close()
|
||
return TestResult{OK: true, LatencyMs: latency}
|
||
}
|
||
|
||
// ResetAllSettings 清除 config + session token,等於 Unpair + 重置所有設定。
|
||
//
|
||
// 對應 Design spec §6.2.5「危險區域 → 重置所有設定」。
|
||
// 流程:
|
||
// 1. Unpair(Manager.Unpair → 清 TokenStore + Stop tunnel + 狀態回 notPaired)
|
||
// 2. 把 configStore Save 成 Default(Validate OK,所以一定成功)
|
||
// 3. 若後續 settings 變動,前端下次 GetAgentSettings 會拿到預設
|
||
func (a *App) ResetAllSettings() error {
|
||
if a.tunnelManager != nil {
|
||
if err := a.tunnelManager.Unpair(); err != nil {
|
||
return fmt.Errorf("unpair: %w", err)
|
||
}
|
||
}
|
||
if a.configStore != nil {
|
||
if err := a.configStore.Save(agentconfig.Default()); err != nil {
|
||
return fmt.Errorf("reset config: %w", err)
|
||
}
|
||
}
|
||
a.appLog("ResetAllSettings: config + session token cleared")
|
||
return nil
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Log 相關(Design spec §6.2 — 設定頁 Log 區塊)
|
||
// -----------------------------------------------------------------------
|
||
//
|
||
// GetRecentLogs / ExportLog 的實作在 server_control.go(沿用 local-tool 的
|
||
// ring buffer 機制);本檔不重複。
|
||
//
|
||
// Design spec §6.2「最近 log」 ↔ a.GetRecentLogs(n) (server_control.go)
|
||
// Design spec §6.2「匯出 log」 ↔ a.ExportLog() (server_control.go)
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 內部轉換:agentconfig.Config ↔ AgentSettings
|
||
// -----------------------------------------------------------------------
|
||
|
||
// agentConfigToSettings 把 persistent config 轉 UI 用的扁平結構。
|
||
func agentConfigToSettings(c agentconfig.Config) AgentSettings {
|
||
strat := ReconnectStrategyAuto
|
||
if c.Relay.Reconnect == agentconfig.ReconnectManual {
|
||
strat = ReconnectStrategyManual
|
||
}
|
||
return AgentSettings{
|
||
RelayURL: c.Relay.URL,
|
||
AutoStart: c.Behavior.Autostart,
|
||
ReconnectStrategy: strat,
|
||
LogLevel: string(c.Log.Level),
|
||
}
|
||
}
|
||
|
||
// settingsToAgentConfig 把 UI 扁平結構轉 persistent config。
|
||
// Version 欄位由 Save() 自動補齊,這裡不設。
|
||
func settingsToAgentConfig(s AgentSettings) agentconfig.Config {
|
||
mode := agentconfig.ReconnectAuto
|
||
if s.ReconnectStrategy == ReconnectStrategyManual {
|
||
mode = agentconfig.ReconnectManual
|
||
}
|
||
level := agentconfig.LogLevel(s.LogLevel)
|
||
if level == "" {
|
||
level = agentconfig.DefaultLogLevel
|
||
}
|
||
return agentconfig.Config{
|
||
Relay: agentconfig.RelayConfig{
|
||
URL: s.RelayURL,
|
||
Reconnect: mode,
|
||
},
|
||
Behavior: agentconfig.BehaviorConfig{
|
||
Autostart: s.AutoStart,
|
||
},
|
||
Log: agentconfig.LogConfig{
|
||
Level: level,
|
||
},
|
||
}
|
||
}
|