Merge branch 'main' into dev

# Conflicts:
#	src/web/handlers/setup.rs
#	web/src/i18n/en-US.ts
#	web/src/i18n/zh-CN.ts
This commit is contained in:
mofeng-git
2026-07-24 11:08:24 +08:00
28 changed files with 1007 additions and 20 deletions

26
web/package-lock.json generated
View File

@@ -13,6 +13,7 @@
"clsx": "^2.1.1",
"lucide-vue-next": "^0.556.0",
"opus-decoder": "^0.7.11",
"opus-media-recorder": "^0.8.0",
"pinia": "^3.0.4",
"qrcode.vue": "^3.10.0",
"reka-ui": "^2.10.1",
@@ -1940,6 +1941,12 @@
"integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==",
"license": "MIT"
},
"node_modules/detect-browser": {
"version": "4.8.0",
"resolved": "https://registry.npmmirror.com/detect-browser/-/detect-browser-4.8.0.tgz",
"integrity": "sha512-f4h2dFgzHUIpjpBLjhnDIteXv8VQiUm8XzAuzQtYUqECX/eKh67ykuiVoyb7Db7a0PUSmJa3OGXStG0CbQFUVw==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2041,6 +2048,15 @@
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"license": "MIT"
},
"node_modules/event-target-shim": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-3.0.2.tgz",
"integrity": "sha512-HK5GhnEAkm7fLy249GtF7DIuYmjLm85Ft6ssj7DhVl8Tx/z9+v0W6aiIVUdT4AXWGYy5Fc+s6gqBI49Bf0LejQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
@@ -2472,6 +2488,16 @@
"url": "https://github.com/sponsors/eshaz"
}
},
"node_modules/opus-media-recorder": {
"version": "0.8.0",
"resolved": "https://registry.npmmirror.com/opus-media-recorder/-/opus-media-recorder-0.8.0.tgz",
"integrity": "sha512-AIvJMpnJqZ18dFAU7Amtt5cZZp8oPzDoAOtobdTcLzwVNm/j815+GJmBupBzBZGBa4L940TEulm7Uu4tGOYDGQ==",
"license": "MIT",
"dependencies": {
"detect-browser": "^4.1.0",
"event-target-shim": "^3.0.2"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",

View File

@@ -14,6 +14,7 @@
"clsx": "^2.1.1",
"lucide-vue-next": "^0.556.0",
"opus-decoder": "^0.7.11",
"opus-media-recorder": "^0.8.0",
"pinia": "^3.0.4",
"qrcode.vue": "^3.10.0",
"reka-ui": "^2.10.1",

View File

@@ -118,6 +118,16 @@ export const otgNetworkApi = {
interfaces: () => request<NetworkInterfaceInfo[]>('/devices/network', {}, { toastOnError: false }),
}
export const uacApi = {
get: () => request<{enabled: boolean; sample_rate: number; channels: number}>('/config/uac'),
update: (config: {enabled: boolean; sample_rate: number; channels: number}) =>
request('/config/uac', {
method: 'PATCH',
body: JSON.stringify(config),
}),
}
export const otgConfigApi = {
update: (config: OtgConfigUpdate) =>
request<OtgConfigResponse>('/config/otg', {

View File

@@ -855,6 +855,7 @@ export {
msdConfigApi,
otgConfigApi,
otgNetworkApi,
uacApi,
atxConfigApi,
audioConfigApi,
extensionsApi,

View File

@@ -3,6 +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 { getMicrophone } from '@/composables/useMicrophone'
import { Button } from '@/components/ui/button'
import { ButtonGroup } from '@/components/ui/button-group'
import {
@@ -31,6 +32,7 @@ import {
} from '@/components/ui/sheet'
import {
ClipboardPaste,
Mic,
HardDrive,
Keyboard,
Settings,
@@ -68,9 +70,13 @@ const props = defineProps<{
showTerminal?: boolean
showComputerUse?: boolean
showPasteText?: boolean
showMic?: boolean
}>()
const showStats = computed(() => (props.videoMode ?? 'mjpeg') !== 'mjpeg')
const showPasteText = computed(() => props.showPasteText !== false)
const showMic = computed(() => props.showMic === true)
const mic = getMicrophone()
const emit = defineEmits<{
(e: 'toggleFullscreen'): void
@@ -350,6 +356,24 @@ const hasRightOverflow = computed(() => {
</PopoverContent>
</Popover>
</div>
<!-- Mic button -->
<div v-if="showMic">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs"
:class="mic.active.value ? 'text-destructive' : mic.error.value ? 'text-yellow-500' : ''"
@click="mic.toggle()">
<Mic class="size-4" :class="mic.active.value ? 'animate-pulse' : ''" />
<span>{{ mic.active.value ? '关闭' : '麦克风' }}</span>
<span v-if="mic.error.value" class="text-[10px]">{{ mic.error.value }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{{ mic.error.value ? mic.error.value : (mic.active.value ? '停止传声' : '开始传声') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</ButtonGroup>
<!-- Right side buttons -->

View File

@@ -0,0 +1,161 @@
import { ref } from 'vue'
let instance: ReturnType<typeof useMicrophone> | null = null
export function getMicrophone() {
if (!instance) instance = useMicrophone()
return instance
}
const WS_BASE = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/ws/uac-audio`
// Opus: 48kHz stereo, 64kbps → ~8 KB/s vs raw PCM 192 KB/s (24× reduction)
const OPUS_CONFIG: AudioEncoderConfig = {
codec: 'opus',
sampleRate: 48000,
numberOfChannels: 2,
bitrate: 64000,
}
// 15-byte binary header matching server-side UAC_AUDIO_HEADER_SIZE
function buildHeader(msgType: number, durationMs: number, dataLen: number): Uint8Array {
const h = new Uint8Array(15)
const v = new DataView(h.buffer)
v.setUint8(0, msgType) // 0x03 = Opus
v.setUint32(1, 0, true) // timestamp (unused)
v.setUint16(5, durationMs, true)
v.setUint32(7, 0, true) // sequence
v.setUint32(11, dataLen, true)
return h
}
export function useMicrophone() {
const active = ref(false)
const error = ref<string | null>(null)
let ws: WebSocket | null = null
let stream: MediaStream | null = null
let encoder: AudioEncoder | null = null
let running = false
let frameCount = 0
let byteCount = 0
// ── AudioEncoder helper ─────────────────────────────────
function createEncoder(onOpusFrame: (data: Uint8Array, durMs: number) => void): AudioEncoder {
const enc = new AudioEncoder({
output: (chunk: EncodedAudioChunk) => {
const buf = new Uint8Array(chunk.byteLength)
chunk.copyTo(buf)
// Opus frame duration in microseconds → milliseconds
const durMs = Math.round(chunk.duration! / 1000)
onOpusFrame(buf, durMs)
},
error: (e: Error) => console.error('[mic] encoder error:', e),
})
enc.configure(OPUS_CONFIG)
return enc
}
// ── start / stop ────────────────────────────────────────
async function start() {
error.value = null
frameCount = 0
byteCount = 0
running = true
console.log('[mic] starting...')
try {
// WebSocket
ws = new WebSocket(WS_BASE)
ws.binaryType = 'arraybuffer'
const wsReady = new Promise<void>((resolve, reject) => {
ws!.onopen = () => { console.log('[mic] WS opened'); active.value = true; resolve() }
ws!.onerror = (ev) => { console.error('[mic] WS error:', ev); reject(new Error('WebSocket failed')) }
})
ws.onclose = (ev) => {
console.log('[mic] WS closed: code=%d frames=%d bytes=%d', ev.code, frameCount, byteCount)
active.value = false
running = false
}
// Microphone
console.log('[mic] getUserMedia...')
stream = await navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 48000, channelCount: 2, echoCancellation: false, noiseSuppression: false }
})
// AudioEncoder (WebCodecs) for Opus compression
encoder = createEncoder((opusData, durMs) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return
const header = buildHeader(0x03, durMs, opusData.length)
const msg = new Uint8Array(15 + opusData.length)
msg.set(header)
msg.set(opusData, 15)
ws.send(msg)
frameCount++
byteCount += msg.byteLength
if (frameCount % 50 === 0) {
console.debug('[mic] frame #%d: opus=%dB dur=%dms',
frameCount, opusData.length, durMs)
}
})
await wsReady
// ScriptProcessor → S16LE PCM → AudioData → AudioEncoder → Opus
const audioCtx = new AudioContext({ sampleRate: 48000 })
const source = audioCtx.createMediaStreamSource(stream)
const processor = audioCtx.createScriptProcessor(4096, 2, 2)
source.connect(processor)
processor.connect(audioCtx.destination)
processor.onaudioprocess = (e: AudioProcessingEvent) => {
if (!running || !encoder || encoder.state !== 'configured') return
if (!e.inputBuffer) return
const buf = e.inputBuffer as any
const left = buf.getChannelData(0) as Float32Array
const right = buf.getChannelData(1) as Float32Array
const samples = left.length
// Float32 → S16LE interleaved
const pcm = new Int16Array(samples * 2)
for (let i = 0; i < samples; i++) {
pcm[i * 2] = Math.max(-32768, Math.min(32767, Math.round((left[i] ?? 0) * 32767)))
pcm[i * 2 + 1] = Math.max(-32768, Math.min(32767, Math.round((right[i] ?? 0) * 32767)))
}
try {
const audioData = new AudioData({
format: 's16',
sampleRate: 48000,
numberOfFrames: samples,
numberOfChannels: 2,
timestamp: 0,
data: pcm.buffer,
})
encoder.encode(audioData)
audioData.close()
} catch (e) {
console.warn('[mic] AudioData/encode error:', e)
}
}
} catch (e) {
console.error('[mic] start error:', e)
error.value = e instanceof Error ? e.message : 'Failed to start microphone'
stop()
}
}
function stop() {
console.log('[mic] stop: frames=%d bytes=%d', frameCount, byteCount)
running = false
if (encoder) { encoder.close(); encoder = null }
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null }
if (ws) { ws.close(); ws = null }
active.value = false
}
function toggle() {
if (active.value) { stop() } else { start() }
}
return { active, error, start, stop, toggle }
}

View File

@@ -654,6 +654,8 @@ export default {
otgNetworkInterface: 'Bridge Interface',
otgNetworkNone: 'None',
otgNetworkInterfacesLoadFailed: 'Failed to load bridge interfaces',
uacMic: 'USB Microphone',
uacMicDesc: 'Creates a virtual USB microphone on the target machine. Audio from your browser is streamed to the target.',
otgDescriptor: 'USB Device Descriptor',
vendorId: 'Vendor ID (VID)',
productId: 'Product ID (PID)',

View File

@@ -92,7 +92,8 @@ export default {
},
actionbar: {
paste: '粘贴文本',
virtualMedia: '虚拟媒体',
micStart: '开始传声',
micStop: '停止传声',
virtualMediaTip: '管理虚拟媒体设备',
power: '电源',
keyboard: '虚拟键盘',
@@ -653,6 +654,8 @@ export default {
otgNetworkInterface: '桥接网卡',
otgNetworkNone: '无',
otgNetworkInterfacesLoadFailed: '无法获取桥接网卡列表',
uacMic: 'USB 麦克风',
uacMicDesc: '启用后目标机将看到一个 USB 麦克风设备,音频从浏览器传入',
otgDescriptor: 'USB 设备描述符',
vendorId: '厂商 ID (VID)',
productId: '产品 ID (PID)',

View File

@@ -14,7 +14,7 @@ import { useComputerUseSocket, type ComputerUseServerMessage } from '@/composabl
import { useFeatureVisibility } from '@/composables/useFeatureVisibility'
import { useTheme } from '@/composables/useTheme'
import { getUnifiedAudio } from '@/composables/useUnifiedAudio'
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi } from '@/api'
import { streamApi, hidApi, atxApi, atxConfigApi, authApi, computerUseApi, uacApi } from '@/api'
import type { ComputerUseScreenshot, ComputerUseSession } from '@/api'
import { CanonicalKey, HidBackend } from '@/types/generated'
import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
@@ -2961,7 +2961,15 @@ function handleToggleMouseMode() {
}
}
const uacEnabled = ref(false)
onMounted(async () => {
// Check if UAC is enabled (show mic button only if USB mic is available)
try {
const uacCfg = await uacApi.get()
uacEnabled.value = uacCfg.enabled
} catch { /* ignore */ }
consoleEvents.subscribe()
watch([wsConnected, wsNetworkError], ([connected, netError], [_prevConnected, prevNetError]) => {
@@ -3166,6 +3174,7 @@ onUnmounted(() => {
:show-terminal="showTerminal"
:show-computer-use="showComputerUse"
:show-paste-text="showPasteText"
:show-mic="uacEnabled"
@toggle-fullscreen="toggleFullscreen"
@toggle-stats="openStatsSheet"
@toggle-virtual-keyboard="handleToggleVirtualKeyboard"

View File

@@ -11,6 +11,7 @@ import {
authApi,
configApi,
otgNetworkApi,
uacApi,
hidApi,
streamApi,
atxConfigApi,
@@ -682,6 +683,7 @@ const config = ref({
turn_server: '',
turn_username: '',
turn_password: '',
uac_enabled: false,
})
const otgNetworkInterfaces = ref<NetworkInterfaceInfo[]>([])
@@ -1477,6 +1479,12 @@ async function saveConfig() {
},
})
otgNetworkStatus.value = response.status
await uacApi.update({
enabled: otgEnabled && config.value.uac_enabled,
sample_rate: 48000,
channels: 2,
})
}
if (activeSection.value !== 'hid') {
@@ -1497,7 +1505,7 @@ async function saveConfig() {
async function loadConfig() {
try {
const [video, stream, hid, msd, otgNetwork] = await Promise.all([
const [video, stream, hid, msd, otgNetwork, uac] = await Promise.all([
configStore.refreshVideo(),
configStore.refreshStream(),
configStore.refreshHid(),
@@ -1509,6 +1517,7 @@ async function loadConfig() {
host_mac: '',
device_mac: '',
})),
uacApi.get().catch(() => ({ enabled: false, sample_rate: 48000, channels: 2 })),
])
config.value = {
@@ -1534,6 +1543,7 @@ async function loadConfig() {
msd_dir: msd.msd_dir || '',
otg_network_enabled: otgNetwork.enabled,
otg_network_driver: otgNetwork.driver_mode,
uac_enabled: uac.enabled,
otg_network_interface: otgNetwork.bridge_interface,
encoder_backend: stream.encoder || 'auto',
stun_server: stream.stun_server || '',
@@ -3364,6 +3374,15 @@ watch(isWindows, () => {
{{ t('settings.otgRuntimeDegraded') }}: {{ otgNetworkStatus.error || t('common.error') }}
</p>
</div>
<div class="space-y-3 rounded-md border border-border/60 p-3">
<div class="flex items-center justify-between gap-4">
<div>
<Label>{{ t('settings.uacMic') }}</Label>
<p class="text-xs text-muted-foreground">{{ t('settings.uacMicDesc') }}</p>
</div>
<Switch v-model="config.uac_enabled" />
</div>
</div>
</div>
<p class="text-xs text-warning">
{{ t('settings.otgProfileWarning') }}