diff --git a/cloud.go b/cloud.go index db47727..3520e2f 100644 --- a/cloud.go +++ b/cloud.go @@ -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 { @@ -68,6 +69,11 @@ func handleCloudRegister(c *gin.Context) { return } + if config.CloudToken == "" { + logger.Info("Starting websocket client due to adoption") + go RunWebsocketClient() + } + config.CloudToken = tokenResp.SecretToken config.CloudURL = req.CloudAPI @@ -187,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 diff --git a/config.go b/config.go index 4230cfb..435b87e 100644 --- a/config.go +++ b/config.go @@ -22,7 +22,8 @@ type Config struct { LocalAuthToken string `json:"local_auth_token"` LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` - DisplayMaxBrightness int `json:"display_max_brightness"` + EdidString string `json:"hdmi_edid_string"` + DisplayMaxBrightness int `json:"display_max_brightness"` DisplayDimAfterSec int `json:"display_dim_after_sec"` DisplayOffAfterSec int `json:"display_off_after_sec"` } diff --git a/jsonrpc.go b/jsonrpc.go index 349ad83..45ed56e 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -189,6 +189,12 @@ func rpcSetEDID(edid string) error { if err != nil { return err } + + // Save EDID to config, allowing it to be restored on reboot. + LoadConfig() + config.EdidString = edid + SaveConfig() + return nil } diff --git a/main.go b/main.go index f94b24e..7ff771f 100644 --- a/main.go +++ b/main.go @@ -66,7 +66,11 @@ func Main() { }() //go RunFuseServer() go RunWebServer() - go RunWebsocketClient() + // 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() + } sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) <-sigs diff --git a/native.go b/native.go index 89e6803..1bd8429 100644 --- a/native.go +++ b/native.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "sync" + "syscall" "time" "github.com/pion/webrtc/v4/pkg/media" @@ -151,6 +152,9 @@ func handleCtrlClient(conn net.Conn) { ctrlSocketConn = conn + // Restore HDMI EDID if applicable + go restoreHdmiEdid() + readBuf := make([]byte, 4096) for { n, err := conn.Read(readBuf) @@ -224,6 +228,12 @@ func ExtractAndRunNativeBin() error { cmd.Stdout = os.Stdout 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 if err := cmd.Start(); err != nil { return fmt.Errorf("failed to start binary: %w", err) @@ -297,3 +307,16 @@ func ensureBinaryUpdated(destPath string) error { return nil } + +// Restore the HDMI EDID value from the config. +// Called after successful connection to jetkvm_native. +func restoreHdmiEdid() { + LoadConfig() + if config.EdidString != "" { + logger.Infof("Restoring HDMI EDID to %v", config.EdidString) + _, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString}) + if err != nil { + logger.Errorf("Failed to restore HDMI EDID: %v", err) + } + } +} diff --git a/network.go b/network.go index f461e45..c9ef919 100644 --- a/network.go +++ b/network.go @@ -6,12 +6,15 @@ import ( "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" "net" + "os/exec" "time" "github.com/vishvananda/netlink" "github.com/vishvananda/netlink/nl" ) +var mDNSConn *mdns.Conn + var networkState struct { Up bool IPv4 string @@ -25,6 +28,23 @@ type LocalIpInfo struct { MAC string } +// setDhcpClientState sends signals to udhcpc to change it's current mode +// of operation. Setting active to true will force udhcpc to renew the DHCP lease. +// Setting active to false will put udhcpc into idle mode. +func setDhcpClientState(active bool) { + var signal string; + if active { + signal = "-SIGUSR1" + } else { + signal = "-SIGUSR2" + } + + cmd := exec.Command("/usr/bin/killall", signal, "udhcpc"); + if err := cmd.Run(); err != nil { + fmt.Printf("network: setDhcpClientState: failed to change udhcpc state: %s\n", err) + } +} + func checkNetworkState() { iface, err := netlink.LinkByName("eth0") if err != nil { @@ -47,22 +67,52 @@ func checkNetworkState() { fmt.Printf("failed to get addresses for eth0: %v\n", err) } + // If the link is going down, put udhcpc into idle mode. + // If the link is coming back up, activate udhcpc and force it to renew the lease. + if newState.Up != networkState.Up { + setDhcpClientState(newState.Up) + } + for _, addr := range addrs { if addr.IP.To4() != nil { - newState.IPv4 = addr.IP.String() + if !newState.Up && networkState.Up { + // If the network is going down, remove all IPv4 addresses from the interface. + fmt.Printf("network: state transitioned to down, removing IPv4 address %s\n", addr.IP.String()) + err := netlink.AddrDel(iface, &addr) + if err != nil { + fmt.Printf("network: failed to delete %s", addr.IP.String()) + } + + newState.IPv4 = "..." + } else { + newState.IPv4 = addr.IP.String() + } } else if addr.IP.To16() != nil && newState.IPv6 == "" { newState.IPv6 = addr.IP.String() } } if newState != networkState { - networkState = newState fmt.Println("network state changed") + //restart MDNS + startMDNS() + networkState = newState requestDisplayUpdate() } } func startMDNS() error { + //If server was previously running, stop it + if mDNSConn != nil { + fmt.Printf("Stopping mDNS server\n") + err := mDNSConn.Close() + if err != nil { + fmt.Printf("failed to stop mDNS server: %v\n", err) + } + } + + //Start a new server + fmt.Printf("Starting mDNS server on jetkvm.local\n") addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4) if err != nil { return err @@ -83,10 +133,11 @@ func startMDNS() error { return err } - _, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{ + mDNSConn, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{ LocalNames: []string{"jetkvm.local"}, //TODO: make it configurable }) if err != nil { + mDNSConn = nil return err } //defer server.Close() @@ -122,7 +173,6 @@ func init() { } } }() - fmt.Println("Starting mDNS server") err := startMDNS() if err != nil { fmt.Println("failed to run mDNS: %v", err) diff --git a/ui/package.json b/ui/package.json index 592a300..9a7fae5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" }, diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index cd5432c..13ab896 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -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()} /> diff --git a/ui/src/components/MountMediaDialog.tsx b/ui/src/components/MountMediaDialog.tsx index 505d233..c3ef59d 100644 --- a/ui/src/components/MountMediaDialog.tsx +++ b/ui/src/components/MountMediaDialog.tsx @@ -26,6 +26,7 @@ import { InputFieldWithLabel } from "./InputField"; import DebianIcon from "@/assets/debian-icon.png"; import UbuntuIcon from "@/assets/ubuntu-icon.png"; import FedoraIcon from "@/assets/fedora-icon.png"; +import OpenSUSEIcon from "@/assets/opensuse-icon.png"; import ArchIcon from "@/assets/arch-icon.png"; import NetBootIcon from "@/assets/netboot-icon.svg"; import { TrashIcon } from "@heroicons/react/16/solid"; @@ -534,17 +535,27 @@ function UrlView({ }, { 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, }, { - name: "Fedora 38", - url: "https://mirror.ihost.md/fedora/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso", + name: "Fedora 41", + 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, }, + { + name: "openSUSE Leap 15.6", + url: "https://download.opensuse.org/distribution/leap/15.6/iso/openSUSE-Leap-15.6-NET-x86_64-Media.iso", + icon: OpenSUSEIcon, + }, + { + name: "openSUSE Tumbleweed", + url: "https://download.opensuse.org/tumbleweed/iso/openSUSE-Tumbleweed-NET-x86_64-Current.iso", + icon: OpenSUSEIcon, + }, { 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, }, { diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index f5f083b..1e5699c 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -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( @@ -425,7 +450,7 @@ export default function WebRTCVideo() { disablePictureInPicture controlsList="nofullscreen" 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, "opacity-0": isLoading || isConnectionError || hdmiError, diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index 48212a5..28691d8 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -501,7 +501,7 @@ export default function SettingsSidebar() {
Finger touching a screen @@ -525,7 +525,7 @@ export default function SettingsSidebar() { >
- Mouse icon + Mouse icon

diff --git a/ui/vite.config.ts b/ui/vite.config.ts index e9c7fe5..b6d26f6 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -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" : "/", }; }); diff --git a/usb.go b/usb.go index 075409a..e302815 100644 --- a/usb.go +++ b/usb.go @@ -132,7 +132,7 @@ func writeGadgetConfig() error { } err = writeGadgetAttrs(hid0Path, [][]string{ {"protocol", "1"}, - {"subclass", "0"}, + {"subclass", "1"}, {"report_length", "8"}, }) if err != nil { @@ -152,7 +152,7 @@ func writeGadgetConfig() error { } err = writeGadgetAttrs(hid1Path, [][]string{ {"protocol", "2"}, - {"subclass", "0"}, + {"subclass", "1"}, {"report_length", "6"}, }) if err != nil { diff --git a/web.go b/web.go index 64f8de7..02c7eea 100644 --- a/web.go +++ b/web.go @@ -17,8 +17,10 @@ import ( var staticFiles embed.FS type WebRTCSessionRequest struct { - Sd string `json:"sd"` - OidcGoogle string `json:"OidcGoogle,omitempty"` + 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 diff --git a/webrtc.go b/webrtc.go index 20ffb99..27084fc 100644 --- a/webrtc.go +++ b/webrtc.go @@ -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