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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 平台直接回 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.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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user