Files
One-KVM/web/src/api/index.ts
2026-07-07 23:52:38 +08:00

899 lines
23 KiB
TypeScript

import { request, ApiError } from './request'
import type {
CanonicalKey,
Ch9329DescriptorState,
ComputerUseButton,
ComputerUseConfigResponse,
ComputerUseConfigUpdate,
ComputerUseSessionStatus,
ComputerUseSessionSummary,
ComputerUseStartRequest,
} from '@/types/generated'
import { useHidWebSocket, type HidKeyboardEvent, type HidMouseEvent } from '@/composables/useHidWebSocket'
const API_BASE = '/api'
export const authApi = {
login: (username: string, password: string) =>
request<{ success: boolean; message?: string }>(
'/auth/login',
{
method: 'POST',
body: JSON.stringify({ username, password }),
},
{ toastOnError: false },
),
logout: () =>
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
check: () =>
request<{ authenticated: boolean; user?: string }>('/auth/check'),
changePassword: (currentPassword: string, newPassword: string) =>
request<{ success: boolean }>('/auth/password', {
method: 'POST',
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
}),
changeUsername: (username: string, currentPassword: string) =>
request<{ success: boolean }>('/auth/username', {
method: 'POST',
body: JSON.stringify({ username, current_password: currentPassword }),
}),
}
export interface NetworkAddress {
interface: string
ip: string
}
export interface DeviceInfo {
hostname: string
cpu_model: string
cpu_usage: number
memory_total: number
memory_used: number
network_addresses: NetworkAddress[]
serial_ports: string[]
}
export interface FeatureCapability {
available: boolean
backends: string[]
selected_backend?: string
reason?: string
}
export interface PlatformCapabilities {
mode: 'android_amlogic' | 'linux' | 'windows'
mode_label: string
video_capture: FeatureCapability
encoder: FeatureCapability
hid: FeatureCapability
atx: FeatureCapability
msd: FeatureCapability
otg: FeatureCapability
audio: FeatureCapability
rustdesk: FeatureCapability
vnc: FeatureCapability
diagnostics: FeatureCapability
extensions: FeatureCapability
service_installation: FeatureCapability
}
export const systemApi = {
info: () =>
request<{
version: string
build_date: string
initialized: boolean
platform: PlatformCapabilities
capabilities: {
video: { available: boolean; backend?: string; reason?: string }
hid: { available: boolean; backend?: string; reason?: string }
msd: { available: boolean; backend?: string; reason?: string }
atx: { available: boolean; backend?: string; reason?: string }
audio: { available: boolean; backend?: string; reason?: string }
rustdesk: { available: boolean; backend?: string; reason?: string }
vnc: { available: boolean; backend?: string; reason?: string }
}
disk_space?: {
total: number
available: number
used: number
}
device_info?: DeviceInfo
}>('/info'),
health: () => request<{ status: string; version: string }>('/health'),
setupStatus: () =>
request<{ initialized: boolean; needs_setup: boolean; platform: PlatformCapabilities }>('/setup'),
setup: (data: {
username: string
password: string
video_device?: string
video_format?: string
video_width?: number
video_height?: number
video_fps?: number
hid_backend?: string
hid_ch9329_port?: string
hid_ch9329_baudrate?: number
hid_otg_udc?: string
hid_otg_profile?: string
hid_otg_endpoint_budget?: string
hid_otg_keyboard_leds?: boolean
msd_enabled?: boolean
encoder_backend?: string
audio_device?: string
ttyd_enabled?: boolean
rustdesk_enabled?: boolean
}) =>
request<{ success: boolean; message?: string }>('/setup/init', {
method: 'POST',
body: JSON.stringify(data),
}),
restart: () =>
request<{ success: boolean; message?: string }>('/system/restart', {
method: 'POST',
}),
}
export type UpdateChannel = 'stable' | 'beta'
export interface UpdateOverviewResponse {
success: boolean
current_version: string
channel: UpdateChannel
latest_version: string
upgrade_available: boolean
target_version?: string
notes_between: Array<{
version: string
published_at: string
notes: string[]
}>
}
export interface UpdateStatusResponse {
success: boolean
phase: 'idle' | 'checking' | 'downloading' | 'verifying' | 'installing' | 'restarting' | 'success' | 'failed'
progress: number
current_version: string
target_version?: string
message?: string
last_error?: string
}
export const updateApi = {
overview: (channel: UpdateChannel = 'stable') =>
request<UpdateOverviewResponse>(
`/update/overview?channel=${encodeURIComponent(channel)}`,
{},
{ toastOnError: false },
),
upgrade: (payload: { channel?: UpdateChannel; target_version?: string }) =>
request<{ success: boolean; message?: string }>('/update/upgrade', {
method: 'POST',
body: JSON.stringify(payload),
}),
status: () =>
request<UpdateStatusResponse>('/update/status'),
}
export interface VideoCodecInfo {
id: string
name: string
protocol: 'http' | 'webrtc'
hardware: boolean
backend: string | null
available: boolean
}
export interface EncoderBackendInfo {
id: string
name: string
is_hardware: boolean
supported_formats: string[]
}
export interface AvailableCodecsResponse {
success: boolean
backends: EncoderBackendInfo[]
codecs: VideoCodecInfo[]
}
export interface StreamConstraintsResponse {
success: boolean
allowed_codecs: string[]
locked_codec: string | null
disallow_mjpeg: boolean
sources: {
rustdesk: boolean
rtsp: boolean
vnc: boolean
}
reason: string
current_mode: string
}
export interface VideoEncoderSelfCheckCodec {
id: string
name: string
}
export interface VideoEncoderSelfCheckCell {
codec_id: string
ok: boolean
elapsed_ms?: number | null
}
export interface VideoEncoderSelfCheckRow {
resolution_id: string
resolution_label: string
width: number
height: number
cells: VideoEncoderSelfCheckCell[]
}
export interface VideoEncoderSelfCheckResponse {
current_hardware_encoder: string
codecs: VideoEncoderSelfCheckCodec[]
rows: VideoEncoderSelfCheckRow[]
}
export const streamApi = {
status: () =>
request<{
state:
| 'uninitialized'
| 'ready'
| 'streaming'
| 'no_signal'
| 'no_cable'
| 'no_sync'
| 'out_of_range'
| 'device_lost'
| 'recovering'
| 'device_busy'
| 'error'
device: string | null
format: string | null
resolution: [number, number] | null
clients: number
target_fps: number
fps: number
}>('/stream/status'),
start: () =>
request<{ success: boolean }>('/stream/start', { method: 'POST' }),
stop: () =>
request<{ success: boolean }>('/stream/stop', { method: 'POST' }),
getMjpegUrl: (clientId?: string) => {
const base = `${API_BASE}/stream/mjpeg`
return clientId ? `${base}?client_id=${clientId}` : base
},
getSnapshotUrl: () => `${API_BASE}/snapshot`,
getMode: () =>
request<{ success: boolean; mode: string; transition_id?: string; switching?: boolean; message?: string }>('/stream/mode'),
setMode: (mode: string) =>
request<{ success: boolean; mode: string; transition_id?: string; switching?: boolean; message?: string }>('/stream/mode', {
method: 'POST',
body: JSON.stringify({ mode }),
}),
getCodecs: () =>
request<AvailableCodecsResponse>('/stream/codecs'),
getConstraints: () =>
request<StreamConstraintsResponse>('/stream/constraints'),
encoderSelfCheck: () =>
request<VideoEncoderSelfCheckResponse>('/video/encoder/self-check'),
setBitratePreset: (bitrate_preset: import('@/types/generated').BitratePreset) =>
request<{ success: boolean; message?: string }>('/stream/bitrate', {
method: 'POST',
body: JSON.stringify({ bitrate_preset }),
}),
}
export interface IceCandidate {
candidate: string
sdpMid?: string
sdpMLineIndex?: number
usernameFragment?: string
}
export interface IceServerConfig {
urls: string[]
username?: string
credential?: string
}
export const webrtcApi = {
createSession: () =>
request<{ session_id: string }>('/webrtc/session', { method: 'POST' }),
offer: (sdp: string) =>
request<{ sdp: string; session_id: string; ice_candidates: IceCandidate[] }>('/webrtc/offer', {
method: 'POST',
body: JSON.stringify({ sdp }),
}),
addIceCandidate: (sessionId: string, candidate: IceCandidate) =>
request<{ success: boolean }>('/webrtc/ice', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, candidate }),
}),
status: () =>
request<{
session_count: number
sessions: Array<{ session_id: string; state: string }>
}>('/webrtc/status'),
close: (sessionId: string) =>
request<{ success: boolean }>('/webrtc/close', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
}),
getIceServers: () =>
request<{ ice_servers: IceServerConfig[]; mdns_mode: string }>('/webrtc/ice-servers'),
}
const hidWs = useHidWebSocket()
let hidWsInitialized = false
async function ensureHidConnection() {
if (!hidWsInitialized) {
hidWsInitialized = true
await hidWs.connect()
}
}
function mapButton(button?: 'left' | 'right' | 'middle'): number | undefined {
if (!button) return undefined
const buttonMap = { left: 0, middle: 1, right: 2 }
return buttonMap[button]
}
export const hidApi = {
status: () =>
request<{
available: boolean
backend: string
initialized: boolean
online: boolean
supports_absolute_mouse: boolean
keyboard_leds_enabled: boolean
led_state: {
num_lock: boolean
caps_lock: boolean
scroll_lock: boolean
compose: boolean
kana: boolean
}
screen_resolution: [number, number] | null
device: string | null
error: string | null
error_code: string | null
}>('/hid/status'),
otgSelfCheck: () =>
request<{
overall_ok: boolean
error_count: number
warning_count: number
hid_backend: string
selected_udc: string | null
bound_udc: string | null
udc_state: string | null
udc_speed: string | null
available_udcs: string[]
other_gadgets: string[]
checks: Array<{
id: string
ok: boolean
level: 'info' | 'warn' | 'error'
message: string
hint?: string
path?: string
}>
}>('/hid/otg/self-check'),
keyboard: async (type: 'down' | 'up', key: CanonicalKey, modifier?: number) => {
await ensureHidConnection()
const event: HidKeyboardEvent = {
type: type === 'down' ? 'keydown' : 'keyup',
key,
modifier: (modifier ?? 0) & 0xff,
}
await hidWs.sendKeyboard(event)
return { success: true }
},
mouse: async (data: {
type: 'move' | 'move_abs' | 'down' | 'up' | 'scroll'
x?: number | null
y?: number | null
button?: 'left' | 'right' | 'middle' | null
scroll?: number | null
}) => {
await ensureHidConnection()
const event: HidMouseEvent = {
type: data.type === 'move_abs' ? 'moveabs' : data.type,
x: data.x ?? undefined,
y: data.y ?? undefined,
button: mapButton(data.button ?? undefined),
scroll: data.scroll ?? undefined,
}
await hidWs.sendMouse(event)
return { success: true }
},
reset: () =>
request<{ success: boolean }>('/hid/reset', { method: 'POST' }),
ch9329Descriptor: (params?: { port?: string; baudRate?: number }) => {
const query = new URLSearchParams()
if (params?.port) query.set('port', params.port)
if (params?.baudRate) query.set('baud_rate', String(params.baudRate))
const suffix = query.toString()
return request<Ch9329DescriptorState>(`/hid/ch9329/descriptor${suffix ? `?${suffix}` : ''}`)
},
consumer: async (usage: number) => {
await ensureHidConnection()
await hidWs.sendConsumer({ usage })
return { success: true }
},
connectWebSocket: () => hidWs.connect(),
disconnectWebSocket: () => hidWs.disconnect(),
isWebSocketConnected: () => hidWs.connected.value,
}
export type ComputerUseStatus = ComputerUseSessionStatus
export type ComputerUseAction =
| { type: 'click'; x: number; y: number; button?: ComputerUseButton }
| { type: 'double_click'; x: number; y: number; button?: ComputerUseButton }
| { type: 'move'; x: number; y: number }
| { type: 'drag'; path: Array<{ x: number; y: number }>; button?: ComputerUseButton }
| { type: 'scroll'; x: number; y: number; dx?: number; dy?: number }
| { type: 'type'; text: string }
| { type: 'keypress'; keys: string[] }
| { type: 'wait'; ms: number }
| { type: 'screenshot' }
export type ComputerUseConversationMessage =
| { role: 'user'; text: string }
| { role: 'assistant'; text: string }
export type ComputerUseConfig = ComputerUseConfigResponse
export type ComputerUseSession = ComputerUseSessionSummary
export const computerUseApi = {
config: () => request<ComputerUseConfig>('/config/computer-use'),
updateConfig: (data: ComputerUseConfigUpdate) =>
request<ComputerUseConfig>('/config/computer-use', {
method: 'PATCH',
body: JSON.stringify(data),
}),
session: () => request<ComputerUseSession>('/computer-use/session'),
start: (data: ComputerUseStartRequest) =>
request<ComputerUseSession>('/computer-use/session', {
method: 'POST',
body: JSON.stringify(data),
}),
stop: () =>
request<ComputerUseSession>('/computer-use/session/stop', {
method: 'POST',
}),
}
export const atxApi = {
status: () =>
request<{
available: boolean
backend: string
initialized: boolean
power_status: 'on' | 'off' | 'unknown'
led_supported: boolean
hdd_status: 'active' | 'inactive' | 'unknown'
hdd_supported: boolean
}>('/atx/status'),
power: (action: 'short' | 'long' | 'reset') =>
request<{ success: boolean; message?: string }>('/atx/power', {
method: 'POST',
body: JSON.stringify({ action }),
}),
}
export interface MsdImage {
id: string
name: string
size: number
created_at: string
}
export interface DriveFile {
name: string
path: string
is_dir: boolean
size: number
}
export const msdApi = {
status: () =>
request<{
available: boolean
state: {
connected: boolean
mode: 'none' | 'image' | 'drive'
current_image: {
id: string
name: string
size: number
created_at: string
} | null
drive_info: {
size: number
used: number
free: number
initialized: boolean
} | null
}
}>('/msd/status'),
listImages: () => request<MsdImage[]>('/msd/images'),
uploadImage: async (file: File, onProgress?: (progress: number) => void) => {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `${API_BASE}/msd/images`)
xhr.withCredentials = true
return new Promise<MsdImage>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress((e.loaded / e.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new ApiError(xhr.status, 'Upload failed'))
}
}
xhr.onerror = () => reject(new ApiError(0, 'Network error'))
xhr.send(formData)
})
},
deleteImage: (id: string) =>
request<{ success: boolean }>(`/msd/images/${id}`, { method: 'DELETE' }),
connect: (mode: 'image' | 'drive', imageId?: string, cdrom?: boolean, readOnly?: boolean) =>
request<{ success: boolean }>('/msd/connect', {
method: 'POST',
body: JSON.stringify({ mode, image_id: imageId, cdrom, read_only: readOnly }),
}),
disconnect: () =>
request<{ success: boolean }>('/msd/disconnect', { method: 'POST' }),
driveInfo: () =>
request<{
size: number
used: number
free: number
initialized: boolean
}>('/msd/drive', {}, { toastOnError: false }),
initDrive: (sizeMb?: number) =>
request<{
size: number
used: number
free: number
initialized: boolean
}>(
'/msd/drive/init',
{
method: 'POST',
body: JSON.stringify({ size_mb: sizeMb }),
},
{ toastOnError: false },
),
deleteDrive: () =>
request<{ success: boolean }>('/msd/drive', { method: 'DELETE' }),
listDriveFiles: (path = '/') =>
request<DriveFile[]>(
`/msd/drive/files?path=${encodeURIComponent(path)}`,
{},
{ toastOnError: false },
),
uploadDriveFile: async (file: File, targetPath = '/', onProgress?: (progress: number) => void) => {
const formData = new FormData()
formData.append('file', file)
const xhr = new XMLHttpRequest()
xhr.open('POST', `${API_BASE}/msd/drive/files?path=${encodeURIComponent(targetPath)}`)
xhr.withCredentials = true
return new Promise<{ success: boolean; message?: string }>((resolve, reject) => {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) {
onProgress((e.loaded / e.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new ApiError(xhr.status, 'Upload failed'))
}
}
xhr.onerror = () => reject(new ApiError(0, 'Network error'))
xhr.send(formData)
})
},
downloadDriveFile: (path: string) =>
`${API_BASE}/msd/drive/files${encodeDrivePath(path)}`,
deleteDriveFile: (path: string) =>
request<{ success: boolean }>(`/msd/drive/files${encodeDrivePath(path)}`, {
method: 'DELETE',
}),
createDirectory: (path: string) =>
request<{ success: boolean }>(`/msd/drive/mkdir${encodeDrivePath(path)}`, {
method: 'POST',
}),
downloadFromUrl: (url: string, filename?: string) =>
request<{
download_id: string
url: string
filename: string
bytes_downloaded: number
total_bytes: number | null
progress_pct: number | null
status: string
error: string | null
}>('/msd/images/download', {
method: 'POST',
body: JSON.stringify({ url, filename }),
}),
cancelDownload: (downloadId: string) =>
request<{ success: boolean }>('/msd/images/download/cancel', {
method: 'POST',
body: JSON.stringify({ download_id: downloadId }),
}),
}
interface SerialDeviceOption {
path: string
name: string
}
function encodeDrivePath(path: string): string {
if (path === '' || path === '/') {
return '/'
}
const hasLeadingSlash = path.startsWith('/')
const hasTrailingSlash = path.endsWith('/')
const encodedSegments = path
.split('/')
.filter(Boolean)
.map(segment => encodeURIComponent(segment))
.join('/')
return `${hasLeadingSlash ? '/' : ''}${encodedSegments}${hasTrailingSlash ? '/' : ''}`
}
function getSerialDevicePriority(path: string): number {
if (/^\/dev\/ttyUSB/i.test(path)) return 0
if (/^\/dev\/(ttyS|S)/i.test(path)) return 2
return 1
}
function sortSerialDevices(serialDevices: SerialDeviceOption[]): SerialDeviceOption[] {
return [...serialDevices].sort((a, b) => {
const priorityDiff = getSerialDevicePriority(a.path) - getSerialDevicePriority(b.path)
if (priorityDiff !== 0) return priorityDiff
return a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: 'base' })
})
}
export const configApi = {
listDevices: async () => {
const result = await request<{
video: Array<{
path: string
name: string
driver: string
formats: Array<{
format: string
description: string
resolutions: Array<{
width: number
height: number
fps: number[]
}>
}>
usb_bus: string | null
has_signal: boolean
}>
serial: Array<{ path: string; name: string }>
audio: Array<{
name: string
description: string
is_hdmi: boolean
usb_bus: string | null
}>
udc: Array<{ name: string }>
extensions: {
ttyd_available: boolean
rustdesk_available: boolean
}
}>('/devices')
return {
...result,
serial: sortSerialDevices(result.serial),
}
},
}
export {
authConfigApi,
videoConfigApi,
streamConfigApi,
hidConfigApi,
msdConfigApi,
atxConfigApi,
audioConfigApi,
extensionsApi,
redfishConfigApi,
rustdeskConfigApi,
rtspConfigApi,
vncConfigApi,
webConfigApi,
type RustDeskConfigResponse,
type RustDeskStatusResponse,
type RustDeskConfigUpdate,
type RustDeskPasswordResponse,
type RtspConfigResponse,
type RedfishConfigResponse,
type RedfishConfigUpdate,
type RtspConfigUpdate,
type RtspStatusResponse,
type VncConfigResponse,
type VncConfigUpdate,
type VncEncoding,
type VncStatusResponse,
type WebConfig,
type WebConfigUpdate,
} from './config'
export type {
AppConfig,
AuthConfig,
AuthConfigUpdate,
VideoConfig,
VideoConfigUpdate,
StreamConfig,
StreamConfigUpdate,
HidConfig,
HidConfigUpdate,
MsdConfig,
MsdConfigUpdate,
AtxConfig,
AtxConfigUpdate,
AudioConfig,
AudioConfigUpdate,
HidBackend,
StreamMode,
EncoderType,
BitratePreset,
ComputerUseButton,
ComputerUseConfigResponse,
ComputerUseConfigUpdate,
ComputerUseScreenshot,
ComputerUseSessionStatus,
ComputerUseSessionSummary,
ComputerUseStartRequest,
} from '@/types/generated'
export const audioApi = {
status: () =>
request<{
enabled: boolean
streaming: boolean
device: string | null
sample_rate: number
channels: number
quality: string
subscriber_count: number
frames_encoded: number
bytes_output: number
error: string | null
}>('/audio/status'),
start: () =>
request<{ success: boolean }>('/audio/start', { method: 'POST' }),
stop: () =>
request<{ success: boolean }>('/audio/stop', { method: 'POST' }),
setQuality: (quality: 'voice' | 'balanced' | 'high') =>
request<{ success: boolean }>('/audio/quality', {
method: 'POST',
body: JSON.stringify({ quality }),
}),
selectDevice: (device: string) =>
request<{ success: boolean }>('/audio/device', {
method: 'POST',
body: JSON.stringify({ device }),
}),
}
export interface UsbDeviceInfo {
bus_num: number
dev_num: number
id_vendor: string
id_product: string
product?: string
manufacturer?: string
speed?: string
authorized?: boolean
video_device?: string
}
export const usbApi = {
listDevices: () =>
request<UsbDeviceInfo[]>('/devices/usb'),
resetDevice: (busNum: number, devNum: number) =>
request<{ success: boolean; message: string }>('/devices/usb/reset', {
method: 'POST',
body: JSON.stringify({ bus_num: busNum, dev_num: devNum }),
}),
}
export { ApiError }