From cd87aa499cc85478a2f9aefb22d927e56e984a29 Mon Sep 17 00:00:00 2001 From: Alex P Date: Sat, 20 Sep 2025 02:30:29 +0300 Subject: [PATCH] [WIP] Cleanup: PR Cleanup --- audio_handlers.go | 154 +---------- internal/audio/rpc_handlers.go | 156 +++++++++++ jsonrpc.go | 46 ++++ ui/src/components/ActionBar.tsx | 2 +- ui/src/components/Combobox.tsx | 2 + ui/src/components/EmptyCard.tsx | 2 + ui/src/components/Header.tsx | 8 +- ui/src/components/JigglerSetting.tsx | 1 + ui/src/components/SelectMenuBasic.tsx | 2 + ui/src/components/Terminal.tsx | 2 + ui/src/components/USBStateStatus.tsx | 4 +- .../components/UpdateInProgressStatusCard.tsx | 2 + ui/src/components/VirtualKeyboard.tsx | 6 +- ui/src/components/WebRTCVideo.tsx | 3 +- .../components/extensions/ATXPowerControl.tsx | 1 + .../components/extensions/DCPowerControl.tsx | 4 +- .../components/extensions/SerialConsole.tsx | 2 +- .../popovers/AudioControlPopover.tsx | 104 +++++-- .../components/popovers/ExtensionPopover.tsx | 2 +- ui/src/components/popovers/MountPopover.tsx | 2 +- ui/src/components/popovers/PasteModal.tsx | 10 +- .../components/popovers/WakeOnLan/Index.tsx | 2 + ui/src/components/sidebar/connectionStats.tsx | 4 +- ui/src/hooks/stores.ts | 2 + ui/src/hooks/useAudioEvents.ts | 48 +++- ui/src/hooks/useHidRpc.ts | 2 + ui/src/hooks/useMicrophone.ts | 258 ++++++++++-------- ui/src/main.tsx | 6 +- ui/src/routes/devices.$id.deregister.tsx | 2 +- ui/src/routes/devices.$id.mount.tsx | 13 +- ui/src/routes/devices.$id.other-session.tsx | 2 +- ui/src/routes/devices.$id.rename.tsx | 3 +- .../devices.$id.settings.access._index.tsx | 10 +- .../devices.$id.settings.general._index.tsx | 2 + .../devices.$id.settings.general.reboot.tsx | 2 +- .../devices.$id.settings.general.update.tsx | 2 +- .../routes/devices.$id.settings.hardware.tsx | 5 +- .../routes/devices.$id.settings.keyboard.tsx | 3 +- ui/src/routes/devices.$id.settings.mouse.tsx | 16 +- .../routes/devices.$id.settings.network.tsx | 24 +- ui/src/routes/devices.$id.settings.tsx | 2 + ui/src/routes/devices.$id.settings.video.tsx | 7 +- ui/src/routes/devices.$id.setup.tsx | 1 + ui/src/routes/devices.$id.tsx | 19 +- ui/src/routes/devices.already-adopted.tsx | 2 +- ui/src/routes/login-local.tsx | 3 + ui/src/routes/welcome-local.mode.tsx | 5 +- ui/src/routes/welcome-local.password.tsx | 2 + ui/src/routes/welcome-local.tsx | 1 + ui/src/services/audioQualityService.ts | 85 +++--- web.go | 10 - 51 files changed, 639 insertions(+), 419 deletions(-) create mode 100644 internal/audio/rpc_handlers.go diff --git a/audio_handlers.go b/audio_handlers.go index b39fe087..b133baf9 100644 --- a/audio_handlers.go +++ b/audio_handlers.go @@ -2,10 +2,8 @@ package kvm import ( "context" - "net/http" "github.com/coder/websocket" - "github.com/gin-gonic/gin" "github.com/jetkvm/kvm/internal/audio" "github.com/pion/webrtc/v4" "github.com/rs/zerolog" @@ -30,6 +28,16 @@ func ensureAudioControlService() *audio.AudioControlService { } return nil }) + + // Set up RPC callback functions for the audio package + audio.SetRPCCallbacks( + func() *audio.AudioControlService { return audioControlService }, + func() audio.AudioConfig { return audioControlService.GetCurrentAudioQuality() }, + func(quality audio.AudioQuality) error { + audioControlService.SetAudioQuality(quality) + return nil + }, + ) } return audioControlService } @@ -129,94 +137,6 @@ func GetCurrentAudioQuality() audio.AudioConfig { return audioControlService.GetCurrentAudioQuality() } -// handleAudioMute handles POST /audio/mute requests -func handleAudioMute(c *gin.Context) { - type muteReq struct { - Muted bool `json:"muted"` - } - var req muteReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": "invalid request"}) - return - } - - var err error - if req.Muted { - err = MuteAudioOutput() - } else { - err = UnmuteAudioOutput() - } - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(200, gin.H{ - "status": "audio mute state updated", - "muted": req.Muted, - }) -} - -// handleMicrophoneStart handles POST /microphone/start requests -func handleMicrophoneStart(c *gin.Context) { - err := StartMicrophone() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"success": true}) -} - -// handleMicrophoneStop handles POST /microphone/stop requests -func handleMicrophoneStop(c *gin.Context) { - err := StopMicrophone() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"success": true}) -} - -// handleMicrophoneMute handles POST /microphone/mute requests -func handleMicrophoneMute(c *gin.Context) { - var req struct { - Muted bool `json:"muted"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - var err error - if req.Muted { - err = StopMicrophone() - } else { - err = StartMicrophone() - } - - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"success": true}) -} - -// handleMicrophoneReset handles POST /microphone/reset requests -func handleMicrophoneReset(c *gin.Context) { - err := ResetMicrophone() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - c.JSON(http.StatusOK, gin.H{"success": true}) -} - // handleSubscribeAudioEvents handles WebSocket audio event subscription func handleSubscribeAudioEvents(connectionID string, wsCon *websocket.Conn, runCtx context.Context, l *zerolog.Logger) { ensureAudioControlService() @@ -228,57 +148,3 @@ func handleUnsubscribeAudioEvents(connectionID string, l *zerolog.Logger) { ensureAudioControlService() audioControlService.UnsubscribeFromAudioEvents(connectionID, l) } - -// handleAudioStatus handles GET requests for audio status -func handleAudioStatus(c *gin.Context) { - ensureAudioControlService() - - status := audioControlService.GetAudioStatus() - c.JSON(200, status) -} - -// handleAudioQuality handles GET requests for audio quality presets -func handleAudioQuality(c *gin.Context) { - presets := GetAudioQualityPresets() - current := GetCurrentAudioQuality() - - c.JSON(200, gin.H{ - "presets": presets, - "current": current, - }) -} - -// handleSetAudioQuality handles POST requests to set audio quality -func handleSetAudioQuality(c *gin.Context) { - var req struct { - Quality int `json:"quality"` - } - - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(400, gin.H{"error": err.Error()}) - return - } - - // Check if audio output is active before attempting quality change - // This prevents race conditions where quality changes are attempted before initialization - if !IsAudioOutputActive() { - c.JSON(503, gin.H{"error": "audio output not active - please wait for initialization to complete"}) - return - } - - // Convert int to AudioQuality type - quality := audio.AudioQuality(req.Quality) - - // Set the audio quality using global convenience function - if err := SetAudioQuality(quality); err != nil { - c.JSON(500, gin.H{"error": err.Error()}) - return - } - - // Return the updated configuration - current := GetCurrentAudioQuality() - c.JSON(200, gin.H{ - "success": true, - "config": current, - }) -} diff --git a/internal/audio/rpc_handlers.go b/internal/audio/rpc_handlers.go new file mode 100644 index 00000000..d05c5552 --- /dev/null +++ b/internal/audio/rpc_handlers.go @@ -0,0 +1,156 @@ +package audio + +import ( + "fmt" +) + +// RPC wrapper functions for audio control +// These functions bridge the RPC layer to the AudioControlService + +// These variables will be set by the main package to provide access to the global service +var ( + getAudioControlServiceFunc func() *AudioControlService + getAudioQualityFunc func() AudioConfig + setAudioQualityFunc func(AudioQuality) error +) + +// SetRPCCallbacks sets the callback functions for RPC operations +func SetRPCCallbacks( + getService func() *AudioControlService, + getQuality func() AudioConfig, + setQuality func(AudioQuality) error, +) { + getAudioControlServiceFunc = getService + getAudioQualityFunc = getQuality + setAudioQualityFunc = setQuality +} + +// RPCAudioMute handles audio mute/unmute RPC requests +func RPCAudioMute(muted bool) error { + if getAudioControlServiceFunc == nil { + return fmt.Errorf("audio control service not available") + } + service := getAudioControlServiceFunc() + if service == nil { + return fmt.Errorf("audio control service not initialized") + } + return service.MuteAudio(muted) +} + +// RPCAudioQuality handles audio quality change RPC requests +func RPCAudioQuality(quality int) (map[string]any, error) { + if getAudioQualityFunc == nil || setAudioQualityFunc == nil { + return nil, fmt.Errorf("audio quality functions not available") + } + + // Convert int to AudioQuality type + audioQuality := AudioQuality(quality) + + // Get current audio quality configuration + currentConfig := getAudioQualityFunc() + + // Set new quality if different + if currentConfig.Quality != audioQuality { + err := setAudioQualityFunc(audioQuality) + if err != nil { + return nil, fmt.Errorf("failed to set audio quality: %w", err) + } + // Get updated config after setting + newConfig := getAudioQualityFunc() + return map[string]any{"config": newConfig}, nil + } + + // Return current config if no change needed + return map[string]any{"config": currentConfig}, nil +} + +// RPCMicrophoneStart handles microphone start RPC requests +func RPCMicrophoneStart() error { + if getAudioControlServiceFunc == nil { + return fmt.Errorf("audio control service not available") + } + service := getAudioControlServiceFunc() + if service == nil { + return fmt.Errorf("audio control service not initialized") + } + return service.StartMicrophone() +} + +// RPCMicrophoneStop handles microphone stop RPC requests +func RPCMicrophoneStop() error { + if getAudioControlServiceFunc == nil { + return fmt.Errorf("audio control service not available") + } + service := getAudioControlServiceFunc() + if service == nil { + return fmt.Errorf("audio control service not initialized") + } + return service.StopMicrophone() +} + +// RPCAudioStatus handles audio status RPC requests (read-only) +func RPCAudioStatus() (map[string]interface{}, error) { + if getAudioControlServiceFunc == nil { + return nil, fmt.Errorf("audio control service not available") + } + service := getAudioControlServiceFunc() + if service == nil { + return nil, fmt.Errorf("audio control service not initialized") + } + return service.GetAudioStatus(), nil +} + +// RPCAudioQualityPresets handles audio quality presets RPC requests (read-only) +func RPCAudioQualityPresets() (map[string]any, error) { + if getAudioControlServiceFunc == nil || getAudioQualityFunc == nil { + return nil, fmt.Errorf("audio control service not available") + } + service := getAudioControlServiceFunc() + if service == nil { + return nil, fmt.Errorf("audio control service not initialized") + } + + presets := service.GetAudioQualityPresets() + current := getAudioQualityFunc() + + return map[string]any{ + "presets": presets, + "current": current, + }, nil +} + +// RPCMicrophoneStatus handles microphone status RPC requests (read-only) +func RPCMicrophoneStatus() (map[string]interface{}, error) { + if getAudioControlServiceFunc == nil { + return nil, fmt.Errorf("audio control service not available") + } + service := getAudioControlServiceFunc() + if service == nil { + return nil, fmt.Errorf("audio control service not initialized") + } + return service.GetMicrophoneStatus(), nil +} + +// RPCMicrophoneReset handles microphone reset RPC requests +func RPCMicrophoneReset() error { + if getAudioControlServiceFunc == nil { + return fmt.Errorf("audio control service not available") + } + service := getAudioControlServiceFunc() + if service == nil { + return fmt.Errorf("audio control service not initialized") + } + return service.ResetMicrophone() +} + +// RPCMicrophoneMute handles microphone mute RPC requests +func RPCMicrophoneMute(muted bool) error { + if getAudioControlServiceFunc == nil { + return fmt.Errorf("audio control service not available") + } + service := getAudioControlServiceFunc() + if service == nil { + return fmt.Errorf("audio control service not initialized") + } + return service.MuteMicrophone(muted) +} diff --git a/jsonrpc.go b/jsonrpc.go index 4fe42cba..cfc777ad 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1339,6 +1339,43 @@ func rpcDoExecuteKeyboardMacro(ctx context.Context, macro []hidrpc.KeyboardMacro return nil } +// Audio control RPC handlers - delegated to audio package +func rpcAudioMute(muted bool) error { + return audio.RPCAudioMute(muted) +} + +func rpcAudioQuality(quality int) (map[string]any, error) { + return audio.RPCAudioQuality(quality) +} + +func rpcMicrophoneStart() error { + return audio.RPCMicrophoneStart() +} + +func rpcMicrophoneStop() error { + return audio.RPCMicrophoneStop() +} + +func rpcAudioStatus() (map[string]interface{}, error) { + return audio.RPCAudioStatus() +} + +func rpcAudioQualityPresets() (map[string]any, error) { + return audio.RPCAudioQualityPresets() +} + +func rpcMicrophoneStatus() (map[string]interface{}, error) { + return audio.RPCMicrophoneStatus() +} + +func rpcMicrophoneReset() error { + return audio.RPCMicrophoneReset() +} + +func rpcMicrophoneMute(muted bool) error { + return audio.RPCMicrophoneMute(muted) +} + var rpcHandlers = map[string]RPCHandler{ "ping": {Func: rpcPing}, "reboot": {Func: rpcReboot, Params: []string{"force"}}, @@ -1388,6 +1425,15 @@ var rpcHandlers = map[string]RPCHandler{ "isUpdatePending": {Func: rpcIsUpdatePending}, "getUsbEmulationState": {Func: rpcGetUsbEmulationState}, "setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}}, + "audioMute": {Func: rpcAudioMute, Params: []string{"muted"}}, + "audioQuality": {Func: rpcAudioQuality, Params: []string{"quality"}}, + "audioStatus": {Func: rpcAudioStatus}, + "audioQualityPresets": {Func: rpcAudioQualityPresets}, + "microphoneStart": {Func: rpcMicrophoneStart}, + "microphoneStop": {Func: rpcMicrophoneStop}, + "microphoneStatus": {Func: rpcMicrophoneStatus}, + "microphoneReset": {Func: rpcMicrophoneReset}, + "microphoneMute": {Func: rpcMicrophoneMute, Params: []string{"muted"}}, "getUsbConfig": {Func: rpcGetUsbConfig}, "setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}}, "checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}}, diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index cd1fde4e..f7126188 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -6,13 +6,13 @@ import { Fragment, useCallback, useRef } from "react"; import { CommandLineIcon } from "@heroicons/react/20/solid"; import { Button } from "@components/Button"; +import Container from "@components/Container"; import { useHidStore, useMountMediaStore, useSettingsStore, useUiStore, } from "@/hooks/stores"; -import Container from "@components/Container"; import { cx } from "@/cva.config"; import PasteModal from "@/components/popovers/PasteModal"; import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index"; diff --git a/ui/src/components/Combobox.tsx b/ui/src/components/Combobox.tsx index 3fce228f..8f115f3b 100644 --- a/ui/src/components/Combobox.tsx +++ b/ui/src/components/Combobox.tsx @@ -11,6 +11,8 @@ import { cva } from "@/cva.config"; import Card from "./Card"; + + export interface ComboboxOption { value: string; label: string; diff --git a/ui/src/components/EmptyCard.tsx b/ui/src/components/EmptyCard.tsx index ad3370e3..ba031205 100644 --- a/ui/src/components/EmptyCard.tsx +++ b/ui/src/components/EmptyCard.tsx @@ -4,6 +4,8 @@ import { GridCard } from "@/components/Card"; import { cx } from "../cva.config"; + + interface Props { IconElm?: React.FC<{ className: string | undefined }>; headline: string; diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index a650693f..86d2a6d7 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -4,20 +4,22 @@ import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/1 import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { LuMonitorSmartphone } from "react-icons/lu"; +import USBStateStatus from "@components/USBStateStatus"; +import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard"; import Container from "@/components/Container"; import Card from "@/components/Card"; import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores"; import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg"; -import USBStateStatus from "@components/USBStateStatus"; -import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard"; import { CLOUD_API, DEVICE_API } from "@/ui.config"; -import api from "../api"; import { isOnDevice } from "../main"; +import api from "../api"; import { LinkButton } from "./Button"; + + interface NavbarProps { isLoggedIn: boolean; primaryLinks?: { title: string; to: string }[]; diff --git a/ui/src/components/JigglerSetting.tsx b/ui/src/components/JigglerSetting.tsx index fc0f50dd..44094d8d 100644 --- a/ui/src/components/JigglerSetting.tsx +++ b/ui/src/components/JigglerSetting.tsx @@ -7,6 +7,7 @@ import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { InputFieldWithLabel } from "./InputField"; import { SelectMenuBasic } from "./SelectMenuBasic"; + export interface JigglerConfig { inactivity_limit_seconds: number; jitter_percentage: number; diff --git a/ui/src/components/SelectMenuBasic.tsx b/ui/src/components/SelectMenuBasic.tsx index b92f837a..2898f8bb 100644 --- a/ui/src/components/SelectMenuBasic.tsx +++ b/ui/src/components/SelectMenuBasic.tsx @@ -1,12 +1,14 @@ import React, { JSX } from "react"; import clsx from "clsx"; + import FieldLabel from "@/components/FieldLabel"; import { cva } from "@/cva.config"; import Card from "./Card"; + type SelectMenuProps = Pick< JSX.IntrinsicElements["select"], "disabled" | "onChange" | "name" | "value" diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index ba3e667c..f5159c78 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -8,11 +8,13 @@ import { WebglAddon } from "@xterm/addon-webgl"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { ClipboardAddon } from "@xterm/addon-clipboard"; + import { cx } from "@/cva.config"; import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores"; import { Button } from "./Button"; + const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2"); // Terminal theme configuration diff --git a/ui/src/components/USBStateStatus.tsx b/ui/src/components/USBStateStatus.tsx index ffe2fce6..2dbd8d4d 100644 --- a/ui/src/components/USBStateStatus.tsx +++ b/ui/src/components/USBStateStatus.tsx @@ -1,9 +1,9 @@ import React from "react"; -import { cx } from "@/cva.config"; -import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png"; import LoadingSpinner from "@components/LoadingSpinner"; import StatusCard from "@components/StatusCards"; +import { cx } from "@/cva.config"; +import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png"; import { USBStates } from "@/hooks/stores"; type StatusProps = Record< diff --git a/ui/src/components/UpdateInProgressStatusCard.tsx b/ui/src/components/UpdateInProgressStatusCard.tsx index b61752f2..fa2bc68e 100644 --- a/ui/src/components/UpdateInProgressStatusCard.tsx +++ b/ui/src/components/UpdateInProgressStatusCard.tsx @@ -1,3 +1,4 @@ + import { cx } from "@/cva.config"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; @@ -6,6 +7,7 @@ import { Button } from "./Button"; import { GridCard } from "./Card"; import LoadingSpinner from "./LoadingSpinner"; + export default function UpdateInProgressStatusCard() { const { navigateTo } = useDeviceUiNavigation(); diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index 83ebd72f..374dcb11 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -4,12 +4,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Keyboard from "react-simple-keyboard"; import { LuKeyboard } from "react-icons/lu"; -import Card from "@components/Card"; -// eslint-disable-next-line import/order -import { Button, LinkButton } from "@components/Button"; - import "react-simple-keyboard/build/css/index.css"; +import Card from "@components/Card"; +import { Button, LinkButton } from "@components/Button"; import DetachIconRaw from "@/assets/detach-icon.svg"; import { cx } from "@/cva.config"; import { useHidStore, useUiStore } from "@/hooks/stores"; diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 3d506914..9d97dfa8 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -3,8 +3,8 @@ import { useResizeObserver } from "usehooks-ts"; import VirtualKeyboard from "@components/VirtualKeyboard"; import Actionbar from "@components/ActionBar"; -import MacroBar from "@/components/MacroBar"; import InfoBar from "@components/InfoBar"; +import MacroBar from "@/components/MacroBar"; import notifications from "@/notifications"; import useKeyboard from "@/hooks/useKeyboard"; import { cx } from "@/cva.config"; @@ -23,6 +23,7 @@ import { PointerLockBar, } from "./VideoOverlay"; + // Type for microphone error interface MicrophoneError { type: 'permission' | 'device' | 'network' | 'unknown'; diff --git a/ui/src/components/extensions/ATXPowerControl.tsx b/ui/src/components/extensions/ATXPowerControl.tsx index 323e2419..6aa65f09 100644 --- a/ui/src/components/extensions/ATXPowerControl.tsx +++ b/ui/src/components/extensions/ATXPowerControl.tsx @@ -9,6 +9,7 @@ import LoadingSpinner from "@/components/LoadingSpinner"; import { JsonRpcResponse, useJsonRpc } from "../../hooks/useJsonRpc"; + const LONG_PRESS_DURATION = 3000; // 3 seconds for long press interface ATXState { diff --git a/ui/src/components/extensions/DCPowerControl.tsx b/ui/src/components/extensions/DCPowerControl.tsx index 7f950491..722f2b67 100644 --- a/ui/src/components/extensions/DCPowerControl.tsx +++ b/ui/src/components/extensions/DCPowerControl.tsx @@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from "react"; import { Button } from "@components/Button"; import Card from "@components/Card"; import { SettingsPageHeader } from "@components/SettingsPageheader"; -import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; -import notifications from "@/notifications"; import FieldLabel from "@components/FieldLabel"; import LoadingSpinner from "@components/LoadingSpinner"; import {SelectMenuBasic} from "@components/SelectMenuBasic"; +import notifications from "@/notifications"; +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; interface DCPowerState { isOn: boolean; diff --git a/ui/src/components/extensions/SerialConsole.tsx b/ui/src/components/extensions/SerialConsole.tsx index e36365ff..b43b820b 100644 --- a/ui/src/components/extensions/SerialConsole.tsx +++ b/ui/src/components/extensions/SerialConsole.tsx @@ -4,10 +4,10 @@ import { useEffect, useState } from "react"; import { Button } from "@components/Button"; import Card from "@components/Card"; import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; import { useUiStore } from "@/hooks/stores"; -import { SelectMenuBasic } from "@components/SelectMenuBasic"; interface SerialSettings { baudRate: string; diff --git a/ui/src/components/popovers/AudioControlPopover.tsx b/ui/src/components/popovers/AudioControlPopover.tsx index 6ad2c87b..d16b46e9 100644 --- a/ui/src/components/popovers/AudioControlPopover.tsx +++ b/ui/src/components/popovers/AudioControlPopover.tsx @@ -5,7 +5,8 @@ import { Button } from "@components/Button"; import { cx } from "@/cva.config"; import { useAudioDevices } from "@/hooks/useAudioDevices"; import { useAudioEvents } from "@/hooks/useAudioEvents"; -import api from "@/api"; +import { useJsonRpc, JsonRpcResponse } from "@/hooks/useJsonRpc"; +import { useRTCStore } from "@/hooks/stores"; import notifications from "@/notifications"; import audioQualityService from "@/services/audioQualityService"; @@ -64,6 +65,17 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP isConnected: wsConnected } = useAudioEvents(); + // RPC for device communication (works both locally and via cloud) + const { rpcDataChannel } = useRTCStore(); + const { send } = useJsonRpc(); + + // Initialize audio quality service with RPC for cloud compatibility + useEffect(() => { + if (send) { + audioQualityService.setRpcSend(send); + } + }, [send]); + // WebSocket-only implementation - no fallback polling // Microphone state from props (keeping hook for legacy device operations) @@ -146,21 +158,22 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP setIsLoading(true); try { - if (isMuted) { - // Unmute: Start audio output process and notify backend - const resp = await api.POST("/audio/mute", { muted: false }); - if (!resp.ok) { - throw new Error(`Failed to unmute audio: ${resp.status}`); - } - // WebSocket will handle the state update automatically - } else { - // Mute: Stop audio output process and notify backend - const resp = await api.POST("/audio/mute", { muted: true }); - if (!resp.ok) { - throw new Error(`Failed to mute audio: ${resp.status}`); - } - // WebSocket will handle the state update automatically + // Use RPC for device communication - works for both local and cloud + if (rpcDataChannel?.readyState !== "open") { + throw new Error("Device connection not available"); } + + await new Promise((resolve, reject) => { + send("audioMute", { muted: !isMuted }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + reject(new Error(resp.error.message)); + } else { + resolve(); + } + }); + }); + + // WebSocket will handle the state update automatically } catch (error) { const errorMessage = error instanceof Error ? error.message : "Failed to toggle audio mute"; notifications.error(errorMessage); @@ -172,13 +185,27 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP const handleQualityChange = async (quality: number) => { setIsLoading(true); try { - const resp = await api.POST("/audio/quality", { quality }); - if (resp.ok) { - const data = await resp.json(); - setCurrentConfig(data.config); + // Use RPC for device communication - works for both local and cloud + if (rpcDataChannel?.readyState !== "open") { + throw new Error("Device connection not available"); } - } catch { - // Failed to change audio quality + + await new Promise((resolve, reject) => { + send("audioQuality", { quality }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + reject(new Error(resp.error.message)); + } else { + // Update local state with response + if ("result" in resp && resp.result && typeof resp.result === 'object' && 'config' in resp.result) { + setCurrentConfig(resp.result.config as AudioConfig); + } + resolve(); + } + }); + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Failed to change audio quality"; + notifications.error(errorMessage); } finally { setIsLoading(false); } @@ -196,17 +223,44 @@ export default function AudioControlPopover({ microphone }: AudioControlPopoverP setIsLoading(true); try { + // Use RPC for device communication - works for both local and cloud + if (rpcDataChannel?.readyState !== "open") { + throw new Error("Device connection not available"); + } + if (isMicrophoneActiveFromHook) { - // Disable: Stop microphone subprocess AND remove WebRTC tracks + // Disable: Stop microphone subprocess via RPC AND remove WebRTC tracks locally + await new Promise((resolve, reject) => { + send("microphoneStop", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + reject(new Error(resp.error.message)); + } else { + resolve(); + } + }); + }); + + // Also stop local WebRTC stream const result = await stopMicrophone(); if (!result.success) { - throw new Error(result.error?.message || "Failed to stop microphone"); + console.warn("Local microphone stop failed:", result.error?.message); } } else { - // Enable: Start microphone subprocess AND add WebRTC tracks + // Enable: Start microphone subprocess via RPC AND add WebRTC tracks locally + await new Promise((resolve, reject) => { + send("microphoneStart", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + reject(new Error(resp.error.message)); + } else { + resolve(); + } + }); + }); + + // Also start local WebRTC stream const result = await startMicrophone(); if (!result.success) { - throw new Error(result.error?.message || "Failed to start microphone"); + throw new Error(result.error?.message || "Failed to start local microphone"); } } } catch (error) { diff --git a/ui/src/components/popovers/ExtensionPopover.tsx b/ui/src/components/popovers/ExtensionPopover.tsx index f36c0503..81c4e54f 100644 --- a/ui/src/components/popovers/ExtensionPopover.tsx +++ b/ui/src/components/popovers/ExtensionPopover.tsx @@ -1,13 +1,13 @@ import { useEffect, useState } from "react"; import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu"; -import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import Card, { GridCard } from "@components/Card"; import { SettingsPageHeader } from "@components/SettingsPageheader"; import { ATXPowerControl } from "@components/extensions/ATXPowerControl"; import { DCPowerControl } from "@components/extensions/DCPowerControl"; import { SerialConsole } from "@components/extensions/SerialConsole"; import { Button } from "@components/Button"; +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; interface Extension { diff --git a/ui/src/components/popovers/MountPopover.tsx b/ui/src/components/popovers/MountPopover.tsx index 8b6a8a55..0ff2d97e 100644 --- a/ui/src/components/popovers/MountPopover.tsx +++ b/ui/src/components/popovers/MountPopover.tsx @@ -10,9 +10,9 @@ import { useLocation } from "react-router"; import { Button } from "@components/Button"; import Card, { GridCard } from "@components/Card"; +import { SettingsPageHeader } from "@components/SettingsPageheader"; import { formatters } from "@/utils"; import { RemoteVirtualMediaState, useMountMediaStore } from "@/hooks/stores"; -import { SettingsPageHeader } from "@components/SettingsPageheader"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import notifications from "@/notifications"; diff --git a/ui/src/components/popovers/PasteModal.tsx b/ui/src/components/popovers/PasteModal.tsx index 6f224eb5..b0d04972 100644 --- a/ui/src/components/popovers/PasteModal.tsx +++ b/ui/src/components/popovers/PasteModal.tsx @@ -3,17 +3,17 @@ import { ExclamationCircleIcon } from "@heroicons/react/16/solid"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LuCornerDownLeft } from "react-icons/lu"; +import { Button } from "@components/Button"; +import { GridCard } from "@components/Card"; +import { InputFieldWithLabel } from "@components/InputField"; +import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { TextAreaWithLabel } from "@components/TextArea"; import { cx } from "@/cva.config"; import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import useKeyboard, { type MacroStep } from "@/hooks/useKeyboard"; import useKeyboardLayout from "@/hooks/useKeyboardLayout"; import notifications from "@/notifications"; -import { Button } from "@components/Button"; -import { GridCard } from "@components/Card"; -import { InputFieldWithLabel } from "@components/InputField"; -import { SettingsPageHeader } from "@components/SettingsPageheader"; -import { TextAreaWithLabel } from "@components/TextArea"; // uint32 max value / 4 const pasteMaxLength = 1073741824; diff --git a/ui/src/components/popovers/WakeOnLan/Index.tsx b/ui/src/components/popovers/WakeOnLan/Index.tsx index 6ebf3c79..6de8a4fd 100644 --- a/ui/src/components/popovers/WakeOnLan/Index.tsx +++ b/ui/src/components/popovers/WakeOnLan/Index.tsx @@ -11,6 +11,8 @@ import EmptyStateCard from "./EmptyStateCard"; import DeviceList, { StoredDevice } from "./DeviceList"; import AddDeviceForm from "./AddDeviceForm"; + + export default function WakeOnLanModal() { const [storedDevices, setStoredDevices] = useState([]); const [showAddForm, setShowAddForm] = useState(false); diff --git a/ui/src/components/sidebar/connectionStats.tsx b/ui/src/components/sidebar/connectionStats.tsx index a69cd94e..20e39dab 100644 --- a/ui/src/components/sidebar/connectionStats.tsx +++ b/ui/src/components/sidebar/connectionStats.tsx @@ -1,11 +1,13 @@ import { useInterval } from "usehooks-ts"; + import SidebarHeader from "@/components/SidebarHeader"; import { useRTCStore, useUiStore } from "@/hooks/stores"; import { someIterable } from "@/utils"; -import { createChartArray, Metric } from "../Metric"; import { SettingsSectionHeader } from "../SettingsSectionHeader"; +import { createChartArray, Metric } from "../Metric"; + export default function ConnectionStatsSidebar() { const { sidebarView, setSidebarView } = useUiStore(); diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index e43e5137..95faeb46 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -9,6 +9,8 @@ import { import { devWarn } from '../utils/debug'; + + // Define the JsonRpc types for better type checking interface JsonRpcResponse { jsonrpc: string; diff --git a/ui/src/hooks/useAudioEvents.ts b/ui/src/hooks/useAudioEvents.ts index aa3dd436..6d8b76b5 100644 --- a/ui/src/hooks/useAudioEvents.ts +++ b/ui/src/hooks/useAudioEvents.ts @@ -4,6 +4,9 @@ import useWebSocket, { ReadyState } from 'react-use-websocket'; import { devError, devWarn } from '../utils/debug'; import { NETWORK_CONFIG } from '../config/constants'; +import { JsonRpcResponse, useJsonRpc } from './useJsonRpc'; +import { useRTCStore } from './stores'; + // Audio event types matching the backend export type AudioEventType = | 'audio-mute-changed' @@ -63,18 +66,34 @@ export function useAudioEvents(onAudioDeviceChanged?: (data: AudioDeviceChangedD const [audioMuted, setAudioMuted] = useState(null); const [microphoneState, setMicrophoneState] = useState(null); - // Fetch initial audio status + // Get RTC store and JSON RPC functionality + const { rpcDataChannel } = useRTCStore(); + const { send } = useJsonRpc(); + + // Fetch initial audio status using RPC for cloud compatibility const fetchInitialAudioStatus = useCallback(async () => { - try { - const response = await fetch('/audio/status'); - if (response.ok) { - const data = await response.json(); - setAudioMuted(data.muted); - } - } catch (error) { - devError('Failed to fetch initial audio status:', error); + // Early return if RPC data channel is not open + if (rpcDataChannel?.readyState !== "open") { + devWarn('RPC connection not available for initial audio status, skipping'); + return; } - }, []); + + try { + await new Promise((resolve) => { + send("audioStatus", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + devError('RPC audioStatus failed:', resp.error); + } else if ("result" in resp) { + const data = resp.result as { muted: boolean }; + setAudioMuted(data.muted); + } + resolve(); // Continue regardless of result + }); + }); + } catch (error) { + devError('Failed to fetch initial audio status via RPC:', error); + } + }, [rpcDataChannel?.readyState, send]); // Local subscription state const [isLocallySubscribed, setIsLocallySubscribed] = useState(false); @@ -253,10 +272,13 @@ export function useAudioEvents(onAudioDeviceChanged?: (data: AudioDeviceChangedD } }, [readyState]); - // Fetch initial audio status on component mount + // Fetch initial audio status on component mount - but only when RPC is ready useEffect(() => { - fetchInitialAudioStatus(); - }, [fetchInitialAudioStatus]); + // Only fetch when RPC data channel is open and ready + if (rpcDataChannel?.readyState === "open") { + fetchInitialAudioStatus(); + } + }, [fetchInitialAudioStatus, rpcDataChannel?.readyState]); // Cleanup on component unmount useEffect(() => { diff --git a/ui/src/hooks/useHidRpc.ts b/ui/src/hooks/useHidRpc.ts index aeb1c4fa..b47d105b 100644 --- a/ui/src/hooks/useHidRpc.ts +++ b/ui/src/hooks/useHidRpc.ts @@ -17,6 +17,8 @@ import { unmarshalHidRpcMessage, } from "./hidRpc"; + + const KEEPALIVE_MESSAGE = new KeypressKeepAliveMessage(); interface sendMessageParams { diff --git a/ui/src/hooks/useMicrophone.ts b/ui/src/hooks/useMicrophone.ts index ec4c92ce..fdd0907e 100644 --- a/ui/src/hooks/useMicrophone.ts +++ b/ui/src/hooks/useMicrophone.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useRTCStore, useSettingsStore } from "@/hooks/stores"; -import api from "@/api"; +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { devLog, devInfo, devWarn, devError, devOnly } from "@/utils/debug"; import { AUDIO_CONFIG } from "@/config/constants"; @@ -21,9 +21,29 @@ export function useMicrophone() { setMicrophoneActive, isMicrophoneMuted, setMicrophoneMuted, + rpcDataChannel, } = useRTCStore(); const { microphoneWasEnabled, setMicrophoneWasEnabled } = useSettingsStore(); + const { send } = useJsonRpc(); + + // RPC helper functions to replace HTTP API calls + const rpcMicrophoneStart = useCallback((): Promise => { + return new Promise((resolve, reject) => { + if (rpcDataChannel?.readyState !== "open") { + reject(new Error("Device connection not available")); + return; + } + + send("microphoneStart", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + reject(new Error(resp.error.message)); + } else { + resolve(); + } + }); + }); + }, [rpcDataChannel?.readyState, send]); const microphoneStreamRef = useRef(null); @@ -60,8 +80,6 @@ export function useMicrophone() { // Cleanup function to stop microphone stream const stopMicrophoneStream = useCallback(async () => { - // Cleaning up microphone stream - if (microphoneStreamRef.current) { microphoneStreamRef.current.getTracks().forEach((track: MediaStreamTrack) => { track.stop(); @@ -106,37 +124,52 @@ export function useMicrophone() { return; } - try { - const response = await api.GET("/microphone/status", {}); - if (response.ok) { - const data = await response.json(); - const backendRunning = data.running; - - // Only sync if there's a significant state difference and we're not in a transition - if (backendRunning !== isMicrophoneActive) { - devInfo(`Syncing microphone state: backend=${backendRunning}, frontend=${isMicrophoneActive}`); - - // If backend is running but frontend thinks it's not, just update frontend state - if (backendRunning && !isMicrophoneActive) { - devLog("Backend running, updating frontend state to active"); - setMicrophoneActive(true); - } - // If backend is not running but frontend thinks it is, clean up and update state - else if (!backendRunning && isMicrophoneActive) { - devLog("Backend not running, cleaning up frontend state"); - setMicrophoneActive(false); - // Only clean up stream if we actually have one - if (microphoneStreamRef.current) { - devLog("Cleaning up orphaned stream"); - await stopMicrophoneStream(); - } - } - } - } - } catch (error) { - devWarn("Failed to sync microphone state:", error); + // Early return if RPC data channel is not ready + if (rpcDataChannel?.readyState !== "open") { + devWarn("RPC connection not available for microphone sync, skipping"); + return; } - }, [isMicrophoneActive, setMicrophoneActive, stopMicrophoneStream]); + + try { + await new Promise((resolve, reject) => { + send("microphoneStatus", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + devError("RPC microphone status failed:", resp.error); + reject(new Error(resp.error.message)); + } else if ("result" in resp) { + const data = resp.result as { running: boolean }; + const backendRunning = data.running; + + // Only sync if there's a significant state difference and we're not in a transition + if (backendRunning !== isMicrophoneActive) { + devInfo(`Syncing microphone state: backend=${backendRunning}, frontend=${isMicrophoneActive}`); + + // If backend is running but frontend thinks it's not, just update frontend state + if (backendRunning && !isMicrophoneActive) { + devLog("Backend running, updating frontend state to active"); + setMicrophoneActive(true); + } + // If backend is not running but frontend thinks it is, clean up and update state + else if (!backendRunning && isMicrophoneActive) { + devLog("Backend not running, cleaning up frontend state"); + setMicrophoneActive(false); + // Only clean up stream if we actually have one + if (microphoneStreamRef.current) { + stopMicrophoneStream(); + } + setMicrophoneMuted(false); + } + } + resolve(); + } else { + reject(new Error("Invalid response")); + } + }); + }); + } catch (error) { + devError("Error syncing microphone state:", error); + } + }, [isMicrophoneActive, setMicrophoneActive, setMicrophoneMuted, stopMicrophoneStream, rpcDataChannel?.readyState, send]); // Start microphone stream const startMicrophone = useCallback(async (deviceId?: string): Promise<{ success: boolean; error?: MicrophoneError }> => { @@ -169,8 +202,6 @@ export function useMicrophone() { audio: audioConstraints }); - // Microphone stream created successfully - // Store the stream in both ref and store microphoneStreamRef.current = stream; setMicrophoneStream(stream); @@ -286,78 +317,54 @@ export function useMicrophone() { // Notify backend that microphone is started devLog("Notifying backend about microphone start..."); - // Retry logic for backend failures + // Retry logic for backend failures let backendSuccess = false; let lastError: Error | string | null = null; for (let attempt = 1; attempt <= 3; attempt++) { - try { - // If this is a retry, first try to reset the backend microphone state - if (attempt > 1) { - devLog(`Backend start attempt ${attempt}, first trying to reset backend state...`); - try { - // Try the new reset endpoint first - const resetResp = await api.POST("/microphone/reset", {}); - if (resetResp.ok) { - devLog("Backend reset successful"); - } else { - // Fallback to stop - await api.POST("/microphone/stop", {}); - } + // If this is a retry, first try to reset the backend microphone state + if (attempt > 1) { + devLog(`Backend start attempt ${attempt}, first trying to reset backend state...`); + try { + // Use RPC for reset (cloud-compatible) + if (rpcDataChannel?.readyState === "open") { + await new Promise((resolve) => { + send("microphoneReset", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + devWarn("RPC microphone reset failed:", resp.error); + // Try stop as fallback + send("microphoneStop", {}, (stopResp: JsonRpcResponse) => { + if ("error" in stopResp) { + devWarn("RPC microphone stop also failed:", stopResp.error); + } + resolve(); // Continue even if both fail + }); + } else { + devLog("RPC microphone reset successful"); + resolve(); + } + }); + }); // Wait a bit for the backend to reset await new Promise(resolve => setTimeout(resolve, 200)); - } catch (resetError) { - devWarn("Failed to reset backend state:", resetError); + } else { + devWarn("RPC connection not available for reset"); } + } catch (resetError) { + devWarn("Failed to reset backend state:", resetError); } + } + + try { + await rpcMicrophoneStart(); + devLog(`Backend RPC microphone start successful (attempt ${attempt})`); + backendSuccess = true; + break; // Exit the retry loop on success + } catch (rpcError) { + lastError = `Backend RPC error: ${rpcError instanceof Error ? rpcError.message : 'Unknown error'}`; + devError(`Backend microphone start failed with RPC error: ${lastError} (attempt ${attempt})`); - const backendResp = await api.POST("/microphone/start", {}); - devLog(`Backend response status (attempt ${attempt}):`, backendResp.status, "ok:", backendResp.ok); - - if (!backendResp.ok) { - lastError = `Backend returned status ${backendResp.status}`; - devError(`Backend microphone start failed with status: ${backendResp.status} (attempt ${attempt})`); - - // For 500 errors, try again after a short delay - if (backendResp.status === 500 && attempt < 3) { - devLog(`Retrying backend start in 500ms (attempt ${attempt + 1}/3)...`); - await new Promise(resolve => setTimeout(resolve, 500)); - continue; - } - } else { - // Success! - const responseData = await backendResp.json(); - devLog("Backend response data:", responseData); - if (responseData.status === "already running") { - devInfo("Backend microphone was already running"); - - // If we're on the first attempt and backend says "already running", - // but frontend thinks it's not active, this might be a stuck state - if (attempt === 1 && !isMicrophoneActive) { - devWarn("Backend reports 'already running' but frontend is not active - possible stuck state"); - devLog("Attempting to reset backend state and retry..."); - - try { - const resetResp = await api.POST("/microphone/reset", {}); - if (resetResp.ok) { - devLog("Backend reset successful, retrying start..."); - await new Promise(resolve => setTimeout(resolve, 200)); - continue; // Retry the start - } - } catch (resetError) { - devWarn("Failed to reset stuck backend state:", resetError); - } - } - } - devLog("Backend microphone start successful"); - backendSuccess = true; - break; - } - } catch (error) { - lastError = error instanceof Error ? error : String(error); - devError(`Backend microphone start threw error (attempt ${attempt}):`, error); - - // For network errors, try again after a short delay + // For RPC errors, try again after a short delay if (attempt < 3) { devLog(`Retrying backend start in 500ms (attempt ${attempt + 1}/3)...`); await new Promise(resolve => setTimeout(resolve, 500)); @@ -414,8 +421,6 @@ export function useMicrophone() { setIsStarting(false); return { success: true }; } catch (error) { - // Failed to start microphone - let micError: MicrophoneError; if (error instanceof Error) { if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError') { @@ -446,7 +451,7 @@ export function useMicrophone() { setIsStarting(false); return { success: false, error: micError }; } - }, [peerConnection, setMicrophoneStream, setMicrophoneSender, setMicrophoneActive, setMicrophoneMuted, setMicrophoneWasEnabled, stopMicrophoneStream, isMicrophoneActive, isMicrophoneMuted, microphoneStream, isStarting, isStopping, isToggling]); + }, [peerConnection, setMicrophoneStream, setMicrophoneSender, setMicrophoneActive, setMicrophoneMuted, setMicrophoneWasEnabled, stopMicrophoneStream, isMicrophoneActive, isMicrophoneMuted, microphoneStream, isStarting, isStopping, isToggling, rpcMicrophoneStart, rpcDataChannel?.readyState, send]); @@ -463,10 +468,22 @@ export function useMicrophone() { // First stop the stream await stopMicrophoneStream(); - // Then notify backend that microphone is stopped + // Then notify backend that microphone is stopped using RPC try { - await api.POST("/microphone/stop", {}); - devLog("Backend notified about microphone stop"); + if (rpcDataChannel?.readyState === "open") { + await new Promise((resolve) => { + send("microphoneStop", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + devWarn("RPC microphone stop failed:", resp.error); + } else { + devLog("Backend notified about microphone stop via RPC"); + } + resolve(); // Continue regardless of result + }); + }); + } else { + devWarn("RPC connection not available for microphone stop"); + } } catch (error) { devWarn("Failed to notify backend about microphone stop:", error); } @@ -494,7 +511,7 @@ export function useMicrophone() { } }; } - }, [stopMicrophoneStream, syncMicrophoneState, setMicrophoneActive, setMicrophoneMuted, setMicrophoneWasEnabled, isStarting, isStopping, isToggling]); + }, [stopMicrophoneStream, syncMicrophoneState, setMicrophoneActive, setMicrophoneMuted, setMicrophoneWasEnabled, isStarting, isStopping, isToggling, rpcDataChannel?.readyState, send]); // Toggle microphone mute const toggleMicrophoneMute = useCallback(async (): Promise<{ success: boolean; error?: MicrophoneError }> => { @@ -569,9 +586,22 @@ export function useMicrophone() { setMicrophoneMuted(newMutedState); - // Notify backend about mute state + // Notify backend about mute state using RPC try { - await api.POST("/microphone/mute", { muted: newMutedState }); + if (rpcDataChannel?.readyState === "open") { + await new Promise((resolve) => { + send("microphoneMute", { muted: newMutedState }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + devWarn("RPC microphone mute failed:", resp.error); + } else { + devLog("Backend notified about microphone mute via RPC"); + } + resolve(); // Continue regardless of result + }); + }); + } else { + devWarn("RPC connection not available for microphone mute"); + } } catch (error) { devWarn("Failed to notify backend about microphone mute:", error); } @@ -589,7 +619,7 @@ export function useMicrophone() { } }; } - }, [microphoneStream, isMicrophoneActive, isMicrophoneMuted, setMicrophoneMuted, isStarting, isStopping, isToggling]); + }, [microphoneStream, isMicrophoneActive, isMicrophoneMuted, setMicrophoneMuted, isStarting, isStopping, isToggling, rpcDataChannel?.readyState, send]); @@ -612,6 +642,12 @@ export function useMicrophone() { // Sync state on mount and auto-restore microphone if it was enabled before page reload useEffect(() => { const autoRestoreMicrophone = async () => { + // Wait for RPC connection to be ready before attempting any operations + if (rpcDataChannel?.readyState !== "open") { + devLog("RPC connection not ready for microphone auto-restore, skipping"); + return; + } + // First sync the current state await syncMicrophoneState(); @@ -631,8 +667,10 @@ export function useMicrophone() { } }; - autoRestoreMicrophone(); - }, [syncMicrophoneState, microphoneWasEnabled, isMicrophoneActive, peerConnection, startMicrophone]); + // Add a delay to ensure RTC connection is fully established + const timer = setTimeout(autoRestoreMicrophone, 1000); + return () => clearTimeout(timer); + }, [syncMicrophoneState, microphoneWasEnabled, isMicrophoneActive, peerConnection, startMicrophone, rpcDataChannel?.readyState]); // Cleanup on unmount - use ref to avoid dependency on stopMicrophoneStream useEffect(() => { diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 79ca6717..7dd0e0a3 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -10,9 +10,6 @@ import { } from "react-router"; import { ExclamationTriangleIcon } from "@heroicons/react/16/solid"; -import { CLOUD_API, DEVICE_API } from "@/ui.config"; -import api from "@/api"; -import Root from "@/root"; import Card from "@components/Card"; import EmptyCard from "@components/EmptyCard"; import NotFoundPage from "@components/NotFoundPage"; @@ -28,6 +25,9 @@ import DeviceIdRename from "@routes/devices.$id.rename"; import DevicesRoute from "@routes/devices"; import SettingsIndexRoute from "@routes/devices.$id.settings._index"; import SettingsAccessIndexRoute from "@routes/devices.$id.settings.access._index"; +import Root from "@/root"; +import api from "@/api"; +import { CLOUD_API, DEVICE_API } from "@/ui.config"; import Notifications from "@/notifications"; const SignupRoute = lazy(() => import("@routes/signup")); const LoginRoute = lazy(() => import("@routes/login")); diff --git a/ui/src/routes/devices.$id.deregister.tsx b/ui/src/routes/devices.$id.deregister.tsx index e5dd2a35..69c0d434 100644 --- a/ui/src/routes/devices.$id.deregister.tsx +++ b/ui/src/routes/devices.$id.deregister.tsx @@ -6,9 +6,9 @@ import { Button, LinkButton } from "@components/Button"; import Card from "@components/Card"; import { CardHeader } from "@components/CardHeader"; import DashboardNavbar from "@components/Header"; +import Fieldset from "@components/Fieldset"; import { User } from "@/hooks/stores"; import { checkAuth } from "@/main"; -import Fieldset from "@components/Fieldset"; import { CLOUD_API } from "@/ui.config"; interface LoaderData { diff --git a/ui/src/routes/devices.$id.mount.tsx b/ui/src/routes/devices.$id.mount.tsx index bc29c455..152ff3c6 100644 --- a/ui/src/routes/devices.$id.mount.tsx +++ b/ui/src/routes/devices.$id.mount.tsx @@ -9,12 +9,12 @@ import { PlusCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/20/sol import { TrashIcon } from "@heroicons/react/16/solid"; import { useNavigate } from "react-router"; -import Card, { GridCard } from "@/components/Card"; import { Button } from "@components/Button"; +import AutoHeight from "@components/AutoHeight"; +import Card, { GridCard } from "@/components/Card"; import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg"; import { formatters } from "@/utils"; -import AutoHeight from "@components/AutoHeight"; import { InputFieldWithLabel } from "@/components/InputField"; import DebianIcon from "@/assets/debian-icon.png"; import UbuntuIcon from "@/assets/ubuntu-icon.png"; @@ -25,16 +25,17 @@ import NetBootIcon from "@/assets/netboot-icon.svg"; import Fieldset from "@/components/Fieldset"; import { DEVICE_API } from "@/ui.config"; -import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc"; -import notifications from "../notifications"; -import { isOnDevice } from "../main"; -import { cx } from "../cva.config"; import { MountMediaState, RemoteVirtualMediaState, useMountMediaStore, useRTCStore, } from "../hooks/stores"; +import { cx } from "../cva.config"; +import { isOnDevice } from "../main"; +import notifications from "../notifications"; +import { JsonRpcResponse, useJsonRpc } from "../hooks/useJsonRpc"; + export default function MountRoute() { const navigate = useNavigate(); diff --git a/ui/src/routes/devices.$id.other-session.tsx b/ui/src/routes/devices.$id.other-session.tsx index 8a767d51..284d0711 100644 --- a/ui/src/routes/devices.$id.other-session.tsx +++ b/ui/src/routes/devices.$id.other-session.tsx @@ -1,7 +1,7 @@ import { useNavigate, useOutletContext } from "react-router"; -import { GridCard } from "@/components/Card"; import { Button } from "@components/Button"; +import { GridCard } from "@/components/Card"; import LogoBlue from "@/assets/logo-blue.svg"; import LogoWhite from "@/assets/logo-white.svg"; diff --git a/ui/src/routes/devices.$id.rename.tsx b/ui/src/routes/devices.$id.rename.tsx index 39f06bcf..c07601cc 100644 --- a/ui/src/routes/devices.$id.rename.tsx +++ b/ui/src/routes/devices.$id.rename.tsx @@ -7,13 +7,14 @@ import Card from "@components/Card"; import { CardHeader } from "@components/CardHeader"; import { InputFieldWithLabel } from "@components/InputField"; import DashboardNavbar from "@components/Header"; +import Fieldset from "@components/Fieldset"; import { User } from "@/hooks/stores"; import { checkAuth } from "@/main"; -import Fieldset from "@components/Fieldset"; import { CLOUD_API } from "@/ui.config"; import api from "../api"; + interface LoaderData { device: { id: string; name: string; user: { googleId: string } }; user: User; diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index b5ccca07..6d2011e6 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -3,8 +3,9 @@ import type { LoaderFunction } from "react-router"; import { ShieldCheckIcon } from "@heroicons/react/24/outline"; import { useCallback, useEffect, useState } from "react"; -import api from "@/api"; import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { TextAreaWithLabel } from "@components/TextArea"; +import api from "@/api"; import { GridCard } from "@/components/Card"; import { Button, LinkButton } from "@/components/Button"; import { InputFieldWithLabel } from "@/components/InputField"; @@ -15,11 +16,12 @@ import notifications from "@/notifications"; import { DEVICE_API } from "@/ui.config"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { isOnDevice } from "@/main"; -import { TextAreaWithLabel } from "@components/TextArea"; -import { LocalDevice } from "./devices.$id"; -import { SettingsItem } from "./devices.$id.settings"; import { CloudState } from "./adopt"; +import { SettingsItem } from "./devices.$id.settings"; +import { LocalDevice } from "./devices.$id"; + + export interface TLSState { mode: "self-signed" | "custom" | "disabled"; diff --git a/ui/src/routes/devices.$id.settings.general._index.tsx b/ui/src/routes/devices.$id.settings.general._index.tsx index c25b994e..1d154e0a 100644 --- a/ui/src/routes/devices.$id.settings.general._index.tsx +++ b/ui/src/routes/devices.$id.settings.general._index.tsx @@ -1,6 +1,7 @@ import { useState , useEffect } from "react"; + import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { SettingsPageHeader } from "../components/SettingsPageheader"; @@ -12,6 +13,7 @@ import { useDeviceStore } from "../hooks/stores"; import { SettingsItem } from "./devices.$id.settings"; + export default function SettingsGeneralRoute() { const { send } = useJsonRpc(); const { navigateTo } = useDeviceUiNavigation(); diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx index db0e0530..4cc7d836 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -1,8 +1,8 @@ import { useNavigate } from "react-router"; import { useCallback } from "react"; -import { useJsonRpc } from "@/hooks/useJsonRpc"; import { Button } from "@components/Button"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; export default function SettingsGeneralRebootRoute() { const navigate = useNavigate(); diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 38c15412..72c864dd 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -2,9 +2,9 @@ import { useLocation, useNavigate } from "react-router"; import { useCallback, useEffect, useRef, useState } from "react"; import { CheckCircleIcon } from "@heroicons/react/20/solid"; +import { Button } from "@components/Button"; import Card from "@/components/Card"; import { useJsonRpc } from "@/hooks/useJsonRpc"; -import { Button } from "@components/Button"; import { UpdateState, useUpdateStore } from "@/hooks/stores"; import LoadingSpinner from "@/components/LoadingSpinner"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index 11c11100..ed457a0f 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -2,15 +2,16 @@ import { useEffect } from "react"; import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsItem } from "@routes/devices.$id.settings"; -import { BacklightSettings, useSettingsStore } from "@/hooks/stores"; -import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { UsbDeviceSetting } from "@components/UsbDeviceSetting"; +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; +import { BacklightSettings, useSettingsStore } from "@/hooks/stores"; import notifications from "../notifications"; import { UsbInfoSetting } from "../components/UsbInfoSetting"; import { FeatureFlag } from "../components/FeatureFlag"; + export default function SettingsHardwareRoute() { const { send } = useJsonRpc(); const settings = useSettingsStore(); diff --git a/ui/src/routes/devices.$id.settings.keyboard.tsx b/ui/src/routes/devices.$id.settings.keyboard.tsx index 6f5c2e86..7096bf32 100644 --- a/ui/src/routes/devices.$id.settings.keyboard.tsx +++ b/ui/src/routes/devices.$id.settings.keyboard.tsx @@ -1,15 +1,16 @@ import { useCallback, useEffect } from "react"; +import { SettingsPageHeader } from "@components/SettingsPageheader"; import { useSettingsStore } from "@/hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import useKeyboardLayout from "@/hooks/useKeyboardLayout"; -import { SettingsPageHeader } from "@components/SettingsPageheader"; import { Checkbox } from "@/components/Checkbox"; import { SelectMenuBasic } from "@/components/SelectMenuBasic"; import notifications from "@/notifications"; import { SettingsItem } from "./devices.$id.settings"; + export default function SettingsKeyboardRoute() { const { setKeyboardLayout } = useSettingsStore(); const { showPressedKeys, setShowPressedKeys } = useSettingsStore(); diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index 76b0ae27..88e6e7da 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -1,19 +1,19 @@ import { CheckCircleIcon } from "@heroicons/react/16/solid"; import { useCallback, useEffect, useState } from "react"; -import MouseIcon from "@/assets/mouse-icon.svg"; -import PointingFinger from "@/assets/pointing-finger.svg"; -import { GridCard } from "@/components/Card"; -import { Checkbox } from "@/components/Checkbox"; -import { useSettingsStore } from "@/hooks/stores"; -import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { JigglerSetting } from "@components/JigglerSetting"; +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; +import { useSettingsStore } from "@/hooks/stores"; +import { Checkbox } from "@/components/Checkbox"; +import { GridCard } from "@/components/Card"; +import PointingFinger from "@/assets/pointing-finger.svg"; +import MouseIcon from "@/assets/mouse-icon.svg"; -import { cx } from "../cva.config"; -import notifications from "../notifications"; import SettingsNestedSection from "../components/SettingsNestedSection"; +import notifications from "../notifications"; +import { cx } from "../cva.config"; import { SettingsItem } from "./devices.$id.settings"; diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index d1ac6966..9a525781 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -3,6 +3,15 @@ import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import { LuEthernetPort } from "react-icons/lu"; +import { Button } from "@components/Button"; +import { GridCard } from "@components/Card"; +import InputField, { InputFieldWithLabel } from "@components/InputField"; +import { SelectMenuBasic } from "@/components/SelectMenuBasic"; +import { SettingsPageHeader } from "@/components/SettingsPageheader"; +import Fieldset from "@/components/Fieldset"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import notifications from "@/notifications"; +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { IPv4Mode, IPv6Mode, @@ -13,20 +22,11 @@ import { TimeSyncMode, useNetworkStateStore, } from "@/hooks/stores"; -import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; -import { Button } from "@components/Button"; -import { GridCard } from "@components/Card"; -import InputField, { InputFieldWithLabel } from "@components/InputField"; -import { SelectMenuBasic } from "@/components/SelectMenuBasic"; -import { SettingsPageHeader } from "@/components/SettingsPageheader"; -import Fieldset from "@/components/Fieldset"; -import { ConfirmDialog } from "@/components/ConfirmDialog"; -import notifications from "@/notifications"; -import Ipv6NetworkCard from "../components/Ipv6NetworkCard"; -import EmptyCard from "../components/EmptyCard"; -import AutoHeight from "../components/AutoHeight"; import DhcpLeaseCard from "../components/DhcpLeaseCard"; +import AutoHeight from "../components/AutoHeight"; +import EmptyCard from "../components/EmptyCard"; +import Ipv6NetworkCard from "../components/Ipv6NetworkCard"; import { SettingsItem } from "./devices.$id.settings"; diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 49f26366..b89fee2e 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -15,6 +15,7 @@ import { import React, { useEffect, useRef, useState } from "react"; import { useResizeObserver } from "usehooks-ts"; + import Card from "@/components/Card"; import { LinkButton } from "@/components/Button"; import { FeatureFlag } from "@/components/FeatureFlag"; @@ -23,6 +24,7 @@ import { useUiStore } from "@/hooks/stores"; import { cx } from "../cva.config"; + /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ export default function SettingsRoute() { const location = useLocation(); diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx index ea1a101a..36ca5974 100644 --- a/ui/src/routes/devices.$id.settings.video.tsx +++ b/ui/src/routes/devices.$id.settings.video.tsx @@ -1,16 +1,17 @@ import { useEffect, useState } from "react"; +import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { SelectMenuBasic } from "@components/SelectMenuBasic"; +import Fieldset from "@components/Fieldset"; import { Button } from "@/components/Button"; import { TextAreaWithLabel } from "@/components/TextArea"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; -import { SettingsPageHeader } from "@components/SettingsPageheader"; import { useSettingsStore } from "@/hooks/stores"; -import { SelectMenuBasic } from "@components/SelectMenuBasic"; -import Fieldset from "@components/Fieldset"; import notifications from "@/notifications"; import { SettingsItem } from "./devices.$id.settings"; + const defaultEdid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"; const edids = [ diff --git a/ui/src/routes/devices.$id.setup.tsx b/ui/src/routes/devices.$id.setup.tsx index 2fd65f50..7814bbb4 100644 --- a/ui/src/routes/devices.$id.setup.tsx +++ b/ui/src/routes/devices.$id.setup.tsx @@ -13,6 +13,7 @@ import { CLOUD_API } from "@/ui.config"; import api from "../api"; + const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => { await checkAuth(); const res = await fetch(`${CLOUD_API}/devices/${params.id}`, { diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 1841e8bd..183a4ad5 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -15,6 +15,9 @@ import { FocusTrap } from "focus-trap-react"; import { motion, AnimatePresence } from "framer-motion"; import useWebSocket from "react-use-websocket"; +import WebRTCVideo from "@components/WebRTCVideo"; +import DashboardNavbar from "@components/Header"; +import { DeviceStatus } from "@routes/welcome-local"; import { CLOUD_API, DEVICE_API } from "@/ui.config"; import api from "@/api"; import { checkAuth, isInCloud, isOnDevice } from "@/main"; @@ -36,11 +39,6 @@ import { } from "@/hooks/stores"; import { useMicrophone } from "@/hooks/useMicrophone"; import { useAudioEvents } from "@/hooks/useAudioEvents"; -import WebRTCVideo from "@components/WebRTCVideo"; -import DashboardNavbar from "@components/Header"; -const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats')); -const Terminal = lazy(() => import('@components/Terminal')); -const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard")); import Modal from "@/components/Modal"; import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc"; import { @@ -50,10 +48,12 @@ import { } from "@/components/VideoOverlay"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider"; -import { DeviceStatus } from "@routes/welcome-local"; -import audioQualityService from "@/services/audioQualityService"; import { useVersion } from "@/hooks/useVersion"; +const ConnectionStatsSidebar = lazy(() => import('@/components/sidebar/connectionStats')); +const Terminal = lazy(() => import('@components/Terminal')); +const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard")); + interface LocalLoaderResp { authMode: "password" | "noPassword" | null; } @@ -573,11 +573,6 @@ export default function KvmIdRoute() { }; }, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]); - // Register callback with audioQualityService - useEffect(() => { - audioQualityService.setReconnectionCallback(setupPeerConnection); - }, [setupPeerConnection]); - // TURN server usage detection useEffect(() => { if (peerConnectionState !== "connected") return; diff --git a/ui/src/routes/devices.already-adopted.tsx b/ui/src/routes/devices.already-adopted.tsx index ee189a8a..81a47f7d 100644 --- a/ui/src/routes/devices.already-adopted.tsx +++ b/ui/src/routes/devices.already-adopted.tsx @@ -1,7 +1,7 @@ +import GridBackground from "@components/GridBackground"; import { LinkButton } from "@/components/Button"; import SimpleNavbar from "@/components/SimpleNavbar"; import Container from "@/components/Container"; -import GridBackground from "@components/GridBackground"; export default function DevicesAlreadyAdopted() { return ( diff --git a/ui/src/routes/login-local.tsx b/ui/src/routes/login-local.tsx index 5fab7e6e..4f4c05b3 100644 --- a/ui/src/routes/login-local.tsx +++ b/ui/src/routes/login-local.tsx @@ -18,6 +18,9 @@ import ExtLink from "../components/ExtLink"; import { DeviceStatus } from "./welcome-local"; + + + const loader: LoaderFunction = async () => { const res = await api .GET(`${DEVICE_API}/device/status`) diff --git a/ui/src/routes/welcome-local.mode.tsx b/ui/src/routes/welcome-local.mode.tsx index 8d1a808b..f2fd9cce 100644 --- a/ui/src/routes/welcome-local.mode.tsx +++ b/ui/src/routes/welcome-local.mode.tsx @@ -5,9 +5,9 @@ import { useState } from "react"; import GridBackground from "@components/GridBackground"; import Container from "@components/Container"; import { Button } from "@components/Button"; -import LogoBlueIcon from "@/assets/logo-blue.png"; -import LogoWhiteIcon from "@/assets/logo-white.svg"; import { DEVICE_API } from "@/ui.config"; +import LogoWhiteIcon from "@/assets/logo-white.svg"; +import LogoBlueIcon from "@/assets/logo-blue.png"; import { GridCard } from "../components/Card"; import { cx } from "../cva.config"; @@ -15,6 +15,7 @@ import api from "../api"; import { DeviceStatus } from "./welcome-local"; + const loader: LoaderFunction = async () => { const res = await api .GET(`${DEVICE_API}/device/status`) diff --git a/ui/src/routes/welcome-local.password.tsx b/ui/src/routes/welcome-local.password.tsx index d0b7c7a9..7d80a5e6 100644 --- a/ui/src/routes/welcome-local.password.tsx +++ b/ui/src/routes/welcome-local.password.tsx @@ -16,6 +16,8 @@ import api from "../api"; import { DeviceStatus } from "./welcome-local"; + + const loader: LoaderFunction = async () => { const res = await api .GET(`${DEVICE_API}/device/status`) diff --git a/ui/src/routes/welcome-local.tsx b/ui/src/routes/welcome-local.tsx index d7ff117e..6fd4e78b 100644 --- a/ui/src/routes/welcome-local.tsx +++ b/ui/src/routes/welcome-local.tsx @@ -14,6 +14,7 @@ import { DEVICE_API } from "@/ui.config"; import api from "../api"; + export interface DeviceStatus { isSetup: boolean; } diff --git a/ui/src/services/audioQualityService.ts b/ui/src/services/audioQualityService.ts index fea16cd3..d2454c62 100644 --- a/ui/src/services/audioQualityService.ts +++ b/ui/src/services/audioQualityService.ts @@ -1,4 +1,4 @@ -import api from '@/api'; +import { JsonRpcResponse } from '@/hooks/useJsonRpc'; interface AudioConfig { Quality: number; @@ -15,6 +15,8 @@ interface AudioQualityResponse { presets: QualityPresets; } +type RpcSendFunction = (method: string, params: Record, callback: (resp: JsonRpcResponse) => void) => void; + class AudioQualityService { private audioPresets: QualityPresets | null = null; private microphonePresets: QualityPresets | null = null; @@ -24,24 +26,44 @@ class AudioQualityService { 2: 'High', 3: 'Ultra' }; - private reconnectionCallback: (() => Promise) | null = null; + private rpcSend: RpcSendFunction | null = null; /** - * Fetch audio quality presets from the backend + * Set RPC send function for cloud compatibility + */ + setRpcSend(rpcSend: RpcSendFunction): void { + this.rpcSend = rpcSend; + } + + /** + * Fetch audio quality presets using RPC (cloud-compatible) */ async fetchAudioQualityPresets(): Promise { + if (!this.rpcSend) { + console.error('RPC not available for audio quality presets'); + return null; + } + try { - const response = await api.GET('/audio/quality'); - if (response.ok) { - const data = await response.json(); - this.audioPresets = data.presets; - this.updateQualityLabels(data.presets); - return data; - } + return await new Promise((resolve) => { + this.rpcSend!("audioQualityPresets", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + console.error('RPC audio quality presets failed:', resp.error); + resolve(null); + } else if ("result" in resp) { + const data = resp.result as AudioQualityResponse; + this.audioPresets = data.presets; + this.updateQualityLabels(data.presets); + resolve(data); + } else { + resolve(null); + } + }); + }); } catch (error) { console.error('Failed to fetch audio quality presets:', error); + return null; } - return null; } /** @@ -80,34 +102,25 @@ class AudioQualityService { } /** - * Set reconnection callback for WebRTC reset - */ - setReconnectionCallback(callback: () => Promise): void { - this.reconnectionCallback = callback; - } - - /** - * Trigger audio track replacement using backend's track replacement mechanism - */ - private async replaceAudioTrack(): Promise { - if (this.reconnectionCallback) { - await this.reconnectionCallback(); - } - } - - /** - * Set audio quality with track replacement + * Set audio quality using RPC (cloud-compatible) */ async setAudioQuality(quality: number): Promise { + if (!this.rpcSend) { + console.error('RPC not available for audio quality change'); + return false; + } + try { - const response = await api.POST('/audio/quality', { quality }); - - if (!response.ok) { - return false; - } - - await this.replaceAudioTrack(); - return true; + return await new Promise((resolve) => { + this.rpcSend!("audioQuality", { quality }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + console.error('RPC audio quality change failed:', resp.error); + resolve(false); + } else { + resolve(true); + } + }); + }); } catch (error) { console.error('Failed to set audio quality:', error); return false; diff --git a/web.go b/web.go index 7f8a8600..d761fb72 100644 --- a/web.go +++ b/web.go @@ -184,16 +184,6 @@ func setupRouter() *gin.Engine { protected.PUT("/auth/password-local", handleUpdatePassword) protected.DELETE("/auth/local-password", handleDeletePassword) protected.POST("/storage/upload", handleUploadHttp) - - // Audio handlers - protected.GET("/audio/status", handleAudioStatus) - protected.POST("/audio/mute", handleAudioMute) - protected.GET("/audio/quality", handleAudioQuality) - protected.POST("/audio/quality", handleSetAudioQuality) - protected.POST("/microphone/start", handleMicrophoneStart) - protected.POST("/microphone/stop", handleMicrophoneStop) - protected.POST("/microphone/mute", handleMicrophoneMute) - protected.POST("/microphone/reset", handleMicrophoneReset) } // Catch-all route for SPA