mirror of https://github.com/jetkvm/kvm.git
Compare commits
7 Commits
8a170e4849
...
15e01b3c4d
Author | SHA1 | Date |
---|---|---|
|
15e01b3c4d | |
|
69168ff062 | |
|
0d7efe5c0e | |
|
15768ee0ab | |
|
2e8ea8cccc | |
|
87f36167f3 | |
|
508ea65fae |
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "JetKVM",
|
"name": "JetKVM Default",
|
||||||
"image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm",
|
"image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/node:1": {
|
"ghcr.io/devcontainers/features/node:1": {
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "JetKVM Podman",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
|
// Should match what is defined in ui/package.json
|
||||||
|
"version": "21.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runArgs": [
|
||||||
|
"--userns=keep-id",
|
||||||
|
"--security-opt=label=disable",
|
||||||
|
"--security-opt=label=nested"
|
||||||
|
],
|
||||||
|
"containerUser": "vscode",
|
||||||
|
"containerEnv": {
|
||||||
|
"HOME": "/home/vscode"
|
||||||
|
}
|
||||||
|
}
|
11
cloud.go
11
cloud.go
|
@ -7,13 +7,14 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"github.com/coder/websocket/wsjson"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CloudRegisterRequest struct {
|
type CloudRegisterRequest struct {
|
||||||
|
@ -192,7 +193,11 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
|
||||||
return fmt.Errorf("google identity mismatch")
|
return fmt.Errorf("google identity mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := newSession()
|
session, err := newSession(SessionConfig{
|
||||||
|
ICEServers: req.ICEServers,
|
||||||
|
LocalIP: req.IP,
|
||||||
|
IsCloud: true,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
|
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
# Exit immediately if a command exits with a non-zero status
|
# Exit immediately if a command exits with a non-zero status
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@
|
||||||
"dev": "vite dev --mode=development",
|
"dev": "vite dev --mode=development",
|
||||||
"build": "npm run build:prod",
|
"build": "npm run build:prod",
|
||||||
"build:device": "tsc && vite build --mode=device --emptyOutDir",
|
"build:device": "tsc && vite build --mode=device --emptyOutDir",
|
||||||
|
"dev:device": "vite dev --mode=device",
|
||||||
"build:prod": "tsc && vite build --mode=production",
|
"build:prod": "tsc && vite build --mode=production",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
},
|
},
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
useMountMediaStore,
|
useMountMediaStore,
|
||||||
useUiStore,
|
useUiStore,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
|
useVideoStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { MdOutlineContentPasteGo } from "react-icons/md";
|
import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||||
import Container from "@components/Container";
|
import Container from "@components/Container";
|
||||||
|
@ -33,6 +34,7 @@ export default function Actionbar({
|
||||||
state => state.remoteVirtualMediaState,
|
state => state.remoteVirtualMediaState,
|
||||||
);
|
);
|
||||||
const developerMode = useSettingsStore(state => state.developerMode);
|
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
|
// 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
|
// 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"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Fullscreen"
|
text="Fullscreen"
|
||||||
|
disabled={hdmiState !== 'ready'}
|
||||||
LeadingIcon={LuMaximize}
|
LeadingIcon={LuMaximize}
|
||||||
onClick={() => requestFullscreen()}
|
onClick={() => requestFullscreen()}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -534,17 +534,17 @@ function UrlView({
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Debian 12",
|
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,
|
icon: DebianIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Fedora 38",
|
name: "Fedora 41",
|
||||||
url: "https://mirror.ihost.md/fedora/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso",
|
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,
|
icon: FedoraIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Arch Linux",
|
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,
|
icon: ArchIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -30,6 +30,8 @@ export default function WebRTCVideo() {
|
||||||
const {
|
const {
|
||||||
setClientSize: setVideoClientSize,
|
setClientSize: setVideoClientSize,
|
||||||
setSize: setVideoSize,
|
setSize: setVideoSize,
|
||||||
|
width: videoWidth,
|
||||||
|
height: videoHeight,
|
||||||
clientWidth: videoClientWidth,
|
clientWidth: videoClientWidth,
|
||||||
clientHeight: videoClientHeight,
|
clientHeight: videoClientHeight,
|
||||||
} = useVideoStore();
|
} = useVideoStore();
|
||||||
|
@ -102,20 +104,43 @@ export default function WebRTCVideo() {
|
||||||
const mouseMoveHandler = useCallback(
|
const mouseMoveHandler = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (!videoClientWidth || !videoClientHeight) return;
|
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
|
// Calculate the effective video display area
|
||||||
const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth);
|
let effectiveWidth = videoClientWidth;
|
||||||
const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight);
|
let effectiveHeight = videoClientHeight;
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
// Normalize mouse position to 0-32767 range (HID absolute coordinate system)
|
if (videoElementAspectRatio > videoStreamAspectRatio) {
|
||||||
const x = Math.round((currMouseX / videoClientWidth) * 32767);
|
// Pillarboxing: black bars on the left and right
|
||||||
const y = Math.round((currMouseY / videoClientHeight) * 32767);
|
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
|
// Send mouse movement
|
||||||
|
const { buttons } = e;
|
||||||
sendMouseMovement(x, y, buttons);
|
sendMouseMovement(x, y, buttons);
|
||||||
},
|
},
|
||||||
[sendMouseMovement, videoClientHeight, videoClientWidth],
|
[sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight],
|
||||||
);
|
);
|
||||||
|
|
||||||
const mouseWheelHandler = useCallback(
|
const mouseWheelHandler = useCallback(
|
||||||
|
@ -425,7 +450,7 @@ export default function WebRTCVideo() {
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
controlsList="nofullscreen"
|
controlsList="nofullscreen"
|
||||||
className={cx(
|
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,
|
"cursor-none": settings.isCursorHidden,
|
||||||
"opacity-0": isLoading || isConnectionError || hdmiError,
|
"opacity-0": isLoading || isConnectionError || hdmiError,
|
||||||
|
|
|
@ -2,13 +2,31 @@ import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react-swc";
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import tsconfigPaths from "vite-tsconfig-paths";
|
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 isCloud = mode === "production";
|
||||||
const onDevice = mode === "device";
|
const onDevice = mode === "device";
|
||||||
|
const { JETKVM_PROXY_URL } = process.env;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [tsconfigPaths(), react()],
|
plugins: [tsconfigPaths(), react()],
|
||||||
build: { outDir: isCloud ? "dist" : "../static" },
|
build: { outDir: isCloud ? "dist" : "../static" },
|
||||||
server: { host: "0.0.0.0" },
|
server: {
|
||||||
base: onDevice ? "/static" : "/",
|
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" : "/",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
8
web.go
8
web.go
|
@ -17,8 +17,10 @@ import (
|
||||||
var staticFiles embed.FS
|
var staticFiles embed.FS
|
||||||
|
|
||||||
type WebRTCSessionRequest struct {
|
type WebRTCSessionRequest struct {
|
||||||
Sd string `json:"sd"`
|
Sd string `json:"sd"`
|
||||||
OidcGoogle string `json:"OidcGoogle,omitempty"`
|
OidcGoogle string `json:"OidcGoogle,omitempty"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
ICEServers []string `json:"iceServers,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetPasswordRequest struct {
|
type SetPasswordRequest struct {
|
||||||
|
@ -116,7 +118,7 @@ func handleWebRTCSession(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := newSession()
|
session, err := newSession(SessionConfig{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
|
||||||
return
|
return
|
||||||
|
|
33
webrtc.go
33
webrtc.go
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/pion/webrtc/v4"
|
"github.com/pion/webrtc/v4"
|
||||||
|
@ -19,6 +20,12 @@ type Session struct {
|
||||||
shouldUmountVirtualMedia bool
|
shouldUmountVirtualMedia bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionConfig struct {
|
||||||
|
ICEServers []string
|
||||||
|
LocalIP string
|
||||||
|
IsCloud bool
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Session) ExchangeOffer(offerStr string) (string, error) {
|
func (s *Session) ExchangeOffer(offerStr string) (string, error) {
|
||||||
b, err := base64.StdEncoding.DecodeString(offerStr)
|
b, err := base64.StdEncoding.DecodeString(offerStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -61,9 +68,29 @@ func (s *Session) ExchangeOffer(offerStr string) (string, error) {
|
||||||
return base64.StdEncoding.EncodeToString(localDescription), nil
|
return base64.StdEncoding.EncodeToString(localDescription), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newSession() (*Session, error) {
|
func newSession(config SessionConfig) (*Session, error) {
|
||||||
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
|
webrtcSettingEngine := webrtc.SettingEngine{}
|
||||||
ICEServers: []webrtc.ICEServer{{}},
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
Loading…
Reference in New Issue