package main // firmware_close_guard.go — M9-4.5 (TDD §8.6.2):Wails 控制台關閉攔截 // // 為什麼有這個檔: // R5-2 設定「關 Wails 視窗 = 結束 server」、預設 OnBeforeClose return false // 讓 Wails 進 OnShutdown 流程;但韌體升降版進行中(會寫 flash)中斷 = device // brick。本檔提供 firmware-aware close guard、在 close 動作前查 server 是否 // 有 active firmware task: // // 1. 有 active task → emit Wails event `app:firmware-in-progress` 給前端 // modal、return true(prevent close)。前端 modal(Design §6a)顯示 // 進度 / ETA / 「繼續等待」/「強制關閉」按鈕。 // 2. 沒 active task → return false、走原本 R5-2 流程 // // 前端「強制關閉」按鈕(經 §6a.5 第二層 FORCE 確認後)會呼叫 App 的 // `ConfirmForceClose` binding、binding 內 set forceCloseAccepted=true、再 // 觸發 Wails Quit、下次 OnBeforeClose 直接 return false 放行。 // // 為什麼用 Wails event 而不是 native dialog: // Design §6a 規格要求 modal 與前端設計系統一致(color tokens / focus // management / i18n / 二層 FORCE 確認)、Wails native dialog 風格不一致也 // 無法做二層確認字串輸入。改用前端 modal、Wails 端只負責 emit event + // 接收 binding 回應。 import ( "context" "fmt" "sync" wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime" ) // firmwareInProgressEvent 是 emit 給前端的事件型別(對齊 TDD §8.6.2)。 // 前端訂閱此事件、收到後渲染 Design §6a 的攔截 modal。 const firmwareInProgressEvent = "app:firmware-in-progress" // FirmwareCloseGuard 封裝關閉攔截邏輯(含 forceCloseAccepted 狀態)。 // 嵌進 App 的 method 是 OnBeforeClose / ConfirmForceClose 兩個 wrapper。 // // 拆出獨立 struct 是為了:(1) 測試友善(可不靠 App 全域 state);(2) // thread-safe — Wails OnBeforeClose 由 main thread 呼叫、ConfirmForceClose // 由 binding goroutine 呼叫、必須 mu 保護。 type FirmwareCloseGuard struct { mu sync.Mutex // forceCloseAccepted:使用者已通過 Design §6a 第二層 FORCE 確認、下次 // OnBeforeClose 被叫到時直接放行。confirmForceClose() 設、放行後自動清。 forceCloseAccepted bool } // NewFirmwareCloseGuard 建空 guard。 func NewFirmwareCloseGuard() *FirmwareCloseGuard { return &FirmwareCloseGuard{} } // ConfirmForceClose 由前端「強制關閉」按鈕(§6a.5 第二層 FORCE 確認後)呼叫。 // 設旗標、下次 OnBeforeClose 直接 return false 放行。 func (g *FirmwareCloseGuard) ConfirmForceClose() { g.mu.Lock() g.forceCloseAccepted = true g.mu.Unlock() } // consumeForceCloseAccepted 取出旗標(讀完即清)、給 OnBeforeClose 判斷用。 // 抽出 method 方便測試直接觀察狀態。 func (g *FirmwareCloseGuard) consumeForceCloseAccepted() bool { g.mu.Lock() defer g.mu.Unlock() if g.forceCloseAccepted { g.forceCloseAccepted = false return true } return false } // CloseGuardDeps 是 evaluateClose 對外部依賴的介面(測試友善)。 // // 實作方:production = App(透過 GetServerStatus 拿 port + emit event 用 ctx); // test = fake 提供確定的 port + 觀察 emit。 type CloseGuardDeps interface { // ServerPort 回傳 server 目前 listen 的 port、<=0 表示 server 沒起來。 ServerPort() int // QueryFirmwareTasks 查詢 server 是否有 active firmware task。 // production 直接打到 queryFirmwareActiveTasks(ctx, port)。 QueryFirmwareTasks(ctx context.Context, port int) (FirmwareActiveTasksResponse, error) // EmitFirmwareInProgress 通知前端「韌體進行中、不能關」。 // production = wailsRuntime.EventsEmit(ctx, firmwareInProgressEvent, payload)。 EmitFirmwareInProgress(payload map[string]interface{}) // AppLog 寫 wails.log。production = App.appLog;test 用 no-op。 AppLog(format string, args ...interface{}) } // evaluateClose 是 OnBeforeClose 的核心邏輯(不直接接 Wails ctx、用 CloseGuardDeps // 抽象出來方便測試)。 // // 回傳 prevent=true → Wails 應該擋住關閉(保留視窗); // 回傳 prevent=false → 走原本 R5-2 流程(OnShutdown → ctrl.Stop → 結束 server)。 // // 邏輯流程: // // 1. forceCloseAccepted=true → 重置 + return false 放行(使用者已通過二層確認) // 2. ServerPort()<=0 → return false(server 沒起來、沒韌體任務可擋) // 3. queryFirmwareTasks 回 hasActive=false 或錯誤 → return false(fail-open) // 4. hasActive=true → emit `app:firmware-in-progress` event + return true func (g *FirmwareCloseGuard) evaluateClose(ctx context.Context, deps CloseGuardDeps) bool { if deps == nil { // 防呆:deps 沒注入、不擋(避免使用者卡住) return false } // 1. 使用者已通過二層確認、放行 if g.consumeForceCloseAccepted() { deps.AppLog("close-guard: force-close confirmed by user, allowing close") return false } port := deps.ServerPort() if port <= 0 { // server 沒起來、沒韌體任務可擋 deps.AppLog("close-guard: server not running (port=%d), allowing close", port) return false } // 2. 查 server firmware active task res, err := deps.QueryFirmwareTasks(ctx, port) if err != nil { // fail-open:查不到狀態不擋(server 端 SIGTERM handler 還會擋一層) deps.AppLog("close-guard: query firmware tasks failed (fail-open allow close): %v", err) return false } if !res.HasActive { deps.AppLog("close-guard: no active firmware task, allowing close") return false } // 3. 有 active task:emit event 通知前端 modal、prevent close tasksAny := make([]interface{}, 0, len(res.Tasks)) for _, t := range res.Tasks { tasksAny = append(tasksAny, map[string]interface{}{ "taskId": t.TaskID, "deviceId": t.DeviceID, "deviceName": t.DeviceName, "chip": t.Chip, "direction": t.Direction, "stage": t.Stage, "elapsedMs": t.ElapsedMs, "etaSeconds": t.EtaSeconds, }) } payload := map[string]interface{}{ "hasActive": true, "tasks": tasksAny, } deps.EmitFirmwareInProgress(payload) deps.AppLog("close-guard: %d active firmware task(s) detected, preventing close + emitted %s event", len(res.Tasks), firmwareInProgressEvent) return true } // ────────────────────────────────────────────────────────────────────── // App 層 wiring(將 App 的 method 適配進 CloseGuardDeps) // ────────────────────────────────────────────────────────────────────── // appCloseGuardDeps 將 *App 包裝成 CloseGuardDeps。production wire-up。 type appCloseGuardDeps struct { app *App } func (a *appCloseGuardDeps) ServerPort() int { if a.app == nil { return 0 } st := a.app.GetServerStatus() if !st.Running { return 0 } return st.Port } func (a *appCloseGuardDeps) QueryFirmwareTasks(ctx context.Context, port int) (FirmwareActiveTasksResponse, error) { return queryFirmwareActiveTasks(ctx, port) } func (a *appCloseGuardDeps) EmitFirmwareInProgress(payload map[string]interface{}) { if a.app == nil || a.app.ctx == nil { return } wailsRuntime.EventsEmit(a.app.ctx, firmwareInProgressEvent, payload) } func (a *appCloseGuardDeps) AppLog(format string, args ...interface{}) { if a.app == nil { return } a.app.appLog(format, args...) } // OnBeforeClose 是給 Wails options.App.OnBeforeClose 用的 callback。 // M9-4.5 之前:永遠 return false。 // M9-4.5 之後:firmware-aware(見 evaluateClose)。 // // 由 App 持有的 firmwareCloseGuard 提供 state;Wails 在 main thread 呼叫、 // CloseGuard 的 mu 保護 ConfirmForceClose 與 OnBeforeClose 的併發。 func (a *App) OnBeforeClose(ctx context.Context) bool { if a.firmwareCloseGuard == nil { // 防呆:guard 沒初始化(極早期啟動 / test 直接 new App)→ 走預設不擋 return false } return a.firmwareCloseGuard.evaluateClose(ctx, &appCloseGuardDeps{app: a}) } // ConfirmForceClose 是 Wails binding、給前端 Design §6a 第二層 FORCE 確認 // modal 的「確認強制關閉」按鈕呼叫。 // // 行為: // 1. 設 forceCloseAccepted=true // 2. 立刻呼叫 wailsRuntime.Quit(a.ctx) 觸發關閉流程 // 3. Wails 之後叫 OnBeforeClose → 看到旗標 → 直接放行 → 走 OnShutdown // → ctrl.Stop() 既有 7+1s graceful shutdown // // 為什麼不直接 ForceKill:使用者「確認強制關閉」的意圖是「我知道風險、繼續 // 走、但仍想優雅關閉 server 業務(會 broadcast offline overlay 給其他 tab)」、 // graceful shutdown 流程比 SIGKILL 更友善。server 端 firmware-aware SIGTERM // handler 收到 SIGTERM 後 ${firmware.MaxShutdownWait}s 內仍會等 firmware task、 // 真的卡死才強制走 — 屬於雙層防護的合理取捨。 // // 回傳 error 給 binding caller 觀察(目前永遠 nil、預留未來擴展)。 func (a *App) ConfirmForceClose() error { if a.firmwareCloseGuard == nil { return fmt.Errorf("firmware close guard not initialized") } a.firmwareCloseGuard.ConfirmForceClose() a.appLog("ConfirmForceClose: user accepted force close, scheduling Wails Quit") if a.ctx != nil { // 用 goroutine 避免 binding caller 阻塞、且讓 Wails event loop 自由 // 處理 close 流程(OnBeforeClose 會被再叫一次、看到旗標放行) go wailsRuntime.Quit(a.ctx) } return nil }