Compare commits

...

4 Commits

Author SHA1 Message Date
Marc Brooks 60a5fd5afa
Merge 80e1517654 into 94521ef6db 2025-08-28 17:45:55 -05:00
Marc Brooks 94521ef6db
chore/Deprecate browser mount (#752)
* chore/Deprecate browser mount

No longer supported.

* Remove device-side go code

* Removed diskChannel and localFile

* Removed RemoteVirtualMediaState.WebRTC

Also removed dead go code (to make that lint happy!)
2025-08-28 23:46:55 +02:00
Marc Brooks 80e1517654
Add the key graphics and missing keys 2025-08-27 19:57:15 -05:00
Marc Brooks 248f0d38c6
Clean up Virtual Keyboard styling
Make the header a bit taller
Add keyboard layout name to header
Make the detached keyboard render smaller key text so you can read them.
Updated the settings text for keyboard layout.
2025-08-26 20:51:52 -05:00
17 changed files with 59 additions and 559 deletions

View File

@ -22,25 +22,12 @@ func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
return 0, errors.New("image not mounted")
}
source := currentVirtualMediaState.Source
mountedImageSize := currentVirtualMediaState.Size
virtualMediaStateMutex.RUnlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
readLen := int64(len(p))
if off+readLen > mountedImageSize {
readLen = mountedImageSize - off
}
var data []byte
switch source {
case WebRTC:
data, err = webRTCDiskReader.Read(ctx, off, readLen)
if err != nil {
return 0, err
}
n = copy(p, data)
return n, nil
case HTTP:
return httpRangeReader.ReadAt(p, off)
default:

114
fuse.go
View File

@ -1,114 +0,0 @@
package kvm
import (
"context"
"os"
"sync"
"syscall"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type WebRTCStreamFile struct {
fs.Inode
mu sync.Mutex
Attr fuse.Attr
size uint64
}
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
var _ = (fs.NodeOpener)((*WebRTCStreamFile)(nil))
func (f *WebRTCStreamFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
}
func (f *WebRTCStreamFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
return 0, syscall.EROFS
}
var _ = (fs.NodeGetattrer)((*WebRTCStreamFile)(nil))
func (f *WebRTCStreamFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
f.mu.Lock()
defer f.mu.Unlock()
out.Attr = f.Attr
out.Size = f.size
return fs.OK
}
func (f *WebRTCStreamFile) Setattr(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
f.mu.Lock()
defer f.mu.Unlock()
out.Attr = f.Attr
return fs.OK
}
func (f *WebRTCStreamFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
return fs.OK
}
type DiskReadRequest struct {
Start uint64 `json:"start"`
End uint64 `json:"end"`
}
var diskReadChan = make(chan []byte, 1)
func (f *WebRTCStreamFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
buf, err := webRTCDiskReader.Read(ctx, off, int64(len(dest)))
if err != nil {
return nil, syscall.EIO
}
return fuse.ReadResultData(buf), fs.OK
}
func (f *WebRTCStreamFile) SetSize(size uint64) {
f.mu.Lock()
defer f.mu.Unlock()
f.size = size
}
type FuseRoot struct {
fs.Inode
}
var webRTCStreamFile = &WebRTCStreamFile{}
func (r *FuseRoot) OnAdd(ctx context.Context) {
ch := r.NewPersistentInode(ctx, webRTCStreamFile, fs.StableAttr{Ino: 2})
r.AddChild("disk", ch, false)
}
func (r *FuseRoot) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Mode = 0755
return 0
}
var _ = (fs.NodeGetattrer)((*FuseRoot)(nil))
var _ = (fs.NodeOnAdder)((*FuseRoot)(nil))
const fuseMountPoint = "/mnt/webrtc"
var fuseServer *fuse.Server
func RunFuseServer() {
opts := &fs.Options{}
opts.DirectMountStrict = true
_ = os.Mkdir(fuseMountPoint, 0755)
var err error
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
if err != nil {
logger.Warn().Err(err).Msg("failed to mount fuse")
}
fuseServer.Wait()
}
type WebRTCImage struct {
Size uint64 `json:"size"`
Filename string `json:"filename"`
}

View File

@ -1103,7 +1103,6 @@ var rpcHandlers = map[string]RPCHandler{
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},

View File

@ -1,62 +0,0 @@
package kvm
import (
"context"
"encoding/json"
"errors"
)
type RemoteImageReader interface {
Read(ctx context.Context, offset int64, size int64) ([]byte, error)
}
type WebRTCDiskReader struct {
}
var webRTCDiskReader WebRTCDiskReader
func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) ([]byte, error) {
virtualMediaStateMutex.RLock()
if currentVirtualMediaState == nil {
virtualMediaStateMutex.RUnlock()
return nil, errors.New("image not mounted")
}
if currentVirtualMediaState.Source != WebRTC {
virtualMediaStateMutex.RUnlock()
return nil, errors.New("image not mounted from webrtc")
}
mountedImageSize := currentVirtualMediaState.Size
virtualMediaStateMutex.RUnlock()
end := min(offset+size, mountedImageSize)
req := DiskReadRequest{
Start: uint64(offset),
End: uint64(end),
}
jsonBytes, err := json.Marshal(req)
if err != nil {
return nil, err
}
if currentSession == nil || currentSession.DiskChannel == nil {
return nil, errors.New("not active session")
}
logger.Debug().Str("request", string(jsonBytes)).Msg("reading from webrtc")
err = currentSession.DiskChannel.SendText(string(jsonBytes))
if err != nil {
return nil, err
}
var buf []byte
for {
select {
case data := <-diskReadChan:
buf = data[16:]
case <-ctx.Done():
return nil, context.Canceled
}
if len(buf) >= int(end-offset) {
break
}
}
return buf, nil
}

8
ui/package-lock.json generated
View File

@ -49,7 +49,7 @@
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.12",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@types/react-dom": "^19.1.8",
"@types/semver": "^7.7.0",
"@types/validator": "^13.15.2",
"@typescript-eslint/eslint-plugin": "^8.41.0",
@ -1970,9 +1970,9 @@
}
},
"node_modules/@types/react-dom": {
"version": "19.1.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.8.tgz",
"integrity": "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"

View File

@ -60,7 +60,7 @@
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.12",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@types/react-dom": "^19.1.8",
"@types/semver": "^7.7.0",
"@types/validator": "^13.15.2",
"@typescript-eslint/eslint-plugin": "^8.41.0",

View File

@ -224,9 +224,10 @@ function KeyboardWrapper() {
<Card
className={cx("overflow-hidden", {
"rounded-none": isAttachedVirtualKeyboardVisible,
"keyboard-detached": !isAttachedVirtualKeyboardVisible
})}
>
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-4 dark:border-b-slate-300/20 dark:bg-slate-800">
<div className="absolute left-2 flex items-center gap-x-2">
{isAttachedVirtualKeyboardVisible ? (
<Button
@ -246,7 +247,7 @@ function KeyboardWrapper() {
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
Virtual Keyboard<span className="text-[10px]"> - {selectedKeyboard.name}</span>
</h2>
<div className="absolute right-2">
<Button

View File

@ -1,9 +1,6 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { forwardRef, useEffect, useCallback } from "react";
import {
LuArrowUpFromLine,
LuCheckCheck,
LuLink,
LuPlus,
LuRadioReceiver,
@ -14,38 +11,17 @@ import { useLocation } from "react-router-dom";
import { Button } from "@components/Button";
import Card, { GridCard } from "@components/Card";
import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
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";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const { diskDataChannelStats } = useRTCStore();
const { send } = useJsonRpc();
const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
useMountMediaStore();
const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null;
const secondLastItem =
Array.from(diskDataChannelStats)[diskDataChannelStats.size - 2];
const lastItem = Array.from(diskDataChannelStats)[diskDataChannelStats.size - 1];
if (!secondLastItem || !lastItem) return 0;
const lastTime = lastItem[0];
const secondLastTime = secondLastItem[0];
const timeDelta = lastTime - secondLastTime;
const lastBytesSent = lastItem[1].bytesSent;
const secondLastBytesSent = secondLastItem[1].bytesSent;
const bytesDelta = lastBytesSent - secondLastBytesSent;
return bytesDelta / timeDelta;
}, [diskDataChannelStats]);
const syncRemoteVirtualMediaState = useCallback(() => {
send("getVirtualMediaState", {}, (response: JsonRpcResponse) => {
if ("error" in response) {
@ -94,42 +70,6 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const { source, filename, size, url, path } = remoteVirtualMediaState;
switch (source) {
case "WebRTC":
return (
<>
<div className="space-y-1">
<div className="flex items-center gap-x-2">
<LuCheckCheck className="h-5 text-green-500" />
<h3 className="text-base font-semibold text-black dark:text-white">
Streaming from Browser
</h3>
</div>
<Card className="w-auto px-2 py-1">
<div className="w-full truncate text-sm text-black dark:text-white">
{formatters.truncateMiddle(filename, 50)}
</div>
</Card>
</div>
<div className="my-2 flex flex-col items-center gap-y-2">
<div className="w-full text-sm text-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between">
<span>{formatters.bytes(size ?? 0)}</span>
<div className="flex items-center gap-x-1">
<LuArrowUpFromLine
className="h-4 text-blue-700 dark:text-blue-500"
strokeWidth={2}
/>
<span>
{bytesSentPerSecond !== null
? `${formatters.bytes(bytesSentPerSecond)}/s`
: "N/A"}
</span>
</div>
</div>
</div>
</div>
</>
);
case "HTTP":
return (
<div className="">
@ -202,18 +142,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
description="Mount an image to boot from or install an operating system."
/>
{remoteVirtualMediaState?.source === "WebRTC" ? (
<Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
<ExclamationTriangleIcon className="h-4 text-yellow-500" />
<div className="flex w-full items-center text-black">
<div>Closing this tab will unmount the image</div>
</div>
</div>
</Card>
) : null}
<div
<div
className="animate-fadeIn opacity-0 space-y-2"
style={{
animationDuration: "0.7s",

View File

@ -105,9 +105,6 @@ export interface RTCState {
setRpcDataChannel: (channel: RTCDataChannel) => void;
rpcDataChannel: RTCDataChannel | null;
diskChannel: RTCDataChannel | null;
setDiskChannel: (channel: RTCDataChannel) => void;
peerConnectionState: RTCPeerConnectionState | null;
setPeerConnectionState: (state: RTCPeerConnectionState) => void;
@ -160,9 +157,6 @@ export const useRTCStore = create<RTCState>(set => ({
peerConnectionState: null,
setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }),
diskChannel: null,
setDiskChannel: (channel: RTCDataChannel) => set({ diskChannel: channel }),
mediaStream: null,
setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }),
@ -381,7 +375,7 @@ export const useSettingsStore = create(
);
export interface RemoteVirtualMediaState {
source: "WebRTC" | "HTTP" | "Storage" | null;
source: "HTTP" | "Storage" | null;
mode: "CDROM" | "Disk" | null;
filename: string | null;
url: string | null;
@ -390,13 +384,10 @@ export interface RemoteVirtualMediaState {
}
export interface MountMediaState {
localFile: File | null;
setLocalFile: (file: MountMediaState["localFile"]) => void;
remoteVirtualMediaState: RemoteVirtualMediaState | null;
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
modalView: "mode" | "browser" | "url" | "device" | "upload" | "error" | null;
modalView: "mode" | "url" | "device" | "upload" | "error" | null;
setModalView: (view: MountMediaState["modalView"]) => void;
isMountMediaDialogOpen: boolean;
@ -410,9 +401,6 @@ export interface MountMediaState {
}
export const useMountMediaStore = create<MountMediaState>(set => ({
localFile: null,
setLocalFile: (file: MountMediaState["localFile"]) => set({ localFile: file }),
remoteVirtualMediaState: null,
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }),

View File

@ -325,6 +325,20 @@ video::-webkit-media-controls {
@apply mr-[2px]! md:mr-[5px]!;
}
/* Reduce font size for selected keys when keyboard is detached */
.keyboard-detached .simple-keyboard-main.simple-keyboard {
min-width: calc(14 * 7ch);
}
.keyboard-detached .simple-keyboard.hg-theme-default div.hg-button {
text-wrap: auto;
text-align: center;
min-width: 6ch;
}
.keyboard-detached .simple-keyboard.hg-theme-default .hg-button span {
font-size: 50%;
}
/* Hide the scrollbar by setting the scrollbar color to the background color */
.xterm .xterm-viewport {
scrollbar-color: var(--color-gray-900) #002b36;

View File

@ -144,33 +144,33 @@ export const keyDisplayMap: Record<string, string> = {
AltMetaEscape: "Alt + Meta + Escape",
CtrlAltBackspace: "Ctrl + Alt + Backspace",
AltGr: "AltGr",
AltLeft: "Alt",
AltRight: "Alt",
AltLeft: "Alt",
AltRight: "Alt",
ArrowDown: "↓",
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
Backspace: "Backspace",
"(Backspace)": "Backspace",
CapsLock: "Caps Lock",
CapsLock: "Caps Lock",
Clear: "Clear",
ControlLeft: "Ctrl",
ControlRight: "Ctrl",
Delete: "Delete",
ControlLeft: "Ctrl",
ControlRight: "Ctrl",
Delete: "Delete",
End: "End",
Enter: "Enter",
Escape: "Esc",
Home: "Home",
Insert: "Insert",
Menu: "Menu",
MetaLeft: "Meta",
MetaRight: "Meta",
MetaLeft: "Meta",
MetaRight: "Meta",
PageDown: "PgDn",
PageUp: "PgUp",
ShiftLeft: "Shift",
ShiftRight: "Shift",
ShiftLeft: "Shift",
ShiftRight: "Shift",
Space: " ",
Tab: "Tab",
Tab: "Tab",
// Letters
KeyA: "a", KeyB: "b", KeyC: "c", KeyD: "d", KeyE: "e",

View File

@ -81,12 +81,6 @@ export const keys = {
Help: 0x75,
Home: 0x4a,
Insert: 0x49,
International1: 0x87,
International2: 0x88,
International3: 0x89,
International4: 0x8a,
International5: 0x8b,
International6: 0x8c,
International7: 0x8d,
International8: 0x8e,
International9: 0x8f,
@ -117,14 +111,20 @@ export const keys = {
KeyX: 0x1b,
KeyY: 0x1c,
KeyZ: 0x1d,
KeyRO: 0x87,
KatakanaHiragana: 0x88,
Yen: 0x89,
Henkan: 0x8a,
Muhenkan: 0x8b,
KPJPComma: 0x8c,
Hangeul: 0x90,
Hanja: 0x91,
Katakana: 0x92,
Hiragana: 0x93,
ZenkakuHankaku:0x94,
LockingCapsLock: 0x82,
LockingNumLock: 0x83,
LockingScrollLock: 0x84,
Lang1: 0x90, // Hangul/English toggle on Korean keyboards
Lang2: 0x91, // Hanja conversion on Korean keyboards
Lang3: 0x92, // Katakana on Japanese keyboards
Lang4: 0x93, // Hiragana on Japanese keyboards
Lang5: 0x94, // Zenkaku/Hankaku toggle on Japanese keyboards
Lang6: 0x95,
Lang7: 0x96,
Lang8: 0x97,
@ -157,7 +157,7 @@ export const keys = {
NumpadClearEntry: 0xd9,
NumpadColon: 0xcb,
NumpadComma: 0x85,
NumpadDecimal: 0x63,
NumpadDecimal: 0x63, // and Delete
NumpadDecimalBase: 0xdc,
NumpadDelete: 0x63,
NumpadDivide: 0x54,
@ -211,7 +211,7 @@ export const keys = {
PageUp: 0x4b,
Paste: 0x7d,
Pause: 0x48,
Period: 0x37,
Period: 0x37, // aka Dot
Power: 0x66,
PrintScreen: 0x46,
Prior: 0x9d,
@ -226,7 +226,7 @@ export const keys = {
Slash: 0x38,
Space: 0x2c,
Stop: 0x78,
SystemRequest: 0x9a,
SystemRequest: 0x9a, // aka Attention
Tab: 0x2b,
ThousandsSeparator: 0xb2,
Tilde: 0x35,

View File

@ -1,9 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LuGlobe,
LuLink,
LuRadioReceiver,
LuHardDrive,
LuCheck,
LuUpload,
} from "react-icons/lu";
@ -50,7 +48,6 @@ export function Dialog({ onClose }: { onClose: () => void }) {
const {
modalView,
setModalView,
setLocalFile,
setRemoteVirtualMediaState,
errorMessage,
setErrorMessage,
@ -60,7 +57,6 @@ export function Dialog({ onClose }: { onClose: () => void }) {
const [incompleteFileName, setIncompleteFileName] = useState<string | null>(null);
const [mountInProgress, setMountInProgress] = useState(false);
function clearMountMediaState() {
setLocalFile(null);
setRemoteVirtualMediaState(null);
}
@ -131,35 +127,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
clearMountMediaState();
}
function handleBrowserMount(file: File, mode: RemoteVirtualMediaState["mode"]) {
console.log(`Mounting ${file.name} as ${mode}`);
setMountInProgress(true);
send(
"mountWithWebRTC",
{ filename: file.name, size: file.size, mode },
async resp => {
if ("error" in resp) triggerError(resp.error.message);
clearMountMediaState();
syncRemoteVirtualMediaState()
.then(() => {
// We need to keep the local file in the store so that the browser can
// continue to stream the file to the device
setLocalFile(file);
navigate("..");
})
.catch(err => {
triggerError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
setMountInProgress(false);
});
},
);
}
const [selectedMode, setSelectedMode] = useState<"browser" | "url" | "device">("url");
const [selectedMode, setSelectedMode] = useState<"url" | "device">("url");
return (
<AutoHeight>
<div
@ -167,7 +135,6 @@ export function Dialog({ onClose }: { onClose: () => void }) {
"max-w-4xl": modalView === "mode",
"max-w-2xl": modalView === "device",
"max-w-xl":
modalView === "browser" ||
modalView === "url" ||
modalView === "upload" ||
modalView === "error",
@ -194,19 +161,6 @@ export function Dialog({ onClose }: { onClose: () => void }) {
/>
)}
{modalView === "browser" && (
<BrowserFileView
mountInProgress={mountInProgress}
onMountFile={(file, mode) => {
handleBrowserMount(file, mode);
}}
onBack={() => {
setMountInProgress(false);
setModalView("mode");
}}
/>
)}
{modalView === "url" && (
<UrlView
mountInProgress={mountInProgress}
@ -275,8 +229,8 @@ function ModeSelectionView({
setSelectedMode,
}: {
onClose: () => void;
selectedMode: "browser" | "url" | "device";
setSelectedMode: (mode: "browser" | "url" | "device") => void;
selectedMode: "url" | "device";
setSelectedMode: (mode: "url" | "device") => void;
}) {
const { setModalView } = useMountMediaStore();
@ -292,14 +246,6 @@ function ModeSelectionView({
</div>
<div className="grid gap-4 md:grid-cols-3">
{[
{
label: "Browser Mount",
value: "browser",
description: "Stream files directly from your browser",
icon: LuGlobe,
tag: "Coming Soon",
disabled: true,
},
{
label: "URL Mount",
value: "url",
@ -338,7 +284,7 @@ function ModeSelectionView({
<div
className="relative z-50 flex flex-col items-start p-4 select-none"
onClick={() =>
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device")
disabled ? null : setSelectedMode(mode as "url" | "device")
}
>
<div>
@ -394,119 +340,6 @@ function ModeSelectionView({
);
}
function BrowserFileView({
onMountFile,
onBack,
mountInProgress,
}: {
onBack: () => void;
onMountFile: (file: File, mode: RemoteVirtualMediaState["mode"]) => void;
mountInProgress: boolean;
}) {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [usbMode, setUsbMode] = useState<RemoteVirtualMediaState["mode"]>("CDROM");
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
setSelectedFile(file);
if (file?.name.endsWith(".iso")) {
setUsbMode("CDROM");
} else if (file?.name.endsWith(".img")) {
setUsbMode("Disk");
}
};
const handleMount = () => {
if (selectedFile) {
console.log(`Mounting ${selectedFile.name} as ${setUsbMode}`);
onMountFile(selectedFile, usbMode);
}
};
return (
<div className="w-full space-y-4">
<ViewHeader
title="Mount from Browser"
description="Select an image file to mount"
/>
<div className="space-y-2">
<div
onClick={() => document.getElementById("file-upload")?.click()}
className="block cursor-pointer select-none"
>
<div
className="group animate-fadeIn opacity-0"
style={{
animationDuration: "0.7s",
}}
>
<Card className="transition-all duration-300 outline-dashed">
<div className="w-full px-4 py-12">
<div className="flex h-full flex-col items-center justify-center text-center">
{selectedFile ? (
<>
<div className="space-y-1">
<LuHardDrive className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm leading-none font-semibold">
{formatters.truncateMiddle(selectedFile.name, 40)}
</h3>
<p className="text-xs leading-none text-slate-700">
{formatters.bytes(selectedFile.size)}
</p>
</div>
</>
) : (
<div className="space-y-1">
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm leading-none font-semibold">
Click to select a file
</h3>
<p className="text-xs leading-none text-slate-700">
Supported formats: ISO, IMG
</p>
</div>
)}
</div>
</div>
</Card>
</div>
</div>
<input
id="file-upload"
type="file"
onChange={handleFileChange}
className="hidden"
accept=".iso, .img"
/>
</div>
<div
className="flex w-full animate-fadeIn items-end justify-between opacity-0"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
}}
>
<Fieldset disabled={!selectedFile}>
<UsbModeSelector usbMode={usbMode} setUsbMode={setUsbMode} />
</Fieldset>
<div className="flex space-x-2">
<Button size="MD" theme="blank" text="Back" onClick={onBack} />
<Button
size="MD"
theme="primary"
text="Mount File"
onClick={handleMount}
disabled={!selectedFile || mountInProgress}
loading={mountInProgress}
/>
</div>
</div>
</div>
);
}
function UrlView({
onBack,
onMount,

View File

@ -53,7 +53,7 @@ export default function SettingsKeyboardRoute() {
<div className="space-y-4">
<SettingsItem
title="Paste text"
title="Keyboard Layout"
description="Keyboard layout of target operating system"
>
<SelectMenuBasic
@ -66,7 +66,7 @@ export default function SettingsKeyboardRoute() {
/>
</SettingsItem>
<p className="text-xs text-slate-600 dark:text-slate-400">
Pasting text sends individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.
The virtual keyboard, paste text, and keyboard macros send individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.
</p>
</div>

View File

@ -29,7 +29,6 @@ import {
USBStates,
useDeviceStore,
useHidStore,
useMountMediaStore,
useNetworkStateStore,
User,
useRTCStore,
@ -132,7 +131,6 @@ export default function KvmIdRoute() {
const {
peerConnection, setPeerConnection,
peerConnectionState, setPeerConnectionState,
diskChannel, setDiskChannel,
setMediaStream,
setRpcDataChannel,
isTurnServerInUse, setTurnServerInUse,
@ -484,18 +482,12 @@ export default function KvmIdRoute() {
setRpcDataChannel(rpcDataChannel);
};
const diskDataChannel = pc.createDataChannel("disk");
diskDataChannel.onopen = () => {
setDiskChannel(diskDataChannel);
};
setPeerConnection(pc);
}, [
cleanupAndStopReconnecting,
iceConfig?.iceServers,
legacyHTTPSignaling,
sendWebRTCSignal,
setDiskChannel,
setMediaStream,
setPeerConnection,
setPeerConnectionState,
@ -719,25 +711,6 @@ export default function KvmIdRoute() {
}
}, [navigate, navigateTo, queryParams, setModalView, setQueryParams]);
const { localFile } = useMountMediaStore();
useEffect(() => {
if (!diskChannel || !localFile) return;
diskChannel.onmessage = async e => {
console.debug("Received", e.data);
const data = JSON.parse(e.data);
const blob = localFile.slice(data.start, data.end);
const buf = await blob.arrayBuffer();
const header = new ArrayBuffer(16);
const headerView = new DataView(header);
headerView.setBigUint64(0, BigInt(data.start), false); // start offset, big-endian
headerView.setBigUint64(8, BigInt(buf.byteLength), false); // length, big-endian
const fullData = new Uint8Array(header.byteLength + buf.byteLength);
fullData.set(new Uint8Array(header), 0);
fullData.set(new Uint8Array(buf), header.byteLength);
diskChannel.send(fullData);
};
}, [diskChannel, localFile]);
// System update
const [kvmTerminal, setKvmTerminal] = useState<RTCDataChannel | null>(null);
const [serialConsole, setSerialConsole] = useState<RTCDataChannel | null>(null);

View File

@ -69,11 +69,6 @@ func setMassStorageMode(cdrom bool) error {
return gadget.UpdateGadgetConfig()
}
func onDiskMessage(msg webrtc.DataChannelMessage) {
logger.Info().Int("len", len(msg.Data)).Msg("Disk Message")
diskReadChan <- msg.Data
}
func mountImage(imagePath string) error {
err := setMassStorageImage("")
if err != nil {
@ -166,7 +161,6 @@ func rpcCheckMountUrl(url string) (*VirtualMediaUrlInfo, error) {
type VirtualMediaSource string
const (
WebRTC VirtualMediaSource = "WebRTC"
HTTP VirtualMediaSource = "HTTP"
Storage VirtualMediaSource = "Storage"
)
@ -234,7 +228,6 @@ func getInitialVirtualMediaState() (*VirtualMediaState, error) {
initialState.Mode = CDROM
}
// TODO: check if it's WebRTC or HTTP
switch diskPath {
case "":
return nil, nil
@ -313,43 +306,6 @@ func rpcMountWithHTTP(url string, mode VirtualMediaMode) error {
return nil
}
func rpcMountWithWebRTC(filename string, size int64, mode VirtualMediaMode) error {
virtualMediaStateMutex.Lock()
if currentVirtualMediaState != nil {
virtualMediaStateMutex.Unlock()
return fmt.Errorf("another virtual media is already mounted")
}
currentVirtualMediaState = &VirtualMediaState{
Source: WebRTC,
Mode: mode,
Filename: filename,
Size: size,
}
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().Msg("Starting nbd device")
nbdDevice = NewNBDDevice()
err := nbdDevice.Start()
if err != nil {
logger.Warn().Err(err).Msg("failed to start nbd device")
return err
}
logger.Debug().Msg("nbd device started")
//TODO: replace by polling on block device having right size
time.Sleep(1 * time.Second)
err = setMassStorageImage("/dev/nbd0")
if err != nil {
return err
}
logger.Info().Msg("usb mass storage mounted")
return nil
}
func rpcMountWithStorage(filename string, mode VirtualMediaMode) error {
filename, err := sanitizeFilename(filename)
if err != nil {

View File

@ -21,7 +21,6 @@ type Session struct {
ControlChannel *webrtc.DataChannel
RPCChannel *webrtc.DataChannel
HidChannel *webrtc.DataChannel
DiskChannel *webrtc.DataChannel
shouldUmountVirtualMedia bool
rpcQueue chan webrtc.DataChannelMessage
}
@ -126,9 +125,6 @@ func newSession(config SessionConfig) (*Session, error) {
triggerOTAStateUpdate()
triggerVideoStateUpdate()
triggerUSBStateUpdate()
case "disk":
session.DiskChannel = d
d.OnMessage(onDiskMessage)
case "terminal":
handleTerminalChannel(d)
case "serial":