mirror of https://github.com/jetkvm/kvm.git
Compare commits
3 Commits
77b4c1c531
...
63c2272c45
Author | SHA1 | Date |
---|---|---|
|
63c2272c45 | |
|
8ee0532f0e | |
|
d0faf03239 |
|
@ -137,6 +137,29 @@ func (u *UsbGadget) GetPath(itemKey string) (string, error) {
|
||||||
return joinPath(u.kvmGadgetPath, item.path), nil
|
return joinPath(u.kvmGadgetPath, item.path), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OverrideGadgetConfig overrides the gadget config for the given item and attribute.
|
||||||
|
// It returns an error if the item is not found or the attribute is not found.
|
||||||
|
// It returns true if the attribute is overridden, false otherwise.
|
||||||
|
func (u *UsbGadget) OverrideGadgetConfig(itemKey string, itemAttr string, value string) (error, bool) {
|
||||||
|
u.configLock.Lock()
|
||||||
|
defer u.configLock.Unlock()
|
||||||
|
|
||||||
|
// get it as a pointer
|
||||||
|
_, ok := u.configMap[itemKey]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("config item %s not found", itemKey), false
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.configMap[itemKey].attrs[itemAttr] == value {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
u.configMap[itemKey].attrs[itemAttr] = value
|
||||||
|
u.log.Info().Str("itemKey", itemKey).Str("itemAttr", itemAttr).Str("value", value).Msg("overriding gadget config")
|
||||||
|
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
func mountConfigFS() error {
|
func mountConfigFS() error {
|
||||||
_, err := os.Stat(gadgetPath)
|
_, err := os.Stat(gadgetPath)
|
||||||
// TODO: check if it's mounted properly
|
// TODO: check if it's mounted properly
|
||||||
|
|
|
@ -14,10 +14,13 @@ var massStorageLun0Config = gadgetConfigItem{
|
||||||
order: 3001,
|
order: 3001,
|
||||||
path: []string{"functions", "mass_storage.usb0", "lun.0"},
|
path: []string{"functions", "mass_storage.usb0", "lun.0"},
|
||||||
attrs: gadgetAttributes{
|
attrs: gadgetAttributes{
|
||||||
"cdrom": "1",
|
"cdrom": "1",
|
||||||
"ro": "1",
|
"ro": "1",
|
||||||
"removable": "1",
|
"removable": "1",
|
||||||
"file": "\n",
|
"file": "\n",
|
||||||
"inquiry_string": "JetKVM Virtual Media",
|
// the additional whitespace is intentional to avoid the "JetKVM V irtual Media" string
|
||||||
|
// https://github.com/jetkvm/rv1106-system/blob/778133a1c153041e73f7de86c9c434a2753ea65d/sysdrv/source/uboot/u-boot/drivers/usb/gadget/f_mass_storage.c#L2556
|
||||||
|
// Vendor (8 chars), product (16 chars)
|
||||||
|
"inquiry_string": "JetKVM Virtual Media",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -566,9 +566,12 @@ type RPCHandler struct {
|
||||||
func rpcSetMassStorageMode(mode string) (string, error) {
|
func rpcSetMassStorageMode(mode string) (string, error) {
|
||||||
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
|
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
|
||||||
var cdrom bool
|
var cdrom bool
|
||||||
if mode == "cdrom" {
|
switch mode {
|
||||||
|
case "cdrom":
|
||||||
cdrom = true
|
cdrom = true
|
||||||
} else if mode != "file" {
|
case "file":
|
||||||
|
cdrom = false
|
||||||
|
default:
|
||||||
logger.Info().Str("mode", mode).Msg("Invalid mode provided")
|
logger.Info().Str("mode", mode).Msg("Invalid mode provided")
|
||||||
return "", fmt.Errorf("invalid mode: %s", mode)
|
return "", fmt.Errorf("invalid mode: %s", mode)
|
||||||
}
|
}
|
||||||
|
@ -587,7 +590,7 @@ func rpcSetMassStorageMode(mode string) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetMassStorageMode() (string, error) {
|
func rpcGetMassStorageMode() (string, error) {
|
||||||
cdrom, err := getMassStorageMode()
|
cdrom, err := getMassStorageCDROMEnabled()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get mass storage mode: %w", err)
|
return "", fmt.Errorf("failed to get mass storage mode: %w", err)
|
||||||
}
|
}
|
||||||
|
|
5
main.go
5
main.go
|
@ -77,6 +77,11 @@ func Main() {
|
||||||
|
|
||||||
initUsbGadget()
|
initUsbGadget()
|
||||||
|
|
||||||
|
err = setInitialVirtualMediaState()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to set initial virtual media state")
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(15 * time.Minute)
|
time.Sleep(15 * time.Minute)
|
||||||
for {
|
for {
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -19,8 +19,8 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.0",
|
"@headlessui/react": "^2.2.2",
|
||||||
"@headlessui/tailwindcss": "^0.2.1",
|
"@headlessui/tailwindcss": "^0.2.2",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-clipboard": "^0.1.0",
|
||||||
|
@ -36,44 +36,43 @@
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"mini-svg-data-uri": "^1.4.4",
|
"mini-svg-data-uri": "^1.4.4",
|
||||||
"react": "^18.2.0",
|
"react": "^19.1.0",
|
||||||
"react-animate-height": "^3.2.3",
|
"react-animate-height": "^3.2.3",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"react-simple-keyboard": "^3.7.112",
|
"react-simple-keyboard": "^3.8.71",
|
||||||
"react-use-websocket": "^4.13.0",
|
"react-use-websocket": "^4.13.0",
|
||||||
"react-xtermjs": "^1.0.9",
|
"react-xtermjs": "^1.0.10",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.3",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.1",
|
||||||
"validator": "^13.12.0",
|
"validator": "^13.15.0",
|
||||||
"xterm": "^5.3.0",
|
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^19.1.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^19.1.3",
|
||||||
"@types/semver": "^7.5.8",
|
"@types/semver": "^7.7.0",
|
||||||
"@types/validator": "^13.12.2",
|
"@types/validator": "^13.15.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.25.0",
|
"@typescript-eslint/eslint-plugin": "^8.32.0",
|
||||||
"@typescript-eslint/parser": "^8.25.0",
|
"@typescript-eslint/parser": "^8.32.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.2",
|
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^8.20.0",
|
"eslint": "^9.26.0",
|
||||||
"eslint-config-prettier": "^10.0.1",
|
"eslint-config-prettier": "^10.1.5",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-react": "^7.37.4",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^5.2.0",
|
"vite": "^5.2.0",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from "react";
|
import React, { JSX } from "react";
|
||||||
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
|
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
|
||||||
|
|
||||||
import ExtLink from "@/components/ExtLink";
|
import ExtLink from "@/components/ExtLink";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef, JSX } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef, JSX } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from "react";
|
import React, { JSX } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
|
|
|
@ -79,10 +79,11 @@ function Terminal({
|
||||||
return () => {
|
return () => {
|
||||||
setDisableKeyboardFocusTrap(false);
|
setDisableKeyboardFocusTrap(false);
|
||||||
};
|
};
|
||||||
}, [enableTerminal, instance, ref, setDisableKeyboardFocusTrap, type]);
|
}, [ref, instance, enableTerminal, setDisableKeyboardFocusTrap, type]);
|
||||||
|
|
||||||
const readyState = dataChannel.readyState;
|
const readyState = dataChannel.readyState;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!instance) return;
|
||||||
if (readyState !== "open") return;
|
if (readyState !== "open") return;
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
@ -93,11 +94,10 @@ function Terminal({
|
||||||
// Handle binary data differently based on browser implementation
|
// Handle binary data differently based on browser implementation
|
||||||
// Firefox sends data as blobs, chrome sends data as arraybuffer
|
// Firefox sends data as blobs, chrome sends data as arraybuffer
|
||||||
if (binaryType === "arraybuffer") {
|
if (binaryType === "arraybuffer") {
|
||||||
instance?.write(new Uint8Array(e.data));
|
instance.write(new Uint8Array(e.data));
|
||||||
} else if (binaryType === "blob") {
|
} else if (binaryType === "blob") {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
if (!instance) return;
|
|
||||||
if (!reader.result) return;
|
if (!reader.result) return;
|
||||||
instance.write(new Uint8Array(reader.result as ArrayBuffer));
|
instance.write(new Uint8Array(reader.result as ArrayBuffer));
|
||||||
};
|
};
|
||||||
|
@ -107,12 +107,12 @@ function Terminal({
|
||||||
{ signal: abortController.signal },
|
{ signal: abortController.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDataHandler = instance?.onData(data => {
|
const onDataHandler = instance.onData(data => {
|
||||||
dataChannel.send(data);
|
dataChannel.send(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup escape key handler
|
// Setup escape key handler
|
||||||
const onKeyHandler = instance?.onKey(e => {
|
const onKeyHandler = instance.onKey(e => {
|
||||||
const { domEvent } = e;
|
const { domEvent } = e;
|
||||||
if (domEvent.key === "Escape") {
|
if (domEvent.key === "Escape") {
|
||||||
setTerminalType("none");
|
setTerminalType("none");
|
||||||
|
@ -123,32 +123,32 @@ function Terminal({
|
||||||
|
|
||||||
// Send initial terminal size
|
// Send initial terminal size
|
||||||
if (dataChannel.readyState === "open") {
|
if (dataChannel.readyState === "open") {
|
||||||
dataChannel.send(JSON.stringify({ rows: instance?.rows, cols: instance?.cols }));
|
dataChannel.send(JSON.stringify({ rows: instance.rows, cols: instance.cols }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
onDataHandler?.dispose();
|
onDataHandler.dispose();
|
||||||
onKeyHandler?.dispose();
|
onKeyHandler.dispose();
|
||||||
};
|
};
|
||||||
}, [dataChannel, instance, readyState, setDisableKeyboardFocusTrap, setTerminalType]);
|
}, [instance, dataChannel, readyState, setDisableKeyboardFocusTrap, setTerminalType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instance) return;
|
if (!instance) return;
|
||||||
|
|
||||||
// Load the fit addon
|
// Load the fit addon
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
instance?.loadAddon(fitAddon);
|
instance.loadAddon(fitAddon);
|
||||||
|
|
||||||
instance?.loadAddon(new ClipboardAddon());
|
instance.loadAddon(new ClipboardAddon());
|
||||||
instance?.loadAddon(new Unicode11Addon());
|
instance.loadAddon(new Unicode11Addon());
|
||||||
instance?.loadAddon(new WebLinksAddon());
|
instance.loadAddon(new WebLinksAddon());
|
||||||
instance.unicode.activeVersion = "11";
|
instance.unicode.activeVersion = "11";
|
||||||
|
|
||||||
if (isWebGl2Supported) {
|
if (isWebGl2Supported) {
|
||||||
const webGl2Addon = new WebglAddon();
|
const webGl2Addon = new WebglAddon();
|
||||||
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
|
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
|
||||||
instance?.loadAddon(webGl2Addon);
|
instance.loadAddon(webGl2Addon);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResize = () => fitAddon.fit();
|
const handleResize = () => fitAddon.fit();
|
||||||
|
@ -158,13 +158,11 @@ function Terminal({
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", handleResize);
|
window.removeEventListener("resize", handleResize);
|
||||||
};
|
};
|
||||||
}, [ref, instance, dataChannel]);
|
}, [ref, instance]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onKeyDown={e => {
|
onKeyDown={e => e.stopPropagation()}
|
||||||
e.stopPropagation();
|
|
||||||
}}
|
|
||||||
onKeyUp={e => e.stopPropagation()}
|
onKeyUp={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import React from "react";
|
import React, { JSX } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
|
|
|
@ -10,7 +10,7 @@ import {
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { keys, modifiers } from "@/keyboardMappings";
|
import { keys, modifiers } from "@/keyboardMappings";
|
||||||
import { useResizeObserver } from "@/hooks/useResizeObserver";
|
import { useResizeObserver } from "usehooks-ts";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import VirtualKeyboard from "@components/VirtualKeyboard";
|
import VirtualKeyboard from "@components/VirtualKeyboard";
|
||||||
import Actionbar from "@components/ActionBar";
|
import Actionbar from "@components/ActionBar";
|
||||||
|
@ -67,7 +67,7 @@ export default function WebRTCVideo() {
|
||||||
|
|
||||||
// Video-related
|
// Video-related
|
||||||
useResizeObserver({
|
useResizeObserver({
|
||||||
ref: videoElm,
|
ref: videoElm as React.RefObject<HTMLElement>,
|
||||||
onResize: ({ width, height }) => {
|
onResize: ({ width, height }) => {
|
||||||
// This is actually client size, not videoSize
|
// This is actually client size, not videoSize
|
||||||
if (width && height) {
|
if (width && height) {
|
||||||
|
@ -330,11 +330,31 @@ export default function WebRTCVideo() {
|
||||||
)
|
)
|
||||||
// Alt: Keep if Alt is pressed or if the key isn't an Alt key
|
// Alt: Keep if Alt is pressed or if the key isn't an Alt key
|
||||||
// Example: If altKey is true, keep all modifiers
|
// Example: If altKey is true, keep all modifiers
|
||||||
// If altKey is false, filter out 0x04 (AltLeft) and 0x40 (AltRight)
|
// If altKey is false, filter out 0x04 (AltLeft)
|
||||||
|
//
|
||||||
|
// But intentionally do not filter out 0x40 (AltRight) to accomodate
|
||||||
|
// Alt Gr (Alt Graph) as a modifier. Oddly, Alt Gr does not declare
|
||||||
|
// itself to be an altKey. For example, the KeyboardEvent for
|
||||||
|
// Alt Gr + 2 has the following structure:
|
||||||
|
// - altKey: false
|
||||||
|
// - code: "Digit2"
|
||||||
|
// - type: [ "keydown" | "keyup" ]
|
||||||
|
//
|
||||||
|
// For context, filteredModifiers aims to keep track which modifiers
|
||||||
|
// are being pressed on the physical keyboard at any point in time.
|
||||||
|
// There is logic in the keyUpHandler and keyDownHandler to add and
|
||||||
|
// remove 0x40 (AltRight) from the list of new modifiers.
|
||||||
|
//
|
||||||
|
// But relying on the two handlers alone to track the state of the
|
||||||
|
// modifier bears the risk that the key up event for Alt Gr could
|
||||||
|
// get lost while the browser window is temporarily out of focus,
|
||||||
|
// which means the Alt Gr key state would then be "stuck". At this
|
||||||
|
// point, we would need to rely on the user to press Alt Gr again
|
||||||
|
// to properly release the state of that modifier.
|
||||||
.filter(
|
.filter(
|
||||||
modifier =>
|
modifier =>
|
||||||
altKey ||
|
altKey ||
|
||||||
(modifier !== modifiers["AltLeft"] && modifier !== modifiers["AltRight"]),
|
(modifier !== modifiers["AltLeft"]),
|
||||||
)
|
)
|
||||||
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
|
// Meta: Keep if Meta is pressed or if the key isn't a Meta key
|
||||||
// Example: If metaKey is true, keep all modifiers
|
// Example: If metaKey is true, keep all modifiers
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
export default function useInterval(callback: () => void, delay: number) {
|
|
||||||
const savedCallback = useRef<typeof callback>();
|
|
||||||
|
|
||||||
// Save the callback directly in the useRef object
|
|
||||||
savedCallback.current = callback;
|
|
||||||
|
|
||||||
// Set up the interval.
|
|
||||||
useEffect(() => {
|
|
||||||
function tick() {
|
|
||||||
if (!savedCallback.current) return;
|
|
||||||
savedCallback.current();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delay !== null) {
|
|
||||||
const id = setInterval(tick, delay);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
}
|
|
||||||
}, [delay]);
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook that determines if the component is currently mounted.
|
|
||||||
* @returns {() => boolean} A function that returns a boolean value indicating whether the component is mounted.
|
|
||||||
* @public
|
|
||||||
* @see [Documentation](https://usehooks-ts.com/react-hook/use-is-mounted)
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const isComponentMounted = useIsMounted();
|
|
||||||
* // Use isComponentMounted() to check if the component is currently mounted before performing certain actions.
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useIsMounted(): () => boolean {
|
|
||||||
const isMounted = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
isMounted.current = true;
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return useCallback(() => isMounted.current, []);
|
|
||||||
}
|
|
|
@ -1,131 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import type { RefObject } from "react";
|
|
||||||
|
|
||||||
import { useIsMounted } from "./useIsMounted";
|
|
||||||
|
|
||||||
/** The size of the observed element. */
|
|
||||||
interface Size {
|
|
||||||
/** The width of the observed element. */
|
|
||||||
width: number | undefined;
|
|
||||||
/** The height of the observed element. */
|
|
||||||
height: number | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The options for the ResizeObserver. */
|
|
||||||
interface UseResizeObserverOptions<T extends HTMLElement = HTMLElement> {
|
|
||||||
/** The ref of the element to observe. */
|
|
||||||
ref: RefObject<T>;
|
|
||||||
/**
|
|
||||||
* When using `onResize`, the hook doesn't re-render on element size changes; it delegates handling to the provided callback.
|
|
||||||
* @default undefined
|
|
||||||
*/
|
|
||||||
onResize?: (size: Size) => void;
|
|
||||||
/**
|
|
||||||
* The box model to use for the ResizeObserver.
|
|
||||||
* @default 'content-box'
|
|
||||||
*/
|
|
||||||
box?: "border-box" | "content-box" | "device-pixel-content-box";
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialSize: Size = {
|
|
||||||
width: undefined,
|
|
||||||
height: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook that observes the size of an element using the ResizeObserver API.
|
|
||||||
* @template T - The type of the element to observe.
|
|
||||||
* @param {UseResizeObserverOptions<T>} options - The options for the ResizeObserver.
|
|
||||||
* @returns {Size} - The size of the observed element.
|
|
||||||
* @public
|
|
||||||
* @see [Documentation](https://usehooks-ts.com/react-hook/use-resize-observer)
|
|
||||||
* @see [MDN ResizeObserver API](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver)
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const myRef = useRef(null);
|
|
||||||
* const { width = 0, height = 0 } = useResizeObserver({
|
|
||||||
* ref: myRef,
|
|
||||||
* box: 'content-box',
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* <div ref={myRef}>Hello, world!</div>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useResizeObserver<T extends HTMLElement = HTMLElement>(
|
|
||||||
options: UseResizeObserverOptions<T>,
|
|
||||||
): Size {
|
|
||||||
const { ref, box = "content-box" } = options;
|
|
||||||
const [{ width, height }, setSize] = useState<Size>(initialSize);
|
|
||||||
const isMounted = useIsMounted();
|
|
||||||
const previousSize = useRef<Size>({ ...initialSize });
|
|
||||||
const onResize = useRef<((size: Size) => void) | undefined>(undefined);
|
|
||||||
onResize.current = options.onResize;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!ref.current) return;
|
|
||||||
|
|
||||||
if (typeof window === "undefined" || !("ResizeObserver" in window)) return;
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(([entry]) => {
|
|
||||||
const boxProp =
|
|
||||||
box === "border-box"
|
|
||||||
? "borderBoxSize"
|
|
||||||
: box === "device-pixel-content-box"
|
|
||||||
? "devicePixelContentBoxSize"
|
|
||||||
: "contentBoxSize";
|
|
||||||
|
|
||||||
const newWidth = extractSize(entry, boxProp, "inlineSize");
|
|
||||||
const newHeight = extractSize(entry, boxProp, "blockSize");
|
|
||||||
|
|
||||||
const hasChanged =
|
|
||||||
previousSize.current.width !== newWidth ||
|
|
||||||
previousSize.current.height !== newHeight;
|
|
||||||
|
|
||||||
if (hasChanged) {
|
|
||||||
const newSize: Size = { width: newWidth, height: newHeight };
|
|
||||||
previousSize.current.width = newWidth;
|
|
||||||
previousSize.current.height = newHeight;
|
|
||||||
|
|
||||||
if (onResize.current) {
|
|
||||||
onResize.current(newSize);
|
|
||||||
} else {
|
|
||||||
if (isMounted()) {
|
|
||||||
setSize(newSize);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
observer.observe(ref.current, { box });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
};
|
|
||||||
}, [box, isMounted, ref]);
|
|
||||||
|
|
||||||
return { width, height };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @private */
|
|
||||||
type BoxSizesKey = keyof Pick<
|
|
||||||
ResizeObserverEntry,
|
|
||||||
"borderBoxSize" | "contentBoxSize" | "devicePixelContentBoxSize"
|
|
||||||
>;
|
|
||||||
|
|
||||||
function extractSize(
|
|
||||||
entry: ResizeObserverEntry,
|
|
||||||
box: BoxSizesKey,
|
|
||||||
sizeType: keyof ResizeObserverSize,
|
|
||||||
): number | undefined {
|
|
||||||
if (!entry[box]) {
|
|
||||||
if (box === "contentBoxSize") {
|
|
||||||
return entry.contentRect[sizeType === "inlineSize" ? "width" : "height"];
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.isArray(entry[box])
|
|
||||||
? entry[box][0][sizeType]
|
|
||||||
: // @ts-expect-error Support Firefox's non-standard behavior
|
|
||||||
(entry[box][sizeType] as number);
|
|
||||||
}
|
|
|
@ -1,6 +1,6 @@
|
||||||
|
// Key codes and modifiers correspond to definitions in the
|
||||||
|
// [Linux USB HID gadget driver](https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt)
|
||||||
export const keys = {
|
export const keys = {
|
||||||
AltLeft: 0xe2,
|
|
||||||
AltRight: 0xe6,
|
|
||||||
ArrowDown: 0x51,
|
ArrowDown: 0x51,
|
||||||
ArrowLeft: 0x50,
|
ArrowLeft: 0x50,
|
||||||
ArrowRight: 0x4f,
|
ArrowRight: 0x4f,
|
||||||
|
|
|
@ -414,7 +414,7 @@ function BrowserFileView({
|
||||||
if (file?.name.endsWith(".iso")) {
|
if (file?.name.endsWith(".iso")) {
|
||||||
setUsbMode("CDROM");
|
setUsbMode("CDROM");
|
||||||
} else if (file?.name.endsWith(".img")) {
|
} else if (file?.name.endsWith(".img")) {
|
||||||
setUsbMode("CDROM");
|
setUsbMode("Disk");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -566,7 +566,7 @@ function UrlView({
|
||||||
if (url.endsWith(".iso")) {
|
if (url.endsWith(".iso")) {
|
||||||
setUsbMode("CDROM");
|
setUsbMode("CDROM");
|
||||||
} else if (url.endsWith(".img")) {
|
} else if (url.endsWith(".img")) {
|
||||||
setUsbMode("CDROM");
|
setUsbMode("Disk");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -773,7 +773,7 @@ function DeviceFileView({
|
||||||
if (file.name.endsWith(".iso")) {
|
if (file.name.endsWith(".iso")) {
|
||||||
setUsbMode("CDROM");
|
setUsbMode("CDROM");
|
||||||
} else if (file.name.endsWith(".img")) {
|
} else if (file.name.endsWith(".img")) {
|
||||||
setUsbMode("CDROM");
|
setUsbMode("Disk");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1579,7 +1579,6 @@ function UsbModeSelector({
|
||||||
type="radio"
|
type="radio"
|
||||||
id="disk"
|
id="disk"
|
||||||
name="mountType"
|
name="mountType"
|
||||||
disabled
|
|
||||||
checked={usbMode === "Disk"}
|
checked={usbMode === "Disk"}
|
||||||
onChange={() => setUsbMode("Disk")}
|
onChange={() => setUsbMode("Disk")}
|
||||||
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
|
className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
|
||||||
|
@ -1588,9 +1587,6 @@ function UsbModeSelector({
|
||||||
<span className="text-sm font-medium leading-none text-slate-900 opacity-50 dark:text-white">
|
<span className="text-sm font-medium leading-none text-slate-900 opacity-50 dark:text-white">
|
||||||
Disk
|
Disk
|
||||||
</span>
|
</span>
|
||||||
<div className="text-[10px] text-slate-500 dark:text-slate-400">
|
|
||||||
Coming soon
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -19,7 +19,7 @@ import { LinkButton } from "../components/Button";
|
||||||
import { cx } from "../cva.config";
|
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 "usehooks-ts";
|
||||||
import LoadingSpinner from "../components/LoadingSpinner";
|
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. */
|
||||||
|
@ -30,7 +30,7 @@ export default function SettingsRoute() {
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||||
const { width } = useResizeObserver({ ref: scrollContainerRef });
|
const { width = 0 } = useResizeObserver({ ref: scrollContainerRef as React.RefObject<HTMLDivElement> });
|
||||||
|
|
||||||
// Handle scroll position to show/hide gradients
|
// Handle scroll position to show/hide gradients
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||||
import DashboardNavbar from "@components/Header";
|
import DashboardNavbar from "@components/Header";
|
||||||
import { LinkButton } from "@components/Button";
|
import { LinkButton } from "@components/Button";
|
||||||
import KvmCard from "@components/KvmCard";
|
import KvmCard from "@components/KvmCard";
|
||||||
import useInterval from "@/hooks/useInterval";
|
import { useInterval } from "usehooks-ts";
|
||||||
import { checkAuth } from "@/main";
|
import { checkAuth } from "@/main";
|
||||||
import { User } from "@/hooks/stores";
|
import { User } from "@/hooks/stores";
|
||||||
import EmptyCard from "@components/EmptyCard";
|
import EmptyCard from "@components/EmptyCard";
|
||||||
|
|
|
@ -26,6 +26,19 @@ func writeFile(path string, data string) error {
|
||||||
return os.WriteFile(path, []byte(data), 0644)
|
return os.WriteFile(path, []byte(data), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getMassStorageImage() (string, error) {
|
||||||
|
massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get mass storage path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePath, err := os.ReadFile(path.Join(massStorageFunctionPath, "file"))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get mass storage image path: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(imagePath)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func setMassStorageImage(imagePath string) error {
|
func setMassStorageImage(imagePath string) error {
|
||||||
massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0")
|
massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -39,19 +52,21 @@ func setMassStorageImage(imagePath string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setMassStorageMode(cdrom bool) error {
|
func setMassStorageMode(cdrom bool) error {
|
||||||
massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get mass storage path: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mode := "0"
|
mode := "0"
|
||||||
if cdrom {
|
if cdrom {
|
||||||
mode = "1"
|
mode = "1"
|
||||||
}
|
}
|
||||||
if err := writeFile(path.Join(massStorageFunctionPath, "lun.0", "cdrom"), mode); err != nil {
|
|
||||||
|
err, changed := gadget.OverrideGadgetConfig("mass_storage_lun0", "cdrom", mode)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set cdrom mode: %w", err)
|
return fmt.Errorf("failed to set cdrom mode: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
if !changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return gadget.UpdateGadgetConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
func onDiskMessage(msg webrtc.DataChannelMessage) {
|
func onDiskMessage(msg webrtc.DataChannelMessage) {
|
||||||
|
@ -113,20 +128,17 @@ func rpcMountBuiltInImage(filename string) error {
|
||||||
return mountImage(imagePath)
|
return mountImage(imagePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMassStorageMode() (bool, error) {
|
func getMassStorageCDROMEnabled() (bool, error) {
|
||||||
massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0")
|
massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to get mass storage path: %w", err)
|
return false, fmt.Errorf("failed to get mass storage path: %w", err)
|
||||||
}
|
}
|
||||||
|
data, err := os.ReadFile(path.Join(massStorageFunctionPath, "cdrom"))
|
||||||
data, err := os.ReadFile(path.Join(massStorageFunctionPath, "lun.0", "cdrom"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("failed to read cdrom mode: %w", err)
|
return false, fmt.Errorf("failed to read cdrom mode: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trim any whitespace characters. It has a newline at the end
|
// Trim any whitespace characters. It has a newline at the end
|
||||||
trimmedData := strings.TrimSpace(string(data))
|
trimmedData := strings.TrimSpace(string(data))
|
||||||
|
|
||||||
return trimmedData == "1", nil
|
return trimmedData == "1", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -191,6 +203,60 @@ func rpcUnmountImage() error {
|
||||||
|
|
||||||
var httpRangeReader *httpreadat.RangeReader
|
var httpRangeReader *httpreadat.RangeReader
|
||||||
|
|
||||||
|
func getInitialVirtualMediaState() (*VirtualMediaState, error) {
|
||||||
|
cdromEnabled, err := getMassStorageCDROMEnabled()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get mass storage cdrom enabled: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
diskPath, err := getMassStorageImage()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get mass storage image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
initialState := &VirtualMediaState{
|
||||||
|
Source: Storage,
|
||||||
|
Mode: Disk,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cdromEnabled {
|
||||||
|
initialState.Mode = CDROM
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: check if it's WebRTC or HTTP
|
||||||
|
if diskPath == "" {
|
||||||
|
return nil, nil
|
||||||
|
} else if diskPath == "/dev/nbd0" {
|
||||||
|
initialState.Source = HTTP
|
||||||
|
initialState.URL = "/"
|
||||||
|
initialState.Size = 1
|
||||||
|
} else {
|
||||||
|
initialState.Filename = filepath.Base(diskPath)
|
||||||
|
// get size from file
|
||||||
|
logger.Info().Str("diskPath", diskPath).Msg("getting file size")
|
||||||
|
info, err := os.Stat(diskPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get file info: %w", err)
|
||||||
|
}
|
||||||
|
initialState.Size = info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialState, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setInitialVirtualMediaState() error {
|
||||||
|
virtualMediaStateMutex.Lock()
|
||||||
|
defer virtualMediaStateMutex.Unlock()
|
||||||
|
initialState, err := getInitialVirtualMediaState()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get initial virtual media state: %w", err)
|
||||||
|
}
|
||||||
|
currentVirtualMediaState = initialState
|
||||||
|
|
||||||
|
logger.Info().Interface("initial_virtual_media_state", initialState).Msg("initial virtual media state set")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func rpcMountWithHTTP(url string, mode VirtualMediaMode) error {
|
func rpcMountWithHTTP(url string, mode VirtualMediaMode) error {
|
||||||
virtualMediaStateMutex.Lock()
|
virtualMediaStateMutex.Lock()
|
||||||
if currentVirtualMediaState != nil {
|
if currentVirtualMediaState != nil {
|
||||||
|
@ -204,6 +270,11 @@ func rpcMountWithHTTP(url string, mode VirtualMediaMode) error {
|
||||||
return fmt.Errorf("failed to use http url: %w", err)
|
return fmt.Errorf("failed to use http url: %w", err)
|
||||||
}
|
}
|
||||||
logger.Info().Str("url", url).Int64("size", n).Msg("using remote url")
|
logger.Info().Str("url", url).Int64("size", n).Msg("using remote url")
|
||||||
|
|
||||||
|
if err := setMassStorageMode(mode == CDROM); err != nil {
|
||||||
|
return fmt.Errorf("failed to set mass storage mode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
currentVirtualMediaState = &VirtualMediaState{
|
currentVirtualMediaState = &VirtualMediaState{
|
||||||
Source: HTTP,
|
Source: HTTP,
|
||||||
Mode: mode,
|
Mode: mode,
|
||||||
|
@ -243,6 +314,11 @@ func rpcMountWithWebRTC(filename string, size int64, mode VirtualMediaMode) erro
|
||||||
Size: size,
|
Size: size,
|
||||||
}
|
}
|
||||||
virtualMediaStateMutex.Unlock()
|
virtualMediaStateMutex.Unlock()
|
||||||
|
|
||||||
|
if err := setMassStorageMode(mode == CDROM); err != nil {
|
||||||
|
return fmt.Errorf("failed to set mass storage mode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
logger.Debug().Interface("currentVirtualMediaState", currentVirtualMediaState).Msg("currentVirtualMediaState")
|
logger.Debug().Interface("currentVirtualMediaState", currentVirtualMediaState).Msg("currentVirtualMediaState")
|
||||||
logger.Debug().Msg("Starting nbd device")
|
logger.Debug().Msg("Starting nbd device")
|
||||||
nbdDevice = NewNBDDevice()
|
nbdDevice = NewNBDDevice()
|
||||||
|
@ -280,6 +356,10 @@ func rpcMountWithStorage(filename string, mode VirtualMediaMode) error {
|
||||||
return fmt.Errorf("failed to get file info: %w", err)
|
return fmt.Errorf("failed to get file info: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := setMassStorageMode(mode == CDROM); err != nil {
|
||||||
|
return fmt.Errorf("failed to set mass storage mode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
err = setMassStorageImage(fullPath)
|
err = setMassStorageImage(fullPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set mass storage image: %w", err)
|
return fmt.Errorf("failed to set mass storage image: %w", err)
|
||||||
|
|
Loading…
Reference in New Issue