feat(hid): 支持控制台鼠标抖动

在控制台鼠键配置菜单增加鼠标抖动开关和间隔设置,默认关闭,间隔支持 10–3600 秒。
设备端按键鼠空闲时间调度,支持配置持久化、热更新、真实输入打断和断连后重新计时。
兼容绝对及相对鼠标模式,按键或按钮按住期间暂停抖动。

验证:608 项 Rust 单测通过;暂存快照前端构建和浏览器交互测试通过;已完成 ARMv7 OpenWrt 部署验证。

Refs #316
This commit is contained in:
mofeng-git
2026-10-28 07:11:44 +08:00
parent f303a8cb2f
commit fc99c6f274
12 changed files with 886 additions and 14 deletions

View File

@@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'
import { Slider } from '@/components/ui/slider'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
@@ -67,6 +68,53 @@ watch(showCursor, (newValue, oldValue) => {
const buttonText = computed(() => t('actionbar.hidConfig'))
const hidSaving = ref(false)
const hidControlsDisabled = computed(() => hidSaving.value || configStore.hidLoading || !configStore.hid)
const jigglerEnabled = computed(() => configStore.hid?.jiggler?.enabled ?? false)
const jigglerInterval = ref<string | number>(60)
const jigglerIntervalInvalid = ref(false)
const hasMouseInterface = computed(() => {
const hid = configStore.hid
if (!hid || hid.backend === HidBackend.None) return false
if (hid.backend !== HidBackend.Otg) return true
if (hid.otg_profile === OtgHidProfile.LegacyKeyboard) return false
if (hid.otg_profile === OtgHidProfile.Custom) {
return !!(hid.otg_functions?.mouse_relative || hid.otg_functions?.mouse_absolute)
}
return true
})
watch(() => [configStore.hid?.jiggler?.interval_secs, configStore.hid?.jiggler?.enabled] as const, ([interval]) => {
jigglerInterval.value = interval ?? 60
jigglerIntervalInvalid.value = false
}, { immediate: true })
async function saveJiggler(update: { enabled?: boolean; interval_secs?: number }) {
if (hidControlsDisabled.value) return
hidSaving.value = true
try {
await configStore.updateHid({ jiggler: update })
} catch (error) {
toast.error(t('config.updateFailed'), {
description: error instanceof Error ? error.message : undefined,
})
await configStore.refreshHid().catch(() => undefined)
jigglerInterval.value = configStore.hid?.jiggler?.interval_secs ?? 60
} finally {
hidSaving.value = false
}
}
function toggleJiggler(enabled: boolean) {
if (enabled && !hasMouseInterface.value) return
void saveJiggler({ enabled })
}
function saveJigglerInterval() {
if (hidControlsDisabled.value || !jigglerEnabled.value) return
const interval = Number(jigglerInterval.value)
jigglerIntervalInvalid.value = !Number.isInteger(interval) || interval < 10 || interval > 3600
if (jigglerIntervalInvalid.value || interval === configStore.hid?.jiggler?.interval_secs) return
void saveJiggler({ interval_secs: interval })
}
const supportsLinuxCompatibility = computed(() => usesCh9329(configStore.hid?.backend))
const supportsMacosCompatibility = computed(() =>
supportsLinuxCompatibility.value || configStore.hid?.backend === HidBackend.Otg
@@ -140,6 +188,8 @@ watch(() => props.open, (open) => {
mouseThrottle.value = loadMouseMoveSendIntervalFromStorage()
showCursor.value = localStorage.getItem('hidShowCursor') !== 'false'
if (!hidSaving.value) void configStore.refreshHid().catch(() => undefined)
jigglerInterval.value = configStore.hid?.jiggler?.interval_secs ?? 60
jigglerIntervalInvalid.value = false
})
</script>
@@ -160,7 +210,7 @@ watch(() => props.open, (open) => {
</PopoverTrigger>
<PopoverContent
@open-auto-focus="focusConsolePanel"
class="console-config-panel w-[min(320px,92vw)] p-3"
class="console-config-panel w-[min(320px,92vw)] max-h-(--reka-popover-content-available-height) overflow-y-auto p-3"
align="start"
:side="props.side ?? 'bottom'"
>
@@ -231,6 +281,45 @@ watch(() => props.open, (open) => {
<Label class="text-xs text-muted-foreground">{{ t('actionbar.showCursor') }}</Label>
<Switch v-model="showCursor" />
</div>
<div class="space-y-2" :aria-busy="hidSaving">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-1">
<Label for="hid-mouse-jiggler" class="text-xs text-muted-foreground">{{ t('actionbar.mouseJiggler') }}</Label>
<HelpTooltip :content="t('actionbar.mouseJigglerHelp')" icon-size="sm" />
</div>
<Switch
id="hid-mouse-jiggler"
:model-value="jigglerEnabled"
:disabled="hidControlsDisabled || (!hasMouseInterface && !jigglerEnabled)"
@update:model-value="toggleJiggler"
/>
</div>
<div v-if="jigglerEnabled" class="flex items-center justify-between gap-3">
<Label for="hid-jiggler-interval" class="text-xs text-muted-foreground">{{ t('actionbar.jigglerInterval') }}</Label>
<div class="flex items-center gap-2">
<Input
id="hid-jiggler-interval"
v-model="jigglerInterval"
type="number" min="10" max="3600" step="1" size="sm"
class="w-20 text-xs"
:disabled="hidControlsDisabled"
:aria-invalid="jigglerIntervalInvalid"
:aria-describedby="jigglerIntervalInvalid ? 'hid-jiggler-interval-error' : undefined"
@input="jigglerIntervalInvalid = false"
@blur="saveJigglerInterval"
@keydown.enter.prevent="saveJigglerInterval"
/>
<span class="text-xs text-muted-foreground">{{ t('actionbar.jigglerSeconds') }}</span>
</div>
</div>
<p v-if="jigglerEnabled && jigglerIntervalInvalid" id="hid-jiggler-interval-error" role="alert" class="text-xs text-destructive">{{ t('actionbar.jigglerIntervalInvalid') }}</p>
<p v-if="!hasMouseInterface && configStore.hid" class="text-xs text-muted-foreground">{{ t('actionbar.jigglerNoMouse') }}</p>
</div>
<p v-if="hidSaving" role="status" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 shrink-0 animate-spin" />
{{ t('actionbar.applying') }}
</p>
</div>
<template v-if="supportsMacosCompatibility">
@@ -265,10 +354,6 @@ watch(() => props.open, (open) => {
</div>
<p v-if="macosDragRequiresInterfaces" class="text-xs text-warning">{{ t('actionbar.macosDragRequiresBoth') }}</p>
<p v-if="hidSaving" role="status" class="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 class="size-3 shrink-0 animate-spin" />
{{ t('actionbar.applying') }}
</p>
</div>
</template>
</div>

View File

@@ -278,6 +278,12 @@ export default {
positioningMode: 'Positioning Mode',
sendInterval: 'Send Interval',
showCursor: 'Show Cursor',
mouseJiggler: 'Mouse jiggler',
mouseJigglerHelp: 'Gently moves the mouse while idle to help prevent the target from sleeping or turning off its display. Pauses during input or while keys or buttons are held. Keeps working after closing this page and remembers settings after restart.',
jigglerInterval: 'Jiggle interval',
jigglerSeconds: 'sec',
jigglerIntervalInvalid: 'Enter a whole number between 10 and 3600 seconds.',
jigglerNoMouse: 'Enable a mouse interface in HID device settings first.',
backend: 'Backend',
devicePath: 'Device Path',
baudrate: 'Baudrate',

View File

@@ -278,6 +278,12 @@ export default {
positioningMode: '定位模式',
sendInterval: '发送间隔',
showCursor: '显示指针',
mouseJiggler: '鼠标抖动',
mouseJigglerHelp: '空闲时自动小幅移动鼠标,帮助防止目标主机息屏或休眠。操作键鼠或按住按键时暂停。关闭网页后仍生效,重启后保留设置。',
jigglerInterval: '抖动间隔',
jigglerSeconds: '秒',
jigglerIntervalInvalid: '请输入 10–3600 之间的整数秒数。',
jigglerNoMouse: '请先在 HID 设备设置中启用鼠标接口。',
backend: '后端类型',
devicePath: '设备路径',
baudrate: '波特率',

View File

@@ -61,7 +61,13 @@ export interface Ch9329DescriptorConfig {
serial_number?: string;
}
export interface MouseJigglerConfig {
enabled: boolean;
interval_secs: number;
}
export interface HidConfig {
jiggler: MouseJigglerConfig;
bluetooth: BluetoothHidConfig;
backend: HidBackend;
otg_udc?: string;
@@ -503,7 +509,13 @@ export interface OtgHidFunctionsUpdate {
consumer?: boolean;
}
export interface MouseJigglerConfigUpdate {
enabled?: boolean;
interval_secs?: number;
}
export interface HidConfigUpdate {
jiggler?: MouseJigglerConfigUpdate;
bluetooth_reset_pairing?: boolean;
bluetooth?: BluetoothHidConfig;
backend?: HidBackend;