Linux AppImage 掃不到 Kneron 裝置的根因是 Wails app 端三個 locator
完全沒讀 AppRun 已 export 的 VISIONA_BUNDLE_LIB_DIR,導致:
- locateBundleDataDir 找不到 models.json → seed user data dir 失敗
- locateBundledPythonAssets 找不到 python tarball + wheels
→ ensurePythonRuntime(Auto) fallback 到 system Python
→ system Python 缺 numpy / kp / pyusb → bridge scan silent fail
→ "No Kneron devices detected"
修復:
1. 三個 locator 優先讀 VISIONA_BUNDLE_LIB_DIR / VISIONA_BUNDLE_BIN_DIR
(AppRun 已 export),AppImage 佈局 usr/lib/visiona-local/{data,python,wheels}
一次到位
2. AppRun 加 VISIONA_PYTHON_MODE=bundled — Linux AppImage 強制走內嵌
Python,避免 system Python 環境差異(符合 R4「完全離線內嵌」決策)
3. InstallUdevRule 合併 pkexec:cp + reload-rules + trigger 用
pkexec sh -c 一次提權,使用者只需輸入 1 次密碼(原本 3 次)
4. build-appimage.sh 加硬檢查:
- python tarball 缺失 → 自動 make vendor-python-linux,仍缺就 exit 1
- wheels 數量 < 4 → 自動 make vendor-wheels-linux,仍不足就 exit 1
- 驗證關鍵 wheel(numpy / opencv / pyusb / kp)存在,少任一 exit 1
5. Makefile payload-linux 同步加硬檢查(python tarball 必存在,
wheels ≥ 4 個)
6. 參照 edge-ai-platform POC 補齊 visiona-local/wheels/linux/ 的
KneronPLUS-2.0.0-py3-none-any.whl(POC 已驗過可用)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
304 lines
9.2 KiB
Go
304 lines
9.2 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
|
||
}
|
||
|
||
// 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 一次提權執行三個動作(cp + reload-rules + trigger),使用者只要輸入一次密碼。
|
||
// 用 sh -c 包起來而不是呼叫 3 次 pkexec(原本每次都彈密碼框)。
|
||
// tmpRule 路徑和 /etc/udev/rules.d/99-kneron.rules 都是固定值,無 shell injection 風險。
|
||
script := fmt.Sprintf(
|
||
"cp %q /etc/udev/rules.d/99-kneron.rules && udevadm control --reload-rules && udevadm trigger",
|
||
tmpRule,
|
||
)
|
||
cpCmd := exec.Command("pkexec", "sh", "-c", script)
|
||
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 install failed: %v (%s)", err, strings.TrimSpace(string(out))),
|
||
}})
|
||
return
|
||
}
|
||
|
||
c.JSON(200, gin.H{"success": true, "data": gin.H{
|
||
"message": "udev rule installed. Please unplug and replug your Kneron device.",
|
||
}})
|
||
}
|