From d6eaeaccbe6e3c94364c803516c8db635d2bf5c6 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Wed, 28 Oct 2026 07:52:31 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E7=B2=BE=E7=AE=80=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E7=BB=93=E6=9E=84=E4=B8=8E=E6=9C=AA=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽离远程访问设置逻辑,合并配置缓存、并发请求及错误处理,修复共享 loading 状态提前结束。统一控制台事件订阅与清理,移除未使用组件、旧 composable 和冗余状态。 已通过前端生产构建、模拟 API 浏览器检查和 OpenWrt ARMv7 部署验证。 --- web/src/components/ui/avatar/Avatar.vue | 18 - .../components/ui/avatar/AvatarFallback.vue | 21 - web/src/components/ui/avatar/AvatarImage.vue | 16 - web/src/components/ui/avatar/index.ts | 3 - web/src/components/ui/card/CardAction.vue | 17 - web/src/components/ui/card/index.ts | 1 - .../ui/dialog/DialogScrollContent.vue | 59 -- .../components/ui/dialog/DialogTrigger.vue | 15 - web/src/components/ui/dialog/index.ts | 2 - .../DropdownMenuCheckboxItem.vue | 39 -- .../ui/dropdown-menu/DropdownMenuGroup.vue | 15 - .../ui/dropdown-menu/DropdownMenuLabel.vue | 23 - .../dropdown-menu/DropdownMenuRadioGroup.vue | 21 - .../dropdown-menu/DropdownMenuRadioItem.vue | 40 -- .../ui/dropdown-menu/DropdownMenuShortcut.vue | 17 - .../ui/dropdown-menu/DropdownMenuSub.vue | 18 - .../dropdown-menu/DropdownMenuSubContent.vue | 30 - .../dropdown-menu/DropdownMenuSubTrigger.vue | 31 -- web/src/components/ui/dropdown-menu/index.ts | 9 - .../ui/native-select/NativeSelect.vue | 54 -- .../ui/native-select/NativeSelectOption.vue | 12 - web/src/components/ui/native-select/index.ts | 2 - web/src/components/ui/select/SelectGroup.vue | 15 - .../components/ui/select/SelectItemText.vue | 15 - web/src/components/ui/select/SelectLabel.vue | 17 - .../components/ui/select/SelectSeparator.vue | 19 - web/src/components/ui/select/index.ts | 4 - web/src/components/ui/toggle/Toggle.vue | 35 -- web/src/components/ui/toggle/index.ts | 1 - .../settings/useRemoteAccessSettings.ts | 433 +++++++++++++++ web/src/composables/useConfigPopover.ts | 60 -- web/src/composables/useConsoleEvents.ts | 69 +-- web/src/composables/useUnifiedAudio.ts | 11 +- web/src/composables/useVideoStream.ts | 217 -------- web/src/lib/configResource.ts | 54 ++ web/src/lib/url.ts | 4 + web/src/stores/auth.ts | 5 +- web/src/stores/config.ts | 524 +++--------------- web/src/stores/index.ts | 3 - web/src/types/websocket.ts | 36 -- web/src/views/ConsoleView.vue | 3 - web/src/views/SettingsView.vue | 436 ++------------- 42 files changed, 621 insertions(+), 1803 deletions(-) delete mode 100644 web/src/components/ui/avatar/Avatar.vue delete mode 100644 web/src/components/ui/avatar/AvatarFallback.vue delete mode 100644 web/src/components/ui/avatar/AvatarImage.vue delete mode 100644 web/src/components/ui/avatar/index.ts delete mode 100644 web/src/components/ui/card/CardAction.vue delete mode 100644 web/src/components/ui/dialog/DialogScrollContent.vue delete mode 100644 web/src/components/ui/dialog/DialogTrigger.vue delete mode 100644 web/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue delete mode 100644 web/src/components/ui/dropdown-menu/DropdownMenuGroup.vue delete mode 100644 web/src/components/ui/dropdown-menu/DropdownMenuLabel.vue delete mode 100644 web/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue delete mode 100644 web/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue delete mode 100644 web/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue delete mode 100644 web/src/components/ui/dropdown-menu/DropdownMenuSub.vue delete mode 100644 web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue delete mode 100644 web/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue delete mode 100644 web/src/components/ui/native-select/NativeSelect.vue delete mode 100644 web/src/components/ui/native-select/NativeSelectOption.vue delete mode 100644 web/src/components/ui/native-select/index.ts delete mode 100644 web/src/components/ui/select/SelectGroup.vue delete mode 100644 web/src/components/ui/select/SelectItemText.vue delete mode 100644 web/src/components/ui/select/SelectLabel.vue delete mode 100644 web/src/components/ui/select/SelectSeparator.vue delete mode 100644 web/src/components/ui/toggle/Toggle.vue create mode 100644 web/src/composables/settings/useRemoteAccessSettings.ts delete mode 100644 web/src/composables/useConfigPopover.ts delete mode 100644 web/src/composables/useVideoStream.ts create mode 100644 web/src/lib/configResource.ts create mode 100644 web/src/lib/url.ts delete mode 100644 web/src/stores/index.ts diff --git a/web/src/components/ui/avatar/Avatar.vue b/web/src/components/ui/avatar/Avatar.vue deleted file mode 100644 index bb7e669f..00000000 --- a/web/src/components/ui/avatar/Avatar.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/web/src/components/ui/avatar/AvatarFallback.vue b/web/src/components/ui/avatar/AvatarFallback.vue deleted file mode 100644 index 16b588ae..00000000 --- a/web/src/components/ui/avatar/AvatarFallback.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/web/src/components/ui/avatar/AvatarImage.vue b/web/src/components/ui/avatar/AvatarImage.vue deleted file mode 100644 index 24a81664..00000000 --- a/web/src/components/ui/avatar/AvatarImage.vue +++ /dev/null @@ -1,16 +0,0 @@ - - - diff --git a/web/src/components/ui/avatar/index.ts b/web/src/components/ui/avatar/index.ts deleted file mode 100644 index cf0e0031..00000000 --- a/web/src/components/ui/avatar/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { default as Avatar } from "./Avatar.vue" -export { default as AvatarFallback } from "./AvatarFallback.vue" -export { default as AvatarImage } from "./AvatarImage.vue" diff --git a/web/src/components/ui/card/CardAction.vue b/web/src/components/ui/card/CardAction.vue deleted file mode 100644 index c91638b6..00000000 --- a/web/src/components/ui/card/CardAction.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/web/src/components/ui/card/index.ts b/web/src/components/ui/card/index.ts index 16277580..e5c7cb24 100644 --- a/web/src/components/ui/card/index.ts +++ b/web/src/components/ui/card/index.ts @@ -1,5 +1,4 @@ export { default as Card } from "./Card.vue" -export { default as CardAction } from "./CardAction.vue" export { default as CardContent } from "./CardContent.vue" export { default as CardDescription } from "./CardDescription.vue" export { default as CardFooter } from "./CardFooter.vue" diff --git a/web/src/components/ui/dialog/DialogScrollContent.vue b/web/src/components/ui/dialog/DialogScrollContent.vue deleted file mode 100644 index d56eaa12..00000000 --- a/web/src/components/ui/dialog/DialogScrollContent.vue +++ /dev/null @@ -1,59 +0,0 @@ - - - diff --git a/web/src/components/ui/dialog/DialogTrigger.vue b/web/src/components/ui/dialog/DialogTrigger.vue deleted file mode 100644 index 49667e99..00000000 --- a/web/src/components/ui/dialog/DialogTrigger.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/web/src/components/ui/dialog/index.ts b/web/src/components/ui/dialog/index.ts index 6768b090..f83f37c5 100644 --- a/web/src/components/ui/dialog/index.ts +++ b/web/src/components/ui/dialog/index.ts @@ -5,6 +5,4 @@ export { default as DialogDescription } from "./DialogDescription.vue" export { default as DialogFooter } from "./DialogFooter.vue" export { default as DialogHeader } from "./DialogHeader.vue" export { default as DialogOverlay } from "./DialogOverlay.vue" -export { default as DialogScrollContent } from "./DialogScrollContent.vue" export { default as DialogTitle } from "./DialogTitle.vue" -export { default as DialogTrigger } from "./DialogTrigger.vue" diff --git a/web/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue b/web/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue deleted file mode 100644 index 25692d39..00000000 --- a/web/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +++ /dev/null @@ -1,39 +0,0 @@ - - - diff --git a/web/src/components/ui/dropdown-menu/DropdownMenuGroup.vue b/web/src/components/ui/dropdown-menu/DropdownMenuGroup.vue deleted file mode 100644 index da634ec0..00000000 --- a/web/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/web/src/components/ui/dropdown-menu/DropdownMenuLabel.vue b/web/src/components/ui/dropdown-menu/DropdownMenuLabel.vue deleted file mode 100644 index 8bca83c4..00000000 --- a/web/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/web/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue b/web/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue deleted file mode 100644 index fe82cadd..00000000 --- a/web/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +++ /dev/null @@ -1,21 +0,0 @@ - - - diff --git a/web/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue b/web/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue deleted file mode 100644 index db2c5b89..00000000 --- a/web/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - diff --git a/web/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue b/web/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue deleted file mode 100644 index 60be75cc..00000000 --- a/web/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/web/src/components/ui/dropdown-menu/DropdownMenuSub.vue b/web/src/components/ui/dropdown-menu/DropdownMenuSub.vue deleted file mode 100644 index 7472e77f..00000000 --- a/web/src/components/ui/dropdown-menu/DropdownMenuSub.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue b/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue deleted file mode 100644 index 40a8422a..00000000 --- a/web/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/web/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue b/web/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue deleted file mode 100644 index c470c3f6..00000000 --- a/web/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/web/src/components/ui/dropdown-menu/index.ts b/web/src/components/ui/dropdown-menu/index.ts index 955fe3aa..f88046b8 100644 --- a/web/src/components/ui/dropdown-menu/index.ts +++ b/web/src/components/ui/dropdown-menu/index.ts @@ -1,16 +1,7 @@ export { default as DropdownMenu } from "./DropdownMenu.vue" -export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue" export { default as DropdownMenuContent } from "./DropdownMenuContent.vue" -export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue" export { default as DropdownMenuItem } from "./DropdownMenuItem.vue" -export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue" -export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue" -export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue" export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue" -export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue" -export { default as DropdownMenuSub } from "./DropdownMenuSub.vue" -export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue" -export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue" export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue" export { DropdownMenuPortal } from "reka-ui" diff --git a/web/src/components/ui/native-select/NativeSelect.vue b/web/src/components/ui/native-select/NativeSelect.vue deleted file mode 100644 index 7e0fdb9d..00000000 --- a/web/src/components/ui/native-select/NativeSelect.vue +++ /dev/null @@ -1,54 +0,0 @@ - - - diff --git a/web/src/components/ui/native-select/NativeSelectOption.vue b/web/src/components/ui/native-select/NativeSelectOption.vue deleted file mode 100644 index 5fe52c6a..00000000 --- a/web/src/components/ui/native-select/NativeSelectOption.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/web/src/components/ui/native-select/index.ts b/web/src/components/ui/native-select/index.ts deleted file mode 100644 index cf632e5f..00000000 --- a/web/src/components/ui/native-select/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as NativeSelect } from "./NativeSelect.vue" -export { default as NativeSelectOption } from "./NativeSelectOption.vue" diff --git a/web/src/components/ui/select/SelectGroup.vue b/web/src/components/ui/select/SelectGroup.vue deleted file mode 100644 index e981c6c5..00000000 --- a/web/src/components/ui/select/SelectGroup.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/web/src/components/ui/select/SelectItemText.vue b/web/src/components/ui/select/SelectItemText.vue deleted file mode 100644 index b6700b17..00000000 --- a/web/src/components/ui/select/SelectItemText.vue +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/web/src/components/ui/select/SelectLabel.vue b/web/src/components/ui/select/SelectLabel.vue deleted file mode 100644 index 5b6650cb..00000000 --- a/web/src/components/ui/select/SelectLabel.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/web/src/components/ui/select/SelectSeparator.vue b/web/src/components/ui/select/SelectSeparator.vue deleted file mode 100644 index 4b5c8857..00000000 --- a/web/src/components/ui/select/SelectSeparator.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/web/src/components/ui/select/index.ts b/web/src/components/ui/select/index.ts index 96eae60c..611f838f 100644 --- a/web/src/components/ui/select/index.ts +++ b/web/src/components/ui/select/index.ts @@ -1,11 +1,7 @@ export { default as Select } from "./Select.vue" export { default as SelectContent } from "./SelectContent.vue" -export { default as SelectGroup } from "./SelectGroup.vue" export { default as SelectItem } from "./SelectItem.vue" -export { default as SelectItemText } from "./SelectItemText.vue" -export { default as SelectLabel } from "./SelectLabel.vue" export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue" export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue" -export { default as SelectSeparator } from "./SelectSeparator.vue" export { default as SelectTrigger } from "./SelectTrigger.vue" export { default as SelectValue } from "./SelectValue.vue" diff --git a/web/src/components/ui/toggle/Toggle.vue b/web/src/components/ui/toggle/Toggle.vue deleted file mode 100644 index aea8e17d..00000000 --- a/web/src/components/ui/toggle/Toggle.vue +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/web/src/components/ui/toggle/index.ts b/web/src/components/ui/toggle/index.ts index 7bf08dce..466c8fd2 100644 --- a/web/src/components/ui/toggle/index.ts +++ b/web/src/components/ui/toggle/index.ts @@ -1,7 +1,6 @@ import type { VariantProps } from "class-variance-authority" import { cva } from "class-variance-authority" -export { default as Toggle } from "./Toggle.vue" export const toggleVariants = cva( "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", diff --git a/web/src/composables/settings/useRemoteAccessSettings.ts b/web/src/composables/settings/useRemoteAccessSettings.ts new file mode 100644 index 00000000..9f68159b --- /dev/null +++ b/web/src/composables/settings/useRemoteAccessSettings.ts @@ -0,0 +1,433 @@ +import { computed, onScopeDispose, ref, type Ref } from 'vue' +import { useI18n } from 'vue-i18n' +import { toast } from 'vue-sonner' +import { + rtspConfigApi, rustdeskConfigApi, vncConfigApi, + type RustDeskConfigResponse, type RustDeskStatusResponse, type RustDeskPasswordResponse, + type RtspStatusResponse, type RtspConfigUpdate, type VncConfigUpdate, type VncStatusResponse, +} from '@/api' +import { useConfigStore } from '@/stores/config' +import { useClipboard } from '@/composables/useClipboard' +import { formatHostForUrl } from '@/lib/url' + +type RemoteService = 'rtsp' | 'vnc' | 'rustdesk' + +interface RemoteAccessSettingsOptions { + loading: Ref + showValidationError: (message: string) => boolean +} + +export function useRemoteAccessSettings({ loading, showValidationError }: RemoteAccessSettingsOptions) { + const { t } = useI18n() + const configStore = useConfigStore() + const { copy: clipboardCopy } = useClipboard() + const serviceSaving = ref(null) + let copiedTimer: ReturnType | undefined + onScopeDispose(() => clearTimeout(copiedTimer)) + + const rustdeskConfig = ref(null) + const rustdeskStatus = ref(null) + const rustdeskPassword = ref(null) + const rustdeskLoading = ref(false) + const rustdeskCopied = ref<'id' | 'password' | null>(null) + 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.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 '' + }) + + const rtspStatus = ref(null) + const rtspLoading = ref(false) + const rtspLocalConfig = ref({ + enabled: false, + bind: '0.0.0.0', + port: 8554, + path: 'live', + allow_one_client: true, + codec: 'h264', + username: '', + password: '', + }) + + const vncStatus = ref(null) + const vncLoading = ref(false) + const vncLocalConfig = ref({ + enabled: false, + bind: '0.0.0.0', + port: 5900, + encoding: 'tight_jpeg', + allow_one_client: true, + password: '', + }) + + const rtspStreamUrl = computed(() => { + const host = formatHostForUrl(window.location.hostname || '127.0.0.1') + const path = normalizeRtspPath(rtspLocalConfig.value.path || 'live') + const port = Number(rtspLocalConfig.value.port) || 8554 + return `rtsp://${host}:${port}/${path}` + }) + + const vncStreamUrl = computed(() => { + const host = formatHostForUrl(window.location.hostname || '127.0.0.1') + const port = Number(vncLocalConfig.value.port) || 5900 + return `${host}:${port}` + }) + + function applyRustdeskStatus(status: RustDeskStatusResponse) { + const config = status.config + rustdeskConfig.value = config + 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 || '', + } + } + + async function loadRustdeskConfig() { + rustdeskLoading.value = true + try { + const status = await configStore.refreshRustdeskStatus() + applyRustdeskStatus(status) + } catch { + } finally { + rustdeskLoading.value = false + } + } + + async function loadRustdeskPassword() { + try { + rustdeskPassword.value = await configStore.refreshRustdeskPassword() + } catch {} + } + + function normalizeRustdeskServer(value: string, defaultPort: number): string { + const trimmed = value.trim() + if (!trimmed) return '' + if (trimmed.includes(':')) return trimmed + return `${trimmed}:${defaultPort}` + } + + /** Strip line breaks from pasted keys. */ + function normalizeRustdeskRelayKey(value: string): string { + return value.replace(/\r?\n/g, '').trim() + } + + function validateRustdeskConfig(): boolean { + return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value) + } + + function validateVncConfig(enabled = vncLocalConfig.value.enabled): boolean { + const password = (vncLocalConfig.value.password || '').trim() + if (enabled && !vncStatus.value?.config.has_password && !password) { + return showValidationError(t('extensions.vnc.passwordRequired')) + } + if (password.length > 8) { + return showValidationError(t('extensions.vnc.passwordMaxLength')) + } + return true + } + + function normalizeRtspPath(path: string): string { + return path.trim().replace(/^\/+|\/+$/g, '') || 'live' + } + + 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), + relay_server: normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117), + relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key), + } + } + + async function saveService( + service: RemoteService, + persist: () => Promise, + reload: () => Promise, + ) { + serviceSaving.value = service + loading.value = true + try { + await persist() + await reload() + toast.success(t('settingsUi.saveDone')) + } catch { + // API requests report their own errors. + } finally { + loading.value = false + serviceSaving.value = null + } + } + + async function saveRustdeskConfig() { + if ( + (rustdeskLocalConfig.value.enabled || rustdeskLocalConfig.value.mode === 'direct_ip') && + !validateRustdeskConfig() + ) + return + + await saveService( + 'rustdesk', () => configStore.updateRustdesk(rustdeskUpdatePayload()), loadRustdeskConfig, + ) + } + + async function regenerateRustdeskId() { + if (!confirm(t('extensions.rustdesk.confirmRegenerateId'))) return + rustdeskLoading.value = true + try { + await configStore.regenerateRustdeskId() + await loadRustdeskConfig() + await loadRustdeskPassword() + } catch { + } finally { + rustdeskLoading.value = false + } + } + + async function regenerateRustdeskPassword() { + if (!confirm(t('extensions.rustdesk.confirmRegeneratePassword'))) return + rustdeskLoading.value = true + try { + await configStore.regenerateRustdeskPassword() + await loadRustdeskConfig() + await loadRustdeskPassword() + } catch { + } finally { + rustdeskLoading.value = false + } + } + + async function startRustdesk() { + if (!validateRustdeskConfig()) return + + rustdeskLoading.value = true + try { + await configStore.updateRustdesk(rustdeskUpdatePayload()) + const status = await rustdeskConfigApi.start() + applyRustdeskStatus(status) + } catch { + } finally { + rustdeskLoading.value = false + } + } + + async function stopRustdesk() { + rustdeskLoading.value = true + try { + const status = await rustdeskConfigApi.stop() + applyRustdeskStatus(status) + } catch { + } finally { + rustdeskLoading.value = false + } + } + + async function copyToClipboard(text: string, type: 'id' | 'password') { + const success = await clipboardCopy(text) + if (success) { + rustdeskCopied.value = type + clearTimeout(copiedTimer) + copiedTimer = setTimeout(() => (rustdeskCopied.value = null), 2000) + } + } + + function applyRtspStatus(status: RtspStatusResponse) { + rtspStatus.value = status + rtspLocalConfig.value = { + enabled: status.config.enabled, + bind: status.config.bind, + port: status.config.port, + path: status.config.path, + allow_one_client: status.config.allow_one_client, + codec: status.config.codec, + username: status.config.username || '', + password: status.config.password || '', + } + } + + async function loadRtspConfig() { + rtspLoading.value = true + try { + const status = await configStore.refreshRtspStatus() + applyRtspStatus(status) + } catch { + } finally { + rtspLoading.value = false + } + } + + function rtspUpdatePayload(enabled = !!rtspLocalConfig.value.enabled): RtspConfigUpdate { + return { + enabled, + bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0', + port: Number(rtspLocalConfig.value.port) || 8554, + path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'), + allow_one_client: !!rtspLocalConfig.value.allow_one_client, + codec: rtspLocalConfig.value.codec || 'h264', + username: (rtspLocalConfig.value.username || '').trim(), + password: (rtspLocalConfig.value.password || '').trim(), + } + } + + async function saveRtspConfig() { + await saveService( + 'rtsp', () => configStore.updateRtsp(rtspUpdatePayload()), loadRtspConfig, + ) + } + + async function startRtsp() { + rtspLoading.value = true + try { + await configStore.updateRtsp(rtspUpdatePayload()) + const status = await rtspConfigApi.start() + applyRtspStatus(status) + } catch { + } finally { + rtspLoading.value = false + } + } + + async function stopRtsp() { + rtspLoading.value = true + try { + const status = await rtspConfigApi.stop() + applyRtspStatus(status) + } catch { + } finally { + rtspLoading.value = false + } + } + + function applyVncStatus(status: VncStatusResponse) { + vncStatus.value = status + vncLocalConfig.value = { + enabled: status.config.enabled, + bind: status.config.bind, + port: status.config.port, + encoding: status.config.encoding, + allow_one_client: status.config.allow_one_client, + password: '', + } + } + + async function loadVncConfig() { + vncLoading.value = true + try { + const status = await configStore.refreshVncStatus() + applyVncStatus(status) + } catch { + } finally { + vncLoading.value = false + } + } + + function vncUpdatePayload(enabled = !!vncLocalConfig.value.enabled): VncConfigUpdate { + const update: VncConfigUpdate = { + enabled, + bind: vncLocalConfig.value.bind?.trim() || '0.0.0.0', + port: Number(vncLocalConfig.value.port) || 5900, + encoding: vncLocalConfig.value.encoding || 'tight_jpeg', + allow_one_client: !!vncLocalConfig.value.allow_one_client, + } + const password = (vncLocalConfig.value.password || '').trim() + if (password) update.password = password + return update + } + + async function saveVncConfig() { + if (!validateVncConfig()) return + + await saveService( + 'vnc', () => configStore.updateVnc(vncUpdatePayload()), loadVncConfig, + ) + } + + async function startVnc() { + if (!validateVncConfig(true)) return + + vncLoading.value = true + try { + await configStore.updateVnc(vncUpdatePayload()) + const status = await vncConfigApi.start() + applyVncStatus(status) + } catch { + } finally { + vncLoading.value = false + } + } + + async function stopVnc() { + vncLoading.value = true + try { + const status = await vncConfigApi.stop() + applyVncStatus(status) + } catch { + } finally { + vncLoading.value = false + } + } + + return { + rustdeskConfig, + rustdeskStatus, + rustdeskPassword, + rustdeskLoading, + rustdeskCopied, + rustdeskLocalConfig, + rustdeskValidationMessage, + rtspStatus, + rtspLoading, + rtspLocalConfig, + vncStatus, + vncLoading, + vncLocalConfig, + rtspStreamUrl, + vncStreamUrl, + serviceSaving, + loadRustdeskConfig, + loadRustdeskPassword, + saveRustdeskConfig, + regenerateRustdeskId, + regenerateRustdeskPassword, + startRustdesk, + stopRustdesk, + copyToClipboard, + loadRtspConfig, + saveRtspConfig, + startRtsp, + stopRtsp, + loadVncConfig, + saveVncConfig, + startVnc, + stopVnc, + } +} diff --git a/web/src/composables/useConfigPopover.ts b/web/src/composables/useConfigPopover.ts deleted file mode 100644 index bbfc3c0a..00000000 --- a/web/src/composables/useConfigPopover.ts +++ /dev/null @@ -1,60 +0,0 @@ - -import { ref, watch, type Ref } from 'vue' - -export interface UseConfigPopoverOptions { - /** Reactive open state from props */ - open: Ref - /** Load device list callback */ - loadDevices?: () => Promise - /** Initialize from current config callback */ - initializeFromCurrent?: () => void -} - -export function useConfigPopover(options: UseConfigPopoverOptions) { - const applying = ref(false) - const loadingDevices = ref(false) - - watch(options.open, async (isOpen) => { - if (isOpen) { - options.initializeFromCurrent?.() - if (options.loadDevices) { - loadingDevices.value = true - try { - await options.loadDevices() - } finally { - loadingDevices.value = false - } - } - } - }) - - async function applyConfig(applyFn: () => Promise) { - applying.value = true - try { - await applyFn() - } catch (e) { - console.info('[ConfigPopover] Apply failed:', e) - } finally { - applying.value = false - } - } - - // Refresh devices - async function refreshDevices() { - if (!options.loadDevices) return - loadingDevices.value = true - try { - await options.loadDevices() - } finally { - loadingDevices.value = false - } - } - - return { - applying, - loadingDevices, - - applyConfig, - refreshDevices, - } -} diff --git a/web/src/composables/useConsoleEvents.ts b/web/src/composables/useConsoleEvents.ts index 6685c250..37804a4e 100644 --- a/web/src/composables/useConsoleEvents.ts +++ b/web/src/composables/useConsoleEvents.ts @@ -1,6 +1,6 @@ import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' -import { useSystemStore } from '@/stores/system' +import { useSystemStore, type DeviceInfoEvent } from '@/stores/system' import type { StreamDeviceLostEventData, StreamStateChangedEventData } from '@/types/websocket' import { useWebSocket } from '@/composables/useWebSocket' import { isAudioStreamDeviceLostPayload } from '@/lib/streamSignal' @@ -17,14 +17,14 @@ export interface ConsoleEventHandlers { onStreamDeviceLost?: (data: StreamDeviceLostEventData) => void onStreamReconnecting?: (data: { device: string; attempt: number }) => void onStreamRecovered?: (data: { device: string }) => void - onDeviceInfo?: (data: any) => void + onDeviceInfo?: (data: DeviceInfoEvent) => void } export function useConsoleEvents(handlers: ConsoleEventHandlers) { const { t } = useI18n() const systemStore = useSystemStore() const { on, off, connect } = useWebSocket() - const noop = () => {} + let subscribed = false function handleStreamDeviceLost(data: StreamDeviceLostEventData) { const audioLost = isAudioStreamDeviceLostPayload(data) @@ -41,53 +41,44 @@ export function useConsoleEvents(handlers: ConsoleEventHandlers) { handlers.onStreamDeviceLost?.(data) } - function handleStreamReconnecting(data: { device: string; attempt: number }) { - handlers.onStreamReconnecting?.(data) - } - - function handleStreamRecovered(_data: { device: string }) { + function handleStreamRecovered(data: { device: string }) { if (systemStore.stream) { systemStore.stream.online = true } - handlers.onStreamRecovered?.(_data) + handlers.onStreamRecovered?.(data) } - function handleStreamStateChangedForward(data: StreamStateChangedEventData) { - handlers.onStreamStateChanged?.(data) - } + // Keep registration and cleanup together so every subscription is removed. + const subscriptions = [ + ['stream.config_changing', handlers.onStreamConfigChanging], + ['stream.config_applied', handlers.onStreamConfigApplied], + ['stream.stats_update', handlers.onStreamStatsUpdate], + ['stream.mode_changed', handlers.onStreamModeChanged], + ['stream.mode_switching', handlers.onStreamModeSwitching], + ['stream.mode_ready', handlers.onStreamModeReady], + ['stream.webrtc_ready', handlers.onWebRTCReady], + ['stream.state_changed', handlers.onStreamStateChanged], + ['stream.device_lost', handleStreamDeviceLost], + ['stream.reconnecting', handlers.onStreamReconnecting], + ['stream.recovered', handleStreamRecovered], + ['system.device_info', handlers.onDeviceInfo], + ] as const function subscribe() { - on('stream.config_changing', handlers.onStreamConfigChanging ?? noop) - on('stream.config_applied', handlers.onStreamConfigApplied ?? noop) - on('stream.stats_update', handlers.onStreamStatsUpdate ?? noop) - on('stream.mode_changed', handlers.onStreamModeChanged ?? noop) - on('stream.mode_switching', handlers.onStreamModeSwitching ?? noop) - on('stream.mode_ready', handlers.onStreamModeReady ?? noop) - on('stream.webrtc_ready', handlers.onWebRTCReady ?? noop) - on('stream.state_changed', handleStreamStateChangedForward) - on('stream.device_lost', handleStreamDeviceLost) - on('stream.reconnecting', handleStreamReconnecting) - on('stream.recovered', handleStreamRecovered) - - on('system.device_info', handlers.onDeviceInfo ?? noop) - + if (subscribed) return + subscribed = true + for (const [event, handler] of subscriptions) { + if (handler) on(event, handler) + } connect() } function unsubscribe() { - off('stream.config_changing', handlers.onStreamConfigChanging ?? noop) - off('stream.config_applied', handlers.onStreamConfigApplied ?? noop) - off('stream.stats_update', handlers.onStreamStatsUpdate ?? noop) - off('stream.mode_changed', handlers.onStreamModeChanged ?? noop) - off('stream.mode_switching', handlers.onStreamModeSwitching ?? noop) - off('stream.mode_ready', handlers.onStreamModeReady ?? noop) - off('stream.webrtc_ready', handlers.onWebRTCReady ?? noop) - off('stream.state_changed', handleStreamStateChangedForward) - off('stream.device_lost', handleStreamDeviceLost) - off('stream.reconnecting', handleStreamReconnecting) - off('stream.recovered', handleStreamRecovered) - - off('system.device_info', handlers.onDeviceInfo ?? noop) + if (!subscribed) return + subscribed = false + for (const [event, handler] of subscriptions) { + if (handler) off(event, handler) + } } return { diff --git a/web/src/composables/useUnifiedAudio.ts b/web/src/composables/useUnifiedAudio.ts index b9b33012..898d4883 100644 --- a/web/src/composables/useUnifiedAudio.ts +++ b/web/src/composables/useUnifiedAudio.ts @@ -1,20 +1,11 @@ // Manages audio playback across different video modes (MJPEG/WebSocket and H264/WebRTC) // Provides a single interface for volume control and audio source switching -import { ref, watch, type Ref } from 'vue' +import { ref, watch } from 'vue' import { getAudioPlayer } from './useAudioPlayer' export type AudioMode = 'ws' | 'webrtc' -export interface UnifiedAudioState { - audioMode: Ref - volume: Ref - muted: Ref - connected: Ref - playing: Ref - error: Ref -} - export function useUnifiedAudio() { const audioMode = ref('ws') const volume = ref(0) // 0-1, default muted (browser autoplay policy) diff --git a/web/src/composables/useVideoStream.ts b/web/src/composables/useVideoStream.ts deleted file mode 100644 index 6c36933f..00000000 --- a/web/src/composables/useVideoStream.ts +++ /dev/null @@ -1,217 +0,0 @@ -// Legacy MJPEG-only streaming composable. -// Deprecated: Console now uses useVideoSession for all switching/connection logic. - -import { ref, computed, type Ref } from 'vue' -import { useSystemStore } from '@/stores/system' -import { streamApi } from '@/api' -import { generateUUID } from '@/lib/utils' - -export type VideoMode = 'mjpeg' - -export interface VideoStreamState { - mode: Ref - loading: Ref - error: Ref - errorMessage: Ref - restarting: Ref - fps: Ref - mjpegUrl: Ref - clientId: string -} - -const BASE_RETRY_DELAY = 2000 -const GRACE_PERIOD = 2000 -const MAX_CONSECUTIVE_ERRORS = 2 - -/** @deprecated Use useVideoSession + ConsoleView instead. */ -export function useVideoStream() { - const systemStore = useSystemStore() - - const videoMode = ref('mjpeg') - const videoLoading = ref(true) - const videoError = ref(false) - const videoErrorMessage = ref('') - const videoRestarting = ref(false) - const backendFps = ref(0) - const mjpegTimestamp = ref(0) - const clientId = generateUUID() - const clientsStats = ref>({}) - - let retryTimeoutId: number | null = null - let retryCount = 0 - let gracePeriodTimeoutId: number | null = null - let consecutiveErrors = 0 - let isRefreshingVideo = false - let initialDeviceInfoReceived = false - - const mjpegUrl = computed(() => { - if (videoMode.value !== 'mjpeg') return '' - if (mjpegTimestamp.value === 0) return '' - return `${streamApi.getMjpegUrl(clientId)}&t=${mjpegTimestamp.value}` - }) - - function refreshVideo() { - backendFps.value = 0 - videoError.value = false - videoErrorMessage.value = '' - isRefreshingVideo = true - videoLoading.value = true - mjpegTimestamp.value = Date.now() - - setTimeout(() => { - isRefreshingVideo = false - if (videoLoading.value) { - videoLoading.value = false - } - }, 1500) - } - - function handleVideoLoad() { - if (videoMode.value === 'mjpeg') { - systemStore.setStreamOnline(true) - } - - if (!videoLoading.value) return - - clearRetryTimers() - videoLoading.value = false - videoError.value = false - videoErrorMessage.value = '' - videoRestarting.value = false - retryCount = 0 - consecutiveErrors = 0 - } - - function handleVideoError() { - if (videoMode.value !== 'mjpeg') return - if (isRefreshingVideo) return - - consecutiveErrors += 1 - - if (consecutiveErrors > MAX_CONSECUTIVE_ERRORS && gracePeriodTimeoutId !== null) { - clearTimeout(gracePeriodTimeoutId) - gracePeriodTimeoutId = null - videoRestarting.value = false - } - - if (videoRestarting.value || gracePeriodTimeoutId !== null) return - - if (retryTimeoutId !== null) { - clearTimeout(retryTimeoutId) - retryTimeoutId = null - } - - videoLoading.value = true - retryCount += 1 - const delay = BASE_RETRY_DELAY * Math.pow(1.5, Math.min(retryCount - 1, 5)) - - retryTimeoutId = window.setTimeout(() => { - retryTimeoutId = null - refreshVideo() - }, delay) - } - - function clearRetryTimers() { - if (retryTimeoutId !== null) { - clearTimeout(retryTimeoutId) - retryTimeoutId = null - } - if (gracePeriodTimeoutId !== null) { - clearTimeout(gracePeriodTimeoutId) - gracePeriodTimeoutId = null - } - } - - function handleStreamConfigChanging() { - clearRetryTimers() - videoRestarting.value = true - videoLoading.value = true - videoError.value = false - retryCount = 0 - consecutiveErrors = 0 - backendFps.value = 0 - } - - function handleStreamConfigApplied() { - consecutiveErrors = 0 - - gracePeriodTimeoutId = window.setTimeout(() => { - gracePeriodTimeoutId = null - consecutiveErrors = 0 - }, GRACE_PERIOD) - - videoRestarting.value = false - refreshVideo() - } - - function handleStreamStatsUpdate(data: { clients?: number; clients_stat?: Record }) { - if (typeof data.clients === 'number') { - systemStore.updateStreamClients(data.clients) - } - - if (data.clients_stat) { - clientsStats.value = data.clients_stat as any - const myStats = data.clients_stat[clientId] - if (myStats) { - backendFps.value = myStats.fps || 0 - } else { - const fpsList = Object.values(data.clients_stat) - .map((s) => s?.fps || 0) - .filter(f => f > 0) - backendFps.value = fpsList.length > 0 ? Math.min(...fpsList) : 0 - } - } else { - backendFps.value = 0 - } - } - - function handleDeviceInfo(data: any) { - systemStore.updateFromDeviceInfo(data) - - if (data.video?.config_changing) return - - if (!initialDeviceInfoReceived) { - initialDeviceInfoReceived = true - if (data.video?.stream_mode === 'mjpeg') { - setTimeout(() => refreshVideo(), 100) - } - } - } - - function handleModeChange(mode: VideoMode) { - if (mode !== 'mjpeg') return - if (mode === videoMode.value) return - videoMode.value = mode - localStorage.setItem('videoMode', mode) - refreshVideo() - } - - function cleanup() { - clearRetryTimers() - } - - const state: VideoStreamState = { - mode: videoMode, - loading: videoLoading, - error: videoError, - errorMessage: videoErrorMessage, - restarting: videoRestarting, - fps: backendFps, - mjpegUrl, - clientId, - } - - return { - state, - clientsStats, - refreshVideo, - handleVideoLoad, - handleVideoError, - handleStreamConfigChanging, - handleStreamConfigApplied, - handleStreamStatsUpdate, - handleDeviceInfo, - handleModeChange, - cleanup, - } -} diff --git a/web/src/lib/configResource.ts b/web/src/lib/configResource.ts new file mode 100644 index 00000000..c6b7ca81 --- /dev/null +++ b/web/src/lib/configResource.ts @@ -0,0 +1,54 @@ +import { ref, type Ref } from 'vue' + +/** Shared request state for a service with several independently cached endpoints. */ +export function createRequestState() { + const loading = ref(false) + const error = ref(null) + let pending = 0 + + async function run(request: () => Promise): Promise { + pending++ + loading.value = true + error.value = null + try { + return await request() + } catch (cause) { + error.value = cause instanceof Error ? cause.message + : typeof cause === 'string' ? cause : 'Unknown error' + throw cause + } finally { + loading.value = --pending > 0 + } + } + + return { loading, error, run } +} + +/** Cache successful reads and coalesce concurrent refreshes of the same endpoint. */ +export function createConfigResource( + fetcher: () => Promise, + state = createRequestState(), + onLoaded?: (response: T) => void, +) { + const data = ref(null) as Ref + let inFlight: Promise | null = null + + function refresh(): Promise { + if (inFlight) return inFlight + inFlight = state.run(async () => { + const response = await fetcher() + data.value = response + onLoaded?.(response) + return response + }).finally(() => { + inFlight = null + }) + return inFlight + } + + function ensure(): Promise { + return data.value === null ? refresh() : Promise.resolve(data.value) + } + + return { data, loading: state.loading, error: state.error, refresh, ensure } +} diff --git a/web/src/lib/url.ts b/web/src/lib/url.ts new file mode 100644 index 00000000..5df9b8ce --- /dev/null +++ b/web/src/lib/url.ts @@ -0,0 +1,4 @@ +export function formatHostForUrl(hostname: string): string { + if (!hostname) return '127.0.0.1' + return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname +} diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts index 1fe04f59..b1e7a3db 100644 --- a/web/src/stores/auth.ts +++ b/web/src/stores/auth.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { ref, computed } from 'vue' +import { ref } from 'vue' import { authApi, systemApi, type AuthLoginResponse } from '@/api' export const useAuthStore = defineStore('auth', () => { @@ -11,8 +11,6 @@ export const useAuthStore = defineStore('auth', () => { const error = ref(null) let pendingUsername: string | null = null - const isLoggedIn = computed(() => isAuthenticated.value && user.value !== null) - async function checkSetupStatus() { try { const status = await systemApi.setupStatus() @@ -136,7 +134,6 @@ export const useAuthStore = defineStore('auth', () => { needsSetup, loading, error, - isLoggedIn, checkSetupStatus, checkAuth, beginLogin, diff --git a/web/src/stores/config.ts b/web/src/stores/config.ts index 81a0d6c7..349f792a 100644 --- a/web/src/stores/config.ts +++ b/web/src/stores/config.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { createConfigResource, createRequestState } from '@/lib/configResource' import { authConfigApi, atxConfigApi, @@ -15,488 +15,88 @@ import { webConfigApi, } from '@/api' import type { - AtxConfig, AtxConfigUpdate, - AudioConfig, AudioConfigUpdate, - AuthConfig, AuthConfigUpdate, - HidConfig, HidConfigUpdate, - MsdConfig, MsdConfigUpdate, OtgConfigResponse, OtgConfigUpdate, - StreamConfigResponse, StreamConfigUpdate, - VideoConfig, VideoConfigUpdate, } from '@/types/generated' import type { - RtspConfigResponse as ApiRtspConfigResponse, RtspConfigUpdate as ApiRtspConfigUpdate, - RtspStatusResponse as ApiRtspStatusResponse, - RustDeskConfigResponse as ApiRustDeskConfigResponse, RustDeskConfigUpdate as ApiRustDeskConfigUpdate, - RustDeskStatusResponse as ApiRustDeskStatusResponse, - RustDeskPasswordResponse as ApiRustDeskPasswordResponse, - VncConfigResponse as ApiVncConfigResponse, VncConfigUpdate as ApiVncConfigUpdate, - VncStatusResponse as ApiVncStatusResponse, - WebConfig, WebConfigUpdate, } from '@/api' -function normalizeErrorMessage(error: unknown): string { - if (error instanceof Error) return error.message - if (typeof error === 'string') return error - return 'Unknown error' -} - export const useConfigStore = defineStore('config', () => { - const auth = ref(null) - const video = ref(null) - const audio = ref(null) - const hid = ref(null) - const msd = ref(null) - const stream = ref(null) - const web = ref(null) - const atx = ref(null) - const rtspConfig = ref(null) - const rtspStatus = ref(null) - const vncConfig = ref(null) - const vncStatus = ref(null) - const rustdeskConfig = ref(null) - const rustdeskStatus = ref(null) - const rustdeskPassword = ref(null) + const { + data: auth, loading: authLoading, error: authError, + refresh: refreshAuth, ensure: ensureAuth, + } = createConfigResource(authConfigApi.get) + const { + data: video, loading: videoLoading, error: videoError, + refresh: refreshVideo, ensure: ensureVideo, + } = createConfigResource(videoConfigApi.get) + const { + data: audio, loading: audioLoading, error: audioError, + refresh: refreshAudio, ensure: ensureAudio, + } = createConfigResource(audioConfigApi.get) + const { + data: hid, loading: hidLoading, error: hidError, + refresh: refreshHid, ensure: ensureHid, + } = createConfigResource(hidConfigApi.get) + const { + data: msd, loading: msdLoading, error: msdError, + refresh: refreshMsd, ensure: ensureMsd, + } = createConfigResource(msdConfigApi.get) + const { + data: stream, loading: streamLoading, error: streamError, + refresh: refreshStream, ensure: ensureStream, + } = createConfigResource(streamConfigApi.get) + const { + data: web, loading: webLoading, error: webError, + refresh: refreshWeb, ensure: ensureWeb, + } = createConfigResource(webConfigApi.get) + const { + data: atx, loading: atxLoading, error: atxError, + refresh: refreshAtx, ensure: ensureAtx, + } = createConfigResource(atxConfigApi.get) - const authLoading = ref(false) - const videoLoading = ref(false) - const audioLoading = ref(false) - const hidLoading = ref(false) - const msdLoading = ref(false) - const streamLoading = ref(false) - const webLoading = ref(false) - const atxLoading = ref(false) - const rtspLoading = ref(false) - const vncLoading = ref(false) - const rustdeskLoading = ref(false) + const rtspRequests = createRequestState() + const { loading: rtspLoading, error: rtspError } = rtspRequests + const { data: rtspConfig, refresh: refreshRtspConfig, ensure: ensureRtspConfig } = + createConfigResource(rtspConfigApi.get, rtspRequests) + const { data: rtspStatus, refresh: refreshRtspStatus } = createConfigResource( + rtspConfigApi.getStatus, rtspRequests, (response) => { + rtspConfig.value = response.config + }, + ) - const authError = ref(null) - const videoError = ref(null) - const audioError = ref(null) - const hidError = ref(null) - const msdError = ref(null) - const streamError = ref(null) - const webError = ref(null) - const atxError = ref(null) - const rtspError = ref(null) - const vncError = ref(null) - const rustdeskError = ref(null) + const vncRequests = createRequestState() + const { loading: vncLoading, error: vncError } = vncRequests + const { data: vncConfig, refresh: refreshVncConfig, ensure: ensureVncConfig } = + createConfigResource(vncConfigApi.get, vncRequests) + const { data: vncStatus, refresh: refreshVncStatus } = createConfigResource( + vncConfigApi.getStatus, vncRequests, (response) => { + vncConfig.value = response.config + }, + ) - let authPromise: Promise | null = null - let videoPromise: Promise | null = null - let audioPromise: Promise | null = null - let hidPromise: Promise | null = null - let msdPromise: Promise | null = null - let streamPromise: Promise | null = null - let webPromise: Promise | null = null - let atxPromise: Promise | null = null - let rtspPromise: Promise | null = null - let rtspStatusPromise: Promise | null = null - let vncPromise: Promise | null = null - let vncStatusPromise: Promise | null = null - let rustdeskPromise: Promise | null = null - let rustdeskStatusPromise: Promise | null = null - let rustdeskPasswordPromise: Promise | null = null - - async function refreshAuth() { - if (authLoading.value && authPromise) return authPromise - authLoading.value = true - authError.value = null - const request = authConfigApi.get() - .then((response) => { - auth.value = response - return response - }) - .catch((error) => { - authError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - authLoading.value = false - authPromise = null - }) - - authPromise = request - return request - } - - async function refreshVideo() { - if (videoLoading.value && videoPromise) return videoPromise - videoLoading.value = true - videoError.value = null - const request = videoConfigApi.get() - .then((response) => { - video.value = response - return response - }) - .catch((error) => { - videoError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - videoLoading.value = false - videoPromise = null - }) - - videoPromise = request - return request - } - - async function refreshAudio() { - if (audioLoading.value && audioPromise) return audioPromise - audioLoading.value = true - audioError.value = null - const request = audioConfigApi.get() - .then((response) => { - audio.value = response - return response - }) - .catch((error) => { - audioError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - audioLoading.value = false - audioPromise = null - }) - - audioPromise = request - return request - } - - async function refreshHid() { - if (hidLoading.value && hidPromise) return hidPromise - hidLoading.value = true - hidError.value = null - const request = hidConfigApi.get() - .then((response) => { - hid.value = response - return response - }) - .catch((error) => { - hidError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - hidLoading.value = false - hidPromise = null - }) - - hidPromise = request - return request - } - - async function refreshMsd() { - if (msdLoading.value && msdPromise) return msdPromise - msdLoading.value = true - msdError.value = null - const request = msdConfigApi.get() - .then((response) => { - msd.value = response - return response - }) - .catch((error) => { - msdError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - msdLoading.value = false - msdPromise = null - }) - - msdPromise = request - return request - } - - async function refreshStream() { - if (streamLoading.value && streamPromise) return streamPromise - streamLoading.value = true - streamError.value = null - const request = streamConfigApi.get() - .then((response) => { - stream.value = response - return response - }) - .catch((error) => { - streamError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - streamLoading.value = false - streamPromise = null - }) - - streamPromise = request - return request - } - - async function refreshWeb() { - if (webLoading.value && webPromise) return webPromise - webLoading.value = true - webError.value = null - const request = webConfigApi.get() - .then((response) => { - web.value = response - return response - }) - .catch((error) => { - webError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - webLoading.value = false - webPromise = null - }) - - webPromise = request - return request - } - - async function refreshAtx() { - if (atxLoading.value && atxPromise) return atxPromise - atxLoading.value = true - atxError.value = null - const request = atxConfigApi.get() - .then((response) => { - atx.value = response - return response - }) - .catch((error) => { - atxError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - atxLoading.value = false - atxPromise = null - }) - - atxPromise = request - return request - } - - async function refreshRtspConfig() { - if (rtspLoading.value && rtspPromise) return rtspPromise - rtspLoading.value = true - rtspError.value = null - const request = rtspConfigApi.get() - .then((response) => { - rtspConfig.value = response - return response - }) - .catch((error) => { - rtspError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - rtspLoading.value = false - rtspPromise = null - }) - - rtspPromise = request - return request - } - - async function refreshRtspStatus() { - if (rtspLoading.value && rtspStatusPromise) return rtspStatusPromise - rtspLoading.value = true - rtspError.value = null - const request = rtspConfigApi.getStatus() - .then((response) => { - rtspStatus.value = response - rtspConfig.value = response.config - return response - }) - .catch((error) => { - rtspError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - rtspLoading.value = false - rtspStatusPromise = null - }) - - rtspStatusPromise = request - return request - } - - async function refreshVncConfig() { - if (vncLoading.value && vncPromise) return vncPromise - vncLoading.value = true - vncError.value = null - const request = vncConfigApi.get() - .then((response) => { - vncConfig.value = response - return response - }) - .catch((error) => { - vncError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - vncLoading.value = false - vncPromise = null - }) - - vncPromise = request - return request - } - - async function refreshVncStatus() { - if (vncLoading.value && vncStatusPromise) return vncStatusPromise - vncLoading.value = true - vncError.value = null - const request = vncConfigApi.getStatus() - .then((response) => { - vncStatus.value = response - vncConfig.value = response.config - return response - }) - .catch((error) => { - vncError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - vncLoading.value = false - vncStatusPromise = null - }) - - vncStatusPromise = request - return request - } - - async function refreshRustdeskConfig() { - if (rustdeskLoading.value && rustdeskPromise) return rustdeskPromise - rustdeskLoading.value = true - rustdeskError.value = null - const request = rustdeskConfigApi.get() - .then((response) => { - rustdeskConfig.value = response - return response - }) - .catch((error) => { - rustdeskError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - rustdeskLoading.value = false - rustdeskPromise = null - }) - - rustdeskPromise = request - return request - } - - async function refreshRustdeskStatus() { - if (rustdeskLoading.value && rustdeskStatusPromise) return rustdeskStatusPromise - rustdeskLoading.value = true - rustdeskError.value = null - const request = rustdeskConfigApi.getStatus() - .then((response) => { - rustdeskStatus.value = response - rustdeskConfig.value = response.config - return response - }) - .catch((error) => { - rustdeskError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - rustdeskLoading.value = false - rustdeskStatusPromise = null - }) - - rustdeskStatusPromise = request - return request - } - - async function refreshRustdeskPassword() { - if (rustdeskLoading.value && rustdeskPasswordPromise) return rustdeskPasswordPromise - rustdeskLoading.value = true - rustdeskError.value = null - const request = rustdeskConfigApi.getPassword() - .then((response) => { - rustdeskPassword.value = response - return response - }) - .catch((error) => { - rustdeskError.value = normalizeErrorMessage(error) - throw error - }) - .finally(() => { - rustdeskLoading.value = false - rustdeskPasswordPromise = null - }) - - rustdeskPasswordPromise = request - return request - } - - function ensureAuth() { - if (auth.value) return Promise.resolve(auth.value) - return refreshAuth() - } - - function ensureVideo() { - if (video.value) return Promise.resolve(video.value) - return refreshVideo() - } - - function ensureAudio() { - if (audio.value) return Promise.resolve(audio.value) - return refreshAudio() - } - - function ensureHid() { - if (hid.value) return Promise.resolve(hid.value) - return refreshHid() - } - - function ensureMsd() { - if (msd.value) return Promise.resolve(msd.value) - return refreshMsd() - } - - function ensureStream() { - if (stream.value) return Promise.resolve(stream.value) - return refreshStream() - } - - function ensureWeb() { - if (web.value) return Promise.resolve(web.value) - return refreshWeb() - } - - function ensureAtx() { - if (atx.value) return Promise.resolve(atx.value) - return refreshAtx() - } - - function ensureRtspConfig() { - if (rtspConfig.value) return Promise.resolve(rtspConfig.value) - return refreshRtspConfig() - } - - function ensureVncConfig() { - if (vncConfig.value) return Promise.resolve(vncConfig.value) - return refreshVncConfig() - } - - function ensureRustdeskConfig() { - if (rustdeskConfig.value) return Promise.resolve(rustdeskConfig.value) - return refreshRustdeskConfig() - } + const rustdeskRequests = createRequestState() + const { loading: rustdeskLoading, error: rustdeskError } = rustdeskRequests + const { data: rustdeskConfig, refresh: refreshRustdeskConfig, ensure: ensureRustdeskConfig } = + createConfigResource(rustdeskConfigApi.get, rustdeskRequests) + const { data: rustdeskStatus, refresh: refreshRustdeskStatus } = createConfigResource( + rustdeskConfigApi.getStatus, rustdeskRequests, (response) => { + rustdeskConfig.value = response.config + }, + ) + const { data: rustdeskPassword, refresh: refreshRustdeskPassword } = + createConfigResource(rustdeskConfigApi.getPassword, rustdeskRequests) async function updateAuth(update: AuthConfigUpdate) { const response = await authConfigApi.update(update) diff --git a/web/src/stores/index.ts b/web/src/stores/index.ts deleted file mode 100644 index 638a5468..00000000 --- a/web/src/stores/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { useAuthStore } from './auth' -export { useConfigStore } from './config' -export { useSystemStore } from './system' diff --git a/web/src/types/websocket.ts b/web/src/types/websocket.ts index 9ccc3f4d..54b57df1 100644 --- a/web/src/types/websocket.ts +++ b/web/src/types/websocket.ts @@ -1,31 +1,3 @@ -import { ref, type Ref } from 'vue' - -/** WebSocket connection state */ -export interface WsConnectionState { - connected: Ref - reconnectAttempts: Ref - networkError: Ref - networkErrorMessage: Ref -} - -/** Create a new WebSocket connection state */ -export function createWsConnectionState(): WsConnectionState { - return { - connected: ref(false), - reconnectAttempts: ref(0), - networkError: ref(false), - networkErrorMessage: ref(null), - } -} - -/** Reset connection state to initial values */ -export function resetWsConnectionState(state: WsConnectionState) { - state.connected.value = false - state.reconnectAttempts.value = 0 - state.networkError.value = false - state.networkErrorMessage.value = null -} - /** Build WebSocket URL from current location */ export function buildWsUrl(path: string): string { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' @@ -73,11 +45,3 @@ export interface StreamStateChangedEventData { reason?: StreamSignalReason | null next_retry_ms?: number | null } - -/** WebSocket ready states */ -export const WS_STATE = { - CONNECTING: WebSocket.CONNECTING, - OPEN: WebSocket.OPEN, - CLOSING: WebSocket.CLOSING, - CLOSED: WebSocket.CLOSED, -} as const diff --git a/web/src/views/ConsoleView.vue b/web/src/views/ConsoleView.vue index 63e6f88d..15faa6a6 100644 --- a/web/src/views/ConsoleView.vue +++ b/web/src/views/ConsoleView.vue @@ -3036,9 +3036,6 @@ function handleToggleMouseMode() { accumulatedDelta = { x: 0, y: 0 } lastMousePosition.value = { x: 0, y: 0 } mousePosition.value = { x: 0, y: 0 } - - if (mouseMode.value === 'relative') { - } } async function refreshUacAvailability() { diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index b1a0633d..287452fa 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -27,22 +27,12 @@ import { atxConfigApi, extensionsApi, redfishConfigApi, - rtspConfigApi, - rustdeskConfigApi, systemApi, updateApi, usbApi, - vncConfigApi, watchdogConfigApi, type EncoderBackendInfo, type AuthConfig, - type RustDeskConfigResponse, - type RustDeskStatusResponse, - type RustDeskPasswordResponse, - type RtspStatusResponse, - type RtspConfigUpdate, - type VncConfigUpdate, - type VncStatusResponse, type WebConfig, type UpdateOverviewResponse, type UpdateStatusResponse, @@ -68,6 +58,8 @@ import type { import { EasytierConfigMode, FrpProxyType, FrpcConfigMode } from '@/types/generated' import { toConfigFps } from '@/lib/fps' import { useClipboard } from '@/composables/useClipboard' +import { useRemoteAccessSettings } from '@/composables/settings/useRemoteAccessSettings' +import { formatHostForUrl } from '@/lib/url' import { useFeatureVisibility } from '@/composables/useFeatureVisibility' import { useTheme } from '@/composables/useTheme' import { useConsoleLayout, type ConsoleLayout } from '@/composables/useConsoleLayout' @@ -200,7 +192,6 @@ const onlineUpgradeSupported = computed(() => systemStore.platform?.mode === 'li const activeSection = ref('appearance') const loading = ref(false) -const saved = ref(false) const saveError = ref('') const SETTINGS_SECTION_IDS = [ 'appearance', @@ -571,80 +562,41 @@ const frpcValidationMessage = computed(() => { return '' }) -const rustdeskConfig = ref(null) -const rustdeskStatus = ref(null) -const rustdeskPassword = ref(null) -const rustdeskLoading = ref(false) -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.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 '' -}) - -const rtspStatus = ref(null) -const rtspLoading = ref(false) -const rtspLocalConfig = ref({ - enabled: false, - bind: '0.0.0.0', - port: 8554, - path: 'live', - allow_one_client: true, - codec: 'h264', - username: '', - password: '', -}) - -const vncStatus = ref(null) -const vncLoading = ref(false) -const vncLocalConfig = ref({ - enabled: false, - bind: '0.0.0.0', - port: 5900, - encoding: 'tight_jpeg', - allow_one_client: true, - password: '', -}) - -function formatHostForUrl(hostname: string): string { - if (!hostname) return '127.0.0.1' - return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname -} - -const rtspStreamUrl = computed(() => { - const host = formatHostForUrl(window.location.hostname || '127.0.0.1') - const path = (rtspLocalConfig.value.path || 'live').trim().replace(/^\/+|\/+$/g, '') || 'live' - const port = Number(rtspLocalConfig.value.port) || 8554 - return `rtsp://${host}:${port}/${path}` -}) - -const vncStreamUrl = computed(() => { - const host = formatHostForUrl(window.location.hostname || '127.0.0.1') - const port = Number(vncLocalConfig.value.port) || 5900 - return `${host}:${port}` -}) +const { + rustdeskConfig, + rustdeskStatus, + rustdeskPassword, + rustdeskLoading, + rustdeskCopied, + rustdeskLocalConfig, + rustdeskValidationMessage, + rtspStatus, + rtspLoading, + rtspLocalConfig, + vncStatus, + vncLoading, + vncLocalConfig, + rtspStreamUrl, + vncStreamUrl, + serviceSaving, + loadRustdeskConfig, + loadRustdeskPassword, + saveRustdeskConfig, + regenerateRustdeskId, + regenerateRustdeskPassword, + startRustdesk, + stopRustdesk, + copyToClipboard, + loadRtspConfig, + saveRtspConfig, + startRtsp, + stopRtsp, + loadVncConfig, + saveVncConfig, + startVnc, + stopVnc, +} = useRemoteAccessSettings({ loading, showValidationError }) const webServerConfig = ref({ http_port: 8080, @@ -1512,7 +1464,6 @@ async function changePassword() { } } -const serviceSaving = ref<'rtsp' | 'vnc' | 'rustdesk' | null>(null) const videoSaving = ref<'capture' | 'encoder' | 'connection' | null>(null) async function saveVideoCard(card: 'capture' | 'encoder' | 'connection') { if (videoSaving.value) return @@ -1555,7 +1506,6 @@ async function saveHidSettings(scope: 'features' | 'identity' = 'features') { if (loading.value) return hidSaveScope.value = scope loading.value = true - saved.value = false saveError.value = '' try { @@ -1630,8 +1580,6 @@ async function saveHidSettings(scope: 'features' | 'identity' = 'features') { if (activeSection.value !== 'hid') { await loadSectionData(activeSection.value) } - saved.value = true - setTimeout(() => (saved.value = false), 2000) } catch (error) { saveError.value = error instanceof Error ? error.message : t('api.operationFailedDesc') } finally { @@ -2126,51 +2074,6 @@ watch( }, ) -function applyRustdeskStatus(status: RustDeskStatusResponse) { - const config = status.config - rustdeskConfig.value = config - 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 || '', - } -} - -async function loadRustdeskConfig() { - rustdeskLoading.value = true - try { - const status = await configStore.refreshRustdeskStatus() - applyRustdeskStatus(status) - } catch { - } finally { - rustdeskLoading.value = false - } -} - -async function loadRustdeskPassword() { - try { - rustdeskPassword.value = await configStore.refreshRustdeskPassword() - } catch {} -} - -function normalizeRustdeskServer(value: string, defaultPort: number): string { - const trimmed = value.trim() - if (!trimmed) return '' - if (trimmed.includes(':')) return trimmed - return `${trimmed}:${defaultPort}` -} - -/** Strip line breaks from pasted keys. */ -function normalizeRustdeskRelayKey(value: string): string { - const cleaned = value.replace(/\r?\n/g, '').trim() - return cleaned -} - function showValidationError(message: string): boolean { toast.error(t('api.operationFailed'), { description: message, @@ -2192,25 +2095,6 @@ function validateExtensionConfig(id: ValidatedExtensionConfigId): boolean { return !message || showValidationError(message) } -function validateRustdeskConfig(): boolean { - return !rustdeskValidationMessage.value || showValidationError(rustdeskValidationMessage.value) -} - -function validateVncConfig(enabled = vncLocalConfig.value.enabled): boolean { - const password = (vncLocalConfig.value.password || '').trim() - if (enabled && !vncStatus.value?.config.has_password && !password) { - return showValidationError(t('extensions.vnc.passwordRequired')) - } - if (password.length > 8) { - return showValidationError(t('extensions.vnc.passwordMaxLength')) - } - return true -} - -function normalizeRtspPath(path: string): string { - return path.trim().replace(/^\/+|\/+$/g, '') || 'live' -} - function normalizeBindAddresses(addresses: string[]): string[] { return addresses.map((addr) => addr.trim()).filter(Boolean) } @@ -2542,252 +2426,6 @@ 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), - relay_server: normalizeRustdeskServer(rustdeskLocalConfig.value.relay_server, 21117), - relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key), - } -} - -async function saveRustdeskConfig() { - if ( - (rustdeskLocalConfig.value.enabled || rustdeskLocalConfig.value.mode === 'direct_ip') && - !validateRustdeskConfig() - ) - return - - serviceSaving.value = 'rustdesk' - loading.value = true - saved.value = false - try { - await configStore.updateRustdesk(rustdeskUpdatePayload()) - await loadRustdeskConfig() - toast.success(t('settingsUi.saveDone')) - } catch { - } finally { - loading.value = false - serviceSaving.value = null - } -} - -async function regenerateRustdeskId() { - if (!confirm(t('extensions.rustdesk.confirmRegenerateId'))) return - rustdeskLoading.value = true - try { - await configStore.regenerateRustdeskId() - await loadRustdeskConfig() - await loadRustdeskPassword() - } catch { - } finally { - rustdeskLoading.value = false - } -} - -async function regenerateRustdeskPassword() { - if (!confirm(t('extensions.rustdesk.confirmRegeneratePassword'))) return - rustdeskLoading.value = true - try { - await configStore.regenerateRustdeskPassword() - await loadRustdeskConfig() - await loadRustdeskPassword() - } catch { - } finally { - rustdeskLoading.value = false - } -} - -async function startRustdesk() { - if (!validateRustdeskConfig()) return - - rustdeskLoading.value = true - try { - await configStore.updateRustdesk(rustdeskUpdatePayload()) - const status = await rustdeskConfigApi.start() - applyRustdeskStatus(status) - } catch { - } finally { - rustdeskLoading.value = false - } -} - -async function stopRustdesk() { - rustdeskLoading.value = true - try { - const status = await rustdeskConfigApi.stop() - applyRustdeskStatus(status) - } catch { - } finally { - rustdeskLoading.value = false - } -} - -async function copyToClipboard(text: string, type: 'id' | 'password') { - const success = await clipboardCopy(text) - if (success) { - rustdeskCopied.value = type - setTimeout(() => (rustdeskCopied.value = null), 2000) - } -} - -function applyRtspStatus(status: RtspStatusResponse) { - rtspStatus.value = status - rtspLocalConfig.value = { - enabled: status.config.enabled, - bind: status.config.bind, - port: status.config.port, - path: status.config.path, - allow_one_client: status.config.allow_one_client, - codec: status.config.codec, - username: status.config.username || '', - password: status.config.password || '', - } -} - -async function loadRtspConfig() { - rtspLoading.value = true - try { - const status = await configStore.refreshRtspStatus() - applyRtspStatus(status) - } catch { - } finally { - rtspLoading.value = false - } -} - -function rtspUpdatePayload(enabled = !!rtspLocalConfig.value.enabled): RtspConfigUpdate { - return { - enabled, - bind: rtspLocalConfig.value.bind?.trim() || '0.0.0.0', - port: Number(rtspLocalConfig.value.port) || 8554, - path: normalizeRtspPath(rtspLocalConfig.value.path || 'live'), - allow_one_client: !!rtspLocalConfig.value.allow_one_client, - codec: rtspLocalConfig.value.codec || 'h264', - username: (rtspLocalConfig.value.username || '').trim(), - password: (rtspLocalConfig.value.password || '').trim(), - } -} - -async function saveRtspConfig() { - serviceSaving.value = 'rtsp' - loading.value = true - saved.value = false - try { - await configStore.updateRtsp(rtspUpdatePayload()) - await loadRtspConfig() - toast.success(t('settingsUi.saveDone')) - } catch { - } finally { - loading.value = false - serviceSaving.value = null - } -} - -async function startRtsp() { - rtspLoading.value = true - try { - await configStore.updateRtsp(rtspUpdatePayload()) - const status = await rtspConfigApi.start() - applyRtspStatus(status) - } catch { - } finally { - rtspLoading.value = false - } -} - -async function stopRtsp() { - rtspLoading.value = true - try { - const status = await rtspConfigApi.stop() - applyRtspStatus(status) - } catch { - } finally { - rtspLoading.value = false - } -} - -function applyVncStatus(status: VncStatusResponse) { - vncStatus.value = status - vncLocalConfig.value = { - enabled: status.config.enabled, - bind: status.config.bind, - port: status.config.port, - encoding: status.config.encoding, - allow_one_client: status.config.allow_one_client, - password: '', - } -} - -async function loadVncConfig() { - vncLoading.value = true - try { - const status = await configStore.refreshVncStatus() - applyVncStatus(status) - } catch { - } finally { - vncLoading.value = false - } -} - -function vncUpdatePayload(enabled = !!vncLocalConfig.value.enabled): VncConfigUpdate { - const update: VncConfigUpdate = { - enabled, - bind: vncLocalConfig.value.bind?.trim() || '0.0.0.0', - port: Number(vncLocalConfig.value.port) || 5900, - encoding: vncLocalConfig.value.encoding || 'tight_jpeg', - allow_one_client: !!vncLocalConfig.value.allow_one_client, - } - const password = (vncLocalConfig.value.password || '').trim() - if (password) update.password = password - return update -} - -async function saveVncConfig() { - if (!validateVncConfig()) return - - serviceSaving.value = 'vnc' - loading.value = true - saved.value = false - try { - await configStore.updateVnc(vncUpdatePayload()) - await loadVncConfig() - toast.success(t('settingsUi.saveDone')) - } catch { - } finally { - loading.value = false - serviceSaving.value = null - } -} - -async function startVnc() { - if (!validateVncConfig(true)) return - - vncLoading.value = true - try { - await configStore.updateVnc(vncUpdatePayload()) - const status = await vncConfigApi.start() - applyVncStatus(status) - } catch { - } finally { - vncLoading.value = false - } -} - -async function stopVnc() { - vncLoading.value = true - try { - const status = await vncConfigApi.stop() - applyVncStatus(status) - } catch { - } finally { - vncLoading.value = false - } -} - onMounted(async () => { const initialSection = normalizeSettingsSection(route.query.tab) if (initialSection) { @@ -5759,7 +5397,6 @@ watch(isWindows, () => { :aria-label="t('extensions.start') + ' ' + t('extensions.rtsp.title')" :disabled=" rtspLoading || - rtspStatus?.service_status === 'starting' || rtspStatus?.service_status === 'running' || rtspStatus?.service_status === 'starting' " @@ -5927,7 +5564,6 @@ watch(isWindows, () => { :aria-label="t('extensions.start') + ' ' + t('extensions.vnc.title')" :disabled=" vncLoading || - vncStatus?.service_status === 'starting' || vncStatus?.service_status === 'running' || vncStatus?.service_status === 'starting' "