jim800121chen db272cac5a feat(local-tool): Linux udev rule 未安裝偵測 + 一鍵安裝 UX
使用者在 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>
2026-04-16 23:20:28 +08:00

288 lines
8.5 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.

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.3Minor 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-4server 啟動時產生的 boot-id32 字元 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.3Minor 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
// (不等 ACKserver 馬上就要 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 平台直接回 successno-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.",
}})
}