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:
parent
ddf0eb8147
commit
db272cac5a
@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@ -67,6 +86,33 @@ export default function DevicesPage() {
|
||||
</Button>
|
||||
</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} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -12,6 +12,7 @@ interface DeviceState {
|
||||
scanning: boolean;
|
||||
connectingId: string | null;
|
||||
disconnectingId: string | null;
|
||||
udevHint: boolean;
|
||||
fetchDevices: () => Promise<void>;
|
||||
scanDevices: () => Promise<void>;
|
||||
fetchDevice: (id: string) => Promise<void>;
|
||||
@ -27,13 +28,14 @@ export const useDeviceStore = create<DeviceState>((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<DeviceState>((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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.",
|
||||
}})
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user