mirror of https://github.com/jetkvm/kvm.git
Compare commits
47 Commits
af972914aa
...
eaf1eb591b
Author | SHA1 | Date |
---|---|---|
|
eaf1eb591b | |
|
69168ff062 | |
|
0d7efe5c0e | |
|
15768ee0ab | |
|
2e8ea8cccc | |
|
727561738e | |
|
b037b8af4a | |
|
1870144d20 | |
|
08defe66b1 | |
|
da1a57eb93 | |
|
194fad972e | |
|
065f1bd21b | |
|
af92498816 | |
|
c5961acbb5 | |
|
abd95ab161 | |
|
58c5875aa7 | |
|
682b5911e8 | |
|
621c333041 | |
|
313f78000e | |
|
6a6ab143a8 | |
|
bffac9a6b5 | |
|
382c07b87a | |
|
f7eba7c257 | |
|
f67b4c1fbc | |
|
318594ae64 | |
|
a67e44560d | |
|
21b458b59a | |
|
ac1403defc | |
|
2e7493c9d1 | |
|
0cd406f35e | |
|
03fd7508de | |
|
e9096c36f7 | |
|
c6ba93c46f | |
|
afebc2dd1e | |
|
8108597740 | |
|
61477e1795 | |
|
ac377c4953 | |
|
e9b68f8131 | |
|
3be51a106f | |
|
30385f8ae3 | |
|
8c54eac167 | |
|
fd6e2fa7df | |
|
789f2bef65 | |
|
f8e668349b | |
|
8329137bae | |
|
391de747a6 | |
|
7361289b68 |
11
cloud.go
11
cloud.go
|
@ -7,13 +7,14 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket/wsjson"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type CloudRegisterRequest struct {
|
||||
|
@ -192,7 +193,11 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
|
|||
return fmt.Errorf("google identity mismatch")
|
||||
}
|
||||
|
||||
session, err := newSession()
|
||||
session, err := newSession(SessionConfig{
|
||||
ICEServers: req.ICEServers,
|
||||
LocalIP: req.IP,
|
||||
IsCloud: true,
|
||||
})
|
||||
if err != nil {
|
||||
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
|
||||
return err
|
||||
|
|
18
config.go
18
config.go
|
@ -11,6 +11,14 @@ type WakeOnLanDevice struct {
|
|||
MacAddress string `json:"macAddress"`
|
||||
}
|
||||
|
||||
type UsbConfig struct {
|
||||
VendorId string `json:"vendor_id"`
|
||||
ProductId string `json:"product_id"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Product string `json:"product"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
CloudURL string `json:"cloud_url"`
|
||||
CloudToken string `json:"cloud_token"`
|
||||
|
@ -22,6 +30,8 @@ type Config struct {
|
|||
LocalAuthToken string `json:"local_auth_token"`
|
||||
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
|
||||
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
||||
UsbConfig UsbConfig `json:"usb_config"`
|
||||
VirtualMediaEnabled bool `json:"virtual_media_enabled"`
|
||||
}
|
||||
|
||||
const configPath = "/userdata/kvm_config.json"
|
||||
|
@ -29,6 +39,14 @@ const configPath = "/userdata/kvm_config.json"
|
|||
var defaultConfig = &Config{
|
||||
CloudURL: "https://api.jetkvm.com",
|
||||
AutoUpdateEnabled: true, // Set a default value
|
||||
VirtualMediaEnabled: true,
|
||||
UsbConfig: UsbConfig{
|
||||
VendorId: "0x1d6b", //The Linux Foundation
|
||||
ProductId: "0x0104", //Multifunction Composite Gadget
|
||||
SerialNumber: "",
|
||||
Manufacturer: "JetKVM",
|
||||
Product: "JetKVM USB Emulation Device",
|
||||
},
|
||||
}
|
||||
|
||||
var config *Config
|
||||
|
|
25
jsonrpc.go
25
jsonrpc.go
|
@ -478,6 +478,29 @@ func rpcSetUsbEmulationState(enabled bool) error {
|
|||
}
|
||||
}
|
||||
|
||||
func rpcGetUsbConfig() (UsbConfig, error) {
|
||||
LoadConfig()
|
||||
return config.UsbConfig, nil
|
||||
}
|
||||
|
||||
func rpcSetUsbConfig(usbConfig UsbConfig) error {
|
||||
LoadConfig()
|
||||
config.UsbConfig = usbConfig
|
||||
|
||||
err := UpdateGadgetConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write gadget config: %w", err)
|
||||
}
|
||||
|
||||
err = SaveConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save usb config: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[jsonrpc.go:rpcSetUsbConfig] usb config set to %s", usbConfig)
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
|
||||
LoadConfig()
|
||||
if config.WakeOnLanDevices == nil {
|
||||
|
@ -542,6 +565,8 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"isUpdatePending": {Func: rpcIsUpdatePending},
|
||||
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
||||
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
||||
"getUsbConfig": {Func: rpcGetUsbConfig},
|
||||
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
||||
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
||||
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
||||
"getStorageSpace": {Func: rpcGetStorageSpace},
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4/pkg/media"
|
||||
|
@ -224,6 +225,12 @@ func ExtractAndRunNativeBin() error {
|
|||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
// Set the process group ID so we can kill the process and its children when this process exits
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setpgid: true,
|
||||
Pdeathsig: syscall.SIGKILL,
|
||||
}
|
||||
|
||||
// Start the command
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start binary: %w", err)
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
"dev": "vite dev --mode=development",
|
||||
"build": "npm run build:prod",
|
||||
"build:device": "tsc && vite build --mode=device --emptyOutDir",
|
||||
"dev:device": "vite dev --mode=device",
|
||||
"build:prod": "tsc && vite build --mode=production",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
useMountMediaStore,
|
||||
useUiStore,
|
||||
useSettingsStore,
|
||||
useVideoStore,
|
||||
} from "@/hooks/stores";
|
||||
import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||
import Container from "@components/Container";
|
||||
|
@ -33,6 +34,7 @@ export default function Actionbar({
|
|||
state => state.remoteVirtualMediaState,
|
||||
);
|
||||
const developerMode = useSettingsStore(state => state.developerMode);
|
||||
const hdmiState = useVideoStore(state => state.hdmiState);
|
||||
|
||||
// This is the only way to get a reliable state change for the popover
|
||||
// at time of writing this there is no mount, or unmount event for the popover
|
||||
|
@ -247,6 +249,7 @@ export default function Actionbar({
|
|||
size="XS"
|
||||
theme="light"
|
||||
text="Fullscreen"
|
||||
disabled={hdmiState !== 'ready'}
|
||||
LeadingIcon={LuMaximize}
|
||||
onClick={() => requestFullscreen()}
|
||||
/>
|
||||
|
|
|
@ -534,17 +534,17 @@ function UrlView({
|
|||
},
|
||||
{
|
||||
name: "Debian 12",
|
||||
url: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-12.7.0-amd64-netinst.iso",
|
||||
url: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-12.9.0-amd64-netinst.iso",
|
||||
icon: DebianIcon,
|
||||
},
|
||||
{
|
||||
name: "Fedora 38",
|
||||
url: "https://mirror.ihost.md/fedora/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso",
|
||||
name: "Fedora 41",
|
||||
url: "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-41-1.4.iso",
|
||||
icon: FedoraIcon,
|
||||
},
|
||||
{
|
||||
name: "Arch Linux",
|
||||
url: "https://archlinux.doridian.net/iso/2024.10.01/archlinux-2024.10.01-x86_64.iso",
|
||||
url: "https://archlinux.doridian.net/iso/2025.02.01/archlinux-2025.02.01-x86_64.iso",
|
||||
icon: ArchIcon,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
import { GridCard } from "@/components/Card";
|
||||
import {useCallback, useEffect, useState} from "react";
|
||||
import { Button } from "@components/Button";
|
||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||
import Modal from "@components/Modal";
|
||||
import { InputFieldWithLabel } from "./InputField";
|
||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useUsbConfigModalStore } from "@/hooks/stores";
|
||||
|
||||
export interface UsbConfigState {
|
||||
vendor_id: string;
|
||||
product_id: string;
|
||||
serial_number: string;
|
||||
manufacturer: string;
|
||||
product: string;
|
||||
}
|
||||
|
||||
export default function USBConfigDialog({
|
||||
open,
|
||||
setOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={() => setOpen(false)}>
|
||||
<Dialog setOpen={setOpen} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
|
||||
const { modalView, setModalView } = useUsbConfigModalStore();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [send] = useJsonRpc();
|
||||
|
||||
const handleUsbConfigChange = useCallback((usbConfig: object) => {
|
||||
send("setUsbConfig", { usbConfig }, resp => {
|
||||
if ("error" in resp) {
|
||||
setError(`Failed to update USB Config: ${resp.error.data || "Unknown error"}`);
|
||||
return;
|
||||
}
|
||||
setModalView("updateUsbConfigSuccess");
|
||||
});
|
||||
}, [send, setModalView]);
|
||||
|
||||
return (
|
||||
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
|
||||
<div className="p-10">
|
||||
{modalView === "updateUsbConfig" && (
|
||||
<UpdateUsbConfigModal
|
||||
onSetUsbConfig={handleUsbConfigChange}
|
||||
onCancel={() => setOpen(false)}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updateUsbConfigSuccess" && (
|
||||
<SuccessModal
|
||||
headline="USB Configuration Updated Successfully"
|
||||
description="You've successfully updated the USB Configuration"
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
|
||||
function UpdateUsbConfigModal({
|
||||
onSetUsbConfig,
|
||||
onCancel,
|
||||
error,
|
||||
}: {
|
||||
onSetUsbConfig: (usb_config: object) => void;
|
||||
onCancel: () => void;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [usbConfigState, setUsbConfigState] = useState<UsbConfigState>({
|
||||
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="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">USB Emulation Configuration</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
Set custom USB parameters to control how the USB device is emulated.
|
||||
The device will rebind once the parameters are updated.
|
||||
</p>
|
||||
</div>
|
||||
<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 className="flex gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="primary"
|
||||
text="Update USB Config"
|
||||
onClick={() => onSetUsbConfig(usbConfigState)}
|
||||
/>
|
||||
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessModal({
|
||||
headline,
|
||||
description,
|
||||
onClose,
|
||||
}: {
|
||||
headline: string;
|
||||
description: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left">
|
||||
<div>
|
||||
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
|
||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{description}</p>
|
||||
</div>
|
||||
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -30,6 +30,8 @@ export default function WebRTCVideo() {
|
|||
const {
|
||||
setClientSize: setVideoClientSize,
|
||||
setSize: setVideoSize,
|
||||
width: videoWidth,
|
||||
height: videoHeight,
|
||||
clientWidth: videoClientWidth,
|
||||
clientHeight: videoClientHeight,
|
||||
} = useVideoStore();
|
||||
|
@ -102,20 +104,43 @@ export default function WebRTCVideo() {
|
|||
const mouseMoveHandler = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!videoClientWidth || !videoClientHeight) return;
|
||||
const { buttons } = e;
|
||||
// Get the aspect ratios of the video element and the video stream
|
||||
const videoElementAspectRatio = videoClientWidth / videoClientHeight;
|
||||
const videoStreamAspectRatio = videoWidth / videoHeight;
|
||||
|
||||
// Clamp mouse position within the video boundaries
|
||||
const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth);
|
||||
const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight);
|
||||
// Calculate the effective video display area
|
||||
let effectiveWidth = videoClientWidth;
|
||||
let effectiveHeight = videoClientHeight;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
// Normalize mouse position to 0-32767 range (HID absolute coordinate system)
|
||||
const x = Math.round((currMouseX / videoClientWidth) * 32767);
|
||||
const y = Math.round((currMouseY / videoClientHeight) * 32767);
|
||||
if (videoElementAspectRatio > videoStreamAspectRatio) {
|
||||
// Pillarboxing: black bars on the left and right
|
||||
effectiveWidth = videoClientHeight * videoStreamAspectRatio;
|
||||
offsetX = (videoClientWidth - effectiveWidth) / 2;
|
||||
} else if (videoElementAspectRatio < videoStreamAspectRatio) {
|
||||
// Letterboxing: black bars on the top and bottom
|
||||
effectiveHeight = videoClientWidth / videoStreamAspectRatio;
|
||||
offsetY = (videoClientHeight - effectiveHeight) / 2;
|
||||
}
|
||||
|
||||
// Clamp mouse position within the effective video boundaries
|
||||
const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth);
|
||||
const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight);
|
||||
|
||||
// Map clamped mouse position to the video stream's coordinate system
|
||||
const relativeX = (clampedX - offsetX) / effectiveWidth;
|
||||
const relativeY = (clampedY - offsetY) / effectiveHeight;
|
||||
|
||||
// Convert to HID absolute coordinate system (0-32767 range)
|
||||
const x = Math.round(relativeX * 32767);
|
||||
const y = Math.round(relativeY * 32767);
|
||||
|
||||
// Send mouse movement
|
||||
const { buttons } = e;
|
||||
sendMouseMovement(x, y, buttons);
|
||||
},
|
||||
[sendMouseMovement, videoClientHeight, videoClientWidth],
|
||||
[sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight],
|
||||
);
|
||||
|
||||
const mouseWheelHandler = useCallback(
|
||||
|
@ -425,7 +450,7 @@ export default function WebRTCVideo() {
|
|||
disablePictureInPicture
|
||||
controlsList="nofullscreen"
|
||||
className={cx(
|
||||
"outline-50 max-h-full max-w-full rounded-md object-contain transition-all duration-1000",
|
||||
"outline-50 max-h-full max-w-full object-contain transition-all duration-1000",
|
||||
{
|
||||
"cursor-none": settings.isCursorHidden,
|
||||
"opacity-0": isLoading || isConnectionError || hdmiError,
|
||||
|
|
|
@ -3,7 +3,7 @@ import {
|
|||
useLocalAuthModalStore,
|
||||
useSettingsStore,
|
||||
useUiStore,
|
||||
useUpdateStore,
|
||||
useUpdateStore, useUsbConfigModalStore,
|
||||
} from "@/hooks/stores";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
|
@ -25,6 +25,7 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog";
|
|||
import { LocalDevice } from "@routes/devices.$id";
|
||||
import { useRevalidator } from "react-router-dom";
|
||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
import USBConfigDialog from "@components/USBConfigDialog";
|
||||
|
||||
export function SettingsItem({
|
||||
title,
|
||||
|
@ -340,7 +341,9 @@ export default function SettingsSidebar() {
|
|||
}, []);
|
||||
|
||||
const { setModalView: setLocalAuthModalView } = useLocalAuthModalStore();
|
||||
const { setModalView: setUsbConfigModalView } = useUsbConfigModalStore();
|
||||
const [isLocalAuthDialogOpen, setIsLocalAuthDialogOpen] = useState(false);
|
||||
const [isUsbConfigDialogOpen, setIsUsbConfigDialogOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnDevice) getDevice();
|
||||
|
@ -354,6 +357,14 @@ export default function SettingsSidebar() {
|
|||
}
|
||||
}, [getDevice, isLocalAuthDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOnDevice) return;
|
||||
// Refresh device status when the local usb config dialog is closed
|
||||
if (!isUsbConfigDialogOpen) {
|
||||
getDevice();
|
||||
}
|
||||
}, [getDevice, isUsbConfigDialogOpen]);
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const [currentTheme, setCurrentTheme] = useState(() => {
|
||||
|
@ -847,7 +858,22 @@ export default function SettingsSidebar() {
|
|||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{settings.developerMode && (
|
||||
<SettingsItem
|
||||
title="USB Configuration"
|
||||
description="Set the USB Descriptors for the JetKVM"
|
||||
>
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
text="Update USB Config"
|
||||
onClick={() => {
|
||||
setUsbConfigModalView("updateUsbConfig")
|
||||
setIsUsbConfigDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
)}
|
||||
{settings.debugMode && (
|
||||
<>
|
||||
<SettingsItem
|
||||
|
@ -894,6 +920,14 @@ export default function SettingsSidebar() {
|
|||
setIsLocalAuthDialogOpen(x);
|
||||
}}
|
||||
/>
|
||||
<USBConfigDialog
|
||||
open={isUsbConfigDialogOpen}
|
||||
setOpen={x => {
|
||||
// Revalidate the current route to refresh the local device status and dependent UI components
|
||||
revalidator.revalidate();
|
||||
setIsUsbConfigDialogOpen(x);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -528,3 +528,19 @@ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
|
|||
setModalView: view => set({ modalView: view }),
|
||||
setErrorMessage: message => set({ errorMessage: message }),
|
||||
}));
|
||||
|
||||
interface UsbConfigModalState {
|
||||
modalView:
|
||||
| "updateUsbConfig"
|
||||
| "updateUsbConfigSuccess";
|
||||
errorMessage: string | null;
|
||||
setModalView: (view: UsbConfigModalState["modalView"]) => void;
|
||||
setErrorMessage: (message: string | null) => void;
|
||||
}
|
||||
|
||||
export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
|
||||
modalView: "updateUsbConfig",
|
||||
errorMessage: null,
|
||||
setModalView: view => set({ modalView: view }),
|
||||
setErrorMessage: message => set({ errorMessage: message }),
|
||||
}));
|
|
@ -2,13 +2,31 @@ import { defineConfig } from "vite";
|
|||
import react from "@vitejs/plugin-react-swc";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
declare const process: {
|
||||
env: {
|
||||
JETKVM_PROXY_URL: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default defineConfig(({ mode, command }) => {
|
||||
const isCloud = mode === "production";
|
||||
const onDevice = mode === "device";
|
||||
const { JETKVM_PROXY_URL } = process.env;
|
||||
|
||||
return {
|
||||
plugins: [tsconfigPaths(), react()],
|
||||
build: { outDir: isCloud ? "dist" : "../static" },
|
||||
server: { host: "0.0.0.0" },
|
||||
base: onDevice ? "/static" : "/",
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
proxy: JETKVM_PROXY_URL ? {
|
||||
'/me': JETKVM_PROXY_URL,
|
||||
'/device': JETKVM_PROXY_URL,
|
||||
'/webrtc': JETKVM_PROXY_URL,
|
||||
'/auth': JETKVM_PROXY_URL,
|
||||
'/storage': JETKVM_PROXY_URL,
|
||||
'/cloud': JETKVM_PROXY_URL,
|
||||
} : undefined
|
||||
},
|
||||
base: onDevice && command === 'build' ? "/static" : "/",
|
||||
};
|
||||
});
|
||||
|
|
47
usb.go
47
usb.go
|
@ -58,6 +58,44 @@ func init() {
|
|||
//TODO: read hid reports(capslock, numlock, etc) from keyboardHidFile
|
||||
}
|
||||
|
||||
func UpdateGadgetConfig() error {
|
||||
LoadConfig()
|
||||
gadgetAttrs := [][]string{
|
||||
{"idVendor", config.UsbConfig.VendorId},
|
||||
{"idProduct", config.UsbConfig.ProductId},
|
||||
}
|
||||
err := writeGadgetAttrs(kvmGadgetPath, gadgetAttrs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Successfully updated usb gadget attributes: %v", gadgetAttrs)
|
||||
|
||||
strAttrs := [][]string{
|
||||
{"serialnumber", config.UsbConfig.SerialNumber},
|
||||
{"manufacturer", config.UsbConfig.Manufacturer},
|
||||
{"product", config.UsbConfig.Product},
|
||||
}
|
||||
gadgetStringsPath := filepath.Join(kvmGadgetPath, "strings", "0x409")
|
||||
err = os.MkdirAll(gadgetStringsPath, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = writeGadgetAttrs(gadgetStringsPath, strAttrs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Successfully updated usb string attributes: %s", strAttrs)
|
||||
|
||||
err = rebindUsb()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeGadgetAttrs(basePath string, attrs [][]string) error {
|
||||
for _, item := range attrs {
|
||||
filePath := filepath.Join(basePath, item[0])
|
||||
|
@ -79,10 +117,11 @@ func writeGadgetConfig() error {
|
|||
return err
|
||||
}
|
||||
|
||||
LoadConfig()
|
||||
err = writeGadgetAttrs(kvmGadgetPath, [][]string{
|
||||
{"bcdUSB", "0x0200"}, //USB 2.0
|
||||
{"idVendor", "0x1d6b"}, //The Linux Foundation
|
||||
{"idProduct", "0104"}, //Multifunction Composite Gadget¬
|
||||
{"idVendor", config.UsbConfig.VendorId},
|
||||
{"idProduct", config.UsbConfig.ProductId},
|
||||
{"bcdDevice", "0100"},
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -97,8 +136,8 @@ func writeGadgetConfig() error {
|
|||
|
||||
err = writeGadgetAttrs(gadgetStringsPath, [][]string{
|
||||
{"serialnumber", GetDeviceID()},
|
||||
{"manufacturer", "JetKVM"},
|
||||
{"product", "JetKVM USB Emulation Device"},
|
||||
{"manufacturer", config.UsbConfig.Manufacturer},
|
||||
{"product", config.UsbConfig.Product},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
4
web.go
4
web.go
|
@ -19,6 +19,8 @@ var staticFiles embed.FS
|
|||
type WebRTCSessionRequest struct {
|
||||
Sd string `json:"sd"`
|
||||
OidcGoogle string `json:"OidcGoogle,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
ICEServers []string `json:"iceServers,omitempty"`
|
||||
}
|
||||
|
||||
type SetPasswordRequest struct {
|
||||
|
@ -116,7 +118,7 @@ func handleWebRTCSession(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
session, err := newSession()
|
||||
session, err := newSession(SessionConfig{})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
|
||||
return
|
||||
|
|
33
webrtc.go
33
webrtc.go
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
@ -19,6 +20,12 @@ type Session struct {
|
|||
shouldUmountVirtualMedia bool
|
||||
}
|
||||
|
||||
type SessionConfig struct {
|
||||
ICEServers []string
|
||||
LocalIP string
|
||||
IsCloud bool
|
||||
}
|
||||
|
||||
func (s *Session) ExchangeOffer(offerStr string) (string, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(offerStr)
|
||||
if err != nil {
|
||||
|
@ -61,9 +68,29 @@ func (s *Session) ExchangeOffer(offerStr string) (string, error) {
|
|||
return base64.StdEncoding.EncodeToString(localDescription), nil
|
||||
}
|
||||
|
||||
func newSession() (*Session, error) {
|
||||
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{{}},
|
||||
func newSession(config SessionConfig) (*Session, error) {
|
||||
webrtcSettingEngine := webrtc.SettingEngine{}
|
||||
iceServer := webrtc.ICEServer{}
|
||||
|
||||
if config.IsCloud {
|
||||
if config.ICEServers == nil {
|
||||
fmt.Printf("ICE Servers not provided by cloud")
|
||||
} else {
|
||||
iceServer.URLs = config.ICEServers
|
||||
fmt.Printf("Using ICE Servers provided by cloud: %v\n", iceServer.URLs)
|
||||
}
|
||||
|
||||
if config.LocalIP == "" || net.ParseIP(config.LocalIP) == nil {
|
||||
fmt.Printf("Local IP address %v not provided or invalid, won't set NAT1To1IPs\n", config.LocalIP)
|
||||
} else {
|
||||
webrtcSettingEngine.SetNAT1To1IPs([]string{config.LocalIP}, webrtc.ICECandidateTypeSrflx)
|
||||
fmt.Printf("Setting NAT1To1IPs to %s\n", config.LocalIP)
|
||||
}
|
||||
}
|
||||
|
||||
api := webrtc.NewAPI(webrtc.WithSettingEngine(webrtcSettingEngine))
|
||||
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{
|
||||
ICEServers: []webrtc.ICEServer{iceServer},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
Loading…
Reference in New Issue