From 4e3dc3e504188491ae1a9fe3b275fcffdc7a2dc2 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Fri, 17 Apr 2026 00:02:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(local-tool):=20Linux=20udev=20rule=20?= =?UTF-8?q?=E8=87=AA=E5=8B=95=E5=81=B5=E6=B8=AC=E5=AE=89=E8=A3=9D=20+=20pk?= =?UTF-8?q?exec=20FUSE=20=E4=BF=AE=E5=BE=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兩個修復: 1. pkexec cp 失敗:AppImage 的 FUSE mount(/tmp/.mount_vision*)有特殊 權限,pkexec 提權後的 root process 無法讀取 user mount 的 FUSE 檔案。 修法:先用 os.ReadFile 把 rule 讀到 /tmp/visiona-local-99-kneron.rules (普通使用者權限可寫 /tmp),再 pkexec cp 從 /tmp 到 /etc/udev/rules.d/。 同時修 server API endpoint 和 Wails 啟動流程兩處。 2. 啟動流程自動偵測 udev:Stage 4 probeDeviceListAndComplete 解析 /api/devices response 的 udevHint 欄位。Linux + 0 裝置 + udevHint=true → 自動找 bundle 裡的 99-kneron.rules → 複製到 /tmp → pkexec cp 安裝 到 /etc/udev/rules.d/ → reload udev。會彈 Linux 圖形化密碼框,使用者 輸入密碼即完成。取消密碼不阻擋啟動流程(log 記錄跳過)。 Wails 控制台 Stage 4 會顯示細步文案:「偵測到 USB 權限未設定,正在 安裝(請在密碼視窗輸入密碼)...」 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../internal/api/handlers/system_handler.go | 18 +++- local-tool/visiona-local/frontend/i18n.js | 2 + local-tool/visiona-local/server_control.go | 88 ++++++++++++++++++- 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/local-tool/server/internal/api/handlers/system_handler.go b/local-tool/server/internal/api/handlers/system_handler.go index 1540224..1774baa 100644 --- a/local-tool/server/internal/api/handlers/system_handler.go +++ b/local-tool/server/internal/api/handlers/system_handler.go @@ -267,8 +267,22 @@ func (h *SystemHandler) InstallUdevRule(c *gin.Context) { 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 提權複製(會彈 Linux 圖形化密碼對話框) - cpCmd := exec.Command("pkexec", "cp", ruleSrc, "/etc/udev/rules.d/99-kneron.rules") + cpCmd := exec.Command("pkexec", "cp", tmpRule, "/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", @@ -277,7 +291,7 @@ func (h *SystemHandler) InstallUdevRule(c *gin.Context) { return } - // reload udev + // reload udev(每個指令單獨 pkexec,避免 shell injection) _ = exec.Command("pkexec", "udevadm", "control", "--reload-rules").Run() _ = exec.Command("pkexec", "udevadm", "trigger").Run() diff --git a/local-tool/visiona-local/frontend/i18n.js b/local-tool/visiona-local/frontend/i18n.js index 9be9268..e3c5d1d 100644 --- a/local-tool/visiona-local/frontend/i18n.js +++ b/local-tool/visiona-local/frontend/i18n.js @@ -85,6 +85,7 @@ const dict = { 'startup.stage.3.detail.waitHealthSlow': '首次啟動較久屬正常,Windows Defender 掃描可能需 1-2 分鐘...', // Stage 4 - 偵測 Kneron 裝置 'startup.stage.4.detail.probe': '正在掃描 USB 裝置...', + 'startup.stage.4.detail.udev': '偵測到 USB 權限未設定,正在安裝(請在密碼視窗輸入密碼)...', // Stage 5 - 開啟瀏覽器 'startup.stage.5.detail.open': '正在開啟系統預設瀏覽器...', // Stage 6 - 等待 Web UI 連線 @@ -195,6 +196,7 @@ const dict = { 'startup.stage.3.detail.waitHealthSlow': 'First launch is slow — Windows Defender scan may take 1-2 minutes...', // Stage 4 'startup.stage.4.detail.probe': 'Scanning USB devices...', + 'startup.stage.4.detail.udev': 'USB permissions not configured, installing (please enter password)...', // Stage 5 'startup.stage.5.detail.open': 'Opening system default browser...', // Stage 6 diff --git a/local-tool/visiona-local/server_control.go b/local-tool/visiona-local/server_control.go index e193b3f..6783639 100644 --- a/local-tool/visiona-local/server_control.go +++ b/local-tool/visiona-local/server_control.go @@ -18,6 +18,7 @@ package main import ( "bufio" "context" + "encoding/json" "fmt" "io" "net/http" @@ -26,6 +27,7 @@ import ( "path/filepath" "runtime" "strconv" + "strings" "sync" "syscall" "time" @@ -763,16 +765,96 @@ func (a *App) probeDeviceListAndComplete(port int) { url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port) client := &http.Client{Timeout: 2 * time.Second} resp, err := client.Get(url) - if resp != nil { + + // Linux udev rule 自動偵測:解析 response body 檢查 udevHint + if runtime.GOOS == "linux" && resp != nil && resp.StatusCode == 200 { + a.checkAndInstallUdevRule(resp) + } else if resp != nil { resp.Body.Close() } - // 不論 err 或 status,都視為階段 4 完成(只是 probe) + if err != nil { - fmt.Fprintf(os.Stderr, "[visiona-local] startup stage 4: device probe non-fatal error: %v\n", err) + a.appLog("startup stage 4: device probe non-fatal error: %v", err) } a.startupPipeline.CompleteStage(4) } +// checkAndInstallUdevRule 在 Linux 啟動流程中偵測 udev rule 是否需要安裝。 +// 解析 /api/devices response body,如果 udevHint=true → 自動嘗試安裝: +// 1. 先從 bundle 讀取 99-kneron.rules 到 /tmp +// 2. 用 pkexec cp 提權複製到 /etc/udev/rules.d/(彈密碼框) +// 3. pkexec udevadm reload + trigger +// 成功後 appLog 提示使用者拔插裝置。失敗不阻擋啟動流程。 +func (a *App) checkAndInstallUdevRule(resp *http.Response) { + defer resp.Body.Close() + + var body struct { + Data struct { + UdevHint bool `json:"udevHint"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return + } + if !body.Data.UdevHint { + return + } + + a.appLog("Linux udev rule 未安裝,正在嘗試自動安裝...") + a.startupPipeline.EmitStageDetail(4, "startup.stage.4.detail.udev", 0) + + // 找 bundle 裡的 99-kneron.rules + 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 == "" { + 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 == "" { + a.appLog("udev rule 來源檔找不到,跳過自動安裝") + return + } + + // AppImage FUSE mount 的檔案在 pkexec 提權後無法被 root 讀取, + // 先 cp 到 /tmp 再 pkexec 從 /tmp 安裝。 + tmpRule := "/tmp/visiona-local-99-kneron.rules" + data, err := os.ReadFile(ruleSrc) + if err != nil { + a.appLog("udev rule 讀取失敗:%v", err) + return + } + if err := os.WriteFile(tmpRule, data, 0o644); err != nil { + a.appLog("udev rule 寫 /tmp 失敗:%v", err) + return + } + defer os.Remove(tmpRule) + + cpCmd := exec.Command("pkexec", "cp", tmpRule, "/etc/udev/rules.d/99-kneron.rules") + if out, err := cpCmd.CombinedOutput(); err != nil { + a.appLog("udev rule 安裝失敗(使用者可能取消了密碼輸入):%v (%s)", err, strings.TrimSpace(string(out))) + return + } + + _ = exec.Command("pkexec", "udevadm", "control", "--reload-rules").Run() + _ = exec.Command("pkexec", "udevadm", "trigger").Run() + + a.appLog("udev rule 安裝成功。請拔掉 Kneron USB 裝置再重新插入,然後在 Web UI 點「掃描裝置」。") +} + func closeLogFilesSafe(files ...*os.File) { for _, f := range files { if f != nil {