使用者在 Ubuntu 上 scan 不到 Kneron 裝置。根因:Linux 預設 USB 裝置 權限是 root only,非 root 使用者的 kp.core.scan_devices 因 permission denied 而 silently 回傳 0 裝置。需要安裝 udev rule。 修法三層: 1. Server:GET/POST /api/devices 在 Linux + 0 裝置 + udev rule 不存在 時帶 udevHint: true 2. 新增 POST /api/system/install-udev:用 pkexec 提權安裝 99-kneron.rules + reload udev(彈 Linux 圖形化密碼對話框) 3. 前端 devices page:udevHint=true 時顯示 amber 色 banner 提示 + 一鍵安裝按鈕,成功後自動 rescan Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
288 lines
8.5 KiB
Go
288 lines
8.5 KiB
Go
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
|
||
}
|
||
|
||
// 用 pkexec 提權複製(會彈 Linux 圖形化密碼對話框)
|
||
cpCmd := exec.Command("pkexec", "cp", ruleSrc, "/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
|
||
_ = 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.",
|
||
}})
|
||
}
|