jim800121chen db272cac5a 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>
2026-04-16 23:20:28 +08:00

133 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { create } from 'zustand';
import { api } from '@/lib/api';
import type { Device, DeviceEvent } from '@/types/device';
import { showSuccess, showError, showApiError } from '@/lib/toast';
import { getTranslation } from '@/lib/i18n';
import { useActivityStore } from './activity-store';
interface DeviceState {
devices: Device[];
selectedDevice: Device | null;
loading: boolean;
scanning: boolean;
connectingId: string | null;
disconnectingId: string | null;
udevHint: boolean;
fetchDevices: () => Promise<void>;
scanDevices: () => Promise<void>;
fetchDevice: (id: string) => Promise<void>;
connectDevice: (id: string) => Promise<void>;
disconnectDevice: (id: string) => Promise<void>;
handleEvent: (event: DeviceEvent) => void;
}
export const useDeviceStore = create<DeviceState>((set, get) => ({
devices: [],
selectedDevice: null,
loading: false,
scanning: false,
connectingId: null,
disconnectingId: null,
udevHint: false,
scanDevices: async () => {
set({ scanning: true });
try {
const res = await api.post<{ devices: Device[]; udevHint?: boolean }>('/devices/scan');
if (res.success && res.data) {
set({ devices: res.data.devices || [], scanning: false, udevHint: !!res.data.udevHint });
} else {
set({ scanning: false });
showApiError(res.error);
}
} catch {
set({ scanning: false });
showError(getTranslation().t('errors.failedToLoadDevices'));
}
},
fetchDevices: async () => {
set({ loading: true });
try {
const res = await api.get<{ devices: Device[]; udevHint?: boolean }>('/devices');
if (res.success && res.data) {
set({ devices: res.data.devices || [], loading: false, udevHint: !!res.data.udevHint });
} else {
set({ loading: false });
showApiError(res.error);
}
} catch {
set({ loading: false });
showError(getTranslation().t('errors.failedToLoadDevices'));
}
},
fetchDevice: async (id) => {
const res = await api.get<Device>(`/devices/${id}`);
if (res.success && res.data) {
set({ selectedDevice: res.data });
} else {
showApiError(res.error);
}
},
connectDevice: async (id) => {
set({ connectingId: id });
try {
const res = await api.post(`/devices/${id}/connect`);
if (res.success) {
showSuccess(getTranslation().t('errors.deviceConnected'));
const name = get().devices.find((d) => d.id === id)?.name || id;
useActivityStore.getState().addActivity('device_connect', `Device connected: ${name}`);
} else {
const msg = res.error?.message || '';
if (/winusb|error.{0,5}code.{0,5}28|KP_ERROR_CONNECT_FAILED/i.test(msg)) {
showApiError({
code: 'DRIVER_NOT_INSTALLED',
message:
'連線失敗Kneron WinUSB driver 尚未安裝。\n' +
'請點擊右上角「安裝 USB Driver」按鈕需要系統管理員權限安裝完成後重新點選連線。',
});
} else {
showApiError(res.error);
}
}
} finally {
set({ connectingId: null });
get().fetchDevices();
}
},
disconnectDevice: async (id) => {
set({ disconnectingId: id });
try {
const res = await api.post(`/devices/${id}/disconnect`);
if (res.success) {
showSuccess(getTranslation().t('errors.deviceDisconnected'));
const name = get().devices.find((d) => d.id === id)?.name || id;
useActivityStore.getState().addActivity('device_disconnect', `Device disconnected: ${name}`);
} else {
showApiError(res.error);
}
} finally {
set({ disconnectingId: null });
get().fetchDevices();
}
},
handleEvent: (event) => {
set((state) => {
const devices = [...state.devices];
const idx = devices.findIndex((d) => d.id === event.device.id);
if (event.event === 'discovered') {
if (idx === -1) devices.push(event.device);
} else if (event.event === 'removed') {
if (idx !== -1) devices.splice(idx, 1);
} else if (event.event === 'updated') {
if (idx !== -1) devices[idx] = event.device;
}
return { devices };
});
},
}));