[WIP] Cleanup: PR Cleanup

This commit is contained in:
Alex P 2025-09-20 02:30:29 +03:00
parent 274854b198
commit cd87aa499c
51 changed files with 639 additions and 419 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -11,6 +11,8 @@ import { cva } from "@/cva.config";
import Card from "./Card";
export interface ComboboxOption {
value: string;
label: string;

View File

@ -4,6 +4,8 @@ import { GridCard } from "@/components/Card";
import { cx } from "../cva.config";
interface Props {
IconElm?: React.FC<{ className: string | undefined }>;
headline: string;

View File

@ -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 }[];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<void>((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<void>((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<void>((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<void>((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) {

View File

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

View File

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

View File

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

View File

@ -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<StoredDevice[]>([]);
const [showAddForm, setShowAddForm] = useState(false);

View File

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

View File

@ -9,6 +9,8 @@ import {
import { devWarn } from '../utils/debug';
// Define the JsonRpc types for better type checking
interface JsonRpcResponse {
jsonrpc: string;

View File

@ -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<boolean | null>(null);
const [microphoneState, setMicrophoneState] = useState<MicrophoneStateData | null>(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<void>((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(() => {

View File

@ -17,6 +17,8 @@ import {
unmarshalHidRpcMessage,
} from "./hidRpc";
const KEEPALIVE_MESSAGE = new KeypressKeepAliveMessage();
interface sendMessageParams {

View File

@ -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<void> => {
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<MediaStream | null>(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<void>((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<void>((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<void>((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<void>((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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}`, {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import { DEVICE_API } from "@/ui.config";
import api from "../api";
export interface DeviceStatus {
isSetup: boolean;
}

View File

@ -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<string, unknown>, 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<void>) | 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<AudioQualityResponse | null> {
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<AudioQualityResponse | null>((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>): void {
this.reconnectionCallback = callback;
}
/**
* Trigger audio track replacement using backend's track replacement mechanism
*/
private async replaceAudioTrack(): Promise<void> {
if (this.reconnectionCallback) {
await this.reconnectionCallback();
}
}
/**
* Set audio quality with track replacement
* Set audio quality using RPC (cloud-compatible)
*/
async setAudioQuality(quality: number): Promise<boolean> {
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<boolean>((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;

10
web.go
View File

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