package handlers import ( "crypto/rand" "encoding/hex" "fmt" "net/http" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "visiona-local/server/internal/api/ws" "visiona-local/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.3(Minor 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-4:server 啟動時產生的 boot-id(32 字元 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.3(Minor 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 // (不等 ACK,server 馬上就要 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 平台直接回 success(no-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-local-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 提權複製(會彈 Linux 圖形化密碼對話框) cpCmd := exec.Command("pkexec", "cp", tmpRule, "/etc/udev/rules.d/99-kneron.rules") 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 cp failed: %v (%s)", err, strings.TrimSpace(string(out))), }}) return } // reload udev(每個指令單獨 pkexec,避免 shell injection) _ = exec.Command("pkexec", "udevadm", "control", "--reload-rules").Run() _ = exec.Command("pkexec", "udevadm", "trigger").Run() c.JSON(200, gin.H{"success": true, "data": gin.H{ "message": "udev rule installed. Please unplug and replug your Kneron device.", }}) }