mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-09-13 11:04:25 +08:00
merge: resolve dev conflicts for CH9329 macOS drag compatibility
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import 'vue-sonner/style.css'
|
||||
import HidDriverDialog from '@/components/HidDriverDialog.vue'
|
||||
import { readPendingHid, type PendingHid } from '@/lib/hidGuide'
|
||||
import '@/sonner-overrides.css'
|
||||
import { computed, KeepAlive, onMounted } from 'vue'
|
||||
import { computed, KeepAlive, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterView, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
@@ -24,6 +26,10 @@ const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const systemStore = useSystemStore()
|
||||
const { isDark } = useTheme()
|
||||
const pendingGuide = ref<PendingHid | null>(null)
|
||||
watch(() => authStore.isAuthenticated, authenticated => {
|
||||
pendingGuide.value = authenticated ? readPendingHid() : null
|
||||
}, { immediate: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -46,7 +52,8 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<HidDriverDialog v-if="pendingGuide && authStore.isAuthenticated" :pending="pendingGuide" @close="pendingGuide = null" />
|
||||
<RouterView v-if="!pendingGuide" v-slot="{ Component, route }">
|
||||
<KeepAlive v-if="authStore.isAuthenticated">
|
||||
<component :is="Component" v-if="route.name === 'Console'" />
|
||||
</KeepAlive>
|
||||
|
||||
@@ -87,8 +87,9 @@ export const streamConfigApi = {
|
||||
export const hidConfigApi = {
|
||||
get: () => request<HidConfig>('/config/hid'),
|
||||
|
||||
update: (config: HidConfigUpdate) =>
|
||||
update: (config: HidConfigUpdate, signal?: AbortSignal) =>
|
||||
request<HidConfig>('/config/hid', {
|
||||
signal,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(config),
|
||||
}),
|
||||
@@ -221,12 +222,13 @@ export const extensionsApi = {
|
||||
|
||||
export interface RustDeskConfigResponse {
|
||||
enabled: boolean
|
||||
mode: 'id' | 'direct_ip'
|
||||
codec: 'h264' | 'h265'
|
||||
direct_access_port: number
|
||||
rendezvous_server: string
|
||||
relay_server: string | null
|
||||
device_id: string
|
||||
has_password: boolean
|
||||
has_keypair: boolean
|
||||
relay_key: string | null
|
||||
}
|
||||
|
||||
@@ -234,11 +236,16 @@ export interface RustDeskStatusResponse {
|
||||
config: RustDeskConfigResponse
|
||||
service_status: string
|
||||
rendezvous_status: string | null
|
||||
connection_count: number
|
||||
listening: boolean
|
||||
listen_port: number | null
|
||||
}
|
||||
|
||||
export interface RustDeskConfigUpdate {
|
||||
enabled?: boolean
|
||||
mode?: 'id' | 'direct_ip'
|
||||
codec?: 'h264' | 'h265'
|
||||
direct_access_port?: number
|
||||
rendezvous_server?: string
|
||||
relay_server?: string
|
||||
relay_key?: string
|
||||
|
||||
@@ -583,6 +583,16 @@ export interface DriveFile {
|
||||
size: number
|
||||
}
|
||||
|
||||
export type DriveFileAccess = 'available' | 'unsupported' | 'blocked_while_connected' | 'unknown'
|
||||
|
||||
export interface DriveInfo {
|
||||
size: number
|
||||
used: number | null
|
||||
free: number | null
|
||||
initialized: boolean
|
||||
file_access: DriveFileAccess
|
||||
}
|
||||
|
||||
export type DiskMode = 'single' | 'multi'
|
||||
export type MountedMediaKind = 'drive' | 'image'
|
||||
|
||||
@@ -604,12 +614,7 @@ export const msdApi = {
|
||||
slot_capacity: number
|
||||
mounted_count: number
|
||||
mounted_media: MountedMedia[]
|
||||
drive_info: {
|
||||
size: number
|
||||
used: number
|
||||
free: number
|
||||
initialized: boolean
|
||||
} | null
|
||||
drive_info: DriveInfo | null
|
||||
usb_reenumerating: boolean
|
||||
}
|
||||
}>('/msd/status', {}, { toastOnError: false }),
|
||||
@@ -650,20 +655,10 @@ export const msdApi = {
|
||||
request<{ success: boolean }>('/msd/drive/mount', { method: 'DELETE' }, { errorTitleKey: 'msd.operations.unmountDrive' }),
|
||||
|
||||
driveInfo: () =>
|
||||
request<{
|
||||
size: number
|
||||
used: number
|
||||
free: number
|
||||
initialized: boolean
|
||||
}>('/msd/drive', {}, { toastOnError: false }),
|
||||
request<DriveInfo>('/msd/drive', {}, { toastOnError: false }),
|
||||
|
||||
initDrive: (sizeMb?: number) =>
|
||||
request<{
|
||||
size: number
|
||||
used: number
|
||||
free: number
|
||||
initialized: boolean
|
||||
}>(
|
||||
request<DriveInfo>(
|
||||
'/msd/drive/init',
|
||||
{
|
||||
method: 'POST',
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import type { VideoScaleMode } from '@/composables/useVideoScaling'
|
||||
import type { VideoRotation, VideoScaleMode } from '@/composables/useVideoScaling'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ButtonGroup } from '@/components/ui/button-group'
|
||||
import {
|
||||
@@ -39,6 +39,10 @@ import {
|
||||
Terminal,
|
||||
MoreHorizontal,
|
||||
Bot,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Keyboard,
|
||||
Scaling,
|
||||
} from 'lucide-vue-next'
|
||||
import PasteModal from '@/components/PasteModal.vue'
|
||||
import AtxPopover from '@/components/AtxPopover.vue'
|
||||
@@ -47,6 +51,7 @@ import HidConfigPopover from '@/components/HidConfigPopover.vue'
|
||||
import AudioConfigPopover from '@/components/AudioConfigPopover.vue'
|
||||
import MsdDialog from '@/components/MsdDialog.vue'
|
||||
import VideoDisplayControls from '@/components/VideoDisplayControls.vue'
|
||||
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
@@ -59,12 +64,14 @@ const isCh9329Backend = computed(() => hidBackend.value.includes('ch9329'))
|
||||
const showMsd = computed(() => {
|
||||
return !!systemStore.msd?.available && !isCh9329Backend.value
|
||||
})
|
||||
const showAtx = computed(() => systemStore.atx?.available === true)
|
||||
|
||||
const props = defineProps<{
|
||||
layout?: ConsoleLayout
|
||||
mouseMode?: 'absolute' | 'relative'
|
||||
videoMode?: VideoMode
|
||||
videoRotation?: VideoRotation
|
||||
ttydRunning?: boolean
|
||||
showPower?: boolean
|
||||
atxEnabled?: boolean
|
||||
showTerminal?: boolean
|
||||
showComputerUse?: boolean
|
||||
showPasteText?: boolean
|
||||
@@ -72,6 +79,20 @@ const props = defineProps<{
|
||||
scaleMode?: VideoScaleMode
|
||||
sourceSizeAvailable?: boolean
|
||||
}>()
|
||||
const isSidebarLayout = computed(() => props.layout === 'sidebar')
|
||||
const isFloatingLayout = computed(() => props.layout === 'floating')
|
||||
const floatingCollapsed = ref(false)
|
||||
const expandButtonRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
const collapseButtonRef = ref<InstanceType<typeof Button> | null>(null)
|
||||
|
||||
async function setFloatingCollapsed(collapsed: boolean) {
|
||||
floatingCollapsed.value = collapsed
|
||||
await nextTick()
|
||||
const target = collapsed ? expandButtonRef.value : collapseButtonRef.value
|
||||
target?.$el?.focus()
|
||||
}
|
||||
const showAtx = computed(() => props.showPower !== false)
|
||||
const atxEnabled = computed(() => props.atxEnabled === true)
|
||||
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
|
||||
const showPasteText = computed(() => props.showPasteText !== false)
|
||||
const showMic = computed(() => props.showMic === true)
|
||||
@@ -84,6 +105,7 @@ const emit = defineEmits<{
|
||||
(e: 'toggleVirtualKeyboard'): void
|
||||
(e: 'toggleMouseMode'): void
|
||||
(e: 'update:videoMode', mode: VideoMode): void
|
||||
(e: 'update:videoRotation', rotation: VideoRotation): void
|
||||
(e: 'powerShort'): void
|
||||
(e: 'powerLong'): void
|
||||
(e: 'reset'): void
|
||||
@@ -133,13 +155,18 @@ const openMobilePaste = () => openFromOverflow(() => {
|
||||
const barRef = ref<HTMLElement | null>(null)
|
||||
const measureRef = ref<HTMLElement | null>(null)
|
||||
const barWidth = ref(0)
|
||||
const barHeight = ref(0)
|
||||
const coreWidth = ref(0)
|
||||
const coreHeight = ref(0)
|
||||
const fixedHeight = ref(0)
|
||||
const actionHeight = ref(36)
|
||||
const minimalDisplayControls = computed(() => isFloatingLayout.value && barWidth.value < 640)
|
||||
const alwaysRightWidth = ref(152)
|
||||
let layoutResizeObserver: ResizeObserver | null = null
|
||||
|
||||
type CollapsibleItem =
|
||||
| 'video' | 'audio' | 'hid'
|
||||
| 'msd' | 'atx' | 'paste'
|
||||
| 'stats' | 'terminal' | 'settings'
|
||||
| 'stats' | 'terminal' | 'settings' | 'ai'
|
||||
|
||||
interface ItemSpec {
|
||||
id: CollapsibleItem
|
||||
@@ -147,15 +174,13 @@ interface ItemSpec {
|
||||
}
|
||||
|
||||
const ITEM_SPECS: ItemSpec[] = [
|
||||
{ id: 'video', side: 'left' },
|
||||
{ id: 'audio', side: 'left' },
|
||||
{ id: 'hid', side: 'left' },
|
||||
{ id: 'msd', side: 'left' },
|
||||
{ id: 'atx', side: 'left' },
|
||||
{ id: 'paste', side: 'left' },
|
||||
{ id: 'stats', side: 'right' },
|
||||
{ id: 'terminal', side: 'right' },
|
||||
{ id: 'settings', side: 'right' },
|
||||
{ id: 'ai', side: 'right' },
|
||||
]
|
||||
|
||||
const measuredWidths = ref<Map<CollapsibleItem, { icon: number; label: number }>>(new Map())
|
||||
@@ -167,7 +192,13 @@ const measureLayout = async () => {
|
||||
const measureContainer = measureRef.value
|
||||
if (!bar || !measureContainer) return
|
||||
|
||||
barWidth.value = bar.clientWidth
|
||||
const style = window.getComputedStyle(bar)
|
||||
barWidth.value = bar.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight)
|
||||
barHeight.value = bar.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom)
|
||||
const core = Array.from(bar.querySelectorAll<HTMLElement>('[data-core-action]'))
|
||||
coreWidth.value = core.reduce((sum, element) => sum + element.offsetWidth, 0)
|
||||
coreHeight.value = core.reduce((sum, element) => sum + element.offsetHeight, 0)
|
||||
actionHeight.value = core[0]?.offsetHeight || 36
|
||||
|
||||
const newWidths = new Map<CollapsibleItem, { icon: number; label: number }>()
|
||||
for (const spec of ITEM_SPECS) {
|
||||
@@ -190,7 +221,12 @@ const measureLayout = async () => {
|
||||
+ Number.parseFloat(style.marginLeft || '0')
|
||||
+ Number.parseFloat(style.marginRight || '0')
|
||||
}, 0)
|
||||
if (width > 0) alwaysRightWidth.value = Math.ceil(width)
|
||||
alwaysRightWidth.value = Math.ceil(width)
|
||||
fixedHeight.value = elements.reduce((sum, element) => {
|
||||
const style = window.getComputedStyle(element)
|
||||
return sum + element.getBoundingClientRect().height
|
||||
+ parseFloat(style.marginTop || '0') + parseFloat(style.marginBottom || '0')
|
||||
}, 0)
|
||||
|
||||
measurementReady.value = true
|
||||
}
|
||||
@@ -202,7 +238,7 @@ const observeLayout = async () => {
|
||||
void measureLayout()
|
||||
})
|
||||
if (barRef.value) layoutResizeObserver.observe(barRef.value)
|
||||
barRef.value?.querySelectorAll('[data-fixed-action]').forEach((element) => {
|
||||
barRef.value?.querySelectorAll('[data-fixed-action], [data-core-action]').forEach((element) => {
|
||||
layoutResizeObserver?.observe(element)
|
||||
})
|
||||
}
|
||||
@@ -220,7 +256,7 @@ watch(locale, () => {
|
||||
void measureLayout()
|
||||
})
|
||||
|
||||
watch(() => props.showComputerUse, () => {
|
||||
watch([() => props.layout, () => props.showComputerUse, floatingCollapsed, minimalDisplayControls], () => {
|
||||
void observeLayout()
|
||||
})
|
||||
|
||||
@@ -241,52 +277,55 @@ watch(showPasteText, (visible) => {
|
||||
const OVERFLOW_BUTTON_BUDGET_PX = 36
|
||||
|
||||
const collapsibleItems = computed(() => {
|
||||
const items = ITEM_SPECS.slice(3).filter(item => {
|
||||
const items = ITEM_SPECS.filter(item => {
|
||||
if (isFloatingLayout.value && item.side === 'right') return false
|
||||
if (item.id === 'msd' && !showMsd.value) return false
|
||||
if (item.id === 'atx' && !showAtx.value) return false
|
||||
if (item.id === 'paste' && !showPasteText.value) return false
|
||||
if (item.id === 'stats' && !showStats.value) return false
|
||||
if (item.id === 'terminal' && props.showTerminal === false) return false
|
||||
if (item.id === 'ai' && props.showComputerUse === false) return false
|
||||
return true
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
const visibleSet = computed(() => {
|
||||
if (!measurementReady.value) {
|
||||
return new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||
}
|
||||
|
||||
const available = barWidth.value - alwaysRightWidth.value - OVERFLOW_BUTTON_BUDGET_PX
|
||||
|
||||
let used = 0
|
||||
if (barRef.value) {
|
||||
const leftContainer = barRef.value.querySelector('.left-buttons') as HTMLElement
|
||||
if (leftContainer) {
|
||||
const children = Array.from(leftContainer.children).slice(0, 3) as HTMLElement[]
|
||||
used = children.reduce((sum, el) => sum + el.offsetWidth, 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (used === 0) used = 330
|
||||
|
||||
const result = new Map<CollapsibleItem, 'icon' | 'label'>()
|
||||
if (!measurementReady.value) return result
|
||||
|
||||
if (isSidebarLayout.value) {
|
||||
// Reserve More and a gap between the two groups before assigning vertical slots.
|
||||
let available = barHeight.value - coreHeight.value - fixedHeight.value - actionHeight.value - 16
|
||||
const priority: CollapsibleItem[] = ['paste', 'settings', 'msd', 'atx', 'stats', 'terminal', 'ai']
|
||||
for (const id of priority) {
|
||||
if (!collapsibleItems.value.some(item => item.id === id)) continue
|
||||
if (available < actionHeight.value) break
|
||||
result.set(id, 'icon')
|
||||
available -= actionHeight.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const available = barWidth.value - alwaysRightWidth.value - Math.max(OVERFLOW_BUTTON_BUDGET_PX, actionHeight.value) - (isFloatingLayout.value ? 48 : 12)
|
||||
let used = coreWidth.value
|
||||
// Keep actions reachable before spending the remaining space on labels.
|
||||
for (const item of collapsibleItems.value) {
|
||||
const widths = measuredWidths.value.get(item.id)
|
||||
if (!widths) continue
|
||||
|
||||
if (used + widths.icon <= available) {
|
||||
if (used + widths.label <= available) {
|
||||
result.set(item.id, 'label')
|
||||
used += widths.label
|
||||
} else {
|
||||
result.set(item.id, 'icon')
|
||||
used += widths.icon
|
||||
}
|
||||
if (!widths || used + widths.icon > available) continue
|
||||
result.set(item.id, 'icon')
|
||||
used += widths.icon
|
||||
}
|
||||
if (isFloatingLayout.value && barWidth.value < 640) return result
|
||||
for (const item of collapsibleItems.value) {
|
||||
const widths = measuredWidths.value.get(item.id)
|
||||
if (!widths || !result.has(item.id)) continue
|
||||
const extra = widths.label - widths.icon
|
||||
if (used + extra <= available) {
|
||||
result.set(item.id, 'label')
|
||||
used += extra
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -304,36 +343,87 @@ const hasRightOverflow = computed(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full border-b bg-background">
|
||||
<div ref="barRef" class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
|
||||
<div
|
||||
:class="[
|
||||
'console-action-bar bg-background',
|
||||
props.layout === 'floating' && 'console-action-bar--floating',
|
||||
isFloatingLayout && floatingCollapsed && 'console-action-bar--collapsed',
|
||||
props.layout === 'sidebar' && 'console-action-bar--sidebar',
|
||||
(!props.layout || props.layout === 'current') && 'w-full border-b',
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
v-if="isFloatingLayout && floatingCollapsed"
|
||||
ref="expandButtonRef"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="console-action-bar__expand gap-1.5 rounded-xl text-xs"
|
||||
:aria-label="t('actionbar.expandToolbar')"
|
||||
:aria-expanded="false"
|
||||
@click="setFloatingCollapsed(false)"
|
||||
>
|
||||
<ChevronDown class="size-4" />{{ t('actionbar.expandToolbar') }}
|
||||
</Button>
|
||||
<div
|
||||
v-show="!isFloatingLayout || !floatingCollapsed"
|
||||
ref="barRef"
|
||||
class="console-action-bar__inner flex items-center"
|
||||
:class="isSidebarLayout
|
||||
? 'h-full flex-col px-1 py-2 sm:px-1.5'
|
||||
: 'px-2 py-1 sm:px-4 sm:py-1.5'"
|
||||
>
|
||||
<!-- Left side buttons -->
|
||||
<ButtonGroup class="left-buttons flex-1 min-w-0 overflow-hidden">
|
||||
<ButtonGroup
|
||||
class="left-buttons min-w-0"
|
||||
:class="isSidebarLayout
|
||||
? 'flex-none flex-col overflow-visible'
|
||||
: 'flex-1 overflow-hidden'"
|
||||
:orientation="isSidebarLayout ? 'vertical' : 'horizontal'"
|
||||
>
|
||||
<!-- Video Config - Always visible -->
|
||||
<VideoConfigPopover
|
||||
v-model:open="videoPopoverOpen"
|
||||
:video-mode="props.videoMode || 'mjpeg'"
|
||||
@update:video-mode="emit('update:videoMode', $event)"
|
||||
/>
|
||||
<div data-core-action class="flex shrink-0">
|
||||
<VideoConfigPopover
|
||||
v-model:open="videoPopoverOpen"
|
||||
:video-mode="props.videoMode || 'mjpeg'"
|
||||
:video-rotation="props.videoRotation ?? 0"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
@update:video-mode="emit('update:videoMode', $event)"
|
||||
@update:video-rotation="emit('update:videoRotation', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Audio Config - Always visible -->
|
||||
<AudioConfigPopover
|
||||
v-model:open="audioPopoverOpen"
|
||||
:microphone-enabled="showMic"
|
||||
/>
|
||||
<div data-core-action class="flex shrink-0">
|
||||
<AudioConfigPopover
|
||||
v-model:open="audioPopoverOpen"
|
||||
:microphone-enabled="showMic"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- HID Config - Always visible -->
|
||||
<HidConfigPopover
|
||||
v-model:open="hidPopoverOpen"
|
||||
:mouse-mode="mouseMode"
|
||||
@update:mouse-mode="emit('toggleMouseMode')"
|
||||
/>
|
||||
<div data-core-action class="flex shrink-0">
|
||||
<HidConfigPopover
|
||||
v-model:open="hidPopoverOpen"
|
||||
:mouse-mode="mouseMode"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
@update:mouse-mode="emit('toggleMouseMode')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Media (MSD) - Adaptive -->
|
||||
<div v-if="showMsd && isVisible('msd')">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('actionbar.virtualMedia')"
|
||||
:title="t('actionbar.virtualMedia')"
|
||||
@click="msdDialogOpen = true"
|
||||
>
|
||||
<HardDrive class="size-4" />
|
||||
<span v-if="visibleSet.get('msd') === 'label'">{{ t('actionbar.virtualMedia') }}</span>
|
||||
</Button>
|
||||
@@ -349,13 +439,24 @@ const hasRightOverflow = computed(() => {
|
||||
<div v-if="showAtx && isVisible('atx')">
|
||||
<Popover v-model:open="atxOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('actionbar.power')"
|
||||
:title="t('actionbar.power')"
|
||||
>
|
||||
<Power class="size-4" />
|
||||
<span v-if="visibleSet.get('atx') === 'label'">{{ t('actionbar.power') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(280px,90vw)] p-0" align="start">
|
||||
<PopoverContent
|
||||
class="w-[min(280px,90vw)] p-0"
|
||||
align="start"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
>
|
||||
<AtxPopover
|
||||
:atx-enabled="atxEnabled"
|
||||
@close="atxOpen = false"
|
||||
@power-short="emit('powerShort')"
|
||||
@power-long="emit('powerLong')"
|
||||
@@ -370,12 +471,24 @@ const hasRightOverflow = computed(() => {
|
||||
<div v-if="showPasteText && isVisible('paste')">
|
||||
<Popover v-model:open="pasteOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('actionbar.paste')"
|
||||
:title="t('actionbar.paste')"
|
||||
>
|
||||
<ClipboardPaste class="size-4" />
|
||||
<span v-if="visibleSet.get('paste') === 'label'">{{ t('actionbar.paste') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(400px,90vw)] p-0" align="start">
|
||||
<PopoverContent
|
||||
:class="isSidebarLayout
|
||||
? 'w-[min(400px,calc(100vw-4.5rem))] p-0'
|
||||
: 'w-[min(400px,90vw)] p-0'"
|
||||
align="start"
|
||||
:side="isSidebarLayout ? 'right' : 'bottom'"
|
||||
>
|
||||
<PasteModal v-if="pasteOpen" @close="pasteOpen = false" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -384,8 +497,16 @@ const hasRightOverflow = computed(() => {
|
||||
</ButtonGroup>
|
||||
|
||||
<!-- Right side buttons -->
|
||||
<ButtonGroup class="shrink-0 ml-1 sm:ml-2">
|
||||
<ButtonGroup
|
||||
class="shrink-0"
|
||||
:class="isSidebarLayout
|
||||
? 'mt-auto flex-none flex-col'
|
||||
: 'ml-1 sm:ml-2'"
|
||||
:orientation="isSidebarLayout ? 'vertical' : 'horizontal'"
|
||||
>
|
||||
<VideoDisplayControls
|
||||
:minimal="minimalDisplayControls"
|
||||
:text-only-scale="isSidebarLayout"
|
||||
:scale-mode="props.scaleMode"
|
||||
:source-size-available="props.sourceSizeAvailable"
|
||||
@toggle-fullscreen="emit('toggleFullscreen')"
|
||||
@@ -398,7 +519,7 @@ const hasRightOverflow = computed(() => {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.stats')" @click="emit('toggleStats')">
|
||||
<BarChart3 class="size-4" />
|
||||
<span v-if="visibleSet.get('stats') === 'label'">{{ t('actionbar.stats') }}</span>
|
||||
</Button>
|
||||
@@ -420,6 +541,7 @@ const hasRightOverflow = computed(() => {
|
||||
size="sm"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:disabled="!props.ttydRunning"
|
||||
:aria-label="t('actionbar.webTerminal')"
|
||||
@click="emit('openTerminal')"
|
||||
>
|
||||
<Terminal class="size-4" />
|
||||
@@ -434,18 +556,18 @@ const hasRightOverflow = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Computer Use - Optional -->
|
||||
<TooltipProvider v-if="props.showComputerUse !== false">
|
||||
<TooltipProvider v-if="isVisible('ai')">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
data-fixed-action
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||
class="h-8 gap-1.5 text-xs"
|
||||
:aria-label="t('computerUse.title')"
|
||||
@click="emit('openComputerUse')"
|
||||
>
|
||||
<Bot class="size-3.5 sm:size-4" />
|
||||
<span class="hidden xl:inline">AI</span>
|
||||
<span v-if="visibleSet.get('ai') === 'label'">AI</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -459,7 +581,7 @@ const hasRightOverflow = computed(() => {
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" :aria-label="t('actionbar.settings')" @click="router.push('/settings')">
|
||||
<Settings class="size-4" />
|
||||
<span v-if="visibleSet.get('settings') === 'label'">{{ t('actionbar.settings') }}</span>
|
||||
</Button>
|
||||
@@ -472,13 +594,25 @@ const hasRightOverflow = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Overflow Menu - Only show if there are overflowed items -->
|
||||
<DropdownMenu v-if="hasOverflow" v-model:open="overflowMenuOpen">
|
||||
<DropdownMenu v-if="hasOverflow || minimalDisplayControls" v-model:open="overflowMenuOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="size-8 p-0">
|
||||
<Button variant="ghost" size="sm" class="size-8 p-0" :aria-label="t('actionbar.more')" :title="t('actionbar.more')">
|
||||
<MoreHorizontal class="size-3.5 sm:size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-48">
|
||||
<DropdownMenuContent align="end" :side="isSidebarLayout ? 'right' : 'bottom'" class="w-56 max-h-[70dvh] overflow-y-auto">
|
||||
<template v-if="minimalDisplayControls">
|
||||
<DropdownMenuItem @click="openFromOverflow(() => emit('toggleVirtualKeyboard'))">
|
||||
<Keyboard class="size-4 mr-2" />{{ t('actionbar.keyboard') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
:disabled="!props.sourceSizeAvailable"
|
||||
@click="emit('update:scaleMode', props.scaleMode === 'actual' ? 'fit' : 'actual')"
|
||||
>
|
||||
<Scaling class="size-4 mr-2" />{{ t(props.scaleMode === 'actual' ? 'actionbar.fitSizeAria' : 'actionbar.actualSizeAria') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</template>
|
||||
<!-- MSD -->
|
||||
<DropdownMenuItem v-if="showMsd && !isVisible('msd')" @click="openFromOverflow(() => msdDialogOpen = true)">
|
||||
<HardDrive class="size-4 mr-2" />
|
||||
@@ -500,14 +634,14 @@ const hasRightOverflow = computed(() => {
|
||||
<DropdownMenuSeparator v-if="hasLeftOverflow && hasRightOverflow" />
|
||||
|
||||
<!-- Stats -->
|
||||
<DropdownMenuItem v-if="showStats && !isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
<DropdownMenuItem v-if="!isFloatingLayout && showStats && !isVisible('stats')" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||
<BarChart3 class="size-4 mr-2" />
|
||||
{{ t('actionbar.stats') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Web Terminal -->
|
||||
<DropdownMenuItem
|
||||
v-if="props.showTerminal !== false && !isVisible('terminal')"
|
||||
v-if="!isFloatingLayout && props.showTerminal !== false && !isVisible('terminal')"
|
||||
:disabled="!props.ttydRunning"
|
||||
@click="openFromOverflow(() => emit('openTerminal'))"
|
||||
>
|
||||
@@ -515,13 +649,30 @@ const hasRightOverflow = computed(() => {
|
||||
{{ t('actionbar.webTerminal') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem v-if="!isFloatingLayout && props.showComputerUse !== false && !isVisible('ai')" @click="openFromOverflow(() => emit('openComputerUse'))">
|
||||
<Bot class="size-4 mr-2" />{{ t('computerUse.title') }}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<!-- Settings -->
|
||||
<DropdownMenuItem v-if="!isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
|
||||
<DropdownMenuItem v-if="!isFloatingLayout && !isVisible('settings')" @click="openFromOverflow(() => router.push('/settings'))">
|
||||
<Settings class="size-4 mr-2" />
|
||||
{{ t('actionbar.settings') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
v-if="isFloatingLayout"
|
||||
ref="collapseButtonRef"
|
||||
data-fixed-action
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:aria-label="t('actionbar.collapseToolbar')"
|
||||
:title="t('actionbar.collapseToolbar')"
|
||||
:aria-expanded="true"
|
||||
@click="setFloatingCollapsed(true)"
|
||||
>
|
||||
<ChevronUp class="size-4" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
@@ -542,6 +693,7 @@ const hasRightOverflow = computed(() => {
|
||||
<SheetTitle>{{ t('actionbar.power') }}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<AtxPopover
|
||||
:atx-enabled="atxEnabled"
|
||||
@close="mobileAtxOpen = false"
|
||||
@power-short="emit('powerShort')"
|
||||
@power-long="emit('powerLong')"
|
||||
@@ -588,13 +740,131 @@ const hasRightOverflow = computed(() => {
|
||||
<!-- Settings -->
|
||||
<Button data-measure="settings-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" /></Button>
|
||||
<Button data-measure="settings-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Settings class="size-4" />{{ t('actionbar.settings') }}</Button>
|
||||
<!-- Always-visible items (for measuring their actual width) -->
|
||||
<Button data-measure="video-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="video-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="audio-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="audio-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="hid-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="hid-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><HardDrive class="size-4" /></Button>
|
||||
<Button data-measure="ai-icon" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" /></Button>
|
||||
<Button data-measure="ai-label" variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"><Bot class="size-4" />AI</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.console-action-bar--floating {
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
background: color-mix(in srgb, var(--background) 96%, transparent);
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 8%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: 2.5rem;
|
||||
bottom: 1.75rem;
|
||||
left: 0;
|
||||
width: 3.5rem;
|
||||
border-right: 1px solid var(--border);
|
||||
background: color-mix(in srgb, var(--background) 94%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep([data-slot='button-group']) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep(button) {
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
padding-inline: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep(button > span) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar :deep([data-fixed-action][aria-hidden='true']) {
|
||||
width: 1.5rem;
|
||||
height: 1px;
|
||||
margin: 0.35rem auto;
|
||||
}
|
||||
|
||||
.console-action-bar--sidebar .console-action-bar__inner {
|
||||
overflow-y: auto;
|
||||
gap: 1rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
padding: 4px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.console-action-bar--floating :deep([data-slot='button-group']) {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||
min-width: 36px;
|
||||
height: 38px;
|
||||
padding-inline: 10px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button[aria-expanded='true']) {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
padding-inline: 4px;
|
||||
gap: 0;
|
||||
}
|
||||
.console-action-bar--floating :deep([data-slot='button-group']) {
|
||||
gap: 0;
|
||||
}
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||
width: 36px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.console-action-bar--floating.console-action-bar--collapsed {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.console-action-bar__expand {
|
||||
min-height: 44px;
|
||||
}
|
||||
.console-action-bar--sidebar :deep(button) {
|
||||
height: 44px;
|
||||
}
|
||||
.console-action-bar--floating .console-action-bar__inner :deep(button) {
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.console-action-bar--floating .console-action-bar__inner {
|
||||
padding-inline: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.console-action-bar--sidebar {
|
||||
top: 3.5rem;
|
||||
width: 4rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,9 +27,17 @@ const emit = defineEmits<{
|
||||
(e: 'wol', macAddress: string): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Whether a hardware ATX controller is configured and available. */
|
||||
atxEnabled?: boolean
|
||||
}>(), {
|
||||
atxEnabled: false,
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const activeTab = ref('atx')
|
||||
const activeTab = ref(props.atxEnabled ? 'atx' : 'wol')
|
||||
const showAtxControls = computed(() => props.atxEnabled)
|
||||
const tabTriggerClass = 'h-8 rounded-md border-0 bg-transparent text-center text-xs text-muted-foreground shadow-none hover:text-foreground data-[state=active]:border-0 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm'
|
||||
|
||||
const powerState = ref<'on' | 'off' | 'unknown'>('unknown')
|
||||
@@ -194,12 +202,17 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(showAtxControls, (enabled) => {
|
||||
// A disabled ATX controller must never leave the hidden ATX tab selected.
|
||||
if (!enabled) activeTab.value = 'wol'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-2.5 space-y-2.5">
|
||||
<Tabs v-model="activeTab">
|
||||
<TabsList class="grid h-auto w-full grid-cols-2 gap-1 rounded-md border border-border bg-muted p-0.5">
|
||||
<TabsList v-if="showAtxControls" class="grid h-auto w-full grid-cols-2 gap-1 rounded-md border border-border bg-muted p-0.5">
|
||||
<TabsTrigger
|
||||
value="atx"
|
||||
:class="tabTriggerClass"
|
||||
@@ -217,7 +230,7 @@ watch(
|
||||
</TabsList>
|
||||
|
||||
<!-- ATX Tab -->
|
||||
<TabsContent value="atx" class="mt-2.5 space-y-2.5">
|
||||
<TabsContent v-if="showAtxControls" value="atx" class="mt-2.5 space-y-2.5">
|
||||
<!-- Status -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2 rounded-md border bg-muted/40 px-2 py-1.5">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Loader2, RefreshCw, Volume2 } from 'lucide-vue-next'
|
||||
import { Loader2, RefreshCw, Volume2, VolumeX } from 'lucide-vue-next'
|
||||
|
||||
import { audioApi, configApi } from '@/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -29,6 +30,7 @@ interface AudioDevice {
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
microphoneEnabled?: boolean
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -40,6 +42,7 @@ const configStore = useConfigStore()
|
||||
const systemStore = useSystemStore()
|
||||
const unifiedAudio = getUnifiedAudio()
|
||||
const microphone = getMicrophone()
|
||||
const playbackMuted = computed(() => unifiedAudio.muted.value || unifiedAudio.volume.value === 0)
|
||||
|
||||
const localVolume = ref([unifiedAudio.volume.value * 100])
|
||||
const devices = ref<AudioDevice[]>([])
|
||||
@@ -150,13 +153,21 @@ onUnmounted(() => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 p-0 text-xs sm:w-auto sm:gap-1.5 sm:px-2"
|
||||
:aria-label="playbackMuted ? `${t('actionbar.audioConfig')} · ${t('actionbar.muted')}` : t('actionbar.audioConfig')"
|
||||
:title="playbackMuted ? `${t('actionbar.audioConfig')} · ${t('actionbar.muted')}` : t('actionbar.audioConfig')"
|
||||
>
|
||||
<Volume2 class="size-3.5 sm:size-4" />
|
||||
<VolumeX v-if="playbackMuted" class="size-3.5 sm:size-4" />
|
||||
<Volume2 v-else class="size-3.5 sm:size-4" />
|
||||
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||
<PopoverContent
|
||||
@open-auto-focus="focusConsolePanel"
|
||||
class="console-config-panel w-[min(320px,92vw)] p-3"
|
||||
align="start"
|
||||
:side="props.side ?? 'bottom'"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
|
||||
|
||||
|
||||
28
web/src/components/BluetoothHidSettings.vue
Normal file
28
web/src/components/BluetoothHidSettings.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { request } from '@/api/request'
|
||||
import type { BluetoothStatus } from '@/types/bluetooth'
|
||||
defineProps<{ bluetooth: BluetoothStatus | null }>()
|
||||
const emit = defineEmits<{ reconfigure: []; refresh: [] }>()
|
||||
const { t } = useI18n()
|
||||
const busy = ref(false), error = ref('')
|
||||
async function action(action: string) {
|
||||
busy.value = true; error.value = ''
|
||||
try { await request('/hid/bluetooth', { method: 'POST', body: JSON.stringify({ action, seconds: 120 }) }) }
|
||||
catch (e) { error.value = e instanceof Error ? e.message : String(e) }
|
||||
finally { busy.value = false; emit('refresh') }
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button size="sm" :disabled="busy || !bluetooth?.initialized || bluetooth?.connected" @click="action('pair')">{{ t('bluetoothHid.openPairing') }}</Button>
|
||||
<Button v-if="bluetooth?.pairing_seconds" size="sm" variant="outline" :disabled="busy" @click="action('close')">{{ t('bluetoothHid.closePairing') }}</Button>
|
||||
<Button size="sm" variant="outline" :disabled="busy || !bluetooth?.connected" @click="action('disconnect')">{{ t('bluetoothHid.disconnect') }}</Button>
|
||||
<Button size="sm" variant="outline" :disabled="busy" @click="emit('reconfigure')">{{ t('hidGuide.repair') }}</Button>
|
||||
</div>
|
||||
<p v-if="error" role="alert" class="text-sm text-destructive">{{ error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
@@ -215,6 +217,8 @@ onMounted(loadConfig)
|
||||
|
||||
<template>
|
||||
<aside
|
||||
data-slot="computer-use-panel"
|
||||
:data-console-layout="consoleAppearance"
|
||||
v-show="open"
|
||||
class="absolute inset-y-0 right-0 z-30 h-full min-h-0 w-full border-l bg-background shadow-xl sm:w-[420px] md:relative md:z-auto xl:w-[460px]"
|
||||
>
|
||||
|
||||
56
web/src/components/ConsoleHeaderActions.vue
Normal file
56
web/src/components/ConsoleHeaderActions.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { BarChart3, Bot, Settings, Terminal, MoreHorizontal } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
|
||||
defineProps<{
|
||||
showStats: boolean
|
||||
showTerminal: boolean
|
||||
terminalRunning: boolean
|
||||
showComputerUse: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'openStats'): void
|
||||
(e: 'openTerminal'): void
|
||||
(e: 'openComputerUse'): void
|
||||
}>()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const menuOpen = ref(false)
|
||||
function openFromMenu(action: () => void) {
|
||||
menuOpen.value = false
|
||||
window.setTimeout(action, 50)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="console-header-actions flex shrink-0 items-center gap-1">
|
||||
<div class="hidden items-center gap-1 md:flex">
|
||||
<Button v-if="showStats" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.stats')" :title="t('actionbar.stats')" @click="emit('openStats')">
|
||||
<BarChart3 class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.stats') }}</span>
|
||||
</Button>
|
||||
<Button v-if="showTerminal" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('actionbar.webTerminal')" :title="t('actionbar.webTerminal')" :disabled="!terminalRunning" @click="emit('openTerminal')">
|
||||
<Terminal class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.webTerminal') }}</span>
|
||||
</Button>
|
||||
<Button v-if="showComputerUse" variant="ghost" size="sm" class="gap-1.5 rounded-lg text-xs" :aria-label="t('computerUse.title')" :title="t('computerUse.title')" @click="emit('openComputerUse')">
|
||||
<Bot class="size-4" /><span class="hidden lg:inline">AI</span>
|
||||
</Button>
|
||||
</div>
|
||||
<DropdownMenu v-if="showStats || showTerminal || showComputerUse" v-model:open="menuOpen">
|
||||
<DropdownMenuTrigger as-child class="md:hidden">
|
||||
<Button variant="ghost" size="icon-sm" :aria-label="t('actionbar.more')"><MoreHorizontal class="size-4" /></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" class="w-48">
|
||||
<DropdownMenuItem v-if="showStats" @select="openFromMenu(() => emit('openStats'))"><BarChart3 class="size-4" />{{ t('actionbar.stats') }}</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="showTerminal" :disabled="!terminalRunning" @select="openFromMenu(() => emit('openTerminal'))"><Terminal class="size-4" />{{ t('actionbar.webTerminal') }}</DropdownMenuItem>
|
||||
<DropdownMenuItem v-if="showComputerUse" @select="openFromMenu(() => emit('openComputerUse'))"><Bot class="size-4" />{{ t('computerUse.title') }}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="ghost" size="sm" class="gap-1.5 rounded-lg px-2 text-xs" :aria-label="t('actionbar.settings')" :title="t('actionbar.settings')" @click="router.push('/settings')">
|
||||
<Settings class="size-4" /><span class="hidden lg:inline">{{ t('actionbar.settings') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
82
web/src/components/ConsoleLayoutPreview.vue
Normal file
82
web/src/components/ConsoleLayoutPreview.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||
|
||||
defineProps<{ layout: ConsoleLayout }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 240 96"
|
||||
class="block h-24 w-full rounded-md border bg-background text-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient :id="`console-preview-${layout}`" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="currentColor" stop-opacity=".08" />
|
||||
<stop offset="1" stop-color="currentColor" stop-opacity=".02" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x=".5" y=".5" width="239" height="95" rx="7" fill="currentColor" opacity=".035" />
|
||||
<path d="M1 7.5A7 7 0 0 1 8 .5h224a7 7 0 0 1 7 7" fill="none" stroke="currentColor" opacity=".12" />
|
||||
<circle cx="10" cy="7" r="2" fill="#ef4444" opacity=".75" />
|
||||
<circle cx="17" cy="7" r="2" fill="#f59e0b" opacity=".75" />
|
||||
<circle cx="24" cy="7" r="2" fill="#22c55e" opacity=".75" />
|
||||
|
||||
<rect
|
||||
:x="layout === 'sidebar' ? 32 : 8"
|
||||
y="14"
|
||||
:width="layout === 'sidebar' ? 200 : 224"
|
||||
height="74"
|
||||
rx="4"
|
||||
fill="#09090b"
|
||||
/>
|
||||
<path
|
||||
:d="layout === 'sidebar' ? 'M46 82 93 36l31 28 25-21 38 39Z' : 'M24 82 80 36l36 29 28-22 48 39Z'"
|
||||
fill="none"
|
||||
stroke="#71717a"
|
||||
stroke-width="1"
|
||||
opacity=".3"
|
||||
/>
|
||||
<rect
|
||||
:x="layout === 'sidebar' ? 40 : 16"
|
||||
y="22"
|
||||
:width="layout === 'sidebar' ? 184 : 208"
|
||||
height="58"
|
||||
rx="2"
|
||||
:fill="`url(#console-preview-${layout})`"
|
||||
/>
|
||||
|
||||
<g v-if="layout === 'current'">
|
||||
<rect x="8" y="14" width="224" height="16" rx="4" fill="var(--background)" />
|
||||
<path d="M8 26h224" stroke="currentColor" opacity=".12" />
|
||||
<circle cx="18" cy="22" r="3" fill="none" stroke="currentColor" opacity=".45" />
|
||||
<path d="M26 20h28m-28 4h18" stroke="currentColor" stroke-width="1.5" opacity=".34" />
|
||||
<g fill="none" stroke="currentColor" opacity=".5">
|
||||
<rect x="139" y="18" width="14" height="8" rx="2" />
|
||||
<path d="m160 19 4 3-4 3m8-6 4 3-4 3m10-6v6m7-6 5 6 5-6" />
|
||||
<rect x="202" y="18" width="20" height="8" rx="4" />
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g v-else-if="layout === 'floating'">
|
||||
<rect x="50" y="19" width="140" height="18" rx="9" fill="var(--background)" stroke="currentColor" stroke-opacity=".2" />
|
||||
<circle cx="62" cy="28" r="4" fill="none" stroke="currentColor" opacity=".45" />
|
||||
<path d="M72 26h26m-26 4h18m18-6v8m10-8 6 8 6-8m10 1 6 6 6-6m11-1v8" fill="none" stroke="currentColor" opacity=".5" />
|
||||
<rect x="168" y="24" width="14" height="8" rx="4" fill="currentColor" opacity=".12" />
|
||||
<circle cx="177" cy="28" r="2" fill="#22c55e" />
|
||||
<path d="m114 84 6-4 6 4" fill="none" stroke="white" opacity=".45" />
|
||||
</g>
|
||||
|
||||
<g v-else>
|
||||
<rect x="8" y="14" width="24" height="74" rx="4" fill="var(--background)" />
|
||||
<path d="M28 14v74" stroke="currentColor" opacity=".12" />
|
||||
<circle cx="20" cy="24" r="4" fill="none" stroke="currentColor" opacity=".5" />
|
||||
<g fill="none" stroke="currentColor" opacity=".52">
|
||||
<rect x="15" y="35" width="10" height="7" rx="2" />
|
||||
<path d="m16 52 4-4 4 4-4 4Zm0 11h8m-4-4v8" />
|
||||
</g>
|
||||
<circle cx="20" cy="81" r="2" fill="#22c55e" />
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
87
web/src/components/ConsoleStatusSummary.vue
Normal file
87
web/src/components/ConsoleStatusSummary.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronDown, AlertCircle, Info } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import type { StatusDetail, ConnectionStatus } from '@/components/StatusCard.vue'
|
||||
|
||||
export interface ConsoleStatusItem {
|
||||
id: string
|
||||
title: string
|
||||
status: ConnectionStatus
|
||||
quickInfo: string
|
||||
details: StatusDetail[]
|
||||
errorMessage?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
items: ConsoleStatusItem[]
|
||||
showVideoInfo?: boolean
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const issues = computed(() => props.items.filter(item =>
|
||||
['error', 'no_signal', 'busy'].includes(item.status) || (item.required && item.status !== 'connected'),
|
||||
))
|
||||
const summary = computed(() => issues.value.length
|
||||
? issues.value.map(item => `${item.title}: ${t(`status.${item.status}`)}`).join(' · ')
|
||||
: t('status.connected'))
|
||||
const videoInfo = computed(() => props.items.find(item => item.id === 'video')?.quickInfo)
|
||||
const hasError = computed(() => issues.value.some(item => item.status === 'error'))
|
||||
|
||||
function dotClass(status: ConsoleStatusItem['status']) {
|
||||
return {
|
||||
connected: 'bg-status-active',
|
||||
connecting: 'bg-warning animate-pulse',
|
||||
disconnected: 'bg-muted-foreground',
|
||||
error: 'bg-destructive',
|
||||
no_signal: 'bg-warning',
|
||||
busy: 'bg-warning',
|
||||
}[status]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="min-w-0 shrink gap-1.5 px-2 text-xs"
|
||||
:class="hasError ? 'text-destructive' : issues.length ? 'text-warning' : ''"
|
||||
:aria-label="`${t('statusCard.connectionDetails')}: ${summary}`"
|
||||
:title="summary"
|
||||
>
|
||||
<AlertCircle v-if="hasError" class="size-3.5 shrink-0" />
|
||||
<Info v-else-if="issues.length" class="size-3.5 shrink-0" />
|
||||
<span v-else class="size-2 shrink-0 rounded-full bg-status-active" />
|
||||
<span class="truncate" role="status">{{ summary }}</span>
|
||||
<span v-if="showVideoInfo && !issues.length" class="hidden text-muted-foreground md:inline">{{ videoInfo }}</span>
|
||||
<ChevronDown class="size-3 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-[min(360px,calc(100vw-1rem))] max-h-[70dvh] overflow-y-auto">
|
||||
<h2 class="mb-3 text-sm font-semibold">{{ t('statusCard.connectionDetails') }}</h2>
|
||||
<div class="divide-y">
|
||||
<section v-for="item in items" :key="item.id" class="space-y-2 py-3 first:pt-0 last:pb-0">
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="font-medium">{{ item.title }}</span>
|
||||
<span class="flex items-center gap-1.5 text-xs">
|
||||
<span class="size-1.5 rounded-full" :class="dotClass(item.status)" />
|
||||
{{ t(`status.${item.status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="item.quickInfo" class="text-xs text-muted-foreground">{{ item.quickInfo }}</p>
|
||||
<p v-if="item.errorMessage" class="break-words text-xs" :class="item.status === 'error' ? 'text-destructive' : 'text-muted-foreground'">{{ item.errorMessage }}</p>
|
||||
<dl class="space-y-1 text-xs">
|
||||
<div v-for="(detail, index) in item.details" :key="index" class="flex justify-between gap-4">
|
||||
<dt class="shrink-0 text-muted-foreground">{{ detail.label }}</dt>
|
||||
<dd class="min-w-0 break-words text-right" :class="detail.status === 'error' ? 'text-destructive' : detail.status === 'warning' ? 'text-warning' : ''">{{ detail.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -12,23 +13,18 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { MousePointer, Move, Loader2, RefreshCw } from 'lucide-vue-next'
|
||||
import { MousePointer, Move } from 'lucide-vue-next'
|
||||
import HelpTooltip from '@/components/HelpTooltip.vue'
|
||||
import { configApi } from '@/api'
|
||||
import HidDeviceOverview from '@/components/HidDeviceOverview.vue'
|
||||
import HidDriverDialog from '@/components/HidDriverDialog.vue'
|
||||
import { useHidConnection } from '@/composables/useHidConnection'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { HidBackend } from '@/types/generated'
|
||||
import type { HidConfigUpdate } from '@/types/generated'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
mouseMode?: 'absolute' | 'relative'
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -70,70 +66,17 @@ watch(showCursor, (newValue, oldValue) => {
|
||||
}
|
||||
})
|
||||
|
||||
// HID Device Settings (requires apply)
|
||||
const hidBackend = ref<HidBackend>(HidBackend.None)
|
||||
const devicePath = ref<string>('')
|
||||
const baudrate = ref<number>(9600)
|
||||
|
||||
const applying = ref(false)
|
||||
const loadingDevices = ref(false)
|
||||
|
||||
// Device lists
|
||||
const serialDevices = ref<Array<{ path: string; name: string }>>([])
|
||||
const udcDevices = ref<Array<{ name: string }>>([])
|
||||
|
||||
const guideOpen = ref(false)
|
||||
const buttonText = computed(() => t('actionbar.hidConfig'))
|
||||
|
||||
// Available device paths based on backend type
|
||||
const availableDevicePaths = computed(() => {
|
||||
if (hidBackend.value === HidBackend.Ch9329) {
|
||||
return serialDevices.value
|
||||
} else if (hidBackend.value === HidBackend.Otg) {
|
||||
// For OTG, we show UDC devices
|
||||
return udcDevices.value.map(udc => ({
|
||||
path: udc.name,
|
||||
name: udc.name,
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Load devices
|
||||
async function loadDevices() {
|
||||
loadingDevices.value = true
|
||||
try {
|
||||
const result = await configApi.listDevices()
|
||||
serialDevices.value = result.serial
|
||||
udcDevices.value = result.udc
|
||||
} catch (e) {
|
||||
console.info('[HidConfig] Failed to load devices')
|
||||
} finally {
|
||||
loadingDevices.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFromCurrent() {
|
||||
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
|
||||
|
||||
const storedCursor = localStorage.getItem('hidShowCursor') !== 'false'
|
||||
showCursor.value = storedCursor
|
||||
|
||||
// Initialize HID device settings from system state
|
||||
const hid = configStore.hid
|
||||
if (hid) {
|
||||
hidBackend.value = hid.backend || HidBackend.None
|
||||
if (hidBackend.value === HidBackend.Ch9329) {
|
||||
devicePath.value = hid.ch9329_port || ''
|
||||
baudrate.value = hid.ch9329_baudrate || 9600
|
||||
} else if (hidBackend.value === HidBackend.Otg) {
|
||||
devicePath.value = hid.otg_udc || ''
|
||||
} else {
|
||||
devicePath.value = ''
|
||||
}
|
||||
}
|
||||
const { status, bluetooth, error } = useHidConnection(computed(() => props.open && !guideOpen.value), computed(() => configStore.hid?.backend))
|
||||
async function configure() {
|
||||
emit('update:open', false)
|
||||
await nextTick()
|
||||
guideOpen.value = true
|
||||
}
|
||||
|
||||
function toggleMouseMode() {
|
||||
if (configStore.hid?.backend === HidBackend.Bluetooth) return
|
||||
const newMode = props.mouseMode === 'absolute' ? 'relative' : 'absolute'
|
||||
emit('update:mouseMode', newMode)
|
||||
|
||||
@@ -156,88 +99,35 @@ function handleThrottleChange(value: number[] | undefined) {
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle backend change
|
||||
function handleBackendChange(backend: unknown) {
|
||||
if (typeof backend !== 'string') return
|
||||
if (backend === HidBackend.Otg || backend === HidBackend.Ch9329 || backend === HidBackend.None) {
|
||||
hidBackend.value = backend
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
// Clear device path when changing backend
|
||||
devicePath.value = ''
|
||||
|
||||
// Auto-select first device if available
|
||||
if (availableDevicePaths.value.length > 0 && availableDevicePaths.value[0]) {
|
||||
devicePath.value = availableDevicePaths.value[0].path
|
||||
}
|
||||
}
|
||||
|
||||
// Handle device path change
|
||||
function handleDevicePathChange(path: unknown) {
|
||||
if (typeof path !== 'string') return
|
||||
devicePath.value = path
|
||||
}
|
||||
|
||||
function handleBaudrateChange(rate: unknown) {
|
||||
if (typeof rate !== 'string') return
|
||||
baudrate.value = Number(rate)
|
||||
}
|
||||
|
||||
// Apply HID device configuration
|
||||
async function applyHidConfig() {
|
||||
applying.value = true
|
||||
try {
|
||||
const config: HidConfigUpdate = {
|
||||
backend: hidBackend.value,
|
||||
}
|
||||
|
||||
if (hidBackend.value === HidBackend.Ch9329) {
|
||||
config.ch9329_port = devicePath.value
|
||||
config.ch9329_baudrate = baudrate.value
|
||||
} else if (hidBackend.value === HidBackend.Otg) {
|
||||
config.otg_udc = devicePath.value
|
||||
}
|
||||
|
||||
await configStore.updateHid(config)
|
||||
|
||||
// HID state will be updated via WebSocket device_info event
|
||||
} catch (e) {
|
||||
console.info('[HidConfig] Failed to apply config:', e)
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (!isOpen) return
|
||||
|
||||
// Load devices on first open
|
||||
if (serialDevices.value.length === 0) {
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
configStore.refreshHid()
|
||||
.then(() => {
|
||||
initializeFromCurrent()
|
||||
})
|
||||
.catch(() => {
|
||||
initializeFromCurrent()
|
||||
})
|
||||
watch(() => props.open, (open) => {
|
||||
if (!open) return
|
||||
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
|
||||
showCursor.value = localStorage.getItem('hidShowCursor') !== 'false'
|
||||
void configStore.refreshHid().catch(() => undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||
:aria-label="buttonText"
|
||||
:title="buttonText"
|
||||
>
|
||||
<MousePointer v-if="mouseMode === 'absolute'" class="size-3.5 sm:size-4" />
|
||||
<Move v-else class="size-3.5 sm:size-4" />
|
||||
<span class="hidden sm:inline">{{ buttonText }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||
<PopoverContent
|
||||
@open-auto-focus="focusConsolePanel"
|
||||
class="console-config-panel w-[min(320px,92vw)] p-3"
|
||||
align="start"
|
||||
:side="props.side ?? 'bottom'"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>
|
||||
|
||||
@@ -256,6 +146,7 @@ watch(() => props.open, (isOpen) => {
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
:variant="mouseMode === 'absolute' ? 'default' : 'outline'"
|
||||
:disabled="configStore.hid?.backend === HidBackend.Bluetooth"
|
||||
size="sm"
|
||||
class="flex-1 h-8 text-xs"
|
||||
@click="toggleMouseMode"
|
||||
@@ -305,96 +196,11 @@ watch(() => props.open, (isOpen) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HID Device Settings (Requires Apply) -->
|
||||
<Separator />
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h5 class="text-xs font-medium text-muted-foreground">{{ t('actionbar.hidDeviceSettings') }}</h5>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
:disabled="loadingDevices"
|
||||
@click="loadDevices"
|
||||
>
|
||||
<RefreshCw :class="['size-3.5', loadingDevices && 'animate-spin']" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Backend Type -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.backend') }}</Label>
|
||||
<Select
|
||||
:model-value="hidBackend"
|
||||
@update:model-value="handleBackendChange"
|
||||
>
|
||||
<SelectTrigger size="sm" class="w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="HidBackend.Otg" class="text-xs">USB OTG</SelectItem>
|
||||
<SelectItem :value="HidBackend.Ch9329" class="text-xs">CH9329 (Serial)</SelectItem>
|
||||
<SelectItem :value="HidBackend.None" class="text-xs">{{ t('common.disabled') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Device Path (OTG or CH9329) -->
|
||||
<div v-if="hidBackend !== HidBackend.None" class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.devicePath') }}</Label>
|
||||
<Select
|
||||
:model-value="devicePath"
|
||||
@update:model-value="handleDevicePathChange"
|
||||
:disabled="availableDevicePaths.length === 0"
|
||||
>
|
||||
<SelectTrigger size="sm" class="w-full text-xs">
|
||||
<SelectValue :placeholder="t('actionbar.selectDevice')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="max-w-[min(360px,calc(100vw-2rem))]">
|
||||
<SelectItem
|
||||
v-for="device in availableDevicePaths"
|
||||
:key="device.path"
|
||||
:value="device.path"
|
||||
:text-value="device.name"
|
||||
class="text-xs"
|
||||
>
|
||||
<span class="block min-w-0 truncate" :title="device.name">{{ device.name }}</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Baudrate (CH9329 only) -->
|
||||
<div v-if="hidBackend === HidBackend.Ch9329" class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.baudrate') }}</Label>
|
||||
<Select
|
||||
:model-value="String(baudrate)"
|
||||
@update:model-value="handleBaudrateChange"
|
||||
>
|
||||
<SelectTrigger size="sm" class="w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="9600" class="text-xs">9600</SelectItem>
|
||||
<SelectItem value="19200" class="text-xs">19200</SelectItem>
|
||||
<SelectItem value="38400" class="text-xs">38400</SelectItem>
|
||||
<SelectItem value="57600" class="text-xs">57600</SelectItem>
|
||||
<SelectItem value="115200" class="text-xs">115200</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Apply Button -->
|
||||
<Button
|
||||
class="w-full h-8 text-xs"
|
||||
:disabled="applying"
|
||||
@click="applyHidConfig"
|
||||
>
|
||||
<Loader2 v-if="applying" class="size-3.5 mr-1.5 animate-spin" />
|
||||
<span>{{ applying ? t('actionbar.applying') : t('common.apply') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<HidDeviceOverview :hid="configStore.hid" :status="status" :bluetooth="bluetooth" :error="error" />
|
||||
<Button variant="outline" class="w-full" @click="configure">{{ t('hidGuide.reconfigure') }}</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<HidDriverDialog v-if="guideOpen" @close="guideOpen = false" />
|
||||
</template>
|
||||
|
||||
33
web/src/components/HidDeviceOverview.vue
Normal file
33
web/src/components/HidDeviceOverview.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { HidConfig } from '@/types/generated'
|
||||
import type { BluetoothStatus } from '@/types/bluetooth'
|
||||
import { hidDeviceError, hidDeviceStage, type HidDeviceStatus } from '@/lib/hidGuide'
|
||||
const props = defineProps<{ hid: HidConfig | null; status?: HidDeviceStatus | null; bluetooth?: BluetoothStatus | null; error?: string }>()
|
||||
const { t } = useI18n()
|
||||
const connectionError = computed(() => props.error || hidDeviceError(props.status) || props.bluetooth?.error)
|
||||
const stage = computed(() => hidDeviceStage(props.status, props.bluetooth ?? null, props.error))
|
||||
</script>
|
||||
<template>
|
||||
<div class="space-y-2 text-sm">
|
||||
<p class="font-medium">{{ !hid ? t('hidGuide.unconfigured') : hid.backend === 'none' ? t('hidGuide.disabled') : t(`hidGuide.driver_${hid.backend}`) }}</p>
|
||||
<dl v-if="hid && hid.backend !== 'none'" class="space-y-1 break-words">
|
||||
<div v-if="hid.backend === 'otg'"><dt class="inline text-muted-foreground">UDC: </dt><dd class="inline">{{ hid.otg_udc || status?.backend === 'otg' && t('hidGuide.legacyAuto') || '—' }}</dd></div>
|
||||
<template v-if="hid.backend === 'ch9329'">
|
||||
<div><dt class="inline text-muted-foreground">{{ t('hidGuide.device_ch9329') }}: </dt><dd class="inline">{{ hid.ch9329_port || '—' }}</dd></div>
|
||||
<div><dt class="inline text-muted-foreground">{{ t('actionbar.baudrate') }}: </dt><dd class="inline">{{ hid.ch9329_baudrate }}</dd></div>
|
||||
</template>
|
||||
<template v-if="hid.backend === 'bluetooth'">
|
||||
<div><dt class="inline text-muted-foreground">{{ t('bluetoothHid.adapter') }}: </dt><dd class="inline">{{ hid.bluetooth.adapter }} <span v-if="bluetooth?.adapter_address">· {{ bluetooth.adapter_address }}</span></dd></div>
|
||||
<div><dt class="inline text-muted-foreground">{{ t('bluetoothHid.name') }}: </dt><dd class="inline">{{ hid.bluetooth.name }}</dd></div>
|
||||
<div v-if="bluetooth?.peer"><dt class="inline text-muted-foreground">{{ t('hidGuide.host') }}: </dt><dd class="inline">{{ bluetooth.devices.find(d => d.address === bluetooth?.peer)?.name }} · {{ bluetooth.peer }}</dd></div>
|
||||
</template>
|
||||
</dl>
|
||||
<p v-if="hid && hid.backend !== 'none'" role="status" :class="!connectionError && stage === 'ready' ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'">
|
||||
{{ t(`hidGuide.${stage}`) }}
|
||||
<span v-if="bluetooth?.pairing_seconds"> · {{ bluetooth.pairing_seconds }}s</span>
|
||||
</p>
|
||||
<p v-if="connectionError" role="alert" class="text-destructive break-words">{{ connectionError }}</p>
|
||||
</div>
|
||||
</template>
|
||||
28
web/src/components/HidDeviceSettings.vue
Normal file
28
web/src/components/HidDeviceSettings.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useHidConnection } from '@/composables/useHidConnection'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import HidDeviceOverview from './HidDeviceOverview.vue'
|
||||
import HidDriverDialog from './HidDriverDialog.vue'
|
||||
import BluetoothHidSettings from './BluetoothHidSettings.vue'
|
||||
const props = defineProps<{ active: boolean; dirty?: boolean }>()
|
||||
const emit = defineEmits<{ applied: []; discard: [] }>()
|
||||
const { t } = useI18n(), store = useConfigStore(), open = ref(false)
|
||||
const active = computed(() => props.active && !open.value)
|
||||
const backend = computed(() => store.hid?.backend)
|
||||
const { status, bluetooth, error, restart } = useHidConnection(active, backend)
|
||||
</script>
|
||||
<template>
|
||||
<section class="rounded-lg border p-5 space-y-4">
|
||||
<h3 class="font-semibold">{{ t('hidGuide.deviceTitle') }}</h3>
|
||||
<HidDeviceOverview :hid="store.hid" :status="status" :bluetooth="bluetooth" :error="error" />
|
||||
<Button variant="outline" @click="open = true">{{ t(store.hid ? 'hidGuide.reconfigure' : 'hidGuide.configure') }}</Button>
|
||||
</section>
|
||||
<section v-if="store.hid?.backend === 'bluetooth'" class="rounded-lg border p-5 space-y-4">
|
||||
<h3 class="font-semibold">{{ t('hidGuide.features') }}</h3>
|
||||
<BluetoothHidSettings :bluetooth="bluetooth" @refresh="restart" @reconfigure="open = true" />
|
||||
</section>
|
||||
<HidDriverDialog v-if="open" :dirty="dirty" @close="open = false" @applied="emit('applied')" @discard="emit('discard')" />
|
||||
</template>
|
||||
190
web/src/components/HidDriverDialog.vue
Normal file
190
web/src/components/HidDriverDialog.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { request } from '@/api/request'
|
||||
import type { HidConfig, MsdConfig } from '@/types/generated'
|
||||
import { useHidConnection } from '@/composables/useHidConnection'
|
||||
import { deviceRequest, selectionFrom, matchesSelection, writePendingHid, pendingHidKey, type PendingHid, type HidSelection } from '@/lib/hidGuide'
|
||||
import HidDriverForm from './HidDriverForm.vue'
|
||||
import HidWiringDiagram from './HidWiringDiagram.vue'
|
||||
import HidDeviceOverview from './HidDeviceOverview.vue'
|
||||
const props = defineProps<{ dirty?: boolean; pending?: PendingHid | null }>()
|
||||
const emit = defineEmits<{ close: []; applied: []; discard: [] }>()
|
||||
const { t } = useI18n(), store = useConfigStore()
|
||||
const draft = ref<HidSelection>(props.pending ? JSON.parse(JSON.stringify(props.pending.selection)) : selectionFrom(store.hid))
|
||||
const valid = ref(false), busy = ref(false), error = ref(''), applied = ref(false), loaded = ref(false)
|
||||
const acknowledged = ref(!props.dirty), uncertain = ref(false)
|
||||
const pairingStarted = ref(false), autoPair = ref(false), hasApplied = ref(false)
|
||||
const connectionStarted = ref(Date.now()), connectionTimedOut = ref(false)
|
||||
const disabledUsb = ref<string[]>([])
|
||||
const autoSubmitPending = ref(props.pending?.phase === 'selected')
|
||||
let disposed = false, closed = false
|
||||
async function cleanupPairing() {
|
||||
if (hasApplied.value && store.hid?.backend === 'bluetooth') {
|
||||
await request('/hid/bluetooth', { method: 'POST', body: JSON.stringify({ action: 'close' }) }, { toastOnError: false }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
onUnmounted(() => {
|
||||
disposed = true; autoPair.value = false; autoSubmitPending.value = false
|
||||
if (!closed && !busy.value) void cleanupPairing()
|
||||
})
|
||||
const backend = computed(() => applied.value ? store.hid?.backend : undefined)
|
||||
const active = computed(() => applied.value && !busy.value)
|
||||
const { status, bluetooth, error: statusError, restart } = useHidConnection(active, backend)
|
||||
watch(status, () => { connectionTimedOut.value = Date.now() - connectionStarted.value >= 120000 })
|
||||
const ready = computed(() => store.hid?.backend === 'none' || (store.hid?.backend === 'bluetooth' ? bluetooth.value?.ready : status.value?.online))
|
||||
function remember(phase: PendingHid['phase']) { if (props.pending) writePendingHid({ selection: draft.value, phase }) }
|
||||
async function readConfig<T>(path: string): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
try { return await request<T>(path, { signal: controller.signal }, { toastOnError: false }) }
|
||||
finally { clearTimeout(timeout) }
|
||||
}
|
||||
async function refreshHid() {
|
||||
const hid = await readConfig<HidConfig>('/config/hid')
|
||||
if (!disposed) store.hid = hid
|
||||
return hid
|
||||
}
|
||||
async function refreshMsd() {
|
||||
const msd = await readConfig<MsdConfig>('/config/msd')
|
||||
if (!disposed) store.msd = msd
|
||||
return msd
|
||||
}
|
||||
async function refreshConfigs() {
|
||||
const results = await Promise.allSettled([refreshHid(), refreshMsd(), readConfig<{ enabled: boolean }>('/config/otg-network'), readConfig<{ enabled: boolean }>('/config/uac')])
|
||||
emit('applied')
|
||||
const failure = results.find(r => r.status === 'rejected')
|
||||
if (failure?.status === 'rejected') throw failure.reason
|
||||
}
|
||||
async function apply() {
|
||||
if (disposed || busy.value || !valid.value || !loaded.value) return
|
||||
autoSubmitPending.value = false
|
||||
busy.value = true; error.value = ''; uncertain.value = false
|
||||
remember('applying')
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30000)
|
||||
try {
|
||||
await store.updateHid(deviceRequest(draft.value), controller.signal)
|
||||
applied.value = true; hasApplied.value = true; connectionStarted.value = Date.now(); remember('applied'); autoPair.value = draft.value.backend === 'bluetooth'
|
||||
await refreshConfigs()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
// Never repeat an ambiguous reset automatically, including after a browser refresh.
|
||||
if (!applied.value) {
|
||||
try {
|
||||
const current = await refreshHid()
|
||||
if (matchesSelection(current, draft.value)) {
|
||||
uncertain.value = true
|
||||
error.value += ` ${t('hidGuide.uncertain')}`
|
||||
}
|
||||
} catch { uncertain.value = true }
|
||||
}
|
||||
} finally { clearTimeout(timeout); busy.value = false; if (disposed && !closed) void cleanupPairing() }
|
||||
}
|
||||
async function action(action: 'pair' | 'close') {
|
||||
if (disposed || busy.value) return
|
||||
busy.value = true; error.value = ''
|
||||
try {
|
||||
await request('/hid/bluetooth', { method: 'POST', body: JSON.stringify({ action, seconds: 120 }) })
|
||||
if (action === 'pair') pairingStarted.value = true
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : String(e) }
|
||||
finally { busy.value = false; if (disposed && !closed) void cleanupPairing(); else restart() }
|
||||
}
|
||||
watch(() => bluetooth.value?.initialized, initialized => {
|
||||
if (initialized && autoPair.value && !pairingStarted.value && !busy.value) {
|
||||
autoPair.value = false
|
||||
void action('pair')
|
||||
}
|
||||
})
|
||||
function applyPending() {
|
||||
if (!autoSubmitPending.value || !valid.value || !loaded.value || disposed) return
|
||||
if (props.pending && JSON.stringify(deviceRequest(draft.value)) !== JSON.stringify(deviceRequest(props.pending.selection))) {
|
||||
autoSubmitPending.value = false
|
||||
error.value = t('hidGuide.resumeRetry')
|
||||
return
|
||||
}
|
||||
void apply()
|
||||
}
|
||||
watch(valid, applyPending)
|
||||
async function edit() {
|
||||
if (store.hid?.backend === 'bluetooth' && bluetooth.value?.initialized) {
|
||||
await action('close')
|
||||
if (error.value) return
|
||||
}
|
||||
applied.value = false; autoPair.value = false; pairingStarted.value = false; uncertain.value = false
|
||||
}
|
||||
async function close() {
|
||||
if (busy.value) return
|
||||
autoPair.value = false
|
||||
if (hasApplied.value && store.hid?.backend === 'bluetooth' && (bluetooth.value?.initialized || pairingStarted.value)) {
|
||||
await action('close')
|
||||
if (error.value) return
|
||||
}
|
||||
if (props.pending) sessionStorage.removeItem(pendingHidKey)
|
||||
closed = true
|
||||
emit('close')
|
||||
}
|
||||
async function load() {
|
||||
loaded.value = false; error.value = ''
|
||||
try {
|
||||
await refreshHid()
|
||||
if (!props.pending) draft.value = selectionFrom(store.hid)
|
||||
if (store.hid?.backend === 'otg') {
|
||||
const [msd, network, audio] = await Promise.all([refreshMsd(), readConfig<{ enabled: boolean }>('/config/otg-network'), readConfig<{ enabled: boolean }>('/config/uac')])
|
||||
disabledUsb.value = [msd.enabled ? t('hidGuide.msd') : '', network.enabled ? t('hidGuide.network') : '', audio.enabled ? t('hidGuide.audio') : ''].filter(Boolean)
|
||||
}
|
||||
if (props.pending && props.pending.phase !== 'selected') {
|
||||
if (store.hid && matchesSelection(store.hid, props.pending.selection)) {
|
||||
applied.value = true; hasApplied.value = true; remember('applied')
|
||||
// The previous reset may have succeeded. Resume observation, never clear again.
|
||||
} else error.value = t('hidGuide.resumeRetry')
|
||||
}
|
||||
loaded.value = true
|
||||
applyPending()
|
||||
} catch (e) { error.value = e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<Dialog :open="true" @update:open="value => { if (!value) void close() }">
|
||||
<DialogContent :show-close-button="!busy" class="w-[calc(100vw-2rem)] sm:max-w-[520px] max-h-[calc(100dvh-2rem)] overflow-y-auto" @escape-key-down="event => { if (busy) event.preventDefault() }" @interact-outside="event => { if (busy) event.preventDefault() }">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t('hidGuide.configure') }}</DialogTitle>
|
||||
<DialogDescription>{{ t(applied ? 'hidGuide.appliedHelp' : 'hidGuide.draftHelp') }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div v-if="!acknowledged" class="space-y-4">
|
||||
<p>{{ t('hidGuide.dirty') }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button variant="outline" @click="emit('close')">{{ t('hidGuide.returnSave') }}</Button>
|
||||
<Button @click="acknowledged = true; emit('discard')">{{ t('hidGuide.discard') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<HidDriverForm v-if="!applied" v-model="draft" :locked="busy || !loaded" @valid="valid = $event" />
|
||||
<template v-else>
|
||||
<HidWiringDiagram :backend="draft.backend" />
|
||||
<HidDeviceOverview :hid="store.hid" :status="status" :bluetooth="bluetooth" :error="statusError" />
|
||||
<template v-if="draft.backend === 'bluetooth'">
|
||||
<p class="text-sm">{{ t('hidGuide.pairInstructions', { name: store.hid?.bluetooth.name }) }}</p>
|
||||
<p v-if="pairingStarted && !bluetooth?.pairing_seconds && !bluetooth?.peer && !ready" class="text-sm">{{ t('hidGuide.pairTimeout') }}</p>
|
||||
<Button v-if="!ready && !bluetooth?.pairing_seconds" variant="outline" :disabled="busy || !bluetooth?.initialized" @click="action('pair')">{{ t('hidGuide.reopenPairing') }}</Button>
|
||||
</template>
|
||||
<p v-if="connectionTimedOut && !ready && draft.backend !== 'bluetooth'" class="text-sm text-warning">{{ t('hidGuide.connectionTimeout') }}</p>
|
||||
</template>
|
||||
<p v-if="!applied && store.hid?.backend === 'otg' && draft.backend !== 'otg' && disabledUsb.length" class="text-sm text-warning">{{ t('hidGuide.disableUsb', { functions: disabledUsb.join('、') }) }}</p>
|
||||
<p v-if="!applied && draft.backend === 'bluetooth'" class="text-sm text-warning">{{ t('hidGuide.resetWarning') }}</p>
|
||||
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
|
||||
<DialogFooter class="gap-2">
|
||||
<Button variant="outline" :disabled="busy" @click="close">{{ t(applied ? ready ? 'hidGuide.done' : 'hidGuide.later' : props.pending ? 'hidGuide.configureLater' : 'common.cancel') }}</Button>
|
||||
<Button v-if="uncertain && !applied" variant="outline" :disabled="busy" @click="applied = true; hasApplied = true; remember('applied'); error = ''">{{ t('hidGuide.checkConnection') }}</Button>
|
||||
<Button v-if="!loaded" variant="outline" @click="load">{{ t('common.refresh') }}</Button>
|
||||
<Button v-if="!applied" :disabled="busy || !loaded || !valid" @click="apply">{{ t(busy ? 'actionbar.applying' : 'common.apply') }}</Button>
|
||||
<Button v-else-if="!ready" variant="outline" :disabled="busy" @click="edit()">{{ t('hidGuide.reconfigure') }}</Button>
|
||||
</DialogFooter>
|
||||
</template>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
84
web/src/components/HidDriverForm.vue
Normal file
84
web/src/components/HidDriverForm.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { configApi } from '@/api'
|
||||
import { request } from '@/api/request'
|
||||
import type { BluetoothAdapter } from '@/types/bluetooth'
|
||||
import { selectDevice, validName, type HidSelection, type Driver } from '@/lib/hidGuide'
|
||||
import HidWiringDiagram from './HidWiringDiagram.vue'
|
||||
const props = defineProps<{ modelValue: HidSelection; locked?: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [HidSelection]; valid: [boolean] }>()
|
||||
const { t } = useI18n()
|
||||
const options = ref<Array<{ value: string; label: string }>>([])
|
||||
const loading = ref(false), error = ref(''), missing = ref('')
|
||||
let generation = 0
|
||||
const drivers: Driver[] = ['otg', 'ch9329', 'bluetooth', 'none']
|
||||
const selected = computed(() => props.modelValue.backend === 'otg' ? props.modelValue.otg_udc : props.modelValue.backend === 'ch9329' ? props.modelValue.ch9329_port : props.modelValue.bluetooth.adapter)
|
||||
function device(value: string) {
|
||||
const draft = { ...props.modelValue, bluetooth: { ...props.modelValue.bluetooth } }
|
||||
if (draft.backend === 'otg') draft.otg_udc = value
|
||||
if (draft.backend === 'ch9329') draft.ch9329_port = value
|
||||
if (draft.backend === 'bluetooth') draft.bluetooth.adapter = value
|
||||
emit('update:modelValue', draft)
|
||||
}
|
||||
async function refresh() {
|
||||
const own = ++generation
|
||||
options.value = []; error.value = ''; missing.value = ''; loading.value = true
|
||||
try {
|
||||
if (props.modelValue.backend === 'none') return
|
||||
let next: typeof options.value
|
||||
if (props.modelValue.backend === 'bluetooth') {
|
||||
next = (await request<BluetoothAdapter[]>('/hid/bluetooth/adapters')).map(a => ({ value: a.name, label: `${a.name} · ${a.address}` }))
|
||||
} else {
|
||||
const devices = await configApi.listDevices()
|
||||
next = props.modelValue.backend === 'otg' ? devices.udc.map(d => ({ value: d.name, label: d.name })) : devices.serial.map(d => ({ value: d.path, label: `${d.name} · ${d.path}` }))
|
||||
}
|
||||
if (own !== generation) return
|
||||
options.value = next
|
||||
if (selected.value && !next.some(d => d.value === selected.value)) missing.value = t('hidGuide.missingDevice', { device: selected.value })
|
||||
device(selectDevice(selected.value, next.map(d => d.value)))
|
||||
if (!next.length) error.value = t('hidGuide.noDevices')
|
||||
} catch (e) { if (own === generation) error.value = e instanceof Error ? e.message : String(e) }
|
||||
finally { if (own === generation) loading.value = false }
|
||||
}
|
||||
const valid = computed(() => props.modelValue.backend === 'none' || (!loading.value && !error.value && options.value.some(d => d.value === selected.value)
|
||||
&& (props.modelValue.backend !== 'bluetooth' || validName(props.modelValue.bluetooth.name))
|
||||
&& (props.modelValue.backend !== 'ch9329' || [9600, 19200, 38400, 57600, 115200].includes(props.modelValue.ch9329_baudrate))))
|
||||
watch(valid, value => emit('valid', value), { immediate: true })
|
||||
watch(() => props.modelValue.backend, refresh, { immediate: true })
|
||||
onUnmounted(() => generation++)
|
||||
</script>
|
||||
<template>
|
||||
<fieldset :disabled="locked" class="space-y-4 min-w-0">
|
||||
<label class="block space-y-1 text-sm">
|
||||
<span>{{ t('hidGuide.driver') }}</span>
|
||||
<select class="w-full rounded-md border bg-background px-3 py-2" :value="modelValue.backend" @change="emit('update:modelValue', { ...modelValue, backend: ($event.target as HTMLSelectElement).value as Driver })">
|
||||
<option v-for="driver in drivers" :key="driver" :value="driver">{{ t(`hidGuide.driver_${driver}`) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<HidWiringDiagram :backend="modelValue.backend" />
|
||||
<label v-if="modelValue.backend !== 'none'" class="block space-y-1 text-sm">
|
||||
<span>{{ t(`hidGuide.device_${modelValue.backend}`) }}</span>
|
||||
<select class="w-full rounded-md border bg-background px-3 py-2" :value="selected" :disabled="loading" @change="device(($event.target as HTMLSelectElement).value)">
|
||||
<option value="" disabled>{{ t('hidGuide.selectDevice') }}</option>
|
||||
<option v-for="option in options" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="modelValue.backend === 'ch9329'" class="block space-y-1 text-sm">
|
||||
<span>{{ t('actionbar.baudrate') }}</span>
|
||||
<select class="w-full rounded-md border bg-background px-3 py-2" :value="modelValue.ch9329_baudrate" @change="emit('update:modelValue', { ...modelValue, ch9329_baudrate: Number(($event.target as HTMLSelectElement).value) })">
|
||||
<option v-for="rate in [9600, 19200, 38400, 57600, 115200]" :key="rate">{{ rate }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="modelValue.backend === 'bluetooth'" class="block space-y-1 text-sm">
|
||||
<span>{{ t('bluetoothHid.name') }}</span>
|
||||
<Input :model-value="modelValue.bluetooth.name" @update:model-value="emit('update:modelValue', { ...modelValue, bluetooth: { ...modelValue.bluetooth, name: String($event) } })" />
|
||||
<span v-if="!validName(modelValue.bluetooth.name)" class="text-destructive text-xs">{{ t('hidGuide.nameInvalid') }}</span>
|
||||
</label>
|
||||
<p v-if="missing" class="text-sm text-warning">{{ missing }}</p>
|
||||
<p v-if="error" role="alert" class="text-sm text-destructive break-words">{{ error }}</p>
|
||||
<Button v-if="modelValue.backend !== 'none'" type="button" variant="outline" size="sm" :disabled="loading" @click="refresh">{{ t('common.refresh') }}</Button>
|
||||
</fieldset>
|
||||
</template>
|
||||
33
web/src/components/HidWireframeDevice.vue
Normal file
33
web/src/components/HidWireframeDevice.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
// Static orthographic line art; coordinates are shared by all wiring diagrams.
|
||||
defineProps<{ kind: 'kvm' | 'computer' }>()
|
||||
</script>
|
||||
<template>
|
||||
<g fill="none" stroke="currentColor" stroke-width="1.35" stroke-linejoin="round" stroke-linecap="round">
|
||||
<template v-if="kind === 'kvm'">
|
||||
<path d="M0 42 66 12 108 35 42 65Z" class="device-face" />
|
||||
<path d="M0 42v22l42 23V65m0 22 66-30V35" />
|
||||
<path d="m9 48 25 14v12L9 60Z" class="device-detail" />
|
||||
<path d="m51 65 17-8v9l-17 8Zm23-11 17-8v9l-17 8Z" />
|
||||
<path d="m20 39 33-15m-25 19 33-15m-25 19 33-15m-25 19 33-15" class="device-detail" />
|
||||
<path d="m7 68 4 2m24 13 4 2m14-1 4-2m39-17 4-2" class="device-detail" />
|
||||
<circle cx="98" cy="46" r="1.5" class="device-indicator" />
|
||||
</template>
|
||||
<template v-else-if="kind === 'computer'">
|
||||
<path d="M19 4 100 22v59L19 63Z" class="device-face" />
|
||||
<path d="m19 4 5-3 81 18v59l-5 3M100 22l5-3" />
|
||||
<path d="m25 13 69 15v43L25 56Z" class="device-detail" />
|
||||
<path d="M19 63 0 83l81 19 19-21" class="device-face" />
|
||||
<path d="M0 83v4l81 19 19-21v-4m-19 21v4" />
|
||||
<path d="m24 70 62 14-7 8-62-14Zm-1 5 59 13M36 73l-6 7m18-4-6 7m18-4-6 7m18-4-6 7" class="device-detail" />
|
||||
<path d="m34 85 20 5-4 4-20-5Z" class="device-detail" />
|
||||
<!-- USB socket on the laptop's left side plane. -->
|
||||
<path d="m8 75 7-7v3l-7 7Z" stroke-width="1.6" />
|
||||
</template>
|
||||
</g>
|
||||
</template>
|
||||
<style scoped>
|
||||
.device-face { fill: var(--background); }
|
||||
.device-detail { opacity: .4; }
|
||||
.device-indicator { fill: currentColor; stroke: none; }
|
||||
</style>
|
||||
87
web/src/components/HidWiringDiagram.vue
Normal file
87
web/src/components/HidWiringDiagram.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Driver } from '@/lib/hidGuide'
|
||||
import HidWireframeDevice from './HidWireframeDevice.vue'
|
||||
|
||||
defineProps<{ backend: Driver }>()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
<template>
|
||||
<figure v-if="backend !== 'none'" class="wiring-diagram rounded-lg border border-border/60 bg-muted/20 p-3 space-y-2">
|
||||
<svg viewBox="0 0 460 184" class="block w-full text-foreground" role="img" :aria-label="t(`hidGuide.wiring_${backend}`)">
|
||||
<!-- A faint ground plane anchors the perspective without shadows or filters. -->
|
||||
<g fill="none" stroke="currentColor" stroke-width=".65" opacity=".07" aria-hidden="true">
|
||||
<path d="m7 87 101-46 75 38-101 46Zm270-5 98-45 79 39-98 46M31 99l101-46m-77 58 101-46M302 94l98-45m-71 58 98-45" />
|
||||
</g>
|
||||
<HidWireframeDevice kind="kvm" transform="translate(29 10)" />
|
||||
<HidWireframeDevice kind="computer" transform="translate(322 0)" />
|
||||
<g class="device-label" fill="currentColor" text-anchor="middle">
|
||||
<text x="83" y="120">{{ backend === 'bluetooth' ? 'One-KVM HID' : backend === 'otg' ? t('hidGuide.otgPort') : 'One-KVM USB' }}</text>
|
||||
<text x="375" y="120">{{ backend === 'bluetooth' ? t('hidGuide.host') : t('hidGuide.hostUsb') }}</text>
|
||||
</g>
|
||||
|
||||
<g v-if="backend === 'otg'" class="connection" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Rectangular USB plugs terminate on the two side-mounted sockets. -->
|
||||
<g class="usb-plug">
|
||||
<path d="m112 61 9-4 10 5-9 4Z" />
|
||||
<path d="m112 61v7l10 5v-7Z" />
|
||||
<path d="m122 66 9-4v7l-9 4Z" />
|
||||
<path d="m112 64-5 2" class="plug-blade" />
|
||||
</g>
|
||||
<g class="usb-plug">
|
||||
<path d="m317 80 7-7 13-5-7 7Z" />
|
||||
<path d="m317 80 13-5v3l-13 5Z" />
|
||||
<path d="m324 73 13-5v3l-13 5Z" />
|
||||
<path d="m330 75 7-7v3l-7 7Z" class="plug-blade" />
|
||||
</g>
|
||||
<path d="M131 68c27 11 29 29 55 40l27 12c34 15 66 9 82-8l22-31" />
|
||||
<path d="m223 117 7 3-7 3" />
|
||||
<path d="M230 129v12" class="leader" />
|
||||
</g>
|
||||
<text v-if="backend === 'otg'" x="230" y="162" class="connection-label" text-anchor="middle" fill="currentColor">{{ t('hidGuide.dataCable') }}</text>
|
||||
|
||||
<template v-else-if="backend === 'bluetooth'">
|
||||
<g class="connection" fill="none" stroke="currentColor" stroke-linecap="round">
|
||||
<path d="M137 48c9 8 9 22 0 30m9-39c15 13 15 35 0 48m164-37c-9 8-9 22 0 30m-9-39c-15 13-15 35 0 48" opacity=".45" />
|
||||
<path d="M166 64h30m64 0h28" stroke-dasharray="2 6" />
|
||||
<path d="m216 48 23 32-13 10V39l13 10-23 31" stroke-width="1.8" />
|
||||
</g>
|
||||
<text x="230" y="162" class="connection-label" text-anchor="middle" fill="currentColor">{{ t('hidGuide.wireless') }}</text>
|
||||
</template>
|
||||
|
||||
<template v-else-if="backend === 'ch9329'">
|
||||
<!-- CH340 and CH9329 are enclosed in the cable and have no visible module. -->
|
||||
<g class="connection" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
|
||||
<g class="usb-plug">
|
||||
<path d="m112 61 9-4 10 5-9 4Z" />
|
||||
<path d="m112 61v7l10 5v-7Z" />
|
||||
<path d="m122 66 9-4v7l-9 4Z" />
|
||||
<path d="m112 64-5 2" class="plug-blade" />
|
||||
</g>
|
||||
<g class="usb-plug">
|
||||
<path d="m317 80 7-7 13-5-7 7Z" />
|
||||
<path d="m317 80 13-5v3l-13 5Z" />
|
||||
<path d="m324 73 13-5v3l-13 5Z" />
|
||||
<path d="m330 75 7-7v3l-7 7Z" class="plug-blade" />
|
||||
</g>
|
||||
<path d="M131 68c25 10 32 39 67 55 33 16 72 19 100-4l19-38" />
|
||||
<path d="m224 132 7 2-5 5" />
|
||||
<path d="M230 143v7" class="leader" />
|
||||
</g>
|
||||
<text x="230" y="169" class="connection-label" text-anchor="middle" fill="currentColor">{{ t('hidGuide.integratedCable') }}</text>
|
||||
</template>
|
||||
</svg>
|
||||
<figcaption class="text-xs leading-relaxed text-muted-foreground">{{ t(`hidGuide.wiring_${backend}`) }}</figcaption>
|
||||
</figure>
|
||||
<p v-else class="text-sm text-muted-foreground">{{ t('hidGuide.disabledHelp') }}</p>
|
||||
</template>
|
||||
<style scoped>
|
||||
.device-label { font-size: 13px; font-weight: 500; }
|
||||
.connection { color: var(--wiring-accent); stroke-width: 1.8; }
|
||||
.connection-label { color: var(--wiring-accent); font-size: 12px; }
|
||||
.usb-plug { fill: var(--background); stroke-width: 1.25; }
|
||||
.plug-blade { fill: none; opacity: .75; }
|
||||
.leader { opacity: .4; stroke-width: 1; }
|
||||
.wiring-diagram { --wiring-accent: #0369a1; }
|
||||
:global(.dark .wiring-diagram) { --wiring-accent: #7dd3fc; }
|
||||
</style>
|
||||
@@ -15,6 +15,8 @@ const props = defineProps<{
|
||||
mousePosition?: { x: number; y: number }
|
||||
debugMode?: boolean
|
||||
compact?: boolean
|
||||
captured?: boolean
|
||||
minimal?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -45,22 +47,23 @@ const keysDisplay = computed(() => {
|
||||
<div class="w-full border-t bg-background">
|
||||
<!-- Compact mode (explicit prop or auto on small screens via sm:hidden) -->
|
||||
<div :class="compact ? '' : 'sm:hidden'">
|
||||
<div class="flex items-center justify-between text-xs px-2 py-0.5">
|
||||
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
|
||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-warning'" />C
|
||||
<div class="flex items-center justify-between gap-3 text-xs px-2 h-7">
|
||||
<div v-if="keyboardLedEnabled && !minimal" class="flex items-center gap-1">
|
||||
<Badge variant="outline" class="h-5 gap-1 px-1 text-xs text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Caps
|
||||
</Badge>
|
||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-warning'" />N
|
||||
<Badge variant="outline" class="h-5 gap-1 px-1 text-xs text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Num
|
||||
</Badge>
|
||||
<Badge variant="outline" class="h-4 gap-1 px-1 text-[10px] text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-warning'" />S
|
||||
<Badge variant="outline" class="h-5 gap-1 px-1 text-xs text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />Scroll
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-else class="text-[10px] text-muted-foreground/60">
|
||||
<div v-else-if="!minimal" class="text-xs text-muted-foreground">
|
||||
{{ t('infobar.keyboardLedUnavailable') }}
|
||||
</div>
|
||||
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[200px]">
|
||||
<span v-if="captured && minimal" class="min-w-0 truncate text-xs text-muted-foreground" :title="t('infobar.pointerCaptured')">{{ t('infobar.pointerCaptured') }}</span>
|
||||
<div v-if="keysDisplay" class="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{{ keysDisplay }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -87,15 +90,15 @@ const keysDisplay = computed(() => {
|
||||
<Separator orientation="vertical" class="h-5" />
|
||||
<template v-if="keyboardLedEnabled">
|
||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-warning'" />
|
||||
<span class="size-1.5 rounded-full" :class="capsLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />
|
||||
{{ t('infobar.caps') }}
|
||||
</Badge>
|
||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-warning'" />
|
||||
<span class="size-1.5 rounded-full" :class="numLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />
|
||||
{{ t('infobar.num') }}
|
||||
</Badge>
|
||||
<Badge variant="outline" class="mx-1 gap-1.5 text-foreground">
|
||||
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-warning'" />
|
||||
<span class="size-1.5 rounded-full" :class="scrollLock ? 'bg-status-active' : 'bg-muted-foreground/30'" />
|
||||
{{ t('infobar.scroll') }}
|
||||
</Badge>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useSystemStore } from '@/stores/system'
|
||||
import { msdApi, type MsdImage, type DriveFile, type MountedMedia, type DiskMode } from '@/api'
|
||||
import { msdApi, type MsdImage, type DriveFile, type DriveInfo, type MountedMedia, type DiskMode } from '@/api'
|
||||
import { ApiError, localizeMsdErrorCode } from '@/api/request'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import {
|
||||
@@ -92,13 +92,12 @@ const showMountOptionsDialog = ref(false)
|
||||
const driveFiles = ref<DriveFile[]>([])
|
||||
const currentPath = ref('/')
|
||||
const loadingDrive = ref(false)
|
||||
const driveInfo = ref<{ size: number; used: number; free: number; initialized: boolean } | null>(null)
|
||||
const driveInfo = ref<DriveInfo | null>(null)
|
||||
const driveInitialized = ref(false)
|
||||
const uploadingFile = ref(false)
|
||||
const fileUploadProgress = ref(0)
|
||||
const driveError = ref<string | null>(null) // filesystem error (e.g. unsupported format)
|
||||
const driveErrorCode = ref<string | null>(null)
|
||||
const driveFilesystemUnsupported = computed(() => driveErrorCode.value === 'MSD_DRIVE_FILESYSTEM_UNSUPPORTED')
|
||||
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTarget = ref<{ type: 'image' | 'file'; id: string; name: string } | null>(null)
|
||||
@@ -175,6 +174,14 @@ const mediaSlotsFull = computed(() => mountedCount.value >= slotCapacity.value)
|
||||
const driveMedia = computed(() => mountedMedia.value.find(media => media.kind === 'drive') ?? null)
|
||||
// Drive is currently mounted on the target machine via USB — file ops are blocked
|
||||
const driveConnectedToTarget = computed(() => !!driveMedia.value)
|
||||
const driveFileAccess = computed(() => {
|
||||
if (driveConnectedToTarget.value) return 'blocked_while_connected'
|
||||
return driveInfo.value?.file_access ?? 'unknown'
|
||||
})
|
||||
const driveFilesAvailable = computed(() => driveFileAccess.value === 'available')
|
||||
const driveFilesystemUnsupported = computed(() =>
|
||||
driveFileAccess.value === 'unsupported' || driveErrorCode.value === 'MSD_DRIVE_FILESYSTEM_UNSUPPORTED',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -253,7 +260,7 @@ async function loadData() {
|
||||
await refreshMsdState()
|
||||
await loadImages()
|
||||
await loadDriveInfo()
|
||||
if (driveInitialized.value) {
|
||||
if (driveFilesAvailable.value) {
|
||||
await loadDriveFiles()
|
||||
}
|
||||
}
|
||||
@@ -369,7 +376,6 @@ async function unmountMedia(media: MountedMedia) {
|
||||
try {
|
||||
if (media.kind === 'drive') {
|
||||
await msdApi.unmountDrive()
|
||||
await refreshDriveBrowser()
|
||||
} else {
|
||||
await msdApi.unmountImage(media.id)
|
||||
}
|
||||
@@ -444,6 +450,7 @@ async function loadDriveInfo() {
|
||||
try {
|
||||
driveInfo.value = await msdApi.driveInfo()
|
||||
driveInitialized.value = true
|
||||
driveFiles.value = driveFilesAvailable.value ? driveFiles.value : []
|
||||
} catch (e: any) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.code === 'MSD_DRIVE_NOT_INITIALIZED' || e.status === 404) {
|
||||
@@ -486,7 +493,7 @@ async function createDrive() {
|
||||
const sizeMb = finalDriveSize.value
|
||||
await msdApi.initDrive(sizeMb)
|
||||
await loadDriveInfo()
|
||||
await loadDriveFiles()
|
||||
if (driveFilesAvailable.value) await loadDriveFiles()
|
||||
await refreshDiskSpace()
|
||||
showDriveInitDialog.value = false
|
||||
} catch (e) {
|
||||
@@ -516,7 +523,7 @@ async function deleteDrive() {
|
||||
async function loadDriveFiles() {
|
||||
// Do not read image file while it is mounted on the target machine:
|
||||
// concurrent access causes filesystem corruption (Windows error 0x80070570)
|
||||
if (driveConnectedToTarget.value) {
|
||||
if (driveConnectedToTarget.value || !driveFilesAvailable.value) {
|
||||
driveFiles.value = []
|
||||
return
|
||||
}
|
||||
@@ -538,7 +545,7 @@ async function loadDriveFiles() {
|
||||
|
||||
async function refreshDriveBrowser() {
|
||||
await loadDriveInfo()
|
||||
if (driveInitialized.value) {
|
||||
if (driveFilesAvailable.value) {
|
||||
await loadDriveFiles()
|
||||
} else {
|
||||
driveFiles.value = []
|
||||
@@ -965,20 +972,27 @@ onUnmounted(() => {
|
||||
? 'border-primary bg-primary/5'
|
||||
: driveError
|
||||
? 'border-destructive/40 bg-destructive/5'
|
||||
: driveFilesystemUnsupported
|
||||
? 'border-warning/40 bg-warning/5'
|
||||
: 'bg-muted/50'"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<HardDrive class="size-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">{{ t('msd.drive') }}</span>
|
||||
<!-- Show size badge only when info is available -->
|
||||
<Badge v-if="driveInfo" variant="outline" class="text-xs">
|
||||
{{ Math.round((driveInfo?.size || 0) / 1024 / 1024) }} MB
|
||||
</Badge>
|
||||
<!-- Show unreadable badge when format is wrong -->
|
||||
<Badge
|
||||
v-if="driveFilesystemUnsupported"
|
||||
variant="outline"
|
||||
class="border-warning/50 text-xs text-warning"
|
||||
>
|
||||
{{ t('msd.driveUnreadable') }}
|
||||
</Badge>
|
||||
<template v-else-if="driveError">
|
||||
<Badge variant="outline" class="text-xs border-destructive/50 text-destructive">
|
||||
{{ driveFilesystemUnsupported ? t('msd.driveUnreadable') : t('common.error') }}
|
||||
{{ t('common.error') }}
|
||||
</Badge>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
@@ -993,19 +1007,7 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- When drive format is unrecognized, only offer re-initialization -->
|
||||
<template v-if="driveFilesystemUnsupported && !msdConnected">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
:disabled="operationInProgress"
|
||||
@click="initializeDrive"
|
||||
>
|
||||
{{ t('msd.reinitializeDrive') }}
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else-if="driveConnectedToTarget">
|
||||
<template v-if="driveConnectedToTarget">
|
||||
<Badge variant="default" class="h-8 px-2 text-xs">
|
||||
<span class="relative flex size-1.5 mr-1.5">
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary-foreground opacity-75"></span>
|
||||
@@ -1030,13 +1032,23 @@ onUnmounted(() => {
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
:disabled="operationInProgress || mediaSlotsFull || !!driveError"
|
||||
:disabled="operationInProgress || mediaSlotsFull || !driveInfo || !!driveError"
|
||||
@click="connectDrive"
|
||||
>
|
||||
<Link v-if="!connecting" class="size-3.5 mr-1" />
|
||||
<span v-if="connecting">{{ t('common.connecting') }}...</span>
|
||||
<span v-else>{{ t('msd.connect') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
v-if="driveFilesystemUnsupported"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 text-xs"
|
||||
:disabled="operationInProgress"
|
||||
@click="initializeDrive"
|
||||
>
|
||||
{{ t('msd.reinitializeDrive') }}
|
||||
</Button>
|
||||
</template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1049,19 +1061,29 @@ onUnmounted(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Storage usage bar — hidden when format is unrecognized -->
|
||||
<div v-if="driveInfo" class="space-y-1.5">
|
||||
<div
|
||||
v-if="driveFilesAvailable && driveInfo?.used !== null && driveInfo?.free !== null"
|
||||
class="space-y-1.5"
|
||||
>
|
||||
<Progress
|
||||
:model-value="driveInfo.size > 0 ? (driveInfo.used / driveInfo.size) * 100 : 0"
|
||||
:model-value="driveInfo && driveInfo.size > 0 ? ((driveInfo.used ?? 0) / driveInfo.size) * 100 : 0"
|
||||
class="h-2"
|
||||
/>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ formatBytes(driveInfo?.used || 0) }} {{ t('msd.usedSpace') }}</span>
|
||||
<span>{{ formatBytes(driveInfo?.free || 0) }} {{ t('msd.freeSpace') }}</span>
|
||||
<span>{{ formatBytes(driveInfo?.used ?? 0) }} {{ t('msd.usedSpace') }}</span>
|
||||
<span>{{ formatBytes(driveInfo?.free ?? 0) }} {{ t('msd.freeSpace') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="driveFilesystemUnsupported"
|
||||
class="flex shrink-0 items-start gap-2 rounded-md border border-warning/40 bg-warning/5 p-3"
|
||||
>
|
||||
<Info class="mt-0.5 size-4 shrink-0 text-warning" />
|
||||
<p class="text-sm text-muted-foreground">{{ t('msd.driveFilesystemUnsupportedHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="driveError"
|
||||
class="flex shrink-0 items-center justify-between gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-3"
|
||||
@@ -1075,7 +1097,7 @@ onUnmounted(() => {
|
||||
|
||||
|
||||
<!-- File Browser -->
|
||||
<div class="flex-1 min-h-0 flex flex-col space-y-2">
|
||||
<div v-if="driveFilesAvailable" class="flex-1 min-h-0 flex flex-col space-y-2">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="shrink-0 flex items-center justify-between gap-2">
|
||||
@@ -1163,21 +1185,13 @@ onUnmounted(() => {
|
||||
|
||||
<!-- File List -->
|
||||
<Skeleton v-if="loadingDrive" class="h-24 w-full" />
|
||||
<Empty v-else-if="driveFiles.length === 0 && !driveConnectedToTarget && !driveError" class="shrink-0 py-6">
|
||||
<Empty v-else-if="driveFiles.length === 0 && !driveError" class="shrink-0 py-6">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon"><Folder /></EmptyMedia>
|
||||
<EmptyDescription>{{ t('msd.emptyFolder') }}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
|
||||
<!-- Connected placeholder: file list hidden while drive mounted on target -->
|
||||
<div
|
||||
v-else-if="driveConnectedToTarget"
|
||||
class="shrink-0 text-center py-6 text-muted-foreground text-sm"
|
||||
>
|
||||
{{ t('msd.driveConnectedFilesHidden') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
@@ -1232,6 +1246,12 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="driveConnectedToTarget"
|
||||
class="shrink-0 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ t('msd.driveConnectedFilesHidden') }}
|
||||
</div>
|
||||
</template>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -408,17 +408,17 @@ onUnmounted(() => {
|
||||
<Sheet :open="props.open" @update:open="emit('update:open', $event)">
|
||||
<SheetContent
|
||||
side="right"
|
||||
class="w-[90vw] max-w-[440px] border-l bg-background p-0"
|
||||
class="w-[90vw] max-w-[440px] gap-0 overflow-hidden border-l bg-background p-0"
|
||||
>
|
||||
<!-- Header -->
|
||||
<SheetHeader class="border-b px-6 py-3">
|
||||
<SheetHeader class="shrink-0 border-b px-6 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<SheetTitle class="text-base">{{ t('stats.title') }}</SheetTitle>
|
||||
<Badge variant="secondary">WebRTC</Badge>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea class="h-[calc(100dvh-60px)]">
|
||||
<ScrollArea class="min-h-0 flex-1">
|
||||
<div class="px-6 py-4 space-y-6">
|
||||
<!-- Video Information -->
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive } from 'lucide-vue-next'
|
||||
import { Monitor, Video, Usb, AlertCircle, CheckCircle, Loader2, Volume2, HardDrive, MonitorOff, Clock } from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error' | 'no_signal' | 'busy'
|
||||
|
||||
export interface StatusDetail {
|
||||
label: string
|
||||
value: string
|
||||
@@ -28,7 +30,7 @@ export interface StatusDetail {
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
type: 'device' | 'video' | 'hid' | 'audio' | 'msd'
|
||||
status: 'connected' | 'connecting' | 'disconnected' | 'error'
|
||||
status: ConnectionStatus
|
||||
quickInfo?: string // Quick info displayed on trigger (e.g., "1920x1080 30fps")
|
||||
subtitle?: string
|
||||
errorMessage?: string
|
||||
@@ -54,6 +56,9 @@ const statusColor = computed(() => {
|
||||
return 'bg-status-active'
|
||||
case 'connecting':
|
||||
return 'bg-warning animate-pulse'
|
||||
case 'no_signal':
|
||||
case 'busy':
|
||||
return 'bg-warning'
|
||||
case 'disconnected':
|
||||
return 'bg-muted-foreground'
|
||||
case 'error':
|
||||
@@ -86,6 +91,10 @@ const statusIcon = computed(() => {
|
||||
return CheckCircle
|
||||
case 'connecting':
|
||||
return Loader2
|
||||
case 'no_signal':
|
||||
return MonitorOff
|
||||
case 'busy':
|
||||
return Clock
|
||||
case 'error':
|
||||
return AlertCircle
|
||||
default:
|
||||
@@ -93,6 +102,8 @@ const statusIcon = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const isNotice = computed(() => ['no_signal', 'busy'].includes(props.status))
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'connected':
|
||||
@@ -103,6 +114,9 @@ const statusText = computed(() => {
|
||||
return t('status.disconnected')
|
||||
case 'error':
|
||||
return t('status.error')
|
||||
case 'no_signal':
|
||||
case 'busy':
|
||||
return t(`status.${props.status}`)
|
||||
default:
|
||||
return props.status
|
||||
}
|
||||
@@ -118,6 +132,9 @@ const statusBadgeText = computed(() => {
|
||||
return t('statusCard.offline')
|
||||
case 'error':
|
||||
return t('status.error')
|
||||
case 'no_signal':
|
||||
case 'busy':
|
||||
return t(`status.${props.status}`)
|
||||
default:
|
||||
return props.status
|
||||
}
|
||||
@@ -142,7 +159,7 @@ const statusBadgeText = computed(() => {
|
||||
<!-- Compact: single row with dot + abbreviated title -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span :class="cn('size-1.5 rounded-full shrink-0', statusColor)" />
|
||||
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||
<span class="text-xs text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -182,11 +199,12 @@ const statusBadgeText = computed(() => {
|
||||
status === 'connected' ? 'text-success' :
|
||||
status === 'connecting' ? 'text-warning animate-spin' :
|
||||
status === 'error' ? 'text-destructive' :
|
||||
isNotice ? 'text-warning' :
|
||||
'text-muted-foreground'
|
||||
)"
|
||||
/>
|
||||
<Badge
|
||||
:variant="status === 'connected' ? 'success' : status === 'connecting' ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
:variant="status === 'connected' ? 'success' : (status === 'connecting' || isNotice) ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ statusBadgeText }}
|
||||
@@ -230,7 +248,7 @@ const statusBadgeText = computed(() => {
|
||||
<!-- Compact: single row with dot + abbreviated title -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span :class="cn('size-1.5 rounded-full shrink-0', statusColor)" />
|
||||
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||
<span class="text-xs text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -270,11 +288,12 @@ const statusBadgeText = computed(() => {
|
||||
status === 'connected' ? 'text-success' :
|
||||
status === 'connecting' ? 'text-warning animate-spin' :
|
||||
status === 'error' ? 'text-destructive' :
|
||||
isNotice ? 'text-warning' :
|
||||
'text-muted-foreground'
|
||||
)"
|
||||
/>
|
||||
<Badge
|
||||
:variant="status === 'connected' ? 'success' : status === 'connecting' ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
:variant="status === 'connected' ? 'success' : (status === 'connecting' || isNotice) ? 'warning' : status === 'error' ? 'destructive' : 'secondary'"
|
||||
class="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{{ statusBadgeText }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { focusConsolePanel } from "@/composables/useConsoleAppearance"
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
@@ -32,6 +33,7 @@ import { toConfigFps } from '@/lib/fps'
|
||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||
import { useConfigStore } from '@/stores/config'
|
||||
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
|
||||
import type { VideoRotation } from '@/composables/useVideoScaling'
|
||||
import VideoInputFields from '@/components/VideoInputFields.vue'
|
||||
|
||||
export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
||||
@@ -39,11 +41,14 @@ export type VideoMode = 'mjpeg' | 'h264' | 'h265' | 'vp8' | 'vp9'
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
videoMode: VideoMode
|
||||
videoRotation: VideoRotation
|
||||
side?: 'top' | 'right' | 'bottom' | 'left'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:open', value: boolean): void
|
||||
(e: 'update:videoMode', value: VideoMode): void
|
||||
(e: 'update:videoRotation', value: VideoRotation): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -207,6 +212,7 @@ const currentConfig = computed(() => ({
|
||||
}))
|
||||
|
||||
const buttonText = computed(() => t('actionbar.videoConfig'))
|
||||
const videoRotationOptions: VideoRotation[] = [0, 90, 180, 270]
|
||||
|
||||
// Available codecs for selection (filtered by backend support and enriched with backend info)
|
||||
const availableCodecs = computed(() => {
|
||||
@@ -538,12 +544,23 @@ watch(
|
||||
<template>
|
||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="size-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||
:aria-label="buttonText"
|
||||
:title="buttonText"
|
||||
>
|
||||
<Monitor class="size-3.5 sm:size-4" />
|
||||
<span class="hidden sm:inline">{{ buttonText }}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||
<PopoverContent
|
||||
@open-auto-focus="focusConsolePanel"
|
||||
class="console-config-panel w-[min(320px,92vw)] p-3"
|
||||
align="start"
|
||||
:side="props.side ?? 'bottom'"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>
|
||||
|
||||
@@ -614,6 +631,27 @@ watch(
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Display Rotation -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs text-muted-foreground">{{ t('actionbar.videoRotation') }}</Label>
|
||||
<div class="grid grid-cols-4 gap-1.5">
|
||||
<Button
|
||||
v-for="rotation in videoRotationOptions"
|
||||
:key="rotation"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="[
|
||||
'h-8 px-1 text-xs tabular-nums',
|
||||
props.videoRotation === rotation && 'border-primary bg-primary/10',
|
||||
]"
|
||||
:aria-pressed="props.videoRotation === rotation"
|
||||
@click="emit('update:videoRotation', rotation)"
|
||||
>
|
||||
{{ rotation }}°
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bitrate Preset - Only shown for WebRTC modes -->
|
||||
<div v-if="props.videoMode !== 'mjpeg'" class="space-y-2">
|
||||
<div class="flex items-center gap-1">
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
const props = defineProps<{
|
||||
minimal?: boolean
|
||||
textOnlyScale?: boolean
|
||||
scaleMode?: VideoScaleMode
|
||||
sourceSizeAvailable?: boolean
|
||||
}>()
|
||||
@@ -51,7 +53,7 @@ function toggleScaleMode() {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<TooltipProvider v-if="!minimal">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<span data-fixed-action class="inline-flex">
|
||||
@@ -64,7 +66,7 @@ function toggleScaleMode() {
|
||||
:aria-pressed="props.scaleMode === 'actual'"
|
||||
@click="toggleScaleMode"
|
||||
>
|
||||
<Scaling class="size-3.5" />
|
||||
<Scaling v-if="!textOnlyScale" class="size-3.5" />
|
||||
1:1
|
||||
</Button>
|
||||
</span>
|
||||
@@ -75,7 +77,7 @@ function toggleScaleMode() {
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<TooltipProvider v-if="!minimal">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
@@ -97,6 +99,7 @@ function toggleScaleMode() {
|
||||
</TooltipProvider>
|
||||
|
||||
<div
|
||||
v-if="!minimal"
|
||||
data-fixed-action
|
||||
aria-hidden="true"
|
||||
class="mx-2 h-5 w-px shrink-0 self-center bg-border"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Keyboard from 'simple-keyboard'
|
||||
@@ -38,6 +39,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
|
||||
const isAttached = ref(props.attached ?? true)
|
||||
const selectedOs = ref<KeyboardOsType>('windows')
|
||||
@@ -594,6 +596,7 @@ onUnmounted(() => {
|
||||
v-if="visible"
|
||||
:id="keyboardId"
|
||||
ref="keyboardRef"
|
||||
:data-console-layout="consoleAppearance"
|
||||
class="vkb"
|
||||
:class="{
|
||||
'vkb--attached': isAttached,
|
||||
@@ -909,7 +912,7 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
min-width: 1200px;
|
||||
max-width: 1600px;
|
||||
width: auto;
|
||||
border-radius: var(--radius-lg);
|
||||
border-radius: var(--console-surface-radius, var(--radius-lg));
|
||||
box-shadow: 0 25px 50px -12px color-mix(in oklch, var(--foreground) 25%, transparent);
|
||||
}
|
||||
|
||||
@@ -936,7 +939,7 @@ html.dark .hg-theme-default .hg-button.down-key,
|
||||
|
||||
.vkb--floating .vkb-header {
|
||||
cursor: move;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
border-radius: var(--console-surface-radius, var(--radius-lg)) var(--console-surface-radius, var(--radius-lg)) 0 0;
|
||||
}
|
||||
|
||||
.vkb-header-left {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { X } from "lucide-vue-next"
|
||||
@@ -31,6 +33,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { DropdownMenuContentEmits, DropdownMenuContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -21,7 +23,7 @@ const props = withDefaults(
|
||||
)
|
||||
const emits = defineEmits<DropdownMenuContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class", "sideOffset")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
@@ -30,6 +32,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { DropdownMenuSubContentEmits, DropdownMenuSubContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -19,6 +21,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<template>
|
||||
<DropdownMenuSubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
v-bind="forwarded"
|
||||
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] max-w-(--reka-dropdown-menu-content-available-width) origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { HoverCardContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -20,7 +22,7 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class", "sideOffset")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
@@ -29,6 +31,8 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
data-slot="hover-card-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
|
||||
v-bind="{ ...$attrs, ...forwardedProps }"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { PopoverContentEmits, PopoverContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -22,7 +24,7 @@ const props = withDefaults(
|
||||
)
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const delegatedProps = reactiveOmit(props, "class", "sideOffset")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
@@ -31,6 +33,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
data-slot="popover-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
:side-offset="props.sideOffset + (consoleAppearance === 'floating' ? 10 : consoleAppearance === 'sidebar' ? 4 : 0)"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -32,6 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
data-slot="select-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { X } from "lucide-vue-next"
|
||||
@@ -36,6 +38,8 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<SheetOverlay />
|
||||
<DialogContent
|
||||
data-slot="sheet-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
:data-edge="side"
|
||||
:class="cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
side === 'right'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useConsoleAppearance } from "@/composables/useConsoleAppearance"
|
||||
const consoleAppearance = useConsoleAppearance()
|
||||
import type { TooltipContentEmits, TooltipContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
@@ -23,6 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
data-slot="tooltip-content"
|
||||
:data-console-layout="consoleAppearance"
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="cn('bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance', props.class)"
|
||||
>
|
||||
|
||||
22
web/src/composables/useConsoleAppearance.ts
Normal file
22
web/src/composables/useConsoleAppearance.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { inject, provide, type InjectionKey, type Ref } from 'vue'
|
||||
import type { ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||
|
||||
// Injection follows component ownership through portals, without styling settings/login.
|
||||
const consoleAppearanceKey: InjectionKey<Readonly<Ref<ConsoleLayout>>> = Symbol('consoleAppearance')
|
||||
|
||||
export function provideConsoleAppearance(layout: Readonly<Ref<ConsoleLayout>>) {
|
||||
provide(consoleAppearanceKey, layout)
|
||||
}
|
||||
|
||||
export function useConsoleAppearance() {
|
||||
return inject(consoleAppearanceKey, undefined)
|
||||
}
|
||||
|
||||
// Focus the panel itself so the first help tooltip does not obscure its controls on open.
|
||||
// Keyboard users can then Tab into the panel's controls in their normal order.
|
||||
export function focusConsolePanel(event: Event) {
|
||||
if (event.target instanceof HTMLElement) {
|
||||
event.preventDefault()
|
||||
event.target.focus({ preventScroll: true })
|
||||
}
|
||||
}
|
||||
32
web/src/composables/useConsoleLayout.ts
Normal file
32
web/src/composables/useConsoleLayout.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
export type ConsoleLayout = 'current' | 'floating' | 'sidebar'
|
||||
|
||||
const STORAGE_KEY = 'consoleLayout'
|
||||
const VALID_LAYOUTS: ConsoleLayout[] = ['current', 'floating', 'sidebar']
|
||||
|
||||
function readStoredLayout(): ConsoleLayout {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return VALID_LAYOUTS.includes(stored as ConsoleLayout)
|
||||
? stored as ConsoleLayout
|
||||
: 'current'
|
||||
}
|
||||
|
||||
const consoleLayout = ref<ConsoleLayout>(readStoredLayout())
|
||||
|
||||
watch(consoleLayout, layout => {
|
||||
localStorage.setItem(STORAGE_KEY, layout)
|
||||
})
|
||||
|
||||
export function useConsoleLayout() {
|
||||
function setConsoleLayout(layout: ConsoleLayout) {
|
||||
if (VALID_LAYOUTS.includes(layout)) {
|
||||
consoleLayout.value = layout
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
consoleLayout,
|
||||
setConsoleLayout,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import type { RemovableRef } from '@vueuse/core'
|
||||
|
||||
export type FeatureVisibilityKey = 'webTerminal' | 'computerUse' | 'pasteText'
|
||||
export type FeatureVisibilityKey = 'power' | 'webTerminal' | 'computerUse' | 'pasteText'
|
||||
export type FeatureVisibility = Record<FeatureVisibilityKey, boolean>
|
||||
|
||||
const DEFAULT_FEATURE_VISIBILITY: FeatureVisibility = {
|
||||
power: true,
|
||||
webTerminal: true,
|
||||
computerUse: true,
|
||||
pasteText: true,
|
||||
|
||||
49
web/src/composables/useHidConnection.ts
Normal file
49
web/src/composables/useHidConnection.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ref, watch, onMounted, onUnmounted, onDeactivated, onActivated, type Ref } from 'vue'
|
||||
import type { hidApi } from '@/api'
|
||||
import { request } from '@/api/request'
|
||||
import type { BluetoothStatus } from '@/types/bluetooth'
|
||||
export function useHidConnection(active: Ref<boolean>, backend: Ref<string | undefined>) {
|
||||
const status = ref<Awaited<ReturnType<typeof hidApi.status>> | null>(null)
|
||||
const bluetooth = ref<BluetoothStatus | null>(null)
|
||||
const error = ref('')
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let controller: AbortController | undefined
|
||||
let generation = 0, inFlight = false, mounted = false, deactivated = false
|
||||
function stop() { generation++; clearTimeout(timer); controller?.abort(); status.value = null; bluetooth.value = null }
|
||||
async function refresh() {
|
||||
if (!mounted || deactivated || !active.value || document.hidden || inFlight) return
|
||||
inFlight = true
|
||||
const own = generation
|
||||
controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
const timeout = setTimeout(() => controller?.abort(), 5000)
|
||||
try {
|
||||
const [hidResult, btResult] = await Promise.allSettled([
|
||||
request<Awaited<ReturnType<typeof hidApi.status>>>('/hid/status', { signal }, { toastOnError: false }),
|
||||
backend.value === 'bluetooth' ? request<BluetoothStatus>('/hid/bluetooth', { signal }, { toastOnError: false }) : Promise.resolve(null),
|
||||
])
|
||||
if (own !== generation) return
|
||||
if (hidResult.status === 'rejected') throw hidResult.reason
|
||||
if (btResult.status === 'rejected') throw btResult.reason
|
||||
const hid = hidResult.value, bt = btResult.value
|
||||
status.value = hid.backend === backend.value ? hid : null
|
||||
bluetooth.value = hid.backend === backend.value ? bt : null
|
||||
error.value = ''
|
||||
} catch (e) {
|
||||
if (own !== generation) return
|
||||
status.value = null; bluetooth.value = null
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
inFlight = false
|
||||
if (mounted && !deactivated && active.value && !document.hidden) timer = setTimeout(refresh, 2000)
|
||||
}
|
||||
}
|
||||
function restart() { stop(); error.value = ''; void refresh() }
|
||||
watch([active, backend], restart)
|
||||
onMounted(() => { mounted = true; document.addEventListener('visibilitychange', restart); restart() })
|
||||
onActivated(() => { deactivated = false; restart() })
|
||||
onDeactivated(() => { deactivated = true; stop() })
|
||||
onUnmounted(() => { mounted = false; stop(); document.removeEventListener('visibilitychange', restart) })
|
||||
return { status, bluetooth, error, restart }
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties, Ref } from 'vue'
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
|
||||
export type VideoScaleMode = 'fit' | 'actual'
|
||||
export type VideoRotation = 0 | 90 | 180 | 270
|
||||
|
||||
export interface VideoSize {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function useVideoScaling() {
|
||||
export function useVideoScaling(options: { rotation?: Readonly<Ref<VideoRotation>> } = {}) {
|
||||
const workspaceRef = ref<HTMLDivElement | null>(null)
|
||||
const scaleMode = ref<VideoScaleMode>('fit')
|
||||
const sourceSize = ref<VideoSize | null>(null)
|
||||
const rotation = options.rotation ?? ref<VideoRotation>(0)
|
||||
const { width: workspaceWidth, height: workspaceHeight } = useElementSize(workspaceRef)
|
||||
|
||||
const sourceSizeAvailable = computed(() => sourceSize.value !== null)
|
||||
@@ -20,8 +22,18 @@ export function useVideoScaling() {
|
||||
scaleMode.value === 'actual' && sourceSizeAvailable.value ? 'actual' : 'fit'
|
||||
))
|
||||
|
||||
const fittedSize = computed<VideoSize | null>(() => {
|
||||
const hasQuarterTurn = computed(() => rotation.value === 90 || rotation.value === 270)
|
||||
const rotatedSourceSize = computed<VideoSize | null>(() => {
|
||||
const source = sourceSize.value
|
||||
if (!source) return null
|
||||
|
||||
return hasQuarterTurn.value
|
||||
? { width: source.height, height: source.width }
|
||||
: source
|
||||
})
|
||||
|
||||
const fittedSize = computed<VideoSize | null>(() => {
|
||||
const source = rotatedSourceSize.value
|
||||
if (!source || workspaceWidth.value <= 0 || workspaceHeight.value <= 0) return null
|
||||
|
||||
const scale = Math.min(
|
||||
@@ -40,7 +52,7 @@ export function useVideoScaling() {
|
||||
)
|
||||
|
||||
const containerStyle = computed<CSSProperties>(() => {
|
||||
const size = effectiveScaleMode.value === 'actual' ? sourceSize.value : fittedSize.value
|
||||
const size = effectiveScaleMode.value === 'actual' ? rotatedSourceSize.value : fittedSize.value
|
||||
if (size) {
|
||||
return {
|
||||
width: `${size.width}px`,
|
||||
@@ -55,6 +67,19 @@ export function useVideoScaling() {
|
||||
}
|
||||
})
|
||||
|
||||
// A quarter turn swaps the displayed dimensions. Keep the video itself at
|
||||
// its unrotated dimensions, then rotate it inside the correctly sized frame.
|
||||
const contentStyle = computed<CSSProperties>(() => {
|
||||
const size = effectiveScaleMode.value === 'actual' ? rotatedSourceSize.value : fittedSize.value
|
||||
const quarterTurn = hasQuarterTurn.value
|
||||
|
||||
return {
|
||||
width: size ? `${quarterTurn ? size.height : size.width}px` : '100%',
|
||||
height: size ? `${quarterTurn ? size.width : size.height}px` : '100%',
|
||||
transform: `rotate(${rotation.value}deg)`,
|
||||
}
|
||||
})
|
||||
|
||||
function updateSourceSize(width: number, height: number) {
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return
|
||||
|
||||
@@ -88,6 +113,7 @@ export function useVideoScaling() {
|
||||
sourceSizeAvailable,
|
||||
stageClass,
|
||||
containerStyle,
|
||||
contentStyle,
|
||||
updateSourceSize,
|
||||
clearSourceSize,
|
||||
setScaleMode,
|
||||
|
||||
@@ -1,4 +1,86 @@
|
||||
export default {
|
||||
hidGuide: {
|
||||
missingDevice: 'The previous device {device} is unavailable. Check the current selection.',
|
||||
connectionTimeout: 'Connection timed out. Check the data cable and the controlled computer’s USB port. You can connect later or reconfigure.',
|
||||
"deviceTitle": "Driver and device",
|
||||
"features": "Feature configuration",
|
||||
"saveFeatures": "Save features",
|
||||
"configure": "Configure driver",
|
||||
"reconfigure": "Reconfigure",
|
||||
"driver": "HID driver",
|
||||
"driver_otg": "USB OTG",
|
||||
"driver_ch9329": "CH9329",
|
||||
"driver_bluetooth": "Bluetooth HID",
|
||||
"driver_none": "Disabled",
|
||||
"device_otg": "UDC",
|
||||
"device_ch9329": "Serial port",
|
||||
"device_bluetooth": "Local Bluetooth adapter",
|
||||
"host": "Controlled computer",
|
||||
"hostUsb": "Host USB",
|
||||
"otgPort": "One-KVM OTG USB",
|
||||
"serialLink": "CH340 → Serial connection → CH9329",
|
||||
"integratedCable": "CH340 + CH9329 integrated cable",
|
||||
"dataCable": "USB data cable",
|
||||
"wireless": "Bluetooth connection",
|
||||
"wiring_otg": "One-KVM OTG USB port → USB data cable → controlled computer USB port.",
|
||||
"wiring_ch9329": "Connect One-KVM to CH9329 over serial, then connect CH9329 USB to the controlled computer.",
|
||||
"wiring_bluetooth": "On the controlled computer, open System Settings → Add Bluetooth device → select the advertised HID name.",
|
||||
"disabledHelp": "One-KVM cannot send keyboard or mouse input while HID is disabled.",
|
||||
"selectDevice": "Select a device",
|
||||
"noDevices": "No devices found. Check the connection and refresh.",
|
||||
"nameInvalid": "Use 1–64 UTF-8 bytes without control characters.",
|
||||
"unconfigured": "HID is not configured",
|
||||
"disabled": "HID is disabled",
|
||||
"legacyAuto": "Automatic (legacy configuration)",
|
||||
"unknown": "Unknown or unable to read status",
|
||||
"ready": "HID ready",
|
||||
"preparing": "Connected, preparing HID",
|
||||
"paired": "Paired, waiting for connection",
|
||||
"pairing": "Waiting for pairing",
|
||||
"waiting": "Applied, waiting for connection",
|
||||
"initializing": "Initializing",
|
||||
"appliedHelp": "Configuration applied. Closing keeps the configuration and completed pairing.",
|
||||
"draftHelp": "Choose a driver and device. The running configuration changes only when you apply.",
|
||||
"dirty": "There are unsaved feature changes. Return to save them, or discard them to continue.",
|
||||
"returnSave": "Return to save",
|
||||
"discard": "Discard changes and continue",
|
||||
"pairInstructions": "Add a Bluetooth device in the controlled computer’s system settings and select “{name}”. The pairing window lasts 120 seconds.",
|
||||
"pairTimeout": "The pairing window ended. You can open it again.",
|
||||
"reopenPairing": "Reopen pairing",
|
||||
"resetWarning": "Previous One-KVM HID bonds will be cleared. Remove the old device on the controlled computer before adding it again.",
|
||||
"disableUsb": "Leaving USB OTG will disable: {functions}.",
|
||||
"msd": "Virtual disk",
|
||||
"network": "USB network",
|
||||
"audio": "USB audio",
|
||||
"done": "Done",
|
||||
"later": "Connect later",
|
||||
"configureLater": "Configure later",
|
||||
"useConfiguration": "Use this configuration",
|
||||
"repair": "Pair again",
|
||||
"uncertain": "The previous request has an unknown result. Check the connection first; applying again will clear bonds again.",
|
||||
"checkConnection": "Check applied configuration",
|
||||
"resumeRetry": "The current configuration differs from the pending selection. Review it and retry."
|
||||
},
|
||||
bluetoothHid: {
|
||||
paired: 'Paired; waiting for the computer to connect keyboard and mouse',
|
||||
"description": "Classic Bluetooth keyboard and relative mouse.",
|
||||
"adapter": "Bluetooth adapter",
|
||||
"name": "Device name",
|
||||
"peer": "Computer address (optional)",
|
||||
"peerHelp": "Leave blank to reuse the only bonded device, or select a computer during pairing.",
|
||||
"ready": "Keyboard and mouse ready",
|
||||
"pairing": "Pairing open: {seconds}s",
|
||||
"connected": "Connected; waiting for HID channels",
|
||||
"waiting": "Waiting for the paired computer",
|
||||
"unavailable": "Bluetooth unavailable or starting",
|
||||
"openPairing": "Pair for 2 minutes",
|
||||
"closePairing": "Close pairing",
|
||||
"disconnect": "Disconnect",
|
||||
"forget": "Forget computer",
|
||||
"windows": "Windows 11: Settings → Bluetooth & devices → Add device → Bluetooth. After forgetting, remove the device in Windows before pairing again.",
|
||||
"applyFirst": "Apply the HID settings first, then open pairing."
|
||||
},
|
||||
|
||||
videoInput: {
|
||||
format: 'Input Format',
|
||||
resolution: 'Resolution',
|
||||
@@ -96,12 +178,19 @@ export default {
|
||||
backToPassword: 'Back to password',
|
||||
},
|
||||
status: {
|
||||
no_signal: 'No signal',
|
||||
busy: 'Device busy',
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting',
|
||||
disconnected: 'Disconnected',
|
||||
error: 'Error',
|
||||
},
|
||||
actionbar: {
|
||||
muted: 'Muted',
|
||||
more: 'More actions',
|
||||
collapseToolbar: 'Collapse toolbar',
|
||||
expandToolbar: 'Expand toolbar',
|
||||
|
||||
paste: 'Paste Text',
|
||||
micStart: 'Start Transfer',
|
||||
micStop: 'Stop Transfer',
|
||||
@@ -144,6 +233,7 @@ export default {
|
||||
streamSettings: 'Stream Settings',
|
||||
deviceSettings: 'Device Settings',
|
||||
videoMode: 'Mode',
|
||||
videoRotation: 'Rotation',
|
||||
selectMode: 'Select mode...',
|
||||
videoModeHint: 'HTTP uses more bandwidth but offers broad compatibility; WebRTC has stricter network requirements but uses less bandwidth',
|
||||
videoDevice: 'Device',
|
||||
@@ -190,6 +280,8 @@ export default {
|
||||
selectAudioDevice: 'Select device...',
|
||||
},
|
||||
infobar: {
|
||||
pointerCaptured: 'Mouse captured · Esc to release',
|
||||
|
||||
keys: 'Keys',
|
||||
pointer: 'Pointer',
|
||||
caps: 'Caps',
|
||||
@@ -439,6 +531,7 @@ export default {
|
||||
uploadFailed: 'File upload failed',
|
||||
driveUnreadable: 'Format unsupported',
|
||||
driveUnreadableTooltip: 'Unable to parse the exFAT filesystem. It may have been formatted with an unsupported format.',
|
||||
driveFilesystemUnsupportedHint: 'Web file browsing does not support this filesystem, but you can still connect the drive to the target machine.',
|
||||
reinitializeDrive: 'Re-initialize',
|
||||
diskMode: 'Drive Mode',
|
||||
singleDiskMode: 'Single',
|
||||
@@ -484,7 +577,7 @@ export default {
|
||||
downloadIncomplete: 'The remote image download was incomplete.',
|
||||
driveNotInitialized: 'The virtual drive is not initialized.',
|
||||
driveConnected: 'The virtual drive is connected to the controlled computer. Disconnect it before editing files.',
|
||||
driveFilesystemUnsupported: 'The virtual drive filesystem is unsupported. Reinitialize it to continue.',
|
||||
driveFilesystemUnsupported: 'Web file management does not support this format, but the drive can still be mounted on the controlled computer.',
|
||||
driveSizeInvalid: 'The virtual drive size is invalid.',
|
||||
storageSpaceUnavailable: 'Available virtual media storage space could not be determined.',
|
||||
storageFull: 'Virtual media storage does not have enough free space.',
|
||||
@@ -551,6 +644,18 @@ export default {
|
||||
networkAddresses: 'Network Addresses',
|
||||
language: 'Language',
|
||||
theme: 'Theme',
|
||||
consoleLayout: 'Console layout',
|
||||
consoleLayoutDesc: 'Choose how console controls are arranged. The preference is saved in this browser.',
|
||||
consoleLayoutHints: {
|
||||
current: 'Full status and labeled tools for configuration and troubleshooting.',
|
||||
sidebar: 'Compact status and fixed icons for frequent actions.',
|
||||
floating: 'Essential floating controls that collapse to keep the video clear.',
|
||||
},
|
||||
consoleLayoutOptions: {
|
||||
current: 'Top bar',
|
||||
floating: 'Floating bar',
|
||||
sidebar: 'Sidebar',
|
||||
},
|
||||
lightMode: 'Light',
|
||||
darkMode: 'Dark',
|
||||
systemMode: 'System',
|
||||
@@ -950,6 +1055,8 @@ export default {
|
||||
},
|
||||
},
|
||||
statusCard: {
|
||||
connectionDetails: 'Connection details',
|
||||
|
||||
device: 'Device',
|
||||
video: 'Video',
|
||||
hid: 'HID',
|
||||
@@ -1074,6 +1181,13 @@ export default {
|
||||
rustdesk: {
|
||||
title: 'RustDesk Remote',
|
||||
desc: 'Configure the RustDesk service; the selected codec will be locked',
|
||||
mode: 'Access Mode',
|
||||
modeId: 'ID Service',
|
||||
modeIdDesc: 'Register with an ID server and use the configured relay when direct connections fail',
|
||||
modeDirectIp: 'Direct IP',
|
||||
modeDirectIpDesc: 'Listen on the device only, without connecting to an ID or relay server',
|
||||
directAccessPort: 'Direct Access Port',
|
||||
directAccessPortInvalid: 'The direct access port must be between 1 and 65535',
|
||||
rendezvousServer: 'ID Server',
|
||||
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
|
||||
rendezvousServerRequired: 'Enter the RustDesk ID server',
|
||||
@@ -1081,7 +1195,6 @@ export default {
|
||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||
relayKey: 'Relay Key',
|
||||
codec: 'Codec',
|
||||
deviceInfo: 'Device Info',
|
||||
deviceId: 'Device ID',
|
||||
devicePassword: 'Device Password',
|
||||
showPassword: 'Show Password',
|
||||
@@ -1097,7 +1210,6 @@ export default {
|
||||
notInitialized: 'Not Initialized',
|
||||
copyId: 'Copy ID',
|
||||
copyPassword: 'Copy Password',
|
||||
keypairGenerated: 'Keypair Generated',
|
||||
},
|
||||
rtsp: {
|
||||
title: 'RTSP Streaming',
|
||||
|
||||
@@ -1,4 +1,86 @@
|
||||
export default {
|
||||
hidGuide: {
|
||||
missingDevice: '原设备 {device} 已不可用,请检查当前选择。',
|
||||
connectionTimeout: '等待连接超时,请检查数据线及被控机 USB 端口。可稍后连接或重新配置。',
|
||||
"deviceTitle": "驱动与设备",
|
||||
"features": "功能配置",
|
||||
"saveFeatures": "保存功能配置",
|
||||
"configure": "配置驱动",
|
||||
"reconfigure": "重新配置",
|
||||
"driver": "HID 驱动",
|
||||
"driver_otg": "USB OTG",
|
||||
"driver_ch9329": "CH9329",
|
||||
"driver_bluetooth": "蓝牙 HID",
|
||||
"driver_none": "禁用",
|
||||
"device_otg": "UDC",
|
||||
"device_ch9329": "串口",
|
||||
"device_bluetooth": "本机蓝牙适配器",
|
||||
"host": "被控机",
|
||||
"hostUsb": "被控机 USB",
|
||||
"otgPort": "One-KVM OTG USB",
|
||||
"serialLink": "CH340 → 串口连接 → CH9329",
|
||||
"integratedCable": "CH340 + CH9329 一体线",
|
||||
"dataCable": "USB 数据线",
|
||||
"wireless": "蓝牙无线连接",
|
||||
"wiring_otg": "One-KVM 的 OTG USB 端口 → USB 数据线 → 被控机 USB 端口。",
|
||||
"wiring_ch9329": "One-KVM 通过串口连接 CH9329,CH9329 的 USB 连接被控机。",
|
||||
"wiring_bluetooth": "在被控机打开系统设置 → 添加蓝牙设备 → 选择广播的 HID 设备名称。",
|
||||
"disabledHelp": "禁用后 One-KVM 无法发送键盘和鼠标输入。",
|
||||
"selectDevice": "请选择具体设备",
|
||||
"noDevices": "未找到可用设备,请检查连接后刷新。",
|
||||
"nameInvalid": "名称须为 1–64 个 UTF-8 字节,且不能含控制字符。",
|
||||
"unconfigured": "尚未配置 HID",
|
||||
"disabled": "HID 已禁用",
|
||||
"legacyAuto": "自动选择(旧配置)",
|
||||
"unknown": "状态未知或读取失败",
|
||||
"ready": "HID 可用",
|
||||
"preparing": "已连接,HID 准备中",
|
||||
"paired": "已配对,等待连接",
|
||||
"pairing": "等待配对",
|
||||
"waiting": "已应用,等待连接",
|
||||
"initializing": "正在初始化",
|
||||
"appliedHelp": "配置已应用。关闭引导会保留配置和已完成的配对。",
|
||||
"draftHelp": "选择驱动和设备,点击应用后才会更改当前配置。",
|
||||
"dirty": "功能配置有未保存的修改。请返回保存,或放弃修改后继续。",
|
||||
"returnSave": "返回保存",
|
||||
"discard": "放弃修改并继续",
|
||||
"pairInstructions": "请在被控机的系统设置中添加蓝牙设备,选择“{name}”。配对窗口为 120 秒。",
|
||||
"pairTimeout": "配对窗口已结束,可重新开启。",
|
||||
"reopenPairing": "重新开启配对",
|
||||
"resetWarning": "将清除原 One-KVM HID 绑定;请在被控机中删除旧设备后重新添加。",
|
||||
"disableUsb": "切出 USB OTG 将关闭:{functions}。",
|
||||
"msd": "虚拟磁盘",
|
||||
"network": "USB 网络",
|
||||
"audio": "USB 音频",
|
||||
"done": "完成",
|
||||
"later": "稍后连接",
|
||||
"configureLater": "稍后配置",
|
||||
"useConfiguration": "使用此配置",
|
||||
"repair": "重新配对",
|
||||
"uncertain": "上次请求结果不明。可先检查连接;再次应用将重新清除绑定。",
|
||||
"checkConnection": "检查已应用配置",
|
||||
"resumeRetry": "已读取当前配置,与待应用选择不一致。请检查选择后重试。"
|
||||
},
|
||||
bluetoothHid: {
|
||||
paired: '已配对,等待电脑建立键鼠连接',
|
||||
"description": "经典蓝牙键盘和相对鼠标。",
|
||||
"adapter": "蓝牙适配器",
|
||||
"name": "设备名称",
|
||||
"peer": "被控电脑地址(可选)",
|
||||
"peerHelp": "留空时复用唯一的已绑定设备,或在配对时选择电脑。",
|
||||
"ready": "键盘和鼠标已就绪",
|
||||
"pairing": "配对窗口剩余 {seconds} 秒",
|
||||
"connected": "已连接,等待 HID 通道建立",
|
||||
"waiting": "等待已配对电脑连接",
|
||||
"unavailable": "蓝牙不可用或正在启动",
|
||||
"openPairing": "开启配对(2 分钟)",
|
||||
"closePairing": "关闭配对",
|
||||
"disconnect": "断开连接",
|
||||
"forget": "忘记电脑",
|
||||
"windows": "Windows 11:设置 → 蓝牙和其他设备 → 添加设备 → 蓝牙。忘记设备后,请同时在 Windows 删除设备再重新配对。",
|
||||
"applyFirst": "先应用 HID 设置,再开启配对。"
|
||||
},
|
||||
|
||||
videoInput: {
|
||||
format: '输入格式',
|
||||
resolution: '分辨率',
|
||||
@@ -96,12 +178,19 @@ export default {
|
||||
backToPassword: '返回密码登录',
|
||||
},
|
||||
status: {
|
||||
no_signal: '无信号',
|
||||
busy: '设备占用中',
|
||||
connected: '已连接',
|
||||
connecting: '连接中',
|
||||
disconnected: '已断开',
|
||||
error: '错误',
|
||||
},
|
||||
actionbar: {
|
||||
muted: '已静音',
|
||||
more: '更多操作',
|
||||
collapseToolbar: '收起工具栏',
|
||||
expandToolbar: '展开工具栏',
|
||||
|
||||
paste: '粘贴文本',
|
||||
micStart: '开始传声',
|
||||
micStop: '停止传声',
|
||||
@@ -144,6 +233,7 @@ export default {
|
||||
streamSettings: '流设置',
|
||||
deviceSettings: '设备配置',
|
||||
videoMode: '视频模式',
|
||||
videoRotation: '视频旋转',
|
||||
selectMode: '选择模式...',
|
||||
videoModeHint: 'HTTP 对带宽占用较大但模式兼容性好;WebRTC 对网络要求较高但带宽占用低',
|
||||
videoDevice: '视频设备',
|
||||
@@ -190,6 +280,8 @@ export default {
|
||||
selectAudioDevice: '选择设备...',
|
||||
},
|
||||
infobar: {
|
||||
pointerCaptured: '鼠标已捕获 · Esc 释放',
|
||||
|
||||
keys: '按键',
|
||||
pointer: '指针',
|
||||
caps: 'Caps',
|
||||
@@ -438,6 +530,7 @@ export default {
|
||||
uploadFailed: '文件上传失败',
|
||||
driveUnreadable: '格式不支持',
|
||||
driveUnreadableTooltip: '无法解析 exFAT 文件系统,可能已被格式化为不支持的格式。',
|
||||
driveFilesystemUnsupportedHint: '网页暂不支持浏览此文件系统,但仍可连接到被控机使用。',
|
||||
reinitializeDrive: '重新初始化',
|
||||
diskMode: '驱动器模式',
|
||||
singleDiskMode: '单驱动器',
|
||||
@@ -483,7 +576,7 @@ export default {
|
||||
downloadIncomplete: '远程镜像下载不完整。',
|
||||
driveNotInitialized: '虚拟盘尚未初始化。',
|
||||
driveConnected: '虚拟盘已连接到被控机,请先断开连接再操作文件。',
|
||||
driveFilesystemUnsupported: '虚拟盘文件系统不受支持,请重新初始化后再操作。',
|
||||
driveFilesystemUnsupported: '网页文件管理不支持此格式,但仍可挂载到被控机使用。',
|
||||
driveSizeInvalid: '虚拟盘大小无效。',
|
||||
storageSpaceUnavailable: '无法获取虚拟媒体存储空间信息。',
|
||||
storageFull: '虚拟媒体存储空间不足。',
|
||||
@@ -550,6 +643,18 @@ export default {
|
||||
networkAddresses: '网络地址',
|
||||
language: '语言',
|
||||
theme: '主题',
|
||||
consoleLayout: '控制台布局',
|
||||
consoleLayoutDesc: '选择控制台操作区的排列方式,设置将保存在当前浏览器。',
|
||||
consoleLayoutHints: {
|
||||
current: '完整状态与文字工具栏,适合配置和排障。',
|
||||
sidebar: '精简状态与固定图标入口,适合频繁操作。',
|
||||
floating: '常用操作悬浮显示,可收起以专注画面。',
|
||||
},
|
||||
consoleLayoutOptions: {
|
||||
current: '顶栏',
|
||||
floating: '浮动栏',
|
||||
sidebar: '侧边栏',
|
||||
},
|
||||
lightMode: '浅色模式',
|
||||
darkMode: '深色模式',
|
||||
systemMode: '跟随系统',
|
||||
@@ -949,6 +1054,8 @@ export default {
|
||||
},
|
||||
},
|
||||
statusCard: {
|
||||
connectionDetails: '连接详情',
|
||||
|
||||
device: '设备',
|
||||
video: '视频',
|
||||
hid: 'HID',
|
||||
@@ -1073,6 +1180,13 @@ export default {
|
||||
rustdesk: {
|
||||
title: 'RustDesk 远程',
|
||||
desc: '配置 RustDesk 服务,将会锁定所选编码',
|
||||
mode: '接入模式',
|
||||
modeId: 'ID 服务',
|
||||
modeIdDesc: '通过 ID 服务器注册,并在直连失败时按配置使用中继服务器',
|
||||
modeDirectIp: 'IP 直连',
|
||||
modeDirectIpDesc: '仅监听设备端口,不连接 ID 或中继服务器',
|
||||
directAccessPort: '直连端口',
|
||||
directAccessPortInvalid: '直连端口必须在 1 到 65535 之间',
|
||||
rendezvousServer: 'ID 服务器',
|
||||
rendezvousServerPlaceholder: 'hbbs.example.com:21116',
|
||||
rendezvousServerRequired: '请填写 RustDesk ID 服务器',
|
||||
@@ -1080,7 +1194,6 @@ export default {
|
||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||
relayKey: '中继密钥',
|
||||
codec: '编码格式',
|
||||
deviceInfo: '设备信息',
|
||||
deviceId: '设备 ID',
|
||||
devicePassword: '设备密码',
|
||||
showPassword: '显示密码',
|
||||
@@ -1096,7 +1209,6 @@ export default {
|
||||
notInitialized: '未初始化',
|
||||
copyId: '复制 ID',
|
||||
copyPassword: '复制密码',
|
||||
keypairGenerated: '密钥对已生成',
|
||||
},
|
||||
rtsp: {
|
||||
title: 'RTSP 视频流',
|
||||
|
||||
73
web/src/lib/hidGuide.ts
Normal file
73
web/src/lib/hidGuide.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { HidConfig, HidConfigUpdate } from '../types/generated'
|
||||
import type { BluetoothStatus } from '../types/bluetooth'
|
||||
export type Driver = 'otg' | 'ch9329' | 'bluetooth' | 'none'
|
||||
export interface HidSelection {
|
||||
backend: Driver; otg_udc: string; ch9329_port: string; ch9329_baudrate: number
|
||||
bluetooth: { adapter: string; name: string }
|
||||
}
|
||||
export function selectionFrom(hid?: HidConfig | null): HidSelection {
|
||||
return { backend: hid?.backend ?? 'ch9329', otg_udc: hid?.otg_udc ?? '',
|
||||
ch9329_port: hid?.ch9329_port ?? '', ch9329_baudrate: hid?.ch9329_baudrate ?? 9600,
|
||||
bluetooth: { adapter: hid?.bluetooth.adapter ?? '', name: hid?.bluetooth.name ?? 'One-KVM HID' } }
|
||||
}
|
||||
export function selectDevice(saved: string, candidates: string[]): string {
|
||||
return candidates.includes(saved) ? saved : candidates.length === 1 ? candidates[0]! : ''
|
||||
}
|
||||
export function validName(name: string): boolean {
|
||||
const length = new TextEncoder().encode(name).length
|
||||
return length > 0 && length <= 64 && !/[\u0000-\u001f\u007f-\u009f]/u.test(name)
|
||||
}
|
||||
export function deviceRequest(draft: HidSelection): HidConfigUpdate {
|
||||
const backend = draft.backend as HidConfigUpdate['backend']
|
||||
switch (draft.backend) {
|
||||
case 'otg': return { backend, otg_udc: draft.otg_udc }
|
||||
case 'ch9329': return { backend, ch9329_port: draft.ch9329_port, ch9329_baudrate: draft.ch9329_baudrate }
|
||||
case 'bluetooth': return { backend, bluetooth: { ...draft.bluetooth }, bluetooth_reset_pairing: true }
|
||||
case 'none': return { backend }
|
||||
}
|
||||
}
|
||||
export function matchesSelection(hid: HidConfig, draft: HidSelection): boolean {
|
||||
if (hid.backend !== draft.backend) return false
|
||||
switch (draft.backend) {
|
||||
case 'otg': return hid.otg_udc === draft.otg_udc
|
||||
case 'ch9329': return hid.ch9329_port === draft.ch9329_port && hid.ch9329_baudrate === draft.ch9329_baudrate
|
||||
case 'bluetooth': return hid.bluetooth.adapter === draft.bluetooth.adapter && hid.bluetooth.name === draft.bluetooth.name
|
||||
case 'none': return true
|
||||
}
|
||||
}
|
||||
export function bluetoothStage(status: BluetoothStatus | null): string {
|
||||
if (!status || status.error) return 'unknown'
|
||||
if (status.ready) return 'ready'
|
||||
if (status.connected) return 'preparing'
|
||||
if (status.devices.some(d => d.address === status.peer && d.paired)) return 'paired'
|
||||
if (status.pairing_seconds > 0) return 'pairing'
|
||||
return status.initialized ? 'waiting' : 'initializing'
|
||||
}
|
||||
export interface HidDeviceStatus {
|
||||
backend: string; online: boolean; error?: string | null; error_code?: string | null
|
||||
}
|
||||
export function hidDeviceError(status?: HidDeviceStatus | null): string | null {
|
||||
// An unplugged OTG cable is an expected connection state, not a hardware fault.
|
||||
if (status?.backend === 'otg' && status.error_code === 'udc_not_configured') return null
|
||||
return status?.error ?? null
|
||||
}
|
||||
export function hidDeviceStage(status: HidDeviceStatus | null | undefined, bluetooth: BluetoothStatus | null, readError = ''): string {
|
||||
if (!status || readError || hidDeviceError(status)) return 'unknown'
|
||||
if (status.backend === 'bluetooth') return bluetoothStage(bluetooth)
|
||||
if (status.backend === 'otg' && status.error_code === 'udc_not_configured') return 'waiting'
|
||||
return status.online ? 'ready' : 'waiting'
|
||||
}
|
||||
export const pendingHidKey = 'one-kvm.pending-hid.v1'
|
||||
export interface PendingHid { selection: HidSelection; phase: 'selected' | 'applying' | 'applied' }
|
||||
export function readPendingHid(): PendingHid | null {
|
||||
try {
|
||||
const value = JSON.parse(sessionStorage.getItem(pendingHidKey) ?? 'null')
|
||||
if (value && ['selected', 'applying', 'applied'].includes(value.phase)
|
||||
&& ['otg', 'ch9329', 'bluetooth', 'none'].includes(value.selection?.backend)
|
||||
&& typeof value.selection.otg_udc === 'string' && typeof value.selection.ch9329_port === 'string'
|
||||
&& typeof value.selection.ch9329_baudrate === 'number'
|
||||
&& typeof value.selection.bluetooth?.adapter === 'string' && typeof value.selection.bluetooth?.name === 'string') return value
|
||||
} catch { /* Invalid or unavailable session storage is not an applied configuration. */ }
|
||||
return null
|
||||
}
|
||||
export function writePendingHid(value: PendingHid) { sessionStorage.setItem(pendingHidKey, JSON.stringify(value)) }
|
||||
41
web/src/lib/hidStatus.ts
Normal file
41
web/src/lib/hidStatus.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
type Status = 'connected' | 'connecting' | 'disconnected' | 'error'
|
||||
|
||||
interface HidState {
|
||||
available: boolean
|
||||
initialized: boolean
|
||||
online: boolean
|
||||
backend: string
|
||||
error?: string | null
|
||||
errorCode?: string | null
|
||||
}
|
||||
|
||||
interface InputTransport {
|
||||
useWebRtc: boolean
|
||||
dataChannelReady: boolean
|
||||
rtcConnecting: boolean
|
||||
rtcConnected: boolean
|
||||
wsConnected: boolean
|
||||
wsNetworkError: boolean
|
||||
wsHidUnavailable: boolean
|
||||
}
|
||||
|
||||
export function getHidStatus(hid: HidState | null, transport: InputTransport): Status {
|
||||
if (hid?.errorCode === 'udc_not_configured') return 'disconnected'
|
||||
if (hid?.error) return 'error'
|
||||
if (!hid?.available) return 'disconnected'
|
||||
|
||||
// A browser DataChannel can stay open after the controlled computer disconnects.
|
||||
// Both the HID backend and the browser input transport must be ready.
|
||||
if (!hid.online) {
|
||||
return hid.initialized && hid.backend !== 'bluetooth' ? 'connecting' : 'disconnected'
|
||||
}
|
||||
|
||||
if (transport.useWebRtc) {
|
||||
if (transport.dataChannelReady) return 'connected'
|
||||
if (transport.rtcConnecting || transport.rtcConnected) return 'connecting'
|
||||
}
|
||||
|
||||
if (transport.wsNetworkError) return 'connecting'
|
||||
if (!transport.wsConnected || transport.wsHidUnavailable) return 'disconnected'
|
||||
return 'connected'
|
||||
}
|
||||
@@ -516,8 +516,8 @@ export const useConfigStore = defineStore('config', () => {
|
||||
return response
|
||||
}
|
||||
|
||||
async function updateHid(update: HidConfigUpdate) {
|
||||
const response = await hidConfigApi.update(update)
|
||||
async function updateHid(update: HidConfigUpdate, signal?: AbortSignal) {
|
||||
const response = await hidConfigApi.update(update, signal)
|
||||
hid.value = response
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -185,3 +185,79 @@ body {
|
||||
.settings-dense [data-slot="card-footer"].border-t {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
/* Console surfaces follow their owning layout, including content rendered in portals. */
|
||||
[data-console-layout="current"] {
|
||||
--console-surface-radius: 8px;
|
||||
--console-control-radius: 5px;
|
||||
--console-menu-row: 32px;
|
||||
--console-surface-shadow: 0 4px 14px rgb(0 0 0 / 12%);
|
||||
}
|
||||
[data-console-layout="floating"] {
|
||||
--console-surface-radius: 16px;
|
||||
--console-control-radius: 10px;
|
||||
--console-menu-row: 38px;
|
||||
--console-surface-shadow: 0 12px 36px rgb(0 0 0 / 20%);
|
||||
}
|
||||
[data-console-layout="sidebar"] {
|
||||
--console-surface-radius: 10px;
|
||||
--console-control-radius: 7px;
|
||||
--console-menu-row: 36px;
|
||||
--console-surface-shadow: 4px 6px 20px rgb(0 0 0 / 14%);
|
||||
}
|
||||
[data-console-layout]:is(
|
||||
[data-slot="popover-content"], [data-slot="hover-card-content"],
|
||||
[data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"],
|
||||
[data-slot="select-content"], [data-slot="dialog-content"]
|
||||
) {
|
||||
border-radius: var(--console-surface-radius);
|
||||
box-shadow: var(--console-surface-shadow);
|
||||
}
|
||||
[data-console-layout][data-slot="popover-content"] {
|
||||
max-height: min(var(--reka-popover-content-available-height, 80dvh), 80dvh);
|
||||
overflow-y: auto;
|
||||
}
|
||||
[data-console-layout] :is([data-slot="input"], [data-slot="textarea"], [data-slot="select-trigger"], [data-slot="button"]) {
|
||||
border-radius: var(--console-control-radius);
|
||||
}
|
||||
[data-console-layout] :is([data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-sub-trigger"], [data-slot="select-item"]) {
|
||||
min-height: var(--console-menu-row);
|
||||
border-radius: var(--console-control-radius);
|
||||
}
|
||||
[data-console-layout="floating"]:is([data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-sub-content"]) {
|
||||
padding: 6px;
|
||||
}
|
||||
.console-config-panel[data-console-layout="floating"] {
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
padding: 16px;
|
||||
}
|
||||
.console-config-panel[data-console-layout="sidebar"] {
|
||||
padding: 14px;
|
||||
}
|
||||
[data-console-layout="floating"][data-slot="tooltip-content"] {
|
||||
border-radius: 8px;
|
||||
}
|
||||
[data-console-layout="floating"][data-slot="sheet-content"] {
|
||||
margin: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
}
|
||||
[data-console-layout="floating"][data-slot="sheet-content"]:is([data-edge="left"], [data-edge="right"]) {
|
||||
height: calc(100% - 24px);
|
||||
max-width: min(24rem, calc(100vw - 24px));
|
||||
}
|
||||
[data-console-layout="sidebar"][data-slot="sheet-content"][data-edge="right"] {
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
[data-console-layout] :is([data-slot="dropdown-menu-item"], [data-slot="select-item"]) {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-console-layout][data-slot="computer-use-panel"] {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--console-surface-radius);
|
||||
box-shadow: var(--console-surface-shadow);
|
||||
}
|
||||
|
||||
7
web/src/types/bluetooth.ts
Normal file
7
web/src/types/bluetooth.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface BluetoothAdapter { name: string; address: string; powered: boolean }
|
||||
export interface BluetoothStatus {
|
||||
initialized: boolean; connected: boolean; ready: boolean; adapter: string; adapter_address: string
|
||||
peer?: string | null; pairing_seconds: number; control_connected: boolean; interrupt_connected: boolean
|
||||
error?: string | null
|
||||
devices: Array<{ address: string; name: string; paired: boolean; connected: boolean }>
|
||||
}
|
||||
@@ -16,9 +16,16 @@ export interface VideoConfig {
|
||||
quality: number;
|
||||
}
|
||||
|
||||
export interface BluetoothHidConfig {
|
||||
adapter: string;
|
||||
name: string;
|
||||
peer?: string;
|
||||
}
|
||||
|
||||
export enum HidBackend {
|
||||
Otg = "otg",
|
||||
Ch9329 = "ch9329",
|
||||
Bluetooth = "bluetooth",
|
||||
None = "none",
|
||||
}
|
||||
|
||||
@@ -54,6 +61,7 @@ export interface Ch9329DescriptorConfig {
|
||||
}
|
||||
|
||||
export interface HidConfig {
|
||||
bluetooth: BluetoothHidConfig;
|
||||
backend: HidBackend;
|
||||
otg_udc?: string;
|
||||
otg_descriptor?: OtgDescriptorConfig;
|
||||
@@ -149,7 +157,6 @@ export enum EncoderType {
|
||||
Amf = "amf",
|
||||
Rkmpp = "rkmpp",
|
||||
V4l2m2m = "v4l2m2m",
|
||||
Amlogic = "amlogic",
|
||||
}
|
||||
|
||||
export type BitratePreset =
|
||||
@@ -250,6 +257,11 @@ export interface ExtensionsConfig {
|
||||
frpc: FrpcConfig;
|
||||
}
|
||||
|
||||
export enum RustDeskMode {
|
||||
Id = "id",
|
||||
DirectIp = "direct_ip",
|
||||
}
|
||||
|
||||
export enum RustDeskCodec {
|
||||
H264 = "h264",
|
||||
H265 = "h265",
|
||||
@@ -257,7 +269,9 @@ export enum RustDeskCodec {
|
||||
|
||||
export interface RustDeskConfig {
|
||||
enabled: boolean;
|
||||
mode: RustDeskMode;
|
||||
codec: RustDeskCodec;
|
||||
direct_access_port: number;
|
||||
rendezvous_server: string;
|
||||
relay_server?: string;
|
||||
device_id: string;
|
||||
@@ -299,6 +313,13 @@ export interface WatchdogConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Configuration for the USB Audio Class microphone gadget. */
|
||||
export interface UacConfig {
|
||||
enabled: boolean;
|
||||
sample_rate: number;
|
||||
channels: number;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
initialized: boolean;
|
||||
auth: AuthConfig;
|
||||
@@ -317,6 +338,7 @@ export interface AppConfig {
|
||||
rtsp: RtspConfig;
|
||||
redfish: RedfishConfig;
|
||||
watchdog: WatchdogConfig;
|
||||
uac: UacConfig;
|
||||
}
|
||||
|
||||
/** Update for a single ATX output binding */
|
||||
@@ -543,6 +565,8 @@ export interface OtgHidFunctionsUpdate {
|
||||
}
|
||||
|
||||
export interface HidConfigUpdate {
|
||||
bluetooth_reset_pairing?: boolean;
|
||||
bluetooth?: BluetoothHidConfig;
|
||||
backend?: HidBackend;
|
||||
ch9329_port?: string;
|
||||
ch9329_baudrate?: number;
|
||||
@@ -636,7 +660,9 @@ export interface RtspStatusResponse {
|
||||
|
||||
export interface RustDeskConfigUpdate {
|
||||
enabled?: boolean;
|
||||
mode?: RustDeskMode;
|
||||
codec?: RustDeskCodec;
|
||||
direct_access_port?: number;
|
||||
rendezvous_server?: string;
|
||||
relay_server?: string;
|
||||
relay_key?: string;
|
||||
|
||||
@@ -10,10 +10,11 @@ import { useConsoleEvents } from '@/composables/useConsoleEvents'
|
||||
import { useHidWebSocket } from '@/composables/useHidWebSocket'
|
||||
import { useWebRTC } from '@/composables/useWebRTC'
|
||||
import { useVideoSession } from '@/composables/useVideoSession'
|
||||
import { useVideoScaling } from '@/composables/useVideoScaling'
|
||||
import { useVideoScaling, type VideoRotation } from '@/composables/useVideoScaling'
|
||||
import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composables/useComputerUseSocket'
|
||||
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useConsoleLayout } from '@/composables/useConsoleLayout'
|
||||
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
|
||||
import { getMicrophone } from '@/composables/useMicrophone'
|
||||
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } from '@/api'
|
||||
@@ -24,14 +25,18 @@ import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/key
|
||||
import { toast } from 'vue-sonner'
|
||||
import { cn, generateUUID } from '@/lib/utils'
|
||||
import { formatFpsValue } from '@/lib/fps'
|
||||
import { getHidStatus } from '@/lib/hidStatus'
|
||||
import { videoDebugLog } from '@/lib/debugLog'
|
||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||
import { isAudioDeviceLostStateReason, isAudioStreamDeviceLostPayload } from '@/lib/streamSignal'
|
||||
import type { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket'
|
||||
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
|
||||
|
||||
import StatusCard, { type StatusDetail } from '@/components/StatusCard.vue'
|
||||
import StatusCard, { type StatusDetail, type ConnectionStatus } from '@/components/StatusCard.vue'
|
||||
import ActionBar from '@/components/ActionBar.vue'
|
||||
import ConsoleHeaderActions from '@/components/ConsoleHeaderActions.vue'
|
||||
import { provideConsoleAppearance } from '@/composables/useConsoleAppearance'
|
||||
import ConsoleStatusSummary, { type ConsoleStatusItem } from '@/components/ConsoleStatusSummary.vue'
|
||||
import InfoBar from '@/components/InfoBar.vue'
|
||||
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
|
||||
import StatsSheet from '@/components/StatsSheet.vue'
|
||||
@@ -100,6 +105,19 @@ const consoleEvents = useConsoleEvents({
|
||||
})
|
||||
|
||||
const videoMode = ref<VideoMode>('mjpeg')
|
||||
const VIDEO_ROTATIONS: VideoRotation[] = [0, 90, 180, 270]
|
||||
const storedVideoRotation = Number(localStorage.getItem('videoRotation'))
|
||||
const videoRotation = ref<VideoRotation>(
|
||||
VIDEO_ROTATIONS.includes(storedVideoRotation as VideoRotation)
|
||||
? storedVideoRotation as VideoRotation
|
||||
: 0,
|
||||
)
|
||||
|
||||
function setVideoRotation(rotation: VideoRotation) {
|
||||
if (!VIDEO_ROTATIONS.includes(rotation)) return
|
||||
videoRotation.value = rotation
|
||||
localStorage.setItem('videoRotation', String(rotation))
|
||||
}
|
||||
const computerUseOpen = ref(false)
|
||||
const computerUseSession = ref<ComputerUseSession | null>(null)
|
||||
const computerUseTimeline = ref<ComputerUseTimelineItem[]>([])
|
||||
@@ -130,10 +148,11 @@ const {
|
||||
sourceSizeAvailable,
|
||||
stageClass: videoStageClass,
|
||||
containerStyle: videoContainerStyle,
|
||||
contentStyle: videoContentStyle,
|
||||
updateSourceSize: updateVideoSourceSize,
|
||||
clearSourceSize: clearVideoSourceSize,
|
||||
setScaleMode: setVideoScaleMode,
|
||||
} = useVideoScaling()
|
||||
} = useVideoScaling({ rotation: videoRotation })
|
||||
|
||||
const backendFps = ref(0)
|
||||
|
||||
@@ -208,7 +227,7 @@ const isConsoleActive = ref(false)
|
||||
function syncMouseModeFromConfig() {
|
||||
const mouseAbsolute = configStore.hid?.mouse_absolute
|
||||
if (typeof mouseAbsolute !== 'boolean') return
|
||||
const nextMode: 'absolute' | 'relative' = mouseAbsolute ? 'absolute' : 'relative'
|
||||
const nextMode: 'absolute' | 'relative' = mouseAbsolute && configStore.hid?.backend !== 'bluetooth' ? 'absolute' : 'relative'
|
||||
if (mouseMode.value !== nextMode) {
|
||||
resetTouchInput()
|
||||
mouseMode.value = nextMode
|
||||
@@ -236,13 +255,19 @@ const ttydStatus = ref<{ available: boolean; running: boolean } | null>(null)
|
||||
const showTerminalDialog = ref(false)
|
||||
const featureVisibility = useFeatureVisibility()
|
||||
const { isDark, toggleTheme } = useTheme()
|
||||
const { consoleLayout } = useConsoleLayout()
|
||||
provideConsoleAppearance(consoleLayout)
|
||||
const terminalAvailable = computed(() => ttydStatus.value?.available !== false)
|
||||
const showPower = computed(() => featureVisibility.value.power)
|
||||
const showTerminal = computed(() => terminalAvailable.value && featureVisibility.value.webTerminal)
|
||||
const showComputerUse = computed(() => featureVisibility.value.computerUse)
|
||||
const showPasteText = computed(() => featureVisibility.value.pasteText)
|
||||
|
||||
const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||
const videoStatus = computed<ConnectionStatus>(() => {
|
||||
if (wsNetworkError.value) return 'connecting'
|
||||
if (streamSignalState.value === 'no_signal') return 'no_signal'
|
||||
if (streamSignalState.value === 'device_busy') return 'busy'
|
||||
if (streamSignalState.value === 'device_lost') return 'error'
|
||||
|
||||
if (videoError.value) return 'error'
|
||||
if (videoLoading.value) return 'connecting'
|
||||
@@ -255,6 +280,24 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
|
||||
return 'disconnected'
|
||||
})
|
||||
|
||||
// Optional idle devices are neutral; video and HID must stay visible when unavailable.
|
||||
const consoleStatusItems = computed<ConsoleStatusItem[]>(() => {
|
||||
const items: ConsoleStatusItem[] = [
|
||||
{ id: 'video', title: t('statusCard.video'), status: videoStatus.value,
|
||||
quickInfo: videoQuickInfo.value, details: videoDetails.value, required: true,
|
||||
errorMessage: showSignalOverlay.value ? signalOverlayInfo.value.title : videoErrorMessage.value },
|
||||
{ id: 'audio', title: t('statusCard.audio'), status: audioStatus.value,
|
||||
quickInfo: audioQuickInfo.value, details: audioDetails.value, errorMessage: audioErrorMessage.value },
|
||||
{ id: 'hid', title: t('statusCard.hid'), status: hidStatus.value,
|
||||
quickInfo: hidQuickInfo.value, details: hidDetails.value, errorMessage: hidErrorMessage.value, required: true },
|
||||
]
|
||||
if (showMsdStatusCard.value) items.push({
|
||||
id: 'msd', title: t('statusCard.msd'), status: msdStatus.value,
|
||||
quickInfo: msdQuickInfo.value, details: msdDetails.value, errorMessage: msdErrorMessage.value,
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
function openStatsSheet() {
|
||||
if (showConnectionStats.value) {
|
||||
statsSheetOpen.value = true
|
||||
@@ -287,6 +330,7 @@ const isMjpegPaused = computed(() => {
|
||||
})
|
||||
|
||||
const videoQuickInfo = computed(() => {
|
||||
if (videoStatus.value === 'no_signal' || videoStatus.value === 'busy') return t(`status.${videoStatus.value}`)
|
||||
const stream = systemStore.stream
|
||||
if (!stream?.resolution) return ''
|
||||
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
|
||||
@@ -305,8 +349,9 @@ const videoDetails = computed<StatusDetail[]>(() => {
|
||||
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}`
|
||||
|
||||
const targetFpsValue = formatFpsValue(stream.targetFps ?? 0)
|
||||
const actualFpsValue = paused ? t('statusCard.paused') : formatFpsValue(receivedFps)
|
||||
const actualStatus: StatusDetail['status'] = paused
|
||||
const signalUnavailable = streamSignalState.value !== 'ok'
|
||||
const actualFpsValue = signalUnavailable ? videoQuickInfo.value : paused ? t('statusCard.paused') : formatFpsValue(receivedFps)
|
||||
const actualStatus: StatusDetail['status'] = signalUnavailable ? 'warning' : paused
|
||||
? undefined
|
||||
: receivedFps > 5 ? 'ok'
|
||||
: receivedFps > 0 ? 'warning'
|
||||
@@ -323,27 +368,15 @@ const videoDetails = computed<StatusDetail[]>(() => {
|
||||
return details
|
||||
})
|
||||
|
||||
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||
const hid = systemStore.hid
|
||||
if (hid?.errorCode === 'udc_not_configured') return 'disconnected'
|
||||
if (hid?.error) return 'error'
|
||||
|
||||
if (videoMode.value !== 'mjpeg') {
|
||||
if (webrtc.dataChannelReady.value) return 'connected'
|
||||
if (webrtc.isConnecting.value) return 'connecting'
|
||||
if (webrtc.isConnected.value) return 'connecting'
|
||||
}
|
||||
|
||||
if (hidWs.networkError.value) return 'connecting'
|
||||
|
||||
if (!hidWs.connected.value) return 'disconnected'
|
||||
|
||||
if (hidWs.hidUnavailable.value) return 'disconnected'
|
||||
|
||||
if (hid?.available && hid.online) return 'connected'
|
||||
if (hid?.available && hid.initialized) return 'connecting'
|
||||
return 'disconnected'
|
||||
})
|
||||
const hidStatus = computed(() => getHidStatus(systemStore.hid, {
|
||||
useWebRtc: videoMode.value !== 'mjpeg',
|
||||
dataChannelReady: webrtc.dataChannelReady.value,
|
||||
rtcConnecting: webrtc.isConnecting.value,
|
||||
rtcConnected: webrtc.isConnected.value,
|
||||
wsConnected: hidWs.connected.value,
|
||||
wsNetworkError: hidWs.networkError.value,
|
||||
wsHidUnavailable: hidWs.hidUnavailable.value,
|
||||
}))
|
||||
|
||||
const hidQuickInfo = computed(() => {
|
||||
const hid = systemStore.hid
|
||||
@@ -2301,6 +2334,13 @@ function getRenderedVideoRect() {
|
||||
const rect = videoElement.getBoundingClientRect()
|
||||
if (rect.width <= 0 || rect.height <= 0) return null
|
||||
|
||||
// For a quarter turn, the transformed element already describes the exact
|
||||
// visible portrait frame. Its original landscape aspect ratio must not be
|
||||
// used to add artificial letterboxing here.
|
||||
if (videoRotation.value === 90 || videoRotation.value === 270) {
|
||||
return rect
|
||||
}
|
||||
|
||||
const contentAspectRatio = getActiveVideoAspectRatio()
|
||||
if (!contentAspectRatio) {
|
||||
return rect
|
||||
@@ -2330,6 +2370,32 @@ function getRenderedVideoRect() {
|
||||
}
|
||||
}
|
||||
|
||||
function rotateAbsolutePosition(x: number, y: number) {
|
||||
switch (videoRotation.value) {
|
||||
case 90:
|
||||
return { x: y, y: 1 - x }
|
||||
case 180:
|
||||
return { x: 1 - x, y: 1 - y }
|
||||
case 270:
|
||||
return { x: 1 - y, y: x }
|
||||
default:
|
||||
return { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
function rotateRelativeDelta(dx: number, dy: number) {
|
||||
switch (videoRotation.value) {
|
||||
case 90:
|
||||
return { dx: dy, dy: -dx }
|
||||
case 180:
|
||||
return { dx: -dx, dy: -dy }
|
||||
case 270:
|
||||
return { dx: -dy, dy: dx }
|
||||
default:
|
||||
return { dx, dy }
|
||||
}
|
||||
}
|
||||
|
||||
function getAbsoluteMousePosition(e: MouseEvent) {
|
||||
const rect = getRenderedVideoRect()
|
||||
if (!rect) return null
|
||||
@@ -2337,9 +2403,10 @@ function getAbsoluteMousePosition(e: MouseEvent) {
|
||||
const normalizedX = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const normalizedY = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
|
||||
|
||||
const sourcePosition = rotateAbsolutePosition(normalizedX, normalizedY)
|
||||
return {
|
||||
x: Math.round(normalizedX * 32767),
|
||||
y: Math.round(normalizedY * 32767),
|
||||
x: Math.round(sourcePosition.x * 32767),
|
||||
y: Math.round(sourcePosition.y * 32767),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2605,11 +2672,12 @@ function handleTouchPointerMove(e: PointerEvent) {
|
||||
|
||||
activePointer.lastX += dx
|
||||
activePointer.lastY += dy
|
||||
accumulatedDelta.x += dx
|
||||
accumulatedDelta.y += dy
|
||||
const rotatedDelta = rotateRelativeDelta(dx, dy)
|
||||
accumulatedDelta.x += rotatedDelta.dx
|
||||
accumulatedDelta.y += rotatedDelta.dy
|
||||
mousePosition.value = {
|
||||
x: mousePosition.value.x + dx,
|
||||
y: mousePosition.value.y + dy,
|
||||
x: mousePosition.value.x + rotatedDelta.dx,
|
||||
y: mousePosition.value.y + rotatedDelta.dy,
|
||||
}
|
||||
updateLocalCrosshairByDelta(dx, dy)
|
||||
requestMouseMoveFlush()
|
||||
@@ -2680,14 +2748,16 @@ function handleMouseMove(e: MouseEvent) {
|
||||
const dy = e.movementY
|
||||
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
accumulatedDelta.x += dx
|
||||
accumulatedDelta.y += dy
|
||||
const rotatedDelta = rotateRelativeDelta(dx, dy)
|
||||
accumulatedDelta.x += rotatedDelta.dx
|
||||
accumulatedDelta.y += rotatedDelta.dy
|
||||
requestMouseMoveFlush()
|
||||
}
|
||||
|
||||
const rotatedDelta = rotateRelativeDelta(dx, dy)
|
||||
mousePosition.value = {
|
||||
x: mousePosition.value.x + dx,
|
||||
y: mousePosition.value.y + dy,
|
||||
x: mousePosition.value.x + rotatedDelta.dx,
|
||||
y: mousePosition.value.y + rotatedDelta.dy,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3042,7 +3112,7 @@ function handleToggleMouseMode() {
|
||||
exitPointerLock()
|
||||
}
|
||||
|
||||
mouseMode.value = mouseMode.value === 'absolute' ? 'relative' : 'absolute'
|
||||
mouseMode.value = configStore.hid?.backend === 'bluetooth' ? 'relative' : (mouseMode.value === 'absolute' ? 'relative' : 'absolute')
|
||||
pendingMouseMove = null
|
||||
accumulatedDelta = { x: 0, y: 0 }
|
||||
lastMousePosition.value = { x: 0, y: 0 }
|
||||
@@ -3156,17 +3226,22 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen h-dvh flex flex-col bg-background">
|
||||
<header class="shrink-0 border-b bg-background">
|
||||
<div class="relative h-screen h-dvh flex flex-col bg-background">
|
||||
<header class="shrink-0 border-b bg-background" :class="consoleLayout === 'floating' && 'console-header--floating'">
|
||||
<div class="px-2 sm:px-4">
|
||||
<div class="h-10 sm:h-14 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 sm:gap-6">
|
||||
<div class="flex items-center gap-1.5 sm:gap-2">
|
||||
<div class="console-header__row h-10 sm:h-14 flex items-center justify-between">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2 sm:gap-6">
|
||||
<div class="flex shrink-0 items-center gap-1.5 whitespace-nowrap sm:gap-2">
|
||||
<BrandMark size="md" class="hidden sm:block" />
|
||||
<BrandMark size="sm" class="sm:hidden" />
|
||||
<span class="font-bold text-sm sm:text-lg">One-KVM</span>
|
||||
</div>
|
||||
<div class="flex md:hidden items-center gap-1">
|
||||
<ConsoleStatusSummary
|
||||
v-if="consoleLayout !== 'current'"
|
||||
:items="consoleStatusItems"
|
||||
:show-video-info="consoleLayout === 'sidebar'"
|
||||
/>
|
||||
<div v-else class="flex md:hidden items-center gap-1">
|
||||
<StatusCard
|
||||
:title="t('statusCard.video')"
|
||||
type="video"
|
||||
@@ -3188,8 +3263,19 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 sm:gap-2">
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<div v-if="consoleLayout === 'floating'" id="console-header-toolbar" class="console-header__toolbar" />
|
||||
<div class="console-header__account flex shrink-0 items-center gap-1 sm:gap-2">
|
||||
<ConsoleHeaderActions
|
||||
v-if="consoleLayout === 'floating'"
|
||||
:show-stats="showConnectionStats"
|
||||
:show-terminal="showTerminal"
|
||||
:terminal-running="!!ttydStatus?.running"
|
||||
:show-computer-use="showComputerUse"
|
||||
@open-stats="openStatsSheet"
|
||||
@open-terminal="openTerminal"
|
||||
@open-computer-use="openComputerUse"
|
||||
/>
|
||||
<div v-if="consoleLayout === 'current'" class="hidden md:flex items-center gap-2">
|
||||
<StatusCard
|
||||
:title="t('statusCard.video')"
|
||||
type="video"
|
||||
@@ -3269,10 +3355,15 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<Teleport :key="consoleLayout" defer :to="consoleLayout === 'floating' ? '#console-header-toolbar' : 'body'" :disabled="consoleLayout !== 'floating'">
|
||||
<ActionBar
|
||||
:layout="consoleLayout"
|
||||
:mouse-mode="mouseMode"
|
||||
:video-mode="videoMode"
|
||||
:video-rotation="videoRotation"
|
||||
:ttyd-running="ttydStatus?.running"
|
||||
:show-power="showPower"
|
||||
:atx-enabled="systemStore.atx?.available === true"
|
||||
:show-terminal="showTerminal"
|
||||
:show-computer-use="showComputerUse"
|
||||
:show-paste-text="showPasteText"
|
||||
@@ -3285,6 +3376,7 @@ onUnmounted(() => {
|
||||
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"
|
||||
@toggle-mouse-mode="handleToggleMouseMode"
|
||||
@update:video-mode="handleVideoModeChange"
|
||||
@update:video-rotation="setVideoRotation"
|
||||
@power-short="handlePowerShort"
|
||||
@power-long="handlePowerLong"
|
||||
@reset="handleReset"
|
||||
@@ -3292,7 +3384,11 @@ onUnmounted(() => {
|
||||
@open-terminal="openTerminal"
|
||||
@open-computer-use="openComputerUse"
|
||||
/>
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
</Teleport>
|
||||
<div
|
||||
class="flex-1 overflow-hidden relative transition-[padding] duration-300"
|
||||
:class="consoleLayout === 'sidebar' && 'pl-14 sm:pl-16'"
|
||||
>
|
||||
<div class="absolute inset-0 dot-grid-bg" />
|
||||
<div class="relative flex h-full w-full min-w-0 items-stretch gap-3 p-1 sm:p-4">
|
||||
<div
|
||||
@@ -3323,32 +3419,34 @@ onUnmounted(() => {
|
||||
@wheel.prevent="handleWheel"
|
||||
@contextmenu="handleContextMenu"
|
||||
>
|
||||
<img
|
||||
v-show="videoMode === 'mjpeg'"
|
||||
ref="videoRef"
|
||||
:src="mjpegUrl"
|
||||
class="size-full object-contain pointer-events-none select-none"
|
||||
:alt="t('console.videoAlt')"
|
||||
draggable="false"
|
||||
@load="handleVideoLoad"
|
||||
@error="handleVideoError"
|
||||
/>
|
||||
<video
|
||||
v-show="videoMode !== 'mjpeg'"
|
||||
ref="webrtcVideoRef"
|
||||
class="size-full object-contain pointer-events-none"
|
||||
autoplay
|
||||
playsinline
|
||||
@loadedmetadata="handleWebRTCVideoResize"
|
||||
@loadeddata="handleWebRTCVideoResize"
|
||||
@resize="handleWebRTCVideoResize"
|
||||
/>
|
||||
<img
|
||||
v-if="frameOverlayUrl"
|
||||
:src="frameOverlayUrl"
|
||||
class="absolute inset-0 size-full object-contain pointer-events-none"
|
||||
alt=""
|
||||
/>
|
||||
<div class="relative shrink-0" :style="videoContentStyle">
|
||||
<img
|
||||
v-show="videoMode === 'mjpeg'"
|
||||
ref="videoRef"
|
||||
:src="mjpegUrl"
|
||||
class="size-full object-contain pointer-events-none select-none"
|
||||
:alt="t('console.videoAlt')"
|
||||
draggable="false"
|
||||
@load="handleVideoLoad"
|
||||
@error="handleVideoError"
|
||||
/>
|
||||
<video
|
||||
v-show="videoMode !== 'mjpeg'"
|
||||
ref="webrtcVideoRef"
|
||||
class="size-full object-contain pointer-events-none"
|
||||
autoplay
|
||||
playsinline
|
||||
@loadedmetadata="handleWebRTCVideoResize"
|
||||
@loadeddata="handleWebRTCVideoResize"
|
||||
@resize="handleWebRTCVideoResize"
|
||||
/>
|
||||
<img
|
||||
v-if="frameOverlayUrl"
|
||||
:src="frameOverlayUrl"
|
||||
class="absolute inset-0 size-full object-contain pointer-events-none"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="cursorVisible && localCrosshairPos"
|
||||
class="pointer-events-none absolute z-[15] -translate-x-1/2 -translate-y-1/2"
|
||||
@@ -3532,6 +3630,13 @@ onUnmounted(() => {
|
||||
</Teleport>
|
||||
<div id="keyboard-anchor"></div>
|
||||
<InfoBar
|
||||
v-if="consoleLayout !== 'floating' || pressedKeys.length > 0 || isPointerLocked"
|
||||
:compact="consoleLayout !== 'current'"
|
||||
:captured="isPointerLocked"
|
||||
:minimal="consoleLayout === 'floating'"
|
||||
:class="consoleLayout === 'floating'
|
||||
? 'pointer-events-none absolute bottom-2 left-1/2 z-30 max-w-[calc(100%-1rem)] -translate-x-1/2 rounded-lg border shadow-sm !w-auto'
|
||||
: 'shrink-0'"
|
||||
:pressed-keys="pressedKeys"
|
||||
:caps-lock="keyboardLed.capsLock"
|
||||
:num-lock="keyboardLed.numLock"
|
||||
@@ -3599,6 +3704,27 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Expanded controls may overlap header actions, but stay outside the video stage. */
|
||||
.console-header--floating .console-header__row {
|
||||
position: relative;
|
||||
height: 60px;
|
||||
}
|
||||
.console-header__toolbar {
|
||||
position: absolute;
|
||||
z-index: 40;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
max-width: 64rem;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.console-header__toolbar :deep(.console-action-bar) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import HidDeviceSettings from '@/components/HidDeviceSettings.vue'
|
||||
import ConsoleLayoutPreview from '@/components/ConsoleLayoutPreview.vue'
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
@@ -46,6 +48,7 @@ import type {
|
||||
AtxDriverType,
|
||||
ActiveLevel,
|
||||
AtxDevices,
|
||||
HidConfigUpdate,
|
||||
OtgHidProfile,
|
||||
OtgHidFunctions,
|
||||
Ch9329DescriptorConfig,
|
||||
@@ -59,6 +62,7 @@ import { toConfigFps } from '@/lib/fps'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useConsoleLayout, type ConsoleLayout } from '@/composables/useConsoleLayout'
|
||||
import { useVideoDeviceConfiguration } from '@/composables/useVideoDeviceConfiguration'
|
||||
import { getVideoFormatState } from '@/lib/video-format-support'
|
||||
import { formatVideoDeviceLabel } from '@/lib/video-device-label'
|
||||
@@ -143,6 +147,9 @@ import {
|
||||
Bot,
|
||||
ClipboardPaste,
|
||||
Wrench,
|
||||
PanelTop,
|
||||
PanelLeft,
|
||||
GalleryHorizontalEnd,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { t, te } = useI18n()
|
||||
@@ -153,6 +160,15 @@ const configStore = useConfigStore()
|
||||
const authStore = useAuthStore()
|
||||
const featureVisibility = useFeatureVisibility()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { consoleLayout, setConsoleLayout } = useConsoleLayout()
|
||||
const consoleLayoutOptions: Array<{
|
||||
value: ConsoleLayout
|
||||
icon: typeof PanelTop
|
||||
}> = [
|
||||
{ value: 'current', icon: PanelTop },
|
||||
{ value: 'floating', icon: GalleryHorizontalEnd },
|
||||
{ value: 'sidebar', icon: PanelLeft },
|
||||
]
|
||||
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
|
||||
|
||||
const isWindows = computed(() => systemStore.platform?.mode === 'windows')
|
||||
@@ -496,16 +512,24 @@ const rustdeskCopied = ref<'id' | 'password' | null>(null)
|
||||
const { copy: clipboardCopy } = useClipboard()
|
||||
const rustdeskLocalConfig = ref({
|
||||
enabled: false,
|
||||
mode: 'id' as 'id' | 'direct_ip',
|
||||
codec: 'h264' as 'h264' | 'h265',
|
||||
direct_access_port: 21118,
|
||||
rendezvous_server: '',
|
||||
relay_server: '',
|
||||
relay_key: '',
|
||||
})
|
||||
|
||||
const rustdeskValidationMessage = computed(() => {
|
||||
if (!rustdeskLocalConfig.value.rendezvous_server?.trim()) {
|
||||
if (rustdeskLocalConfig.value.mode === 'id' && !rustdeskLocalConfig.value.rendezvous_server?.trim()) {
|
||||
return t('extensions.rustdesk.rendezvousServerRequired')
|
||||
}
|
||||
if (
|
||||
rustdeskLocalConfig.value.mode === 'direct_ip'
|
||||
&& (rustdeskLocalConfig.value.direct_access_port < 1 || rustdeskLocalConfig.value.direct_access_port > 65535)
|
||||
) {
|
||||
return t('extensions.rustdesk.directAccessPortInvalid')
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -1430,14 +1454,11 @@ async function saveConfig() {
|
||||
if (!isHidSettingsValid.value) {
|
||||
return
|
||||
}
|
||||
const hidUpdate: any = {
|
||||
backend: config.value.hid_backend as any,
|
||||
ch9329_port: config.value.hid_serial_device || undefined,
|
||||
ch9329_baudrate: config.value.hid_serial_baudrate,
|
||||
ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse,
|
||||
ch9329_macos_drag: config.value.hid_ch9329_macos_drag,
|
||||
otg_udc: config.value.hid_otg_udc,
|
||||
}
|
||||
const hidUpdate: HidConfigUpdate = configStore.hid?.backend === 'ch9329'
|
||||
? {
|
||||
ch9329_hybrid_mouse: config.value.hid_ch9329_hybrid_mouse,
|
||||
ch9329_macos_drag: config.value.hid_ch9329_macos_drag,
|
||||
} : {}
|
||||
if (config.value.hid_backend === 'ch9329' && isCh9329DescriptorDirty.value) {
|
||||
hidUpdate.ch9329_descriptor = {
|
||||
vendor_id: parseInt(ch9329VendorIdHex.value, 16) || 0x1a86,
|
||||
@@ -1455,32 +1476,37 @@ async function saveConfig() {
|
||||
product: otgProduct.value || 'One-KVM USB Device',
|
||||
serial_number: otgSerialNumber.value || undefined,
|
||||
}
|
||||
hidUpdate.otg_profile = 'custom'
|
||||
hidUpdate.otg_profile = 'custom' as OtgHidProfile
|
||||
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
|
||||
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
|
||||
}
|
||||
const otgEnabled = config.value.hid_backend === 'otg'
|
||||
const response = await configStore.updateOtg({
|
||||
hid: hidUpdate,
|
||||
msd: {
|
||||
enabled: otgEnabled && config.value.msd_enabled,
|
||||
msd_dir: config.value.msd_dir || undefined,
|
||||
flash_inquiry_string: config.value.msd_flash_inquiry_string,
|
||||
cdrom_inquiry_string: config.value.msd_cdrom_inquiry_string,
|
||||
},
|
||||
network: {
|
||||
enabled: otgEnabled && config.value.otg_network_enabled,
|
||||
driver_mode: config.value.otg_network_driver as any,
|
||||
bridge_interface: config.value.otg_network_interface,
|
||||
},
|
||||
})
|
||||
otgNetworkStatus.value = response.status
|
||||
if (configStore.hid?.backend === 'otg') {
|
||||
const otgEnabled = config.value.hid_backend === 'otg'
|
||||
const response = await configStore.updateOtg({
|
||||
hid: hidUpdate,
|
||||
msd: {
|
||||
enabled: otgEnabled && config.value.msd_enabled,
|
||||
msd_dir: config.value.msd_dir || undefined,
|
||||
flash_inquiry_string: config.value.msd_flash_inquiry_string,
|
||||
cdrom_inquiry_string: config.value.msd_cdrom_inquiry_string,
|
||||
},
|
||||
network: {
|
||||
enabled: otgEnabled && config.value.otg_network_enabled,
|
||||
driver_mode: config.value.otg_network_driver as any,
|
||||
bridge_interface: config.value.otg_network_interface,
|
||||
},
|
||||
})
|
||||
otgNetworkStatus.value = response.status
|
||||
|
||||
await uacApi.update({
|
||||
enabled: otgEnabled && config.value.uac_enabled,
|
||||
sample_rate: 48000,
|
||||
channels: 2,
|
||||
})
|
||||
await uacApi.update({
|
||||
enabled: otgEnabled && config.value.uac_enabled,
|
||||
sample_rate: 48000,
|
||||
channels: 2,
|
||||
})
|
||||
} else if (configStore.hid?.backend === 'ch9329') {
|
||||
await configStore.updateHid(hidUpdate)
|
||||
}
|
||||
await loadConfig()
|
||||
}
|
||||
|
||||
if (activeSection.value !== 'hid') {
|
||||
@@ -1499,6 +1525,15 @@ async function saveConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
const hidFeatureBaseline = ref('')
|
||||
function hidFeatureSnapshot() {
|
||||
return JSON.stringify({
|
||||
fields: Object.fromEntries(Object.entries(config.value).filter(([key]) => key.startsWith('msd_') || key.startsWith('otg_network_') || key.startsWith('uac_') || ['hid_otg_functions', 'hid_otg_keyboard_leds', 'hid_ch9329_hybrid_mouse', 'hid_ch9329_macos_drag'].includes(key))),
|
||||
descriptor: [otgVendorIdHex.value, otgProductIdHex.value, otgManufacturer.value, otgProduct.value, otgSerialNumber.value],
|
||||
})
|
||||
}
|
||||
const hidFeaturesDirty = computed(() => !!hidFeatureBaseline.value && (hidFeatureBaseline.value !== hidFeatureSnapshot() || isCh9329DescriptorDirty.value))
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const [video, stream, hid, msd, otgNetwork, uac] = await Promise.all([
|
||||
@@ -1572,6 +1607,8 @@ async function loadConfig() {
|
||||
clearCh9329DescriptorState()
|
||||
}
|
||||
otgNetworkStatus.value = await otgNetworkApi.status().catch(() => null)
|
||||
await nextTick()
|
||||
hidFeatureBaseline.value = hidFeatureSnapshot()
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
@@ -1944,7 +1981,9 @@ function applyRustdeskStatus(status: RustDeskStatusResponse) {
|
||||
rustdeskStatus.value = status
|
||||
rustdeskLocalConfig.value = {
|
||||
enabled: config.enabled,
|
||||
mode: config.mode || 'id',
|
||||
codec: config.codec || 'h264',
|
||||
direct_access_port: config.direct_access_port || 21118,
|
||||
rendezvous_server: config.rendezvous_server,
|
||||
relay_server: config.relay_server || '',
|
||||
relay_key: config.relay_key || '',
|
||||
@@ -2338,7 +2377,9 @@ function updateStatusBadgeText(): string {
|
||||
function rustdeskUpdatePayload(enabled = rustdeskLocalConfig.value.enabled) {
|
||||
return {
|
||||
enabled,
|
||||
mode: rustdeskLocalConfig.value.mode,
|
||||
codec: rustdeskLocalConfig.value.codec,
|
||||
direct_access_port: rustdeskLocalConfig.value.direct_access_port,
|
||||
rendezvous_server: normalizeRustdeskServer(
|
||||
rustdeskLocalConfig.value.rendezvous_server,
|
||||
21116,
|
||||
@@ -2349,7 +2390,10 @@ function rustdeskUpdatePayload(enabled = rustdeskLocalConfig.value.enabled) {
|
||||
}
|
||||
|
||||
async function saveRustdeskConfig() {
|
||||
if (rustdeskLocalConfig.value.enabled && !validateRustdeskConfig()) return
|
||||
if (
|
||||
(rustdeskLocalConfig.value.enabled || rustdeskLocalConfig.value.mode === 'direct_ip')
|
||||
&& !validateRustdeskConfig()
|
||||
) return
|
||||
|
||||
loading.value = true
|
||||
saved.value = false
|
||||
@@ -2785,6 +2829,33 @@ watch(isWindows, () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.consoleLayout') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.consoleLayoutDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
v-for="option in consoleLayoutOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="group rounded-lg border p-3 text-left transition-colors hover:bg-accent"
|
||||
:class="consoleLayout === option.value ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'border-border'"
|
||||
:aria-pressed="consoleLayout === option.value"
|
||||
@click="setConsoleLayout(option.value)"
|
||||
>
|
||||
<ConsoleLayoutPreview :layout="option.value" class="mb-3" />
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="option.icon" class="size-4 text-muted-foreground" />
|
||||
<span class="text-sm font-medium">{{ t(`settings.consoleLayoutOptions.${option.value}`) }}</span>
|
||||
<Check v-if="consoleLayout === option.value" class="ml-auto size-4 text-primary" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.language') }}</CardTitle>
|
||||
@@ -2801,6 +2872,20 @@ watch(isWindows, () => {
|
||||
<CardDescription>{{ t('settings.featureVisibilityDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-1">
|
||||
<div class="flex items-center justify-between gap-4 px-3 py-3">
|
||||
<Label for="feature-power" class="flex min-w-0 items-center gap-2 font-normal">
|
||||
<Power class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ t('actionbar.power') }}</span>
|
||||
</Label>
|
||||
<Switch id="feature-power" v-model="featureVisibility.power" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4 px-3 py-3">
|
||||
<Label for="feature-paste-text" class="flex min-w-0 items-center gap-2 font-normal">
|
||||
<ClipboardPaste class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ t('settings.pasteText') }}</span>
|
||||
</Label>
|
||||
<Switch id="feature-paste-text" v-model="featureVisibility.pasteText" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4 px-3 py-3">
|
||||
<Label for="feature-web-terminal" class="flex min-w-0 items-center gap-2 font-normal">
|
||||
<Terminal class="size-4 shrink-0 text-muted-foreground" />
|
||||
@@ -2815,13 +2900,6 @@ watch(isWindows, () => {
|
||||
</Label>
|
||||
<Switch id="feature-computer-use" v-model="featureVisibility.computerUse" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4 px-3 py-3">
|
||||
<Label for="feature-paste-text" class="flex min-w-0 items-center gap-2 font-normal">
|
||||
<ClipboardPaste class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="truncate">{{ t('settings.pasteText') }}</span>
|
||||
</Label>
|
||||
<Switch id="feature-paste-text" v-model="featureVisibility.pasteText" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -3044,64 +3122,10 @@ watch(isWindows, () => {
|
||||
|
||||
<!-- HID Section -->
|
||||
<div v-show="activeSection === 'hid'" class="space-y-4">
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-start justify-between space-y-0">
|
||||
<div class="space-y-1.5">
|
||||
<CardTitle>{{ t('settings.hidSettings') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.hidSettingsDesc') }}</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon-sm" :aria-label="t('common.refresh')" @click="loadHidDeviceOptions">
|
||||
<RefreshCw class="size-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<HidDeviceSettings :active="activeSection === 'hid'" :dirty="hidFeaturesDirty" @applied="loadConfig" @discard="loadConfig" />
|
||||
<Card v-if="configStore.hid?.backend === 'otg' || configStore.hid?.backend === 'ch9329'">
|
||||
<CardHeader><CardTitle>{{ t('hidGuide.features') }}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="hid-backend">{{ t('settings.hidBackend') }}</Label>
|
||||
<Select v-model="config.hid_backend">
|
||||
<SelectTrigger id="hid-backend" class="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ch9329">CH9329 (Serial)</SelectItem>
|
||||
<SelectItem value="otg">USB OTG</SelectItem>
|
||||
<SelectItem value="none">{{ t('common.disabled') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="config.hid_backend === 'ch9329'" class="space-y-2">
|
||||
<Label for="serial-device">{{ t('settings.serialDevice') }}</Label>
|
||||
<Select
|
||||
:model-value="config.hid_serial_device"
|
||||
@update:model-value="value => config.hid_serial_device = value === EMPTY_SELECT_VALUE ? '' : String(value)"
|
||||
>
|
||||
<SelectTrigger id="serial-device" class="w-full"><SelectValue :placeholder="t('settings.selectDevice')" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('settings.selectDevice') }}</SelectItem>
|
||||
<SelectItem v-for="dev in devices.serial" :key="dev.path" :value="dev.path">{{ dev.name }} ({{ dev.path }})</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="config.hid_backend === 'ch9329'" class="space-y-2">
|
||||
<Label for="serial-baudrate">{{ t('settings.baudRate') }}</Label>
|
||||
<Select :model-value="config.hid_serial_baudrate" @update:model-value="value => config.hid_serial_baudrate = Number(value)">
|
||||
<SelectTrigger id="serial-baudrate" class="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="baud in [9600, 19200, 38400, 57600, 115200]" :key="baud" :value="baud">{{ baud }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="config.hid_backend === 'otg'" class="space-y-2">
|
||||
<Label for="otg-udc">{{ t('settings.otgUdc') }}</Label>
|
||||
<Select
|
||||
:model-value="config.hid_otg_udc"
|
||||
@update:model-value="value => config.hid_otg_udc = value === EMPTY_SELECT_VALUE ? '' : String(value)"
|
||||
>
|
||||
<SelectTrigger id="otg-udc" class="w-full"><SelectValue :placeholder="t('settings.autoRecommended')" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('settings.autoRecommended') }}</SelectItem>
|
||||
<SelectItem v-for="udc in devices.udc" :key="udc.name" :value="udc.name">{{ udc.name }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<template v-if="config.hid_backend === 'ch9329'">
|
||||
<Separator class="my-4" />
|
||||
<div class="space-y-4">
|
||||
@@ -5069,6 +5093,21 @@ watch(isWindows, () => {
|
||||
<Label>{{ t('extensions.autoStart') }}</Label>
|
||||
<Switch v-model="rustdeskLocalConfig.enabled" />
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.mode') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Select v-model="rustdeskLocalConfig.mode" :disabled="rustdeskStatus?.service_status === 'running'">
|
||||
<SelectTrigger class="w-full"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="id">{{ t('extensions.rustdesk.modeId') }}</SelectItem>
|
||||
<SelectItem value="direct_ip">{{ t('extensions.rustdesk.modeDirectIp') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ rustdeskLocalConfig.mode === 'id' ? t('extensions.rustdesk.modeIdDesc') : t('extensions.rustdesk.modeDirectIpDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.codec') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
@@ -5078,7 +5117,7 @@ watch(isWindows, () => {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<div v-if="rustdeskLocalConfig.mode === 'id'" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.rendezvousServer') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input
|
||||
@@ -5089,7 +5128,7 @@ watch(isWindows, () => {
|
||||
<p v-if="rustdeskLocalConfig.enabled && rustdeskValidationMessage" class="text-xs text-destructive">{{ rustdeskValidationMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<div v-if="rustdeskLocalConfig.mode === 'id'" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.relayServer') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input
|
||||
@@ -5099,7 +5138,7 @@ watch(isWindows, () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<div v-if="rustdeskLocalConfig.mode === 'id'" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.relayKey') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<div class="relative">
|
||||
@@ -5126,15 +5165,26 @@ watch(isWindows, () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="rustdeskLocalConfig.mode === 'direct_ip'" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.directAccessPort') }}</Label>
|
||||
<div class="sm:col-span-3 space-y-1">
|
||||
<Input
|
||||
v-model.number="rustdeskLocalConfig.direct_access_port"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
:disabled="rustdeskStatus?.service_status === 'running'"
|
||||
/>
|
||||
<p v-if="rustdeskValidationMessage" class="text-xs text-destructive">{{ rustdeskValidationMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<!-- Device Info -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-medium">{{ t('extensions.rustdesk.deviceInfo') }}</h4>
|
||||
|
||||
<!-- Device ID -->
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<div v-if="rustdeskLocalConfig.mode === 'id'" class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.deviceId') }}</Label>
|
||||
<div class="sm:col-span-3 flex items-center gap-2">
|
||||
<code class="font-mono text-lg bg-muted px-3 py-1 rounded">{{ rustdeskConfig?.device_id || '-' }}</code>
|
||||
@@ -5179,15 +5229,6 @@ watch(isWindows, () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keypair Status -->
|
||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||
<Label class="sm:text-right">{{ t('extensions.rustdesk.keypairGenerated') }}</Label>
|
||||
<div class="sm:col-span-3">
|
||||
<Badge :variant="rustdeskConfig?.has_keypair ? 'default' : 'secondary'">
|
||||
{{ rustdeskConfig?.has_keypair ? t('common.yes') : t('common.no') }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter class="border-t pt-4 justify-end">
|
||||
@@ -5362,7 +5403,7 @@ watch(isWindows, () => {
|
||||
</div>
|
||||
|
||||
<!-- Save Button (sticky) -->
|
||||
<div v-if="['video', 'hid'].includes(activeSection)" class="sticky bottom-0 pt-3 sm:pt-4 pb-3 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||
<div v-if="activeSection === 'video' || (activeSection === 'hid' && ['otg', 'ch9329'].includes(configStore.hid?.backend ?? ''))" class="sticky bottom-0 pt-3 sm:pt-4 pb-3 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||
<div class="flex items-center justify-between gap-2 sm:gap-3">
|
||||
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="flex min-w-0 items-center gap-1.5 text-xs text-warning">
|
||||
<AlertTriangle class="size-3.5 shrink-0" />
|
||||
@@ -5379,7 +5420,7 @@ watch(isWindows, () => {
|
||||
<p v-if="saveError" class="text-xs text-destructive">{{ saveError }}</p>
|
||||
<p v-else class="text-xs text-muted-foreground hidden sm:block">{{ t('settings.unsavedChangesHint') }}</p>
|
||||
<Button class="shrink-0 ml-auto" :disabled="loading || (activeSection === 'hid' && !isHidSettingsValid)" @click="saveConfig">
|
||||
<Loader2 v-if="loading" class="size-4 mr-2 animate-spin" /><Check v-else-if="saved" class="size-4 mr-2" /><Save v-else class="size-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t('common.save') }}
|
||||
<Loader2 v-if="loading" class="size-4 mr-2 animate-spin" /><Check v-else-if="saved" class="size-4 mr-2" /><Save v-else class="size-4 mr-2" />{{ loading ? t('actionbar.applying') : saved ? t('common.success') : t(activeSection === 'hid' ? 'hidGuide.saveFeatures' : 'common.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import HidDriverForm from '@/components/HidDriverForm.vue'
|
||||
import { selectionFrom, readPendingHid, writePendingHid } from '@/lib/hidGuide'
|
||||
|
||||
import { ref, computed, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -77,13 +80,8 @@ const audioSupported = computed(() => platform.value?.audio.available ?? true)
|
||||
const totalSteps = 4
|
||||
const EMPTY_SELECT_VALUE = '__one-kvm-empty-select-value__'
|
||||
|
||||
const hidBackend = ref('ch9329')
|
||||
const ch9329Port = ref('')
|
||||
const ch9329Baudrate = ref(9600)
|
||||
const otgUdc = ref('')
|
||||
const hidOtgProfile = ref('full_no_consumer')
|
||||
const otgMsdEnabled = ref(true)
|
||||
const otgKeyboardLeds = ref(true)
|
||||
const hidSelection = ref(readPendingHid()?.selection ?? selectionFrom())
|
||||
const hidSelectionValid = ref(false)
|
||||
|
||||
const ttydEnabled = ref(false)
|
||||
const ttydAvailable = ref(false)
|
||||
@@ -154,15 +152,6 @@ const {
|
||||
refreshingInputStatus,
|
||||
} = videoConfiguration
|
||||
|
||||
function applyOtgDefaults() {
|
||||
if (hidBackend.value !== 'otg') return
|
||||
|
||||
hidOtgProfile.value = 'full_no_consumer'
|
||||
otgKeyboardLeds.value = true
|
||||
}
|
||||
|
||||
const baudRates = [9600, 19200, 38400, 57600, 115200]
|
||||
|
||||
const stepLabels = computed(() => [
|
||||
t('setup.stepAccount'),
|
||||
t('setup.stepAudioVideo'),
|
||||
@@ -231,29 +220,10 @@ watch(videoDevice, (newDevice) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Watch HID backend change to set defaults
|
||||
watch(hidBackend, (newBackend) => {
|
||||
if (newBackend === 'ch9329' && !ch9329Port.value && devices.value.serial.length > 0) {
|
||||
ch9329Port.value = devices.value.serial[0]?.path || ''
|
||||
}
|
||||
if (newBackend === 'otg' && !otgUdc.value && devices.value.udc.length > 0) {
|
||||
otgUdc.value = devices.value.udc[0]?.name || ''
|
||||
}
|
||||
applyOtgDefaults()
|
||||
})
|
||||
|
||||
watch(otgUdc, () => {
|
||||
applyOtgDefaults()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const status = await authStore.checkSetupStatus()
|
||||
platform.value = status.platform
|
||||
if (isWindows.value) {
|
||||
hidBackend.value = 'ch9329'
|
||||
otgMsdEnabled.value = false
|
||||
}
|
||||
if (!audioSupported.value) {
|
||||
audioEnabled.value = false
|
||||
audioDevice.value = '__none__'
|
||||
@@ -270,16 +240,6 @@ onMounted(async () => {
|
||||
videoDevice.value = result.video[0].path
|
||||
}
|
||||
|
||||
// Auto-select first serial device for CH9329
|
||||
if (result.serial.length > 0 && result.serial[0]) {
|
||||
ch9329Port.value = result.serial[0].path
|
||||
}
|
||||
|
||||
if (!isWindows.value && result.udc.length > 0 && result.udc[0]) {
|
||||
otgUdc.value = result.udc[0].name
|
||||
}
|
||||
applyOtgDefaults()
|
||||
|
||||
// Auto-select audio device if available (and no video device to trigger watch)
|
||||
if (audioSupported.value && result.audio.length > 0 && !audioDevice.value) {
|
||||
// Prefer HDMI audio device
|
||||
@@ -356,14 +316,11 @@ function validateStep2(): boolean {
|
||||
}
|
||||
|
||||
function validateStep3(): boolean {
|
||||
if (hidBackend.value === 'ch9329' && !ch9329Port.value) {
|
||||
error.value = t('setup.selectSerialPort')
|
||||
return false
|
||||
}
|
||||
if (hidBackend.value === 'otg' && !otgUdc.value) {
|
||||
error.value = t('setup.selectUdc')
|
||||
if (!hidSelectionValid.value) {
|
||||
error.value = t('hidGuide.selectDevice')
|
||||
return false
|
||||
}
|
||||
writePendingHid({ selection: hidSelection.value, phase: 'selected' })
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -372,6 +329,7 @@ function nextStep() {
|
||||
|
||||
if (step.value === 1 && !validateStep1()) return
|
||||
if (step.value === 2 && !validateStep2()) return
|
||||
if (step.value === 3 && !validateStep3()) return
|
||||
|
||||
if (step.value < totalSteps) {
|
||||
slideDirection.value = 'forward'
|
||||
@@ -390,9 +348,22 @@ function prevStep() {
|
||||
async function handleSetup() {
|
||||
error.value = ''
|
||||
|
||||
if (!validateStep3()) return
|
||||
if (!readPendingHid() || loading.value) return
|
||||
|
||||
loading.value = true
|
||||
// Reconcile a previous timed-out account request before submitting again.
|
||||
try {
|
||||
const status = await authStore.checkSetupStatus()
|
||||
if (status.initialized) {
|
||||
loading.value = false
|
||||
await router.push('/login')
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const [width, height] = (videoResolution.value || '').split('x').map(Number)
|
||||
|
||||
@@ -415,17 +386,8 @@ async function handleSetup() {
|
||||
setupData.video_fps = toConfigFps(videoFps.value)
|
||||
}
|
||||
|
||||
setupData.hid_backend = hidBackend.value
|
||||
if (hidBackend.value === 'ch9329') {
|
||||
setupData.hid_ch9329_port = ch9329Port.value
|
||||
setupData.hid_ch9329_baudrate = ch9329Baudrate.value
|
||||
}
|
||||
if (hidBackend.value === 'otg' && otgUdc.value) {
|
||||
setupData.hid_otg_udc = otgUdc.value
|
||||
setupData.hid_otg_profile = hidOtgProfile.value
|
||||
setupData.hid_otg_keyboard_leds = otgKeyboardLeds.value
|
||||
setupData.msd_enabled = otgMsdEnabled.value
|
||||
}
|
||||
setupData.hid_backend = 'none'
|
||||
setupData.msd_enabled = false
|
||||
|
||||
// Encoder backend setting
|
||||
if (encoderBackend.value !== 'auto') {
|
||||
@@ -441,8 +403,8 @@ async function handleSetup() {
|
||||
const success = await authStore.setup(setupData)
|
||||
|
||||
if (success) {
|
||||
await authStore.login(username.value, password.value)
|
||||
router.push('/')
|
||||
const loggedIn = await authStore.login(username.value, password.value)
|
||||
router.push(loggedIn ? '/' : '/login')
|
||||
} else {
|
||||
error.value = authStore.error || t('setup.setupFailed')
|
||||
}
|
||||
@@ -728,92 +690,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
<div v-else-if="step === 3" key="step3" class="space-y-4">
|
||||
<h3 class="text-lg font-medium text-center">{{ t('setup.stepHid') }}</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="hidBackend">{{ t('setup.hidBackend') }}</Label>
|
||||
<Select v-model="hidBackend">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ch9329">
|
||||
CH9329 ({{ t('setup.serialHid') }})
|
||||
</SelectItem>
|
||||
<SelectItem v-if="!isWindows" value="otg">USB OTG</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- CH9329 Settings -->
|
||||
<div v-if="hidBackend === 'ch9329'" class="space-y-4 p-4 rounded-lg bg-muted/50">
|
||||
<div class="flex items-start gap-2 text-sm text-muted-foreground mb-2">
|
||||
<HelpCircle class="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<p>{{ t('setup.ch9329Help') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="ch9329Port">{{ t('setup.serialPort') }}</Label>
|
||||
<Select
|
||||
:model-value="ch9329Port"
|
||||
@update:model-value="value => ch9329Port = value === EMPTY_SELECT_VALUE ? '' : String(value)"
|
||||
>
|
||||
<SelectTrigger id="ch9329Port" class="w-full">
|
||||
<SelectValue :placeholder="t('setup.selectSerialPort')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('setup.selectSerialPort') }}</SelectItem>
|
||||
<SelectItem v-for="port in devices.serial" :key="port.path" :value="port.path">
|
||||
{{ port.name }} ({{ port.path }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="!devices.serial.length" class="text-xs text-muted-foreground">
|
||||
{{ t('setup.noSerialDevices') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="ch9329Baudrate">{{ t('setup.baudRate') }}</Label>
|
||||
<Select :model-value="ch9329Baudrate" @update:model-value="value => ch9329Baudrate = Number(value)">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="rate in baudRates" :key="rate" :value="rate">
|
||||
{{ rate }} bps
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OTG Settings -->
|
||||
<div v-if="hidBackend === 'otg' && !isWindows" class="space-y-4 p-4 rounded-lg bg-muted/50">
|
||||
<div class="flex items-start gap-2 text-sm text-muted-foreground mb-2">
|
||||
<HelpCircle class="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<p>{{ t('setup.otgHelp') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="otgUdc">{{ t('setup.udc') }}</Label>
|
||||
<Select
|
||||
:model-value="otgUdc"
|
||||
@update:model-value="value => otgUdc = value === EMPTY_SELECT_VALUE ? '' : String(value)"
|
||||
>
|
||||
<SelectTrigger id="otgUdc" class="w-full">
|
||||
<SelectValue :placeholder="t('setup.selectUdc')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="EMPTY_SELECT_VALUE">{{ t('setup.selectUdc') }}</SelectItem>
|
||||
<SelectItem v-for="udc in devices.udc" :key="udc.name" :value="udc.name">
|
||||
{{ udc.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="!devices.udc.length" class="text-xs text-muted-foreground">
|
||||
{{ t('setup.noUdcDevices') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<HidDriverForm v-model="hidSelection" @valid="hidSelectionValid = $event" />
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Extensions Settings -->
|
||||
@@ -860,8 +737,8 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
{{ t('common.back') }}
|
||||
</Button>
|
||||
|
||||
<Button v-if="step < totalSteps" class="flex-1" @click="nextStep">
|
||||
{{ t('common.next') }}
|
||||
<Button v-if="step < totalSteps" class="flex-1" :disabled="step === 3 && !hidSelectionValid" @click="nextStep">
|
||||
{{ t(step === 3 ? 'hidGuide.useConfiguration' : 'common.next') }}
|
||||
<ChevronRight class="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
@@ -874,7 +751,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
||||
<!-- Keyboard shortcuts hint -->
|
||||
<p class="text-xs text-muted-foreground text-center">
|
||||
<kbd class="px-1.5 py-0.5 bg-muted rounded text-xs">Enter</kbd>
|
||||
{{ t('common.next') }}
|
||||
{{ t(step === 3 ? 'hidGuide.useConfiguration' : 'common.next') }}
|
||||
<span v-if="step > 1" class="ml-2">
|
||||
<kbd class="px-1.5 py-0.5 bg-muted rounded text-xs">Esc</kbd>
|
||||
{{ t('common.back') }}
|
||||
|
||||
Reference in New Issue
Block a user