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

861 lines
29 KiB
Go
Raw 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.

// manager.go — Tunnel lifecycle 完整管理AB5 範圍)。
//
// 本檔實作 TDD §4 + §6.4 定義的 tunnel 狀態機,負責:
//
// 1. Session Token 配對Pair / Unpair— 委派給 PairingExchanger。
// 2. Client 生命週期Start / Stop / Reconnect
// 3. 狀態機六狀態notPaired / connecting / online / reconnecting / offline / error
// 4. 重連策略(指數退避 + max retry + auto/manual mode
// 5. 心跳監控(依賴 yamux 的 10s/30s 規則 + Client hooks 的 session down 通知)。
// 6. 狀態事件推送Subscribe / fanout上層 Wails app 可 emit "connection:status")。
//
// 設計原則:
//
// - 單一寫入者:所有狀態變更都走 transition() 一個函式,包 mu 保護。
// - 事件 fanout 不持鎖:避免 listener 呼叫 Manager 方法造成 deadlock。
// - Manager 本身 thread-safePublic APIPair / Unpair / Start / Stop / Reconnect /
// Status / Subscribe都可從任意 goroutine 呼叫。
// - 錯誤只 log、不 panic雛形階段容錯優先崩了整個 Wails app 會倒)。
//
// 參考:
// - .autoflow/04-architecture/visiona-agent-tdd.md §4.3 / §4.4 / §4.5 / §6.4
// - .autoflow/04-architecture/tunnel.md §4.2
// - .autoflow/03-design/visiona-agent-spec.md §4 / §5
package tunnel
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
)
// ConnectionState 對應 TDD §6.4:六種狀態機狀態。
// ⚠️ JSON 值必須對齊 frontend `src/types/agent.ts ConnectionState`。
type ConnectionState string
const (
// StateNotPaired無 session token等待使用者配對。
StateNotPaired ConnectionState = "notPaired"
// StateConnecting首次建立 tunnel尚未成功建立 yamux session
StateConnecting ConnectionState = "connecting"
// StateOnlinetunnel 連線正常。
StateOnline ConnectionState = "online"
// StateReconnecting曾連上後掉線正在重試中。
StateReconnecting ConnectionState = "reconnecting"
// StateOffline手動斷線或所有重試用盡後的停等狀態。等待 Reconnect() 或 Unpair()。
StateOffline ConnectionState = "offline"
// StateError致命錯誤config 無效、token 被撤銷等),需要使用者介入。
StateError ConnectionState = "error"
)
// ReconnectMode 控制 max retry 用盡後的行為。
type ReconnectMode string
const (
// ReconnectAuto達到 MaxRetry 後繼續嘗試(退避 cap 住,不會無限退避變大)。
// 對應設定頁「自動重連」。
ReconnectAuto ReconnectMode = "auto"
// ReconnectManual達到 MaxRetry 後停在 StateOffline等使用者點「重新連線」。
// 對應設定頁「手動重連」。
ReconnectManual ReconnectMode = "manual"
)
// Config 是 Manager 的必要輸入。
//
// SessionToken / RelayURL / Account 三個欄位會在 Pair() 成功後更新到 Manager 內部
// 的可變欄位cfg。呼叫者不應在 Manager 啟動後直接改 Config。
type Config struct {
// RelayURL 是 wss://host/tunnel/connect 格式的 URL。
// Pair() 時若雲端回傳 RelayURL 會覆蓋此值。
RelayURL string
// SessionToken 是雲端下發的 Session TokenvAs_...)。
// 若啟動時為空但 TokenStore 有值Start() 會從 TokenStore 載入。
SessionToken string
// Account 是雲端帳號 email雛形由 Pair() exchange 回應或 mock 預設值提供。
// 空字串代表尚未配對或 AB11 未上線不影響功能UI 顯示 "—")。
Account string
// LocalAddr 是 "host:port" 格式tunnel 會把進來的 HTTP 轉發到這裡。
// 通常是 "127.0.0.1:<server random port>"。
LocalAddr string
// Logger 可選。nil 時 Client 會用 log.Default()。
Logger Logger
// TokenStore 用於持久化 Session Token。nil 時 Manager 會建立一個 in-memory store。
TokenStore TokenStore
// Exchanger 用於 Pair() 的 /api/pairing/exchange 呼叫。nil 時 Manager 會建立
// 一個預設 HTTPPairingExchanger需要 CloudAPIURL 才能用)。
Exchanger PairingExchanger
// MaxRetry 為重試上限0 代表使用預設 5 次;負值代表無上限)。
// 達上限後Auto 模式 → 退避 cap 住繼續重試Manual 模式 → 停在 offline。
MaxRetry int
// ReconnectMode 控制 max retry 耗盡後的行為(預設 Auto
ReconnectMode ReconnectMode
}
// 預設值。
const (
defaultMaxRetry = 5
)
// ConnectionStatus 是對外暴露的快照(對齊 TDD §6.4 Snapshot + frontend types/agent.ts
//
// ⚠️ json tag 必須對齊 frontend 的 ConnectionSnapshot 介面。
type ConnectionStatus struct {
State ConnectionState `json:"state"`
// LastError 在 StateError / reconnecting 時有值。
LastError string `json:"error,omitempty"`
// AttemptNo 目前是第幾次重試(連線中 / reconnecting 時有值)。
AttemptNo int `json:"attemptNo,omitempty"`
// RelayURL 當前設定的 relay未配對也會帶供 InfoCard 顯示)。
RelayURL string `json:"relayUrl"`
// Account 配對帳號 email空字串代表未配對。
Account string `json:"account,omitempty"`
// ConnectedSince 是最近一次進入 online 狀態的時間nil 代表未曾上線)。
ConnectedSince *time.Time `json:"connectedSince,omitempty"`
// LastSeenAt 最近一次 session 活著的時間yamux 正常建立即更新;
// 更精細的 pong-level 追蹤留待 Phase 1 改用 yamux 原生 stats
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
// SessionTokenPreview 遮蔽後的 token 顯示("vAs_a1b2c3d4 ··· e7f8")。
SessionTokenPreview string `json:"sessionTokenPreview,omitempty"`
}
// StatusListener 是狀態變更的觀察者 callback。
// Manager 會在每次 transition 後(含 Pair/Unpair/Start/Stop 觸發的)呼叫所有 listener。
//
// ⚠️ listener 不應阻塞太久Manager 會在獨立 goroutine 呼叫,但同一時序仍是 serialized
type StatusListener func(ConnectionStatus)
// Manager 管理 tunnel client 的 lifecycle + 狀態機。
type Manager struct {
// lifecycleMu 序列化 lifecycle methodsPair / Unpair / Start / Stop / Reconnect
// 不保護 cfg / state — 這些由 mu / atomic / mu 各自負責。
//
// 為什麼需要:使用者可能快速連按 UI 按鈕Pair → Reconnect → Pair— 沒有此鎖
// 兩個 lifecycle 操作會交錯執行,導致 attemptNo 計數錯亂、cfg race、state 半完成。
// 此鎖確保任一時刻只有一個 lifecycle 操作在進行。
//
// 實作注意lifecycle methods 呼叫對方時要走 *Locked 版本(不再取鎖),避免遞迴
// 死鎖Public method 進入時取鎖return 前釋放。
//
// 詳見 Fix-A5。
lifecycleMu sync.Mutex
mu sync.Mutex // 保護 cfg / client / current / startedAt 等可變欄位
cfg Config
// state 用 atomic.Value 以加速 Status() 快速讀取(不搶 mu
state atomic.Value // ConnectionState
current ConnectionStatus // under mu最新快照
connectedSince *time.Time // under mu
lastSeenAt *time.Time // under mu
attemptNo int32 // atomic
lastError atomic.Value // string
client *Client // under mu
// 管理 run 迴圈 cancel
ctx context.Context
cancel context.CancelFunc
running atomic.Bool // 是否已 Start()Stop 後會 reset
// listenersfanout events。Subscribe 回傳 unsubscribe func。
listenerMu sync.Mutex
listeners map[int]StatusListener
listenerNextID int
emitCh chan ConnectionStatus // 單工 channel避免 listener 阻塞觸發
emitOnce sync.Once
emitCloseOnce sync.Once
emitClosedCh chan struct{}
// emitClosed 在 Close() 把 emitCh 關閉前就被 set所有 emit() 呼叫者先檢查這個
// atomic flag 避免對已關閉 channel 寫入 panic。此 race 的觸發情境:測試中
// defer cancel() + defer m.Close() 順序是 Close 先跑LIFOClose 關 emitCh
// 後 ctx 才被 cancelStart 起的 ctx.Done→Stop goroutine 這時呼叫 Stop→
// transition→emit會對已關 channel 送資料。AB6 測試發現race detector 100%
// 必觸發,單跑非 race 約 1/5 機率)。
emitClosed atomic.Bool
}
// 狀態錯誤。
var (
ErrAlreadyStarted = errors.New("tunnel manager already started")
ErrNotStarted = errors.New("tunnel manager not started")
ErrMissingConfig = errors.New("tunnel manager missing required config (RelayURL / SessionToken / LocalAddr)")
ErrNotPaired = errors.New("tunnel manager not paired (no session token)")
ErrAlreadyPaired = errors.New("tunnel manager already paired; call Unpair() first")
)
// NewManager 建立 Manager。不立即連線需呼叫 Start() 或 Pair()。
//
// cfg 可部分為空:
// - SessionToken == "" 時 Manager 會在 Start() 嘗試從 TokenStore 載入。
// - TokenStore == nil 時 Manager 會建立 in-memory storeAB7 前行為)。
// - Exchanger == nil 時呼叫 Pair() 會回錯Start() 不受影響。
func NewManager(cfg Config) *Manager {
if cfg.TokenStore == nil {
cfg.TokenStore = NewMemoryTokenStore()
}
if cfg.MaxRetry == 0 {
cfg.MaxRetry = defaultMaxRetry
}
if cfg.ReconnectMode == "" {
cfg.ReconnectMode = ReconnectAuto
}
m := &Manager{
cfg: cfg,
listeners: make(map[int]StatusListener),
emitCh: make(chan ConnectionStatus, 16),
emitClosedCh: make(chan struct{}),
}
m.state.Store(StateNotPaired)
m.lastError.Store("")
// 初始 current snapshot若 config 已帶 SessionToken進 offline等 Start 決定);否則 notPaired。
initial := StateNotPaired
if cfg.SessionToken != "" {
initial = StateOffline
}
m.current = m.buildSnapshotLocked(initial, "", 0)
m.state.Store(initial)
// 啟動 fanout goroutinelazy
m.startEmitterOnce()
return m
}
// Start 啟動 tunnel client。若 SessionToken 為空,嘗試從 TokenStore 載入;仍無則回 ErrNotPaired。
//
// 冪等:若已啟動,回 ErrAlreadyStarted呼叫者可先 Stop 再 Start
// ctx == nil 時用 context.Background()。
//
// 並發保護Fix-A5透過 lifecycleMu 與 Pair / Unpair / Stop / Reconnect 序列化。
func (m *Manager) Start(ctx context.Context) error {
m.lifecycleMu.Lock()
defer m.lifecycleMu.Unlock()
return m.startLocked(ctx)
}
// startLocked 是 Start 的內部版本,假設 lifecycleMu 已被持有(呼叫者責任)。
// 用於 Pair / Reconnect 等 lifecycle method 內部呼叫,避免遞迴死鎖。
func (m *Manager) startLocked(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
m.mu.Lock()
if m.running.Load() {
m.mu.Unlock()
return ErrAlreadyStarted
}
// 嘗試補 SessionTokenPair 後重啟 / 重連場景)
if m.cfg.SessionToken == "" && m.cfg.TokenStore != nil {
if tok, err := m.cfg.TokenStore.Load(); err == nil && tok != "" {
m.cfg.SessionToken = tok
}
}
if m.cfg.SessionToken == "" {
m.mu.Unlock()
m.transition(StateNotPaired, "", 0)
return ErrNotPaired
}
if m.cfg.RelayURL == "" || m.cfg.LocalAddr == "" {
m.mu.Unlock()
return ErrMissingConfig
}
childCtx, cancel := context.WithCancel(ctx)
m.ctx = childCtx
m.cancel = cancel
m.running.Store(true)
m.client = m.newClientLocked()
m.mu.Unlock()
m.transition(StateConnecting, "", 0)
m.mu.Lock()
client := m.client
m.mu.Unlock()
if client != nil {
client.Start()
}
// ctx cancel 時自動 Stop避免呼叫者忘了收尾
//
// 並發 raceFix-A5 配套修復):
// - Reconnect / ApplyRelaySettings 內部會呼叫 stopLocked → cancel() → 觸發此 goroutine
// - 之後 Reconnect 立即呼叫 startLocked 建立新 client + 新 ctx
// - 若此 goroutine 走 m.Stop() 會誤停剛建好的新 client
//
// 保護方式:此 goroutine 在 cancel 觸發後,只有當 m.ctx 仍等於自己對應的 childCtx
// 時才執行 stopLocked否則代表 manager 已被 restarted此 goroutine 屬於上一輪,
// 不該動最新的 state。
go func(myCtx context.Context) {
<-myCtx.Done()
m.lifecycleMu.Lock()
defer m.lifecycleMu.Unlock()
m.mu.Lock()
stale := m.ctx != myCtx
m.mu.Unlock()
if stale {
return
}
_ = m.stopLocked()
}(childCtx)
return nil
}
// newClientLocked 建立 Client 並掛 hooks。必須持有 m.mu。
func (m *Manager) newClientLocked() *Client {
c := NewClient(m.cfg.RelayURL, m.cfg.SessionToken, m.cfg.LocalAddr, m.cfg.Logger)
c.SetHooks(ClientHooks{
OnDialAttempt: func(attempt int) {
m.onDialAttempt(attempt)
},
OnSessionUp: func() {
m.onSessionUp()
},
OnSessionDown: func(err error) {
m.onSessionDown(err)
},
OnDialFailed: func(attempt int, err error) {
m.onDialFailed(attempt, err)
},
OnRetryScheduled: func(attempt int, delay time.Duration) {
// log-onlyManager 已經透過 OnDialFailed 轉進 reconnecting 狀態。
if m.cfg.Logger != nil {
m.cfg.Logger.Printf("[tunnel] retry #%d scheduled in %v", attempt, delay)
}
},
})
return c
}
// Stop 停止 tunnel client 並把 state 改為 offline仍保留 session token 以便 Reconnect
// 冪等;已停止時回 nil。
//
// 並發保護Fix-A5透過 lifecycleMu 與其他 lifecycle method 序列化。
func (m *Manager) Stop() error {
m.lifecycleMu.Lock()
defer m.lifecycleMu.Unlock()
return m.stopLocked()
}
// stopLocked 是 Stop 的內部版本,假設 lifecycleMu 已被持有。
func (m *Manager) stopLocked() error {
m.mu.Lock()
if !m.running.Load() {
m.mu.Unlock()
return nil
}
m.running.Store(false)
client := m.client
m.client = nil
if m.cancel != nil {
m.cancel()
m.cancel = nil
}
m.mu.Unlock()
if client != nil {
client.Stop()
}
// Stop 後進入 offline未配對場景走 notPaired
next := StateOffline
if m.sessionToken() == "" {
next = StateNotPaired
}
m.transition(next, "", 0)
return nil
}
// Reconnect 手動觸發重連。若 Manager 已在 running 則先 Stop 再 Start避免重覆 client
// 對應 Design spec §4.5「立即重試」按鈕。
//
// 並發保護Fix-A5透過 lifecycleMu 與其他 lifecycle method 序列化。
func (m *Manager) Reconnect(ctx context.Context) error {
m.lifecycleMu.Lock()
defer m.lifecycleMu.Unlock()
if m.running.Load() {
if err := m.stopLocked(); err != nil {
return err
}
}
atomic.StoreInt32(&m.attemptNo, 0)
return m.startLocked(ctx)
}
// Pair 呼叫 PairingExchanger 換 Session Token儲存後自動啟動 tunnel。
//
// 流程對齊 TDD §4.3 雛形流程:
// 1. 驗證 pairing token 格式vAc_ + 32 hex— Exchanger 內建
// 2. 呼叫雲端 /api/pairing/exchange或 mock mode 本地產)
// 3. 儲存 Session Token 到 TokenStore
// 4. 更新 cfgRelayURL / Account 若雲端有回傳則覆蓋)
// 5. 若 Manager 已在 running 先 Stop然後 Start
//
// 失敗 / 部分失敗的 state 處理Fix-A4
//
// ┌──────────────────────────┬─────────────┬──────────┬──────────────────────┐
// │ 失敗點 │ TokenStore │ 終態 │ 理由 │
// ├──────────────────────────┼─────────────┼──────────┼──────────────────────┤
// │ exchange 失敗 │ 不變 │ 不變 │ 沒做任何改變 │
// │ exchange OK / Save 失敗 │ 不變 │ notPaired │ 確保 cfg/state 一致 │
// │ exchange / Save OK / Start 失敗 │ token 已存 │ error │ token 持久化成功,可 Reconnect 重試 │
// └──────────────────────────┴─────────────┴──────────┴──────────────────────┘
//
// 並發保護Fix-A5lifecycleMu 確保 Pair / Unpair / Start / Stop / Reconnect
// 序列化執行,避免使用者快速連按按鈕造成 attempt 計數錯亂或 cfg race。
func (m *Manager) Pair(ctx context.Context, pairingToken string) error {
m.lifecycleMu.Lock()
defer m.lifecycleMu.Unlock()
if m.cfg.Exchanger == nil {
return errors.New("tunnel manager: no pairing exchanger configured")
}
if err := ValidatePairingToken(pairingToken); err != nil {
return err
}
result, err := m.cfg.Exchanger.Exchange(pairingToken)
if err != nil {
// 不 transition 到 error — Pair 失敗由呼叫端決定 UI 反應Manager 維持當前狀態。
// TokenStore 沒被動過、cfg 沒被動過,符合「失敗回到呼叫前」原則。
return err
}
// 儲存 token + 更新 cfg
m.mu.Lock()
if err := m.cfg.TokenStore.Save(result.SessionToken); err != nil {
m.mu.Unlock()
// Save 失敗 → cfg 沒被改、TokenStore 也沒被改Save 失敗即不寫入)。
// 確保 state 是 notPaired如果原本就是— 不主動 transition 避免覆蓋 error 狀態。
// 呼叫者拿到 error 後決定 UI 反應。
return fmt.Errorf("save session token: %w", err)
}
m.cfg.SessionToken = result.SessionToken
if result.RelayURL != "" {
m.cfg.RelayURL = result.RelayURL
}
if result.Account != "" {
m.cfg.Account = result.Account
}
wasRunning := m.running.Load()
m.mu.Unlock()
// 若已在跑(改配對 to 新 token先 Stop
if wasRunning {
_ = m.stopLocked()
}
// 重置 attempt 並啟動
atomic.StoreInt32(&m.attemptNo, 0)
if err := m.startLocked(ctx); err != nil {
// Start 失敗 → token 已持久化(這個取捨:可下次 Reconnect 試)。
// state 顯式設為 error讓 UI 顯示「配對成功但連線失敗」可動作的訊息。
m.transition(StateError, fmt.Sprintf("pair: start failed: %v", err), 0)
return err
}
return nil
}
// Unpair 清除 Session Token + 停 tunnel + 重置狀態到 notPaired。
// 對應 Design spec §4.2 「重新配對」按鈕 + §6.2.4 設定頁「清除本地資料」。
//
// 並發保護Fix-A5透過 lifecycleMu 與其他 lifecycle method 序列化。
func (m *Manager) Unpair() error {
m.lifecycleMu.Lock()
defer m.lifecycleMu.Unlock()
// 先停 tunnel
_ = m.stopLocked()
m.mu.Lock()
if err := m.cfg.TokenStore.Delete(); err != nil {
m.mu.Unlock()
return fmt.Errorf("delete session token: %w", err)
}
m.cfg.SessionToken = ""
m.cfg.Account = ""
m.connectedSince = nil
m.lastSeenAt = nil
m.mu.Unlock()
atomic.StoreInt32(&m.attemptNo, 0)
m.transition(StateNotPaired, "", 0)
return nil
}
// Status 回傳目前狀態快照。可從任意 goroutine 呼叫。
func (m *Manager) Status() ConnectionStatus {
m.mu.Lock()
defer m.mu.Unlock()
// 重新組 snapshot 以帶上最新 attemptNoatomic
attempt := int(atomic.LoadInt32(&m.attemptNo))
errStr := ""
if v := m.lastError.Load(); v != nil {
if s, ok := v.(string); ok {
errStr = s
}
}
state := m.stateValue()
snap := m.buildSnapshotLocked(state, errStr, attempt)
// 更新 current 以便 Subscribe 的 initial emit 拿得到一致快照
m.current = snap
return snap
}
// Subscribe 註冊 listener 接收狀態變更。
// 回傳的 unsubscribe func 是冪等的。Subscribe 後會立即以目前快照觸發一次,讓訂閱者拿到 initial state。
func (m *Manager) Subscribe(listener StatusListener) (unsubscribe func()) {
if listener == nil {
return func() {}
}
m.listenerMu.Lock()
id := m.listenerNextID
m.listenerNextID++
m.listeners[id] = listener
m.listenerMu.Unlock()
// initial emit — 用 Status() 拿最新快照(含 attemptNo
go func(l StatusListener) {
defer recoverListener("initial emit")
l(m.Status())
}(listener)
var once sync.Once
return func() {
once.Do(func() {
m.listenerMu.Lock()
delete(m.listeners, id)
m.listenerMu.Unlock()
})
}
}
// ApplyRelaySettings 更新 cfg 中的 RelayURL / ReconnectMode必要時 bounce 連線。
//
// 由 Wails binding `SaveAgentSettings` 呼叫,對應 Design spec §6.2.1「Relay URL
// 變更不會立即套用;下方顯示未套用 badge」——我們這裡是立即套用版本
// RelayURL 改變且 Manager 正在 running執行 Stop+Start若只改 ReconnectMode
// 則不 bounce。
//
// ctx 在需要 Start 時使用nil 時用 context.Background()。
//
// 回傳 bool 表示是否觸發了 reconnect。錯誤只在 Start 失敗時回傳。
//
// 並發保護Fix-A5透過 lifecycleMu 與其他 lifecycle method 序列化。
// (此方法可能會呼叫 Stop+Start跟 lifecycle 同類型操作。)
func (m *Manager) ApplyRelaySettings(ctx context.Context, relayURL string, reconnectMode ReconnectMode) (bool, error) {
m.lifecycleMu.Lock()
defer m.lifecycleMu.Unlock()
if ctx == nil {
ctx = context.Background()
}
if reconnectMode == "" {
reconnectMode = ReconnectAuto
}
m.mu.Lock()
oldRelay := m.cfg.RelayURL
m.cfg.RelayURL = relayURL
m.cfg.ReconnectMode = reconnectMode
wasRunning := m.running.Load()
m.mu.Unlock()
// 只有 URL 改變 + 目前在跑 才需要 bounce
if oldRelay != relayURL && wasRunning {
if err := m.stopLocked(); err != nil {
return false, err
}
atomic.StoreInt32(&m.attemptNo, 0)
if err := m.startLocked(ctx); err != nil {
return true, err
}
return true, nil
}
// 未 bounce 也要 emit 一次新 snapshotRelayURL 會反映到 Status()
m.transition(m.stateValue(), m.errorString(), int(atomic.LoadInt32(&m.attemptNo)))
return false, nil
}
// Close 釋放 Manager 所有資源(停 fanout goroutine
// 通常只在 Wails app shutdown 呼叫;測試也會用。
//
// Close 內部呼叫 Stop取 lifecycleMu。如果 Close 與其他 lifecycle method 並發,
// Close 會等對方完成才執行 Stop可接受Close 是 shutdown 路徑,等一下無妨)。
func (m *Manager) Close() error {
_ = m.Stop()
m.emitCloseOnce.Do(func() {
// 先 set flag 再 close讓其他 goroutine 即將呼叫 emit 的人跳過寫入。
m.emitClosed.Store(true)
close(m.emitCh)
<-m.emitClosedCh
})
return nil
}
// ------------------------------------------------------------------
// Internal: state transition + event fanout
// ------------------------------------------------------------------
// transition 是唯一更新狀態的通道。更新後透過 emitCh 非同步廣播給 listeners。
//
// nextState目標狀態
// errStr若為錯誤相關狀態帶錯誤字串其他情況傳 ""
// attemptreconnecting 時的 attempt 數;其他情況 0
func (m *Manager) transition(nextState ConnectionState, errStr string, attempt int) {
m.mu.Lock()
prevState := m.stateValue()
m.state.Store(nextState)
m.lastError.Store(errStr)
if attempt > 0 {
atomic.StoreInt32(&m.attemptNo, int32(attempt))
} else if nextState == StateOnline || nextState == StateNotPaired || nextState == StateOffline {
atomic.StoreInt32(&m.attemptNo, 0)
}
if nextState == StateOnline {
now := time.Now()
m.connectedSince = &now
m.lastSeenAt = &now
}
if nextState == StateNotPaired {
m.connectedSince = nil
m.lastSeenAt = nil
}
snap := m.buildSnapshotLocked(nextState, errStr, int(atomic.LoadInt32(&m.attemptNo)))
m.current = snap
m.mu.Unlock()
if prevState != nextState || errStr != "" {
m.emit(snap)
}
}
// buildSnapshotLocked 組裝 ConnectionStatus。呼叫者必須持有 m.mu。
func (m *Manager) buildSnapshotLocked(state ConnectionState, errStr string, attempt int) ConnectionStatus {
s := ConnectionStatus{
State: state,
LastError: errStr,
AttemptNo: attempt,
RelayURL: m.cfg.RelayURL,
Account: m.cfg.Account,
}
if m.cfg.SessionToken != "" {
s.SessionTokenPreview = MaskSessionToken(m.cfg.SessionToken)
}
if m.connectedSince != nil {
t := *m.connectedSince
s.ConnectedSince = &t
}
if m.lastSeenAt != nil {
t := *m.lastSeenAt
s.LastSeenAt = &t
}
return s
}
// emit 把快照丟進 fanout channelchannel 滿時 drop oldest避免 listener 慢拖垮 Manager
//
// Close() 之後呼叫 emit 是允許的Close 後仍會有 Stop→transition→emit 的尾聲),
// emitClosed flag 確保不會對已關 channel 送資料。
func (m *Manager) emit(snap ConnectionStatus) {
if m.emitClosed.Load() {
return
}
// 即便上方檢查通過Close 仍可能在這瞬間被呼叫。defer recover 當最後防線;
// channel full fallback 的 drop-oldest 邏輯也放在 recover 內部路徑中,保
// 證 state transition 永遠不 panic。
defer func() {
if r := recover(); r != nil {
// race windowflag 已設但 close 還沒做完 → 我們不寫也不 drain直接吃掉。
_ = r
}
}()
select {
case m.emitCh <- snap:
default:
// Channel 滿 → drop 最舊一筆再放入(不會阻塞 state transition
select {
case <-m.emitCh:
default:
}
select {
case m.emitCh <- snap:
default:
}
}
}
// startEmitterOnce 啟動 fanout goroutine。所有 listener 都在這個 goroutine 內 serialize 呼叫。
func (m *Manager) startEmitterOnce() {
m.emitOnce.Do(func() {
go func() {
defer close(m.emitClosedCh)
for snap := range m.emitCh {
m.listenerMu.Lock()
// 複製 listener slice釋放鎖再呼叫避免 listener 又呼叫 Manager 方法造成 deadlock
ls := make([]StatusListener, 0, len(m.listeners))
for _, l := range m.listeners {
ls = append(ls, l)
}
m.listenerMu.Unlock()
for _, l := range ls {
func(l StatusListener) {
defer recoverListener("fanout")
l(snap)
}(l)
}
}
}()
})
}
func recoverListener(tag string) {
if r := recover(); r != nil {
// 不讓 listener panic 拖垮 Manager。
// 實務上應有結構化 log雛形階段忽略。
_ = r
_ = tag
}
}
// sessionToken 提供 thread-safe 讀 SessionToken 的捷徑。
func (m *Manager) sessionToken() string {
m.mu.Lock()
defer m.mu.Unlock()
return m.cfg.SessionToken
}
func (m *Manager) stateValue() ConnectionState {
if v := m.state.Load(); v != nil {
if s, ok := v.(ConnectionState); ok {
return s
}
}
return StateNotPaired
}
// ------------------------------------------------------------------
// Client hook handlers
// ------------------------------------------------------------------
// onDialAttempt每次 Client.connect() 呼叫前觸發。
// attempt=1 代表首次;>1 代表重試。
func (m *Manager) onDialAttempt(attempt int) {
atomic.StoreInt32(&m.attemptNo, int32(attempt))
// 決定要進哪一個狀態:首次且還沒上過線 → connecting否則 reconnecting。
curr := m.stateValue()
var next ConnectionState
switch curr {
case StateOnline, StateReconnecting:
next = StateReconnecting
case StateConnecting:
next = StateConnecting
default:
if attempt == 1 {
next = StateConnecting
} else {
next = StateReconnecting
}
}
// Max retry 判斷(只在 Manual 模式生效Auto 模式 cap 住退避、永遠重試)
if m.shouldStopForMaxRetry(attempt) {
// 停止 clientstate 改 offline
go func() {
_ = m.Stop()
}()
return
}
errStr := ""
if v := m.lastError.Load(); v != nil {
if s, ok := v.(string); ok {
errStr = s
}
}
m.transition(next, errStr, attempt)
}
// onSessionUpyamux session 成功建立。
func (m *Manager) onSessionUp() {
atomic.StoreInt32(&m.attemptNo, 0)
m.lastError.Store("")
m.transition(StateOnline, "", 0)
}
// onSessionDownsession 已結束(非 dial 失敗)。通常是 relay 斷線、heartbeat timeout。
// Manager 不主動 Stop讓 Client.run() 的重連迴圈接手Manager 只把 state 改為 reconnecting。
func (m *Manager) onSessionDown(err error) {
if !m.running.Load() {
// 呼叫者已經 Stop → 不要覆寫 offline/notPaired 狀態。
return
}
errStr := ""
if err != nil {
errStr = err.Error()
}
m.lastError.Store(errStr)
// lastSeenAt 更新成掉線時間,讓 UI 能算「已離線多久」
m.mu.Lock()
now := time.Now()
m.lastSeenAt = &now
m.mu.Unlock()
// 先進 reconnecting下一次 OnDialAttempt 會帶 attempt 數。
m.transition(StateReconnecting, errStr, 1)
}
// onDialFailedconnect() 建立失敗dial / yamux handshake
func (m *Manager) onDialFailed(attempt int, err error) {
if err != nil {
m.lastError.Store(err.Error())
}
// 已經在 OnDialAttempt 進了 connecting / reconnecting這裡只更新 error 字串。
// 不重新 transition state避免多餘 event但 update snapshot 供下次 Status() 帶新 error。
m.mu.Lock()
m.current = m.buildSnapshotLocked(m.stateValue(), m.errorString(), attempt)
snap := m.current
m.mu.Unlock()
m.emit(snap)
}
func (m *Manager) errorString() string {
if v := m.lastError.Load(); v != nil {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// shouldStopForMaxRetry 只在 manual 模式生效。Auto 模式永遠 false由 Client backoff cap 擋住)。
func (m *Manager) shouldStopForMaxRetry(attempt int) bool {
m.mu.Lock()
mode := m.cfg.ReconnectMode
max := m.cfg.MaxRetry
m.mu.Unlock()
if mode != ReconnectManual {
return false
}
if max <= 0 {
return false
}
return attempt > max
}