feat(local-tool): Linux udev rule 未安裝偵測 + 一鍵安裝 UX

使用者在 Ubuntu 上 scan 不到 Kneron 裝置。根因:Linux 預設 USB 裝置
權限是 root only,非 root 使用者的 kp.core.scan_devices 因 permission
denied 而 silently 回傳 0 裝置。需要安裝 udev rule。

修法三層:
1. Server:GET/POST /api/devices 在 Linux + 0 裝置 + udev rule 不存在
   時帶 udevHint: true
2. 新增 POST /api/system/install-udev:用 pkexec 提權安裝 99-kneron.rules
   + reload udev(彈 Linux 圖形化密碼對話框)
3. 前端 devices page:udevHint=true 時顯示 amber 色 banner 提示 +
   一鍵安裝按鈕,成功後自動 rescan

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-04-16 23:20:28 +08:00
parent ddf0eb8147
commit db272cac5a
5 changed files with 148 additions and 18 deletions

View File

@ -5,7 +5,7 @@ import { useDeviceStore } from '@/stores/device-store';
import { useDeviceEvents } from '@/hooks/use-device-events'; import { useDeviceEvents } from '@/hooks/use-device-events';
import { DeviceList } from '@/components/devices/device-list'; import { DeviceList } from '@/components/devices/device-list';
import { Button } from '@/components/ui/button'; 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 { useTranslation } from '@/lib/i18n';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -17,8 +17,9 @@ function isWindows(): boolean {
export default function DevicesPage() { export default function DevicesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { devices, loading, scanning, fetchDevices, scanDevices } = useDeviceStore(); const { devices, loading, scanning, udevHint, fetchDevices, scanDevices } = useDeviceStore();
const [installingDriver, setInstallingDriver] = useState(false); const [installingDriver, setInstallingDriver] = useState(false);
const [installingUdev, setInstallingUdev] = useState(false);
useDeviceEvents(); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -67,6 +86,33 @@ export default function DevicesPage() {
</Button> </Button>
</div> </div>
</div> </div>
{/* Linux udev rule 未安裝提示 */}
{udevHint && (
<div className="flex items-start gap-3 rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-950/30">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
Kneron USB USB
</p>
<p className="text-xs text-amber-700 dark:text-amber-300">
Linux udev rule root 使 Kneron USB
</p>
<Button
onClick={handleInstallUdev}
disabled={installingUdev}
size="sm"
variant="outline"
className="border-amber-400 text-amber-800 hover:bg-amber-100 dark:border-amber-600 dark:text-amber-200"
>
<Usb className={`mr-2 h-4 w-4 ${installingUdev ? 'animate-pulse' : ''}`} />
{installingUdev ? '安裝中(請在密碼視窗輸入密碼)...' : '安裝 USB 存取權限'}
</Button>
</div>
</div>
)}
<DeviceList devices={devices} loading={loading} /> <DeviceList devices={devices} loading={loading} />
</div> </div>
); );

View File

@ -12,6 +12,7 @@ interface DeviceState {
scanning: boolean; scanning: boolean;
connectingId: string | null; connectingId: string | null;
disconnectingId: string | null; disconnectingId: string | null;
udevHint: boolean;
fetchDevices: () => Promise<void>; fetchDevices: () => Promise<void>;
scanDevices: () => Promise<void>; scanDevices: () => Promise<void>;
fetchDevice: (id: string) => Promise<void>; fetchDevice: (id: string) => Promise<void>;
@ -27,13 +28,14 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
scanning: false, scanning: false,
connectingId: null, connectingId: null,
disconnectingId: null, disconnectingId: null,
udevHint: false,
scanDevices: async () => { scanDevices: async () => {
set({ scanning: true }); set({ scanning: true });
try { 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) { if (res.success && res.data) {
set({ devices: res.data.devices || [], scanning: false }); set({ devices: res.data.devices || [], scanning: false, udevHint: !!res.data.udevHint });
} else { } else {
set({ scanning: false }); set({ scanning: false });
showApiError(res.error); showApiError(res.error);
@ -47,9 +49,9 @@ export const useDeviceStore = create<DeviceState>((set, get) => ({
fetchDevices: async () => { fetchDevices: async () => {
set({ loading: true }); set({ loading: true });
try { try {
const res = await api.get<{ devices: Device[] }>('/devices'); const res = await api.get<{ devices: Device[]; udevHint?: boolean }>('/devices');
if (res.success && res.data) { if (res.success && res.data) {
set({ devices: res.data.devices || [], loading: false }); set({ devices: res.data.devices || [], loading: false, udevHint: !!res.data.udevHint });
} else { } else {
set({ loading: false }); set({ loading: false });
showApiError(res.error); showApiError(res.error);

View File

@ -3,6 +3,8 @@ package handlers
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"runtime"
"time" "time"
"visiona-local/server/internal/api/ws" "visiona-local/server/internal/api/ws"
@ -14,6 +16,12 @@ import (
"github.com/gin-gonic/gin" "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 { type DeviceHandler struct {
deviceMgr *device.Manager deviceMgr *device.Manager
flashSvc *flash.Service flashSvc *flash.Service
@ -37,22 +45,25 @@ func NewDeviceHandler(
func (h *DeviceHandler) ScanDevices(c *gin.Context) { func (h *DeviceHandler) ScanDevices(c *gin.Context) {
devices := h.deviceMgr.Rescan() devices := h.deviceMgr.Rescan()
c.JSON(200, gin.H{ resp := gin.H{
"success": true, "devices": devices,
"data": 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) { func (h *DeviceHandler) ListDevices(c *gin.Context) {
devices := h.deviceMgr.ListDevices() devices := h.deviceMgr.ListDevices()
c.JSON(200, gin.H{ resp := gin.H{
"success": true, "devices": devices,
"data": 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) { func (h *DeviceHandler) GetDevice(c *gin.Context) {

View File

@ -3,8 +3,13 @@ package handlers
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt"
"net/http" "net/http"
"os"
"os/exec"
"path/filepath"
"runtime" "runtime"
"strings"
"time" "time"
"visiona-local/server/internal/api/ws" "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 平台直接回 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
}
// 用 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.",
}})
}

View File

@ -59,6 +59,7 @@ func NewRouter(
api.GET("/system/boot-id", systemHandler.BootID) // M8-4瀏覽器 tab 用於偵測 server 重啟 api.GET("/system/boot-id", systemHandler.BootID) // M8-4瀏覽器 tab 用於偵測 server 重啟
api.POST("/system/restart", systemHandler.Restart) api.POST("/system/restart", systemHandler.Restart)
api.POST("/system/install-driver", systemHandler.InstallDriver) 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 // MAJ-4 補丁Wails shutdown / Restart 前廣播 server:shutdown-imminent
// 到 /ws/system讓瀏覽器 tab 立即顯示 Offline Overlay。 // 到 /ws/system讓瀏覽器 tab 立即顯示 Offline Overlay。
api.POST("/system/shutdown-notify", systemHandler.ShutdownNotify) api.POST("/system/shutdown-notify", systemHandler.ShutdownNotify)