Compare commits

...

4 Commits

Author SHA1 Message Date
Jack M. 5aeb4fafd1
Merge 7b023a3e8e into 69168ff062 2025-02-11 20:17:02 +01: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
Jack M. 7b023a3e8e
fix: NTP requests sleep for 1 hour between attempts.
Fixes #74 - When the device is unable to connect to the time servers, it
was looping every two seconds.  This lead to unecessary processing, and
if the device was able to connect to DNS but not the time servers, it
was causing undue load on the DNS server as well.
2025-01-25 06:42:39 -07:00
8 changed files with 103 additions and 20 deletions

View File

@ -7,13 +7,14 @@ import (
"fmt"
"net/http"
"net/url"
"github.com/coder/websocket/wsjson"
"time"
"github.com/coder/websocket/wsjson"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
)
type CloudRegisterRequest struct {
@ -192,7 +193,11 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
return fmt.Errorf("google identity mismatch")
}
session, err := newSession()
session, err := newSession(SessionConfig{
ICEServers: req.ICEServers,
LocalIP: req.IP,
IsCloud: true,
})
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
return err

2
ntp.go
View File

@ -20,6 +20,8 @@ func TimeSyncLoop() {
err := SyncSystemTime()
if err != nil {
log.Printf("Failed to sync system time: %v", err)
// Sync failed for all 4 endpoints, likely network issue, wait for 1 hour before retrying
time.Sleep(1 * time.Hour)
continue
}
log.Printf("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))

View File

@ -10,6 +10,7 @@
"dev": "vite dev --mode=development",
"build": "npm run build:prod",
"build:device": "tsc && vite build --mode=device --emptyOutDir",
"dev:device": "vite dev --mode=device",
"build:prod": "tsc && vite build --mode=production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},

View File

@ -4,6 +4,7 @@ import {
useMountMediaStore,
useUiStore,
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container";
@ -33,6 +34,7 @@ export default function Actionbar({
state => state.remoteVirtualMediaState,
);
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
// 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"
theme="light"
text="Fullscreen"
disabled={hdmiState !== 'ready'}
LeadingIcon={LuMaximize}
onClick={() => requestFullscreen()}
/>

View File

@ -30,6 +30,8 @@ export default function WebRTCVideo() {
const {
setClientSize: setVideoClientSize,
setSize: setVideoSize,
width: videoWidth,
height: videoHeight,
clientWidth: videoClientWidth,
clientHeight: videoClientHeight,
} = useVideoStore();
@ -102,20 +104,43 @@ export default function WebRTCVideo() {
const mouseMoveHandler = useCallback(
(e: MouseEvent) => {
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
const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth);
const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight);
// Calculate the effective video display area
let effectiveWidth = videoClientWidth;
let effectiveHeight = videoClientHeight;
let offsetX = 0;
let offsetY = 0;
// Normalize mouse position to 0-32767 range (HID absolute coordinate system)
const x = Math.round((currMouseX / videoClientWidth) * 32767);
const y = Math.round((currMouseY / videoClientHeight) * 32767);
if (videoElementAspectRatio > videoStreamAspectRatio) {
// Pillarboxing: black bars on the left and right
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
const { buttons } = e;
sendMouseMovement(x, y, buttons);
},
[sendMouseMovement, videoClientHeight, videoClientWidth],
[sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight],
);
const mouseWheelHandler = useCallback(

View File

@ -2,13 +2,31 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
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 onDevice = mode === "device";
const { JETKVM_PROXY_URL } = process.env;
return {
plugins: [tsconfigPaths(), react()],
build: { outDir: isCloud ? "dist" : "../static" },
server: { host: "0.0.0.0" },
base: onDevice ? "/static" : "/",
server: {
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 {
Sd string `json:"sd"`
OidcGoogle string `json:"OidcGoogle,omitempty"`
IP string `json:"ip,omitempty"`
ICEServers []string `json:"iceServers,omitempty"`
}
type SetPasswordRequest struct {
@ -116,7 +118,7 @@ func handleWebRTCSession(c *gin.Context) {
return
}
session, err := newSession()
session, err := newSession(SessionConfig{})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err})
return

View File

@ -4,6 +4,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"net"
"strings"
"github.com/pion/webrtc/v4"
@ -19,6 +20,12 @@ type Session struct {
shouldUmountVirtualMedia bool
}
type SessionConfig struct {
ICEServers []string
LocalIP string
IsCloud bool
}
func (s *Session) ExchangeOffer(offerStr string) (string, error) {
b, err := base64.StdEncoding.DecodeString(offerStr)
if err != nil {
@ -61,9 +68,29 @@ func (s *Session) ExchangeOffer(offerStr string) (string, error) {
return base64.StdEncoding.EncodeToString(localDescription), nil
}
func newSession() (*Session, error) {
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{{}},
func newSession(config SessionConfig) (*Session, error) {
webrtcSettingEngine := webrtc.SettingEngine{}
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 {
return nil, err