Refactor: remove USB configuration components and update settings structure (#271)

This commit is contained in:
Adam Shiervani 2025-03-19 15:57:53 +01:00 committed by GitHub
parent d52e7d04d1
commit 8e2ed6059d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 326 additions and 198 deletions

View File

@ -1,117 +0,0 @@
import { Button } from "@components/Button";
import { InputFieldWithLabel } from "./InputField";
import { UsbConfigState } from "@/hooks/stores";
import { useEffect, useCallback, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { USBConfig } from "./UsbConfigSetting";
export default function UpdateUsbConfigModal({
onSetUsbConfig,
onRestoreToDefault,
}: {
onSetUsbConfig: (usbConfig: USBConfig) => void;
onRestoreToDefault: () => void;
}) {
const [usbConfigState, setUsbConfigState] = useState<USBConfig>({
vendor_id: "",
product_id: "",
serial_number: "",
manufacturer: "",
product: "",
});
const [send] = useJsonRpc();
const syncUsbConfig = useCallback(() => {
send("getUsbConfig", {}, resp => {
if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error);
} else {
setUsbConfigState(resp.result as UsbConfigState);
}
});
}, [send, setUsbConfigState]);
// Load stored usb config from the backend
useEffect(() => {
syncUsbConfig();
}, [syncUsbConfig]);
const handleUsbVendorIdChange = (value: string) => {
setUsbConfigState({ ...usbConfigState, vendor_id: value });
};
const handleUsbProductIdChange = (value: string) => {
setUsbConfigState({ ...usbConfigState, product_id: value });
};
const handleUsbSerialChange = (value: string) => {
setUsbConfigState({ ...usbConfigState, serial_number: value });
};
const handleUsbManufacturer = (value: string) => {
setUsbConfigState({ ...usbConfigState, manufacturer: value });
};
const handleUsbProduct = (value: string) => {
setUsbConfigState({ ...usbConfigState, product: value });
};
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<InputFieldWithLabel
required
label="Vendor ID"
placeholder="Enter Vendor ID"
pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.vendor_id}
onChange={e => handleUsbVendorIdChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Product ID"
placeholder="Enter Product ID"
pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.product_id}
onChange={e => handleUsbProductIdChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Serial Number"
placeholder="Enter Serial Number"
defaultValue={usbConfigState?.serial_number}
onChange={e => handleUsbSerialChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Manufacturer"
placeholder="Enter Manufacturer"
defaultValue={usbConfigState?.manufacturer}
onChange={e => handleUsbManufacturer(e.target.value)}
/>
<InputFieldWithLabel
required
label="Product Name"
placeholder="Enter Product Name"
defaultValue={usbConfigState?.product}
onChange={e => handleUsbProduct(e.target.value)}
/>
</div>
<div className="flex gap-x-2">
<Button
size="SM"
theme="primary"
text="Update USB Config"
onClick={() => onSetUsbConfig(usbConfigState)}
/>
<Button
size="SM"
theme="light"
text="Restore to Default"
onClick={onRestoreToDefault}
/>
</div>
</div>
);
}

View File

@ -5,6 +5,10 @@ import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications"; import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings"; import { SettingsItem } from "../routes/devices.$id.settings";
import Checkbox from "./Checkbox"; import Checkbox from "./Checkbox";
import { Button } from "./Button";
import { SelectMenuBasic } from "./SelectMenuBasic";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import Fieldset from "./Fieldset";
export interface USBConfig { export interface USBConfig {
vendor_id: string; vendor_id: string;
@ -26,12 +30,43 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = {
absolute_mouse: true, absolute_mouse: true,
relative_mouse: true, relative_mouse: true,
mass_storage: true, mass_storage: true,
} };
const usbPresets = [
{
label: "Keyboard, Mouse and Mass Storage",
value: "default",
config: {
keyboard: true,
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
},
},
{
label: "Keyboard Only",
value: "keyboard_only",
config: {
keyboard: true,
absolute_mouse: false,
relative_mouse: false,
mass_storage: false,
},
},
{
label: "Custom",
value: "custom",
},
];
export function UsbDeviceSetting() { export function UsbDeviceSetting() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const [loading, setLoading] = useState(false);
const [usbDeviceConfig, setUsbDeviceConfig] =
useState<UsbDeviceConfig>(defaultUsbDeviceConfig);
const [selectedPreset, setSelectedPreset] = useState<string>("default");
const [usbDeviceConfig, setUsbDeviceConfig] = useState<UsbDeviceConfig>(defaultUsbDeviceConfig);
const syncUsbDeviceConfig = useCallback(() => { const syncUsbDeviceConfig = useCallback(() => {
send("getUsbDevices", {}, resp => { send("getUsbDevices", {}, resp => {
if ("error" in resp) { if ("error" in resp) {
@ -40,90 +75,168 @@ export function UsbDeviceSetting() {
`Failed to load USB devices: ${resp.error.data || "Unknown error"}`, `Failed to load USB devices: ${resp.error.data || "Unknown error"}`,
); );
} else { } else {
console.log("syncUsbDeviceConfig#getUsbDevices result:", resp.result);
const usbConfigState = resp.result as UsbDeviceConfig; const usbConfigState = resp.result as UsbDeviceConfig;
setUsbDeviceConfig(usbConfigState); setUsbDeviceConfig(usbConfigState);
// Set the appropriate preset based on current config
const matchingPreset = usbPresets.find(
preset =>
preset.value !== "custom" &&
preset.config &&
Object.keys(preset.config).length === Object.keys(usbConfigState).length &&
Object.keys(preset.config).every(key => {
const configKey = key as keyof typeof preset.config;
return preset.config[configKey] === usbConfigState[configKey];
}),
);
setSelectedPreset(matchingPreset ? matchingPreset.value : "custom");
} }
}); });
}, [send]); }, [send]);
const handleUsbConfigChange = useCallback( const handleUsbConfigChange = useCallback(
(devices: UsbDeviceConfig) => { (devices: UsbDeviceConfig) => {
send("setUsbDevices", { devices }, resp => { setLoading(true);
send("setUsbDevices", { devices }, async resp => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`, `Failed to set usb devices: ${resp.error.data || "Unknown error"}`,
); );
setLoading(false);
return; return;
} }
notifications.success(
`USB Devices updated` // We need some time to ensure the USB devices are updated
); await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
syncUsbDeviceConfig(); syncUsbDeviceConfig();
notifications.success(`USB Devices updated`);
}); });
}, },
[send, syncUsbDeviceConfig], [send, syncUsbDeviceConfig],
); );
const onUsbConfigItemChange = useCallback((key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => { const onUsbConfigItemChange = useCallback(
setUsbDeviceConfig((val) => { (key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => {
val[key] = e.target.checked; setUsbDeviceConfig(val => {
handleUsbConfigChange(val); val[key] = e.target.checked;
return val; handleUsbConfigChange(val);
}); return val;
}, [handleUsbConfigChange]); });
},
[handleUsbConfigChange],
);
const handlePresetChange = useCallback(
async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newPreset = e.target.value;
setSelectedPreset(newPreset);
if (newPreset !== "custom") {
const presetConfig = usbPresets.find(
preset => preset.value === newPreset,
)?.config;
if (presetConfig) {
handleUsbConfigChange(presetConfig);
}
}
},
[handleUsbConfigChange],
);
useEffect(() => { useEffect(() => {
syncUsbDeviceConfig(); syncUsbDeviceConfig();
}, [syncUsbDeviceConfig]); }, [syncUsbDeviceConfig]);
return ( return (
<> <Fieldset disabled={loading} className="space-y-4">
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" /> <div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="space-y-4">
<SettingsItem <SettingsSectionHeader
title="Enable Keyboard" title="USB Device"
description="Enable Keyboard" description="USB devices to emulate on the target computer"
> />
<Checkbox
checked={usbDeviceConfig.keyboard} <SettingsItem
onChange={onUsbConfigItemChange("keyboard")} loading={loading}
/> title="Classes"
</SettingsItem> description="USB device classes in the composite device"
</div> >
<div className="space-y-4"> <SelectMenuBasic
<SettingsItem size="SM"
title="Enable Absolute Mouse (Pointer)" label=""
description="Enable Absolute Mouse (Pointer)" className="max-w-[292px]"
> value={selectedPreset}
<Checkbox fullWidth
checked={usbDeviceConfig.absolute_mouse} onChange={handlePresetChange}
onChange={onUsbConfigItemChange("absolute_mouse")} options={usbPresets}
/> />
</SettingsItem> </SettingsItem>
</div>
<div className="space-y-4"> {selectedPreset === "custom" && (
<SettingsItem <div className="ml-2 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 ">
title="Enable Relative Mouse" <div className="space-y-4">
description="Enable Relative Mouse" <div className="space-y-4">
> <SettingsItem title="Enable Keyboard" description="Enable Keyboard">
<Checkbox <Checkbox
checked={usbDeviceConfig.relative_mouse} checked={usbDeviceConfig.keyboard}
onChange={onUsbConfigItemChange("relative_mouse")} onChange={onUsbConfigItemChange("keyboard")}
/> />
</SettingsItem> </SettingsItem>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem <SettingsItem
title="Enable USB Mass Storage" title="Enable Absolute Mouse (Pointer)"
description="Sometimes it might need to be disabled to prevent issues with certain devices" description="Enable Absolute Mouse (Pointer)"
> >
<Checkbox <Checkbox
checked={usbDeviceConfig.mass_storage} checked={usbDeviceConfig.absolute_mouse}
onChange={onUsbConfigItemChange("mass_storage")} onChange={onUsbConfigItemChange("absolute_mouse")}
/> />
</SettingsItem> </SettingsItem>
</div> </div>
</> <div className="space-y-4">
<SettingsItem
title="Enable Relative Mouse"
description="Enable Relative Mouse"
>
<Checkbox
checked={usbDeviceConfig.relative_mouse}
onChange={onUsbConfigItemChange("relative_mouse")}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Enable USB Mass Storage"
description="Sometimes it might need to be disabled to prevent issues with certain devices"
>
<Checkbox
checked={usbDeviceConfig.mass_storage}
onChange={onUsbConfigItemChange("mass_storage")}
/>
</SettingsItem>
</div>
</div>
<div className="mt-6 flex gap-x-2">
<Button
size="SM"
loading={loading}
theme="primary"
text="Update USB Classes"
onClick={() => handleUsbConfigChange(usbDeviceConfig)}
/>
<Button
size="SM"
theme="light"
text="Restore to Default"
onClick={() => handleUsbConfigChange(defaultUsbDeviceConfig)}
/>
</div>
</div>
)}
</Fieldset>
); );
} }

View File

@ -1,6 +1,8 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { Button } from "@components/Button";
import { InputFieldWithLabel } from "./InputField";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { UsbConfigState } from "../hooks/stores"; import { UsbConfigState } from "../hooks/stores";
@ -8,7 +10,7 @@ import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications"; import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings"; import { SettingsItem } from "../routes/devices.$id.settings";
import { SelectMenuBasic } from "./SelectMenuBasic"; import { SelectMenuBasic } from "./SelectMenuBasic";
import USBConfigDialog from "./USBConfigDialog"; import Fieldset from "./Fieldset";
const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&"); const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&");
@ -51,8 +53,9 @@ const usbConfigs = [
type UsbConfigMap = Record<string, USBConfig>; type UsbConfigMap = Record<string, USBConfig>;
export function UsbConfigSetting() { export function UsbInfoSetting() {
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const [loading, setLoading] = useState(false);
const [usbConfigProduct, setUsbConfigProduct] = useState(""); const [usbConfigProduct, setUsbConfigProduct] = useState("");
const [deviceId, setDeviceId] = useState(""); const [deviceId, setDeviceId] = useState("");
@ -110,17 +113,23 @@ export function UsbConfigSetting() {
const handleUsbConfigChange = useCallback( const handleUsbConfigChange = useCallback(
(usbConfig: USBConfig) => { (usbConfig: USBConfig) => {
send("setUsbConfig", { usbConfig }, resp => { setLoading(true);
send("setUsbConfig", { usbConfig }, async resp => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to set usb config: ${resp.error.data || "Unknown error"}`, `Failed to set usb config: ${resp.error.data || "Unknown error"}`,
); );
setLoading(false);
return; return;
} }
// setUsbConfigProduct(usbConfig.product);
// We need some time to ensure the USB devices are updated
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
notifications.success( notifications.success(
`USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`, `USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`,
); );
syncUsbConfigProduct(); syncUsbConfigProduct();
}); });
}, },
@ -141,18 +150,18 @@ export function UsbConfigSetting() {
}, [send, syncUsbConfigProduct]); }, [send, syncUsbConfigProduct]);
return ( return (
<> <Fieldset disabled={loading} className="space-y-4">
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsItem <SettingsItem
title="USB Device Emulation" loading={loading}
description="Set a Preconfigured USB Device" title="Identifiers"
description="USB device identifiers exposed to the target computer"
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
label="" label=""
className="max-w-[192px]" className="max-w-[192px]"
value={usbConfigProduct} value={usbConfigProduct}
fullWidth
onChange={e => { onChange={e => {
if (e.target.value === "custom") { if (e.target.value === "custom") {
setUsbConfigProduct(e.target.value); setUsbConfigProduct(e.target.value);
@ -165,13 +174,130 @@ export function UsbConfigSetting() {
/> />
</SettingsItem> </SettingsItem>
{usbConfigProduct === "custom" && ( {usbConfigProduct === "custom" && (
<USBConfigDialog <div className="ml-2 space-y-4 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 ">
onSetUsbConfig={usbConfig => handleUsbConfigChange(usbConfig)} <USBConfigDialog
onRestoreToDefault={() => loading={loading}
handleUsbConfigChange(usbConfigData[usbConfigs[0].value]) onSetUsbConfig={usbConfig => handleUsbConfigChange(usbConfig)}
} onRestoreToDefault={() =>
/> handleUsbConfigChange(usbConfigData[usbConfigs[0].value])
}
/>
</div>
)} )}
</> </Fieldset>
);
}
function USBConfigDialog({
loading,
onSetUsbConfig,
onRestoreToDefault,
}: {
loading: boolean;
onSetUsbConfig: (usbConfig: USBConfig) => void;
onRestoreToDefault: () => void;
}) {
const [usbConfigState, setUsbConfigState] = useState<USBConfig>({
vendor_id: "",
product_id: "",
serial_number: "",
manufacturer: "",
product: "",
});
const [send] = useJsonRpc();
const syncUsbConfig = useCallback(() => {
send("getUsbConfig", {}, resp => {
if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error);
} else {
setUsbConfigState(resp.result as UsbConfigState);
}
});
}, [send, setUsbConfigState]);
// Load stored usb config from the backend
useEffect(() => {
syncUsbConfig();
}, [syncUsbConfig]);
const handleUsbVendorIdChange = (value: string) => {
setUsbConfigState({ ...usbConfigState, vendor_id: value });
};
const handleUsbProductIdChange = (value: string) => {
setUsbConfigState({ ...usbConfigState, product_id: value });
};
const handleUsbSerialChange = (value: string) => {
setUsbConfigState({ ...usbConfigState, serial_number: value });
};
const handleUsbManufacturer = (value: string) => {
setUsbConfigState({ ...usbConfigState, manufacturer: value });
};
const handleUsbProduct = (value: string) => {
setUsbConfigState({ ...usbConfigState, product: value });
};
return (
<div className="">
<div className="grid grid-cols-2 gap-4">
<InputFieldWithLabel
required
label="Vendor ID"
placeholder="Enter Vendor ID"
pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.vendor_id}
onChange={e => handleUsbVendorIdChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Product ID"
placeholder="Enter Product ID"
pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.product_id}
onChange={e => handleUsbProductIdChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Serial Number"
placeholder="Enter Serial Number"
defaultValue={usbConfigState?.serial_number}
onChange={e => handleUsbSerialChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Manufacturer"
placeholder="Enter Manufacturer"
defaultValue={usbConfigState?.manufacturer}
onChange={e => handleUsbManufacturer(e.target.value)}
/>
<InputFieldWithLabel
required
label="Product Name"
placeholder="Enter Product Name"
defaultValue={usbConfigState?.product}
onChange={e => handleUsbProduct(e.target.value)}
/>
</div>
<div className="mt-6 flex gap-x-2">
<Button
loading={loading}
size="SM"
theme="primary"
text="Update USB Identifiers"
onClick={() => onSetUsbConfig(usbConfigState)}
/>
<Button
size="SM"
theme="light"
text="Restore to Default"
onClick={onRestoreToDefault}
/>
</div>
</div>
); );
} }

View File

@ -6,7 +6,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../notifications"; import notifications from "../notifications";
import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbConfigSetting } from "../components/UsbConfigSetting"; import { UsbInfoSetting } from "../components/UsbInfoSetting";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting"; import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { FeatureFlag } from "../components/FeatureFlag"; import { FeatureFlag } from "../components/FeatureFlag";
@ -131,11 +131,11 @@ export default function SettingsHardwareRoute() {
</div> </div>
<FeatureFlag minAppVersion="0.3.8"> <FeatureFlag minAppVersion="0.3.8">
<UsbConfigSetting /> <UsbDeviceSetting />
</FeatureFlag> </FeatureFlag>
<FeatureFlag minAppVersion="0.3.8"> <FeatureFlag minAppVersion="0.3.8">
<UsbDeviceSetting /> <UsbInfoSetting />
</FeatureFlag> </FeatureFlag>
</div> </div>
); );

View File

@ -16,6 +16,7 @@ import { cx } from "../cva.config";
import { useUiStore } from "../hooks/stores"; import { useUiStore } from "../hooks/stores";
import useKeyboard from "../hooks/useKeyboard"; import useKeyboard from "../hooks/useKeyboard";
import { useResizeObserver } from "../hooks/useResizeObserver"; import { useResizeObserver } from "../hooks/useResizeObserver";
import LoadingSpinner from "../components/LoadingSpinner";
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ /* 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() { export default function SettingsRoute() {
@ -206,7 +207,7 @@ export default function SettingsRoute() {
</div> </div>
</Card> </Card>
</div> </div>
<div className="w-full md:col-span-5"> <div className="w-full md:col-span-6">
{/* <AutoHeight> */} {/* <AutoHeight> */}
<Card className="dark:bg-slate-800"> <Card className="dark:bg-slate-800">
<div <div
@ -230,12 +231,14 @@ export function SettingsItem({
description, description,
children, children,
className, className,
loading,
}: { }: {
title: string; title: string;
description: string | React.ReactNode; description: string | React.ReactNode;
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
name?: string; name?: string;
loading?: boolean;
}) { }) {
return ( return (
<label <label
@ -245,7 +248,10 @@ export function SettingsItem({
)} )}
> >
<div className="space-y-0.5"> <div className="space-y-0.5">
<h3 className="text-base font-semibold text-black dark:text-white">{title}</h3> <div className="flex items-center gap-x-2">
<h3 className="text-base font-semibold text-black dark:text-white">{title}</h3>
{loading && <LoadingSpinner className="h-4 w-4 text-blue-500" />}
</div>
<p className="text-sm text-slate-700 dark:text-slate-300">{description}</p> <p className="text-sm text-slate-700 dark:text-slate-300">{description}</p>
</div> </div>
{children ? <div>{children}</div> : null} {children ? <div>{children}</div> : null}