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)