merge: resolve dev conflicts for CH9329 macOS drag compatibility

This commit is contained in:
raymond
2026-09-07 23:26:57 +08:00
155 changed files with 12027 additions and 5223 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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',

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View 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>

View File

@@ -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]"
>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 }}

View File

@@ -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">

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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)"
>

View File

@@ -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)"
>

View File

@@ -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(

View File

@@ -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(

View File

@@ -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',

View File

@@ -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'

View File

@@ -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)"
>

View 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 })
}
}

View 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,
}
}

View File

@@ -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,

View 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 }
}

View File

@@ -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,

View File

@@ -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 computers 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 164 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 computers 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',

View File

@@ -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 通过串口连接 CH9329CH9329 的 USB 连接被控机。",
"wiring_bluetooth": "在被控机打开系统设置 → 添加蓝牙设备 → 选择广播的 HID 设备名称。",
"disabledHelp": "禁用后 One-KVM 无法发送键盘和鼠标输入。",
"selectDevice": "请选择具体设备",
"noDevices": "未找到可用设备,请检查连接后刷新。",
"nameInvalid": "名称须为 164 个 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
View 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
View 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'
}

View File

@@ -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
}

View File

@@ -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);
}

View 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 }>
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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') }}