Compare commits

..

7 Commits

Author SHA1 Message Date
Cameron Fleming d4ca1574ee
Merge 1e589ede16 into 69168ff062 2025-02-11 13:02:12 -06:00
Brandon Tuttle 69168ff062
Fix fullscreen video relative mouse movements (#85) 2025-02-11 20:00:50 +01:00
Aveline 0d7efe5c0e
feat: add ICE servers and local IP address returned by the API to fix connectivity issues behind NAT (#146)
Add ICE servers and local IP address returned by the API to fix connectivity issues behind NAT
2025-02-11 15:45:14 +01:00
Brandon Tuttle 15768ee0ab
Remove rounded corners from video stream (#86) 2025-02-11 15:13:41 +01:00
Antony Messerli 2e8ea8cccc
Update to latest ISO versions (#78)
* Fedora 38 is EOL, bump to 41 and use main Fedora mirror
* Bumps Arch Linux and Debian to latest builds
2025-02-11 15:13:29 +01:00
Brandon Tuttle 727561738e
Clean up native subprocess is main process dies (#19) 2025-02-11 14:55:02 +01:00
Cameron Fleming a9767b650c
fix(cloud): only start WS Client if config.CloudToken is set (#27) 2025-02-11 14:51:18 +01:00
10 changed files with 123 additions and 26 deletions

View File

@ -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 {
@ -68,6 +69,11 @@ func handleCloudRegister(c *gin.Context) {
return return
} }
if config.CloudToken == "" {
logger.Info("Starting websocket client due to adoption")
go RunWebsocketClient()
}
config.CloudToken = tokenResp.SecretToken config.CloudToken = tokenResp.SecretToken
config.CloudURL = req.CloudAPI config.CloudURL = req.CloudAPI
@ -187,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

View File

@ -66,7 +66,11 @@ func Main() {
}() }()
//go RunFuseServer() //go RunFuseServer()
go RunWebServer() go RunWebServer()
// If the cloud token isn't set, the client won't be started by default.
// However, if the user adopts the device via the web interface, handleCloudRegister will start the client.
if config.CloudToken != "" {
go RunWebsocketClient() go RunWebsocketClient()
}
sigs := make(chan os.Signal, 1) sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs <-sigs

View File

@ -11,6 +11,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"sync" "sync"
"syscall"
"time" "time"
"github.com/pion/webrtc/v4/pkg/media" "github.com/pion/webrtc/v4/pkg/media"
@ -224,6 +225,12 @@ func ExtractAndRunNativeBin() error {
cmd.Stdout = os.Stdout cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr 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 // Start the command
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start binary: %w", err) return fmt.Errorf("failed to start binary: %w", err)

View File

@ -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"
}, },

View File

@ -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()}
/> />

View File

@ -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,
}, },
{ {

View File

@ -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,

View File

@ -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" : "/",
}; };
}); });

4
web.go
View File

@ -19,6 +19,8 @@ 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

View File

@ -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