依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。
程式碼變動
- M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
Makefile vendor / installer / bootstrap / CI workflow,-555 行)
- M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
VISIONA_MOCK 環境變數,-528 行)
- M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
LGPL binary,macOS 自 build minimal decoder-only 進 git
(vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
- M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
- M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
- M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
state 視覺、log panel、startup progress panel、Stage 6 manual CTA
pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
- M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
- M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
wsEverConnected 容錯 + Page Visibility)
- M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
- MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
(/ws/system endpoint + notifyShutdownImminent helper)
- M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)
品質
- ~105+ 新 unit test + race detector (-count=2) 全綠
- 10 個 milestone 全部通過 Reviewer 審查
- 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
收錄在 .autoflow/
交付前待處理(M8-10)
- 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
- 三平台 end-to-end build 驗證
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
218 lines
6.4 KiB
Go
218 lines
6.4 KiB
Go
package handlers
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"net/http"
|
||
"runtime"
|
||
"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()
|
||
}
|
||
}()
|
||
}
|