fix(local-tool): Linux udev rule 自動偵測安裝 + pkexec FUSE 修復

兩個修復:

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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-17 00:02:37 +08:00
parent 372259b4b1
commit 4e3dc3e504
3 changed files with 103 additions and 5 deletions

View File

@ -267,8 +267,22 @@ func (h *SystemHandler) InstallUdevRule(c *gin.Context) {
return 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 圖形化密碼對話框) // 用 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 { if out, err := cpCmd.CombinedOutput(); err != nil {
c.JSON(500, gin.H{"success": false, "error": gin.H{ c.JSON(500, gin.H{"success": false, "error": gin.H{
"code": "UDEV_INSTALL_FAILED", "code": "UDEV_INSTALL_FAILED",
@ -277,7 +291,7 @@ func (h *SystemHandler) InstallUdevRule(c *gin.Context) {
return return
} }
// reload udev // reload udev(每個指令單獨 pkexec避免 shell injection
_ = exec.Command("pkexec", "udevadm", "control", "--reload-rules").Run() _ = exec.Command("pkexec", "udevadm", "control", "--reload-rules").Run()
_ = exec.Command("pkexec", "udevadm", "trigger").Run() _ = exec.Command("pkexec", "udevadm", "trigger").Run()

View File

@ -85,6 +85,7 @@ const dict = {
'startup.stage.3.detail.waitHealthSlow': '首次啟動較久屬正常Windows Defender 掃描可能需 1-2 分鐘...', 'startup.stage.3.detail.waitHealthSlow': '首次啟動較久屬正常Windows Defender 掃描可能需 1-2 分鐘...',
// Stage 4 - 偵測 Kneron 裝置 // Stage 4 - 偵測 Kneron 裝置
'startup.stage.4.detail.probe': '正在掃描 USB 裝置...', 'startup.stage.4.detail.probe': '正在掃描 USB 裝置...',
'startup.stage.4.detail.udev': '偵測到 USB 權限未設定,正在安裝(請在密碼視窗輸入密碼)...',
// Stage 5 - 開啟瀏覽器 // Stage 5 - 開啟瀏覽器
'startup.stage.5.detail.open': '正在開啟系統預設瀏覽器...', 'startup.stage.5.detail.open': '正在開啟系統預設瀏覽器...',
// Stage 6 - 等待 Web UI 連線 // 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...', 'startup.stage.3.detail.waitHealthSlow': 'First launch is slow — Windows Defender scan may take 1-2 minutes...',
// Stage 4 // Stage 4
'startup.stage.4.detail.probe': 'Scanning USB devices...', 'startup.stage.4.detail.probe': 'Scanning USB devices...',
'startup.stage.4.detail.udev': 'USB permissions not configured, installing (please enter password)...',
// Stage 5 // Stage 5
'startup.stage.5.detail.open': 'Opening system default browser...', 'startup.stage.5.detail.open': 'Opening system default browser...',
// Stage 6 // Stage 6

View File

@ -18,6 +18,7 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -26,6 +27,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
"strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@ -763,16 +765,96 @@ func (a *App) probeDeviceListAndComplete(port int) {
url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port) url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port)
client := &http.Client{Timeout: 2 * time.Second} client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(url) 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() resp.Body.Close()
} }
// 不論 err 或 status都視為階段 4 完成(只是 probe
if err != nil { 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) 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) { func closeLogFilesSafe(files ...*os.File) {
for _, f := range files { for _, f := range files {
if f != nil { if f != nil {