diff --git a/local-tool/frontend/src/app/devices/page.tsx b/local-tool/frontend/src/app/devices/page.tsx index e12ac4f..eb56091 100644 --- a/local-tool/frontend/src/app/devices/page.tsx +++ b/local-tool/frontend/src/app/devices/page.tsx @@ -5,7 +5,7 @@ import { useDeviceStore } from '@/stores/device-store'; import { useDeviceEvents } from '@/hooks/use-device-events'; import { DeviceList } from '@/components/devices/device-list'; import { Button } from '@/components/ui/button'; -import { RefreshCw, Usb } from 'lucide-react'; +import { RefreshCw, Usb, AlertTriangle } from 'lucide-react'; import { useTranslation } from '@/lib/i18n'; import { api } from '@/lib/api'; import { toast } from 'sonner'; @@ -17,8 +17,9 @@ function isWindows(): boolean { export default function DevicesPage() { const { t } = useTranslation(); - const { devices, loading, scanning, fetchDevices, scanDevices } = useDeviceStore(); + const { devices, loading, scanning, udevHint, fetchDevices, scanDevices } = useDeviceStore(); const [installingDriver, setInstallingDriver] = useState(false); + const [installingUdev, setInstallingUdev] = useState(false); useDeviceEvents(); @@ -42,6 +43,24 @@ export default function DevicesPage() { } } + async function handleInstallUdev() { + setInstallingUdev(true); + try { + const res = await api.post<{ message: string }>('/system/install-udev'); + if (res.success) { + toast.success(res.data?.message || 'USB 權限安裝完成,請拔掉 Kneron 裝置再重新插入,然後重新掃描。'); + // 重新 scan 一次 + setTimeout(() => scanDevices(), 2000); + } else { + toast.error(res.error?.message || 'udev rule 安裝失敗'); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setInstallingUdev(false); + } + } + return (
@@ -67,6 +86,33 @@ export default function DevicesPage() {
+ + {/* Linux udev rule 未安裝提示 */} + {udevHint && ( +
+ +
+

+ 偵測不到 Kneron USB 裝置 — 可能需要安裝 USB 存取權限 +

+

+ Linux 需要安裝 udev rule 才能讓非 root 使用者存取 Kneron USB 裝置。 + 點擊下方按鈕安裝(會彈出密碼輸入視窗),安裝後請拔掉裝置再重新插入。 +

+ +
+
+ )} + ); diff --git a/local-tool/frontend/src/stores/device-store.ts b/local-tool/frontend/src/stores/device-store.ts index 4133419..0bc63f6 100644 --- a/local-tool/frontend/src/stores/device-store.ts +++ b/local-tool/frontend/src/stores/device-store.ts @@ -12,6 +12,7 @@ interface DeviceState { scanning: boolean; connectingId: string | null; disconnectingId: string | null; + udevHint: boolean; fetchDevices: () => Promise; scanDevices: () => Promise; fetchDevice: (id: string) => Promise; @@ -27,13 +28,14 @@ export const useDeviceStore = create((set, get) => ({ scanning: false, connectingId: null, disconnectingId: null, + udevHint: false, scanDevices: async () => { set({ scanning: true }); try { - const res = await api.post<{ devices: Device[] }>('/devices/scan'); + const res = await api.post<{ devices: Device[]; udevHint?: boolean }>('/devices/scan'); if (res.success && res.data) { - set({ devices: res.data.devices || [], scanning: false }); + set({ devices: res.data.devices || [], scanning: false, udevHint: !!res.data.udevHint }); } else { set({ scanning: false }); showApiError(res.error); @@ -47,9 +49,9 @@ export const useDeviceStore = create((set, get) => ({ fetchDevices: async () => { set({ loading: true }); try { - const res = await api.get<{ devices: Device[] }>('/devices'); + const res = await api.get<{ devices: Device[]; udevHint?: boolean }>('/devices'); if (res.success && res.data) { - set({ devices: res.data.devices || [], loading: false }); + set({ devices: res.data.devices || [], loading: false, udevHint: !!res.data.udevHint }); } else { set({ loading: false }); showApiError(res.error); diff --git a/local-tool/server/internal/api/handlers/device_handler.go b/local-tool/server/internal/api/handlers/device_handler.go index afd1a95..424006a 100644 --- a/local-tool/server/internal/api/handlers/device_handler.go +++ b/local-tool/server/internal/api/handlers/device_handler.go @@ -3,6 +3,8 @@ package handlers import ( "context" "fmt" + "os" + "runtime" "time" "visiona-local/server/internal/api/ws" @@ -14,6 +16,12 @@ import ( "github.com/gin-gonic/gin" ) +// udevRuleInstalled checks if the Kneron udev rule is installed on Linux. +func udevRuleInstalled() bool { + _, err := os.Stat("/etc/udev/rules.d/99-kneron.rules") + return err == nil +} + type DeviceHandler struct { deviceMgr *device.Manager flashSvc *flash.Service @@ -37,22 +45,25 @@ func NewDeviceHandler( func (h *DeviceHandler) ScanDevices(c *gin.Context) { devices := h.deviceMgr.Rescan() - c.JSON(200, gin.H{ - "success": true, - "data": gin.H{ - "devices": devices, - }, - }) + resp := gin.H{ + "devices": devices, + } + // Linux: 0 裝置 + udev rule 不存在 → 提示使用者安裝 USB 權限 + if runtime.GOOS == "linux" && len(devices) == 0 && !udevRuleInstalled() { + resp["udevHint"] = true + } + c.JSON(200, gin.H{"success": true, "data": resp}) } func (h *DeviceHandler) ListDevices(c *gin.Context) { devices := h.deviceMgr.ListDevices() - c.JSON(200, gin.H{ - "success": true, - "data": gin.H{ - "devices": devices, - }, - }) + resp := gin.H{ + "devices": devices, + } + if runtime.GOOS == "linux" && len(devices) == 0 && !udevRuleInstalled() { + resp["udevHint"] = true + } + c.JSON(200, gin.H{"success": true, "data": resp}) } func (h *DeviceHandler) GetDevice(c *gin.Context) { diff --git a/local-tool/server/internal/api/handlers/system_handler.go b/local-tool/server/internal/api/handlers/system_handler.go index a3fb739..1540224 100644 --- a/local-tool/server/internal/api/handlers/system_handler.go +++ b/local-tool/server/internal/api/handlers/system_handler.go @@ -3,8 +3,13 @@ package handlers import ( "crypto/rand" "encoding/hex" + "fmt" "net/http" + "os" + "os/exec" + "path/filepath" "runtime" + "strings" "time" "visiona-local/server/internal/api/ws" @@ -215,3 +220,68 @@ func (h *SystemHandler) Restart(c *gin.Context) { } }() } + +// 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 + } + + // 用 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.", + }}) +} diff --git a/local-tool/server/internal/api/router.go b/local-tool/server/internal/api/router.go index 1cdbff6..b92985b 100644 --- a/local-tool/server/internal/api/router.go +++ b/local-tool/server/internal/api/router.go @@ -59,6 +59,7 @@ func NewRouter( api.GET("/system/boot-id", systemHandler.BootID) // M8-4:瀏覽器 tab 用於偵測 server 重啟 api.POST("/system/restart", systemHandler.Restart) api.POST("/system/install-driver", systemHandler.InstallDriver) + api.POST("/system/install-udev", systemHandler.InstallUdevRule) // Linux udev rule 安裝 // MAJ-4 補丁:Wails shutdown / Restart 前廣播 server:shutdown-imminent // 到 /ws/system,讓瀏覽器 tab 立即顯示 Offline Overlay。 api.POST("/system/shutdown-notify", systemHandler.ShutdownNotify)