jim800121chen 6c21beb7b6 fix(local-tool): Linux AppImage bundled Python + udev 單次密碼
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>
2026-04-18 23:15:52 +08:00

304 lines
9.2 KiB
Go
Raw Permalink 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
}
// 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.",
}})
}