Compare commits

...

8 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
Cameron Fleming 8ffe66a1bc
chore: use github.com/coder/websocket instead of nhooyr.io/websocket (#14)
nhooyr.io/websocket has been deprecated and is now maintained by Coder,
https://github.com/coder/websocket.

Also bumps to 1.8.12 which is compatible.
2025-01-02 23:51:29 +01:00
12 changed files with 128 additions and 31 deletions

View File

@ -7,13 +7,14 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"nhooyr.io/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/coder/websocket"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"nhooyr.io/websocket"
) )
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

6
go.mod
View File

@ -7,7 +7,9 @@ toolchain go1.21.1
require ( require (
github.com/Masterminds/semver/v3 v3.3.0 github.com/Masterminds/semver/v3 v3.3.0
github.com/beevik/ntp v1.3.1 github.com/beevik/ntp v1.3.1
github.com/coder/websocket v1.8.12
github.com/coreos/go-oidc/v3 v3.11.0 github.com/coreos/go-oidc/v3 v3.11.0
github.com/creack/pty v1.1.23
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
@ -17,10 +19,10 @@ require (
github.com/pion/mdns/v2 v2.0.7 github.com/pion/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.0.0 github.com/pion/webrtc/v4 v4.0.0
github.com/pojntfx/go-nbd v0.3.2 github.com/pojntfx/go-nbd v0.3.2
github.com/psanford/httpreadat v0.1.0
github.com/vishvananda/netlink v1.3.0 github.com/vishvananda/netlink v1.3.0
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.28.0
golang.org/x/net v0.30.0 golang.org/x/net v0.30.0
nhooyr.io/websocket v1.8.11
) )
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
@ -30,7 +32,6 @@ require (
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/creack/pty v1.1.23 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/go-jose/go-jose/v4 v4.0.2 // indirect
@ -60,7 +61,6 @@ require (
github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect github.com/pion/turn/v4 v4.0.0 // indirect
github.com/psanford/httpreadat v0.1.0 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect

4
go.sum
View File

@ -12,6 +12,8 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
@ -171,7 +173,5 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0=
nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

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