mirror of https://github.com/jetkvm/kvm.git
Merge branch 'dev' into feat/tls-config
This commit is contained in:
commit
01022eaa64
|
@ -1,12 +1,22 @@
|
|||
---
|
||||
linters:
|
||||
enable:
|
||||
# - goimports
|
||||
# - misspell
|
||||
- forbidigo
|
||||
- goimports
|
||||
- misspell
|
||||
# - revive
|
||||
- whitespace
|
||||
|
||||
issues:
|
||||
exclude-rules:
|
||||
- path: _test.go
|
||||
linters:
|
||||
- errcheck
|
||||
|
||||
linters-settings:
|
||||
forbidigo:
|
||||
forbid:
|
||||
- p: ^fmt\.Print.*$
|
||||
msg: Do not commit print statements. Use logger package.
|
||||
- p: ^log\.(Fatal|Panic|Print)(f|ln)?.*$
|
||||
msg: Do not commit log statements. Use logger package.
|
||||
|
|
|
@ -23,7 +23,7 @@ We welcome contributions from the community! Whether it's improving the firmware
|
|||
|
||||
## I need help
|
||||
|
||||
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW).
|
||||
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://jetkvm.com/discord).
|
||||
|
||||
## I want to report an issue
|
||||
|
||||
|
|
153
cloud.go
153
cloud.go
|
@ -10,6 +10,8 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/coder/websocket/wsjson"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
|
||||
|
@ -36,6 +38,97 @@ const (
|
|||
CloudWebSocketPingInterval = 15 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
metricCloudConnectionStatus = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "jetkvm_cloud_connection_status",
|
||||
Help: "The status of the cloud connection",
|
||||
},
|
||||
)
|
||||
metricCloudConnectionEstablishedTimestamp = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "jetkvm_cloud_connection_established_timestamp",
|
||||
Help: "The timestamp when the cloud connection was established",
|
||||
},
|
||||
)
|
||||
metricCloudConnectionLastPingTimestamp = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "jetkvm_cloud_connection_last_ping_timestamp",
|
||||
Help: "The timestamp when the last ping response was received",
|
||||
},
|
||||
)
|
||||
metricCloudConnectionLastPingDuration = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "jetkvm_cloud_connection_last_ping_duration",
|
||||
Help: "The duration of the last ping response",
|
||||
},
|
||||
)
|
||||
metricCloudConnectionPingDuration = promauto.NewHistogram(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "jetkvm_cloud_connection_ping_duration",
|
||||
Help: "The duration of the ping response",
|
||||
Buckets: []float64{
|
||||
0.1, 0.5, 1, 10,
|
||||
},
|
||||
},
|
||||
)
|
||||
metricCloudConnectionTotalPingCount = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_cloud_connection_total_ping_count",
|
||||
Help: "The total number of pings sent to the cloud",
|
||||
},
|
||||
)
|
||||
metricCloudConnectionSessionRequestCount = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_cloud_connection_session_total_request_count",
|
||||
Help: "The total number of session requests received from the cloud",
|
||||
},
|
||||
)
|
||||
metricCloudConnectionSessionRequestDuration = promauto.NewHistogram(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "jetkvm_cloud_connection_session_request_duration",
|
||||
Help: "The duration of session requests",
|
||||
Buckets: []float64{
|
||||
0.1, 0.5, 1, 10,
|
||||
},
|
||||
},
|
||||
)
|
||||
metricCloudConnectionLastSessionRequestTimestamp = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "jetkvm_cloud_connection_last_session_request_timestamp",
|
||||
Help: "The timestamp of the last session request",
|
||||
},
|
||||
)
|
||||
metricCloudConnectionLastSessionRequestDuration = promauto.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "jetkvm_cloud_connection_last_session_request_duration",
|
||||
Help: "The duration of the last session request",
|
||||
},
|
||||
)
|
||||
metricCloudConnectionFailureCount = promauto.NewCounter(
|
||||
prometheus.CounterOpts{
|
||||
Name: "jetkvm_cloud_connection_failure_count",
|
||||
Help: "The number of times the cloud connection has failed",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
func cloudResetMetrics(established bool) {
|
||||
metricCloudConnectionLastPingTimestamp.Set(-1)
|
||||
metricCloudConnectionLastPingDuration.Set(-1)
|
||||
|
||||
metricCloudConnectionLastSessionRequestTimestamp.Set(-1)
|
||||
metricCloudConnectionLastSessionRequestDuration.Set(-1)
|
||||
|
||||
if established {
|
||||
metricCloudConnectionEstablishedTimestamp.SetToCurrentTime()
|
||||
metricCloudConnectionStatus.Set(1)
|
||||
} else {
|
||||
metricCloudConnectionEstablishedTimestamp.Set(-1)
|
||||
metricCloudConnectionStatus.Set(-1)
|
||||
}
|
||||
}
|
||||
|
||||
func handleCloudRegister(c *gin.Context) {
|
||||
var req CloudRegisterRequest
|
||||
|
||||
|
@ -90,11 +183,6 @@ func handleCloudRegister(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if config.CloudToken == "" {
|
||||
cloudLogger.Info("Starting websocket client due to adoption")
|
||||
go RunWebsocketClient()
|
||||
}
|
||||
|
||||
config.CloudToken = tokenResp.SecretToken
|
||||
|
||||
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
|
||||
|
@ -130,19 +218,23 @@ func runWebsocketClient() error {
|
|||
time.Sleep(5 * time.Second)
|
||||
return fmt.Errorf("cloud token is not set")
|
||||
}
|
||||
|
||||
wsURL, err := url.Parse(config.CloudURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse config.CloudURL: %w", err)
|
||||
}
|
||||
|
||||
if wsURL.Scheme == "http" {
|
||||
wsURL.Scheme = "ws"
|
||||
} else {
|
||||
wsURL.Scheme = "wss"
|
||||
}
|
||||
|
||||
header := http.Header{}
|
||||
header.Set("X-Device-ID", GetDeviceID())
|
||||
header.Set("Authorization", "Bearer "+config.CloudToken)
|
||||
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
|
||||
|
||||
defer cancelDial()
|
||||
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
|
||||
HTTPHeader: header,
|
||||
|
@ -152,17 +244,35 @@ func runWebsocketClient() error {
|
|||
}
|
||||
defer c.CloseNow() //nolint:errcheck
|
||||
cloudLogger.Infof("websocket connected to %s", wsURL)
|
||||
|
||||
// set the metrics when we successfully connect to the cloud.
|
||||
cloudResetMetrics(true)
|
||||
|
||||
runCtx, cancelRun := context.WithCancel(context.Background())
|
||||
defer cancelRun()
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(CloudWebSocketPingInterval)
|
||||
|
||||
// set the timer for the ping duration
|
||||
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
|
||||
metricCloudConnectionLastPingDuration.Set(v)
|
||||
metricCloudConnectionPingDuration.Observe(v)
|
||||
}))
|
||||
|
||||
err := c.Ping(runCtx)
|
||||
|
||||
if err != nil {
|
||||
cloudLogger.Warnf("websocket ping error: %v", err)
|
||||
cancelRun()
|
||||
return
|
||||
}
|
||||
|
||||
// dont use `defer` here because we want to observe the duration of the ping
|
||||
timer.ObserveDuration()
|
||||
|
||||
metricCloudConnectionTotalPingCount.Inc()
|
||||
metricCloudConnectionLastPingTimestamp.SetToCurrentTime()
|
||||
}
|
||||
}()
|
||||
for {
|
||||
|
@ -184,6 +294,8 @@ func runWebsocketClient() error {
|
|||
cloudLogger.Infof("new session request: %v", req.OidcGoogle)
|
||||
cloudLogger.Tracef("session request info: %v", req)
|
||||
|
||||
metricCloudConnectionSessionRequestCount.Inc()
|
||||
metricCloudConnectionLastSessionRequestTimestamp.SetToCurrentTime()
|
||||
err = handleSessionRequest(runCtx, c, req)
|
||||
if err != nil {
|
||||
cloudLogger.Infof("error starting new session: %v", err)
|
||||
|
@ -193,6 +305,12 @@ func runWebsocketClient() error {
|
|||
}
|
||||
|
||||
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
|
||||
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
|
||||
metricCloudConnectionLastSessionRequestDuration.Set(v)
|
||||
metricCloudConnectionSessionRequestDuration.Observe(v)
|
||||
}))
|
||||
defer timer.ObserveDuration()
|
||||
|
||||
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
|
||||
defer cancelOIDC()
|
||||
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
|
||||
|
@ -253,9 +371,34 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
|
|||
|
||||
func RunWebsocketClient() {
|
||||
for {
|
||||
// reset the metrics when we start the websocket client.
|
||||
cloudResetMetrics(false)
|
||||
|
||||
// If the cloud token is not set, we don't need to run the websocket client.
|
||||
if config.CloudToken == "" {
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// If the network is not up, well, we can't connect to the cloud.
|
||||
if !networkState.Up {
|
||||
cloudLogger.Warn("waiting for network to be up, will retry in 3 seconds")
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail.
|
||||
if isTimeSyncNeeded() && !timeSyncSuccess {
|
||||
cloudLogger.Warn("system time is not synced, will retry in 3 seconds")
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
err := runWebsocketClient()
|
||||
if err != nil {
|
||||
cloudLogger.Errorf("websocket client error: %v", err)
|
||||
metricCloudConnectionStatus.Set(0)
|
||||
metricCloudConnectionFailureCount.Inc()
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
|
8
main.go
8
main.go
|
@ -74,11 +74,9 @@ func Main() {
|
|||
initCertStore()
|
||||
go RunWebSecureServer()
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
// As websocket client already checks if the cloud token is set, we can start it here.
|
||||
go RunWebsocketClient()
|
||||
|
||||
initSerialPort()
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
|
|
@ -1,15 +1,11 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
|
||||
"github.com/prometheus/common/version"
|
||||
)
|
||||
|
||||
var promHandler http.Handler
|
||||
|
||||
func initPrometheus() {
|
||||
// A Prometheus metrics endpoint.
|
||||
version.Version = builtAppVersion
|
||||
|
|
|
@ -66,7 +66,6 @@ func runATXControl() {
|
|||
newLedPWRState != ledPWRState ||
|
||||
newBtnRSTState != btnRSTState ||
|
||||
newBtnPWRState != btnPWRState {
|
||||
|
||||
logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v",
|
||||
newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState)
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ export default function DashboardNavbar({
|
|||
picture,
|
||||
kvmName,
|
||||
}: NavbarProps) {
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
const setUser = useUserStore(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
const onLogout = useCallback(async () => {
|
||||
|
@ -82,14 +82,14 @@ export default function DashboardNavbar({
|
|||
<div className="hidden items-center gap-x-2 md:flex">
|
||||
<div className="w-[159px]">
|
||||
<PeerConnectionStatusCard
|
||||
state={peerConnectionState}
|
||||
state={peerConnection?.connectionState}
|
||||
title={kvmName}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-[159px] md:block">
|
||||
<USBStateStatus
|
||||
state={usbState}
|
||||
peerConnectionState={peerConnectionState}
|
||||
peerConnectionState={peerConnection?.connectionState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -9,19 +9,22 @@ const PeerConnectionStatusMap = {
|
|||
failed: "Connection failed",
|
||||
closed: "Closed",
|
||||
new: "Connecting",
|
||||
};
|
||||
} as Record<RTCPeerConnectionState | "error" | "closing", string>;
|
||||
|
||||
export type PeerConnections = keyof typeof PeerConnectionStatusMap;
|
||||
|
||||
type StatusProps = Record<PeerConnections, {
|
||||
type StatusProps = Record<
|
||||
PeerConnections,
|
||||
{
|
||||
statusIndicatorClassName: string;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
|
||||
export default function PeerConnectionStatusCard({
|
||||
state,
|
||||
title,
|
||||
}: {
|
||||
state?: PeerConnections;
|
||||
state?: RTCPeerConnectionState | null;
|
||||
title?: string;
|
||||
}) {
|
||||
if (!state) return null;
|
||||
|
|
|
@ -8,11 +8,14 @@ import { HidState } from "@/hooks/stores";
|
|||
|
||||
type USBStates = HidState["usbState"];
|
||||
|
||||
type StatusProps = Record<USBStates, {
|
||||
type StatusProps = Record<
|
||||
USBStates,
|
||||
{
|
||||
icon: React.FC<{ className: string | undefined }>;
|
||||
iconClassName: string;
|
||||
statusIndicatorClassName: string;
|
||||
}>;
|
||||
}
|
||||
>;
|
||||
|
||||
const USBStateMap: Record<USBStates, string> = {
|
||||
configured: "Connected",
|
||||
|
@ -27,9 +30,8 @@ export default function USBStateStatus({
|
|||
peerConnectionState,
|
||||
}: {
|
||||
state: USBStates;
|
||||
peerConnectionState?: RTCPeerConnectionState;
|
||||
peerConnectionState?: RTCPeerConnectionState | null;
|
||||
}) {
|
||||
|
||||
const StatusCardProps: StatusProps = {
|
||||
configured: {
|
||||
icon: ({ className }) => (
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import { ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { LuPlay } from "react-icons/lu";
|
||||
|
||||
|
@ -25,12 +25,12 @@ interface LoadingOverlayProps {
|
|||
show: boolean;
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ show }: LoadingOverlayProps) {
|
||||
export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="absolute inset-0 aspect-video h-full w-full"
|
||||
className="aspect-video h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
|
@ -55,21 +55,59 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) {
|
|||
);
|
||||
}
|
||||
|
||||
interface ConnectionErrorOverlayProps {
|
||||
interface LoadingConnectionOverlayProps {
|
||||
show: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
|
||||
export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverlayProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="absolute inset-0 z-10 aspect-video h-full w-full"
|
||||
className="aspect-video h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
duration: 0.4,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<OverlayContent>
|
||||
<div className="flex flex-col items-center justify-center gap-y-1">
|
||||
<div className="animate flex h-12 w-12 items-center justify-center">
|
||||
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
|
||||
</div>
|
||||
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
|
||||
{text}
|
||||
</p>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectionErrorOverlayProps {
|
||||
show: boolean;
|
||||
setupPeerConnection: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConnectionErrorOverlay({
|
||||
show,
|
||||
setupPeerConnection,
|
||||
}: ConnectionErrorOverlayProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="aspect-video h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
|
@ -87,14 +125,21 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
|
|||
<li>Try restarting both the device and your computer</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<LinkButton
|
||||
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
||||
theme="light"
|
||||
theme="primary"
|
||||
text="Troubleshooting Guide"
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
size="SM"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setupPeerConnection()}
|
||||
LeadingIcon={ArrowPathIcon}
|
||||
text="Try again"
|
||||
size="SM"
|
||||
theme="light"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -19,9 +19,8 @@ import { useJsonRpc } from "@/hooks/useJsonRpc";
|
|||
|
||||
import {
|
||||
HDMIErrorOverlay,
|
||||
LoadingVideoOverlay,
|
||||
NoAutoplayPermissionsOverlay,
|
||||
ConnectionErrorOverlay,
|
||||
LoadingOverlay,
|
||||
} from "./VideoOverlay";
|
||||
|
||||
export default function WebRTCVideo() {
|
||||
|
@ -46,15 +45,13 @@ export default function WebRTCVideo() {
|
|||
|
||||
// RTC related states
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||
|
||||
// HDMI and UI states
|
||||
const hdmiState = useVideoStore(state => state.hdmiState);
|
||||
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
|
||||
const isLoading = !hdmiError && !isPlaying;
|
||||
const isConnectionError = ["error", "failed", "disconnected", "closed"].includes(
|
||||
peerConnectionState || "",
|
||||
);
|
||||
const isVideoLoading = !isPlaying;
|
||||
|
||||
// console.log("peerConnection?.connectionState", peerConnection?.connectionState);
|
||||
|
||||
// Keyboard related states
|
||||
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
|
||||
|
@ -379,25 +376,52 @@ export default function WebRTCVideo() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const addStreamToVideoElm = useCallback(
|
||||
(mediaStream: MediaStream) => {
|
||||
if (!videoElm.current) return;
|
||||
const videoElmRefValue = videoElm.current;
|
||||
console.log("Adding stream to video element", videoElmRefValue);
|
||||
videoElmRefValue.srcObject = mediaStream;
|
||||
updateVideoSizeStore(videoElmRefValue);
|
||||
},
|
||||
[updateVideoSizeStore],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function updateVideoStreamOnNewTrack() {
|
||||
if (!peerConnection) return;
|
||||
const abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
peerConnection.addEventListener(
|
||||
"track",
|
||||
(e: RTCTrackEvent) => {
|
||||
console.log("Adding stream to video element");
|
||||
addStreamToVideoElm(e.streams[0]);
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
},
|
||||
[addStreamToVideoElm, peerConnection],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function updateVideoStream() {
|
||||
if (!mediaStream) return;
|
||||
if (!videoElm.current) return;
|
||||
if (peerConnection?.iceConnectionState !== "connected") return;
|
||||
|
||||
setTimeout(() => {
|
||||
if (videoElm?.current) {
|
||||
videoElm.current.srcObject = mediaStream;
|
||||
}
|
||||
}, 0);
|
||||
updateVideoSizeStore(videoElm.current);
|
||||
console.log("Updating video stream from mediaStream");
|
||||
// We set the as early as possible
|
||||
addStreamToVideoElm(mediaStream);
|
||||
},
|
||||
[
|
||||
setVideoClientSize,
|
||||
setVideoSize,
|
||||
mediaStream,
|
||||
updateVideoSizeStore,
|
||||
peerConnection?.iceConnectionState,
|
||||
peerConnection,
|
||||
addStreamToVideoElm,
|
||||
],
|
||||
);
|
||||
|
||||
|
@ -474,6 +498,8 @@ export default function WebRTCVideo() {
|
|||
const local = resetMousePosition;
|
||||
window.addEventListener("blur", local, { signal });
|
||||
document.addEventListener("visibilitychange", local, { signal });
|
||||
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
|
||||
videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal });
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
|
@ -517,17 +543,17 @@ export default function WebRTCVideo() {
|
|||
);
|
||||
|
||||
const hasNoAutoPlayPermissions = useMemo(() => {
|
||||
if (peerConnectionState !== "connected") return false;
|
||||
if (peerConnection?.connectionState !== "connected") return false;
|
||||
if (isPlaying) return false;
|
||||
if (hdmiError) return false;
|
||||
if (videoHeight === 0 || videoWidth === 0) return false;
|
||||
return true;
|
||||
}, [peerConnectionState, isPlaying, hdmiError, videoHeight, videoWidth]);
|
||||
}, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]);
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full grid-rows-layout">
|
||||
<div className="min-h-[39.5px]">
|
||||
<fieldset disabled={peerConnectionState !== "connected"}>
|
||||
<fieldset disabled={peerConnection?.connectionState !== "connected"}>
|
||||
<Actionbar
|
||||
requestFullscreen={async () =>
|
||||
videoElm.current?.requestFullscreen({
|
||||
|
@ -575,28 +601,29 @@ export default function WebRTCVideo() {
|
|||
"cursor-none":
|
||||
settings.mouseMode === "absolute" &&
|
||||
settings.isCursorHidden,
|
||||
"opacity-0": isLoading || isConnectionError || hdmiError,
|
||||
"opacity-0": isVideoLoading || hdmiError,
|
||||
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
|
||||
isPlaying,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
style={{ animationDuration: "500ms" }}
|
||||
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
|
||||
>
|
||||
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||
<LoadingOverlay show={isLoading} />
|
||||
<ConnectionErrorOverlay show={isConnectionError} />
|
||||
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
|
||||
<NoAutoplayPermissionsOverlay
|
||||
show={hasNoAutoPlayPermissions}
|
||||
onPlayClick={() => {
|
||||
videoElm.current?.play();
|
||||
}}
|
||||
/>
|
||||
{peerConnection?.connectionState == "connected" && (
|
||||
<div
|
||||
style={{ animationDuration: "500ms" }}
|
||||
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
|
||||
>
|
||||
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||
<LoadingVideoOverlay show={isVideoLoading} />
|
||||
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
|
||||
<NoAutoplayPermissionsOverlay
|
||||
show={hasNoAutoPlayPermissions}
|
||||
onPlayClick={() => {
|
||||
videoElm.current?.play();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<VirtualKeyboard />
|
||||
|
|
|
@ -6,7 +6,7 @@ import LogoBlue from "@/assets/logo-blue.svg";
|
|||
import LogoWhite from "@/assets/logo-white.svg";
|
||||
|
||||
interface ContextType {
|
||||
connectWebRTC: () => Promise<void>;
|
||||
setupPeerConnection: () => Promise<void>;
|
||||
}
|
||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||
|
||||
|
@ -16,7 +16,7 @@ export default function OtherSessionRoute() {
|
|||
|
||||
// Function to handle closing the modal
|
||||
const handleClose = () => {
|
||||
outletContext?.connectWebRTC().then(() => navigate(".."));
|
||||
outletContext?.setupPeerConnection().then(() => navigate(".."));
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -45,6 +45,10 @@ import Modal from "../components/Modal";
|
|||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||
import { FeatureFlagProvider } from "../providers/FeatureFlagProvider";
|
||||
import notifications from "../notifications";
|
||||
import {
|
||||
ConnectionErrorOverlay,
|
||||
LoadingConnectionOverlay,
|
||||
} from "../components/VideoOverlay";
|
||||
|
||||
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
|
@ -126,8 +130,6 @@ export default function KvmIdRoute() {
|
|||
|
||||
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
|
||||
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
|
||||
const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
|
||||
const setPeerConnection = useRTCStore(state => state.setPeerConnection);
|
||||
const setDiskChannel = useRTCStore(state => state.setDiskChannel);
|
||||
|
@ -135,77 +137,55 @@ export default function KvmIdRoute() {
|
|||
const setTransceiver = useRTCStore(state => state.setTransceiver);
|
||||
const location = useLocation();
|
||||
|
||||
const [connectionAttempts, setConnectionAttempts] = useState(0);
|
||||
|
||||
const [startedConnectingAt, setStartedConnectingAt] = useState<Date | null>(null);
|
||||
const [connectedAt, setConnectedAt] = useState<Date | null>(null);
|
||||
|
||||
const [connectionFailed, setConnectionFailed] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { otaState, setOtaState, setModalView } = useUpdateStore();
|
||||
|
||||
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
|
||||
const closePeerConnection = useCallback(
|
||||
function closePeerConnection() {
|
||||
console.log("Closing peer connection");
|
||||
|
||||
setConnectionFailed(true);
|
||||
connectionFailedRef.current = true;
|
||||
|
||||
peerConnection?.close();
|
||||
// "closed" is a valid RTCPeerConnection state according to the WebRTC spec
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState#closed
|
||||
// However, the onconnectionstatechange event doesn't fire when close() is called manually
|
||||
// So we need to explicitly update our state to maintain consistency
|
||||
// I don't know why this is happening, but this is the best way I can think of to handle it
|
||||
setPeerConnectionState("closed");
|
||||
signalingAttempts.current = 0;
|
||||
},
|
||||
[peerConnection, setPeerConnectionState],
|
||||
[peerConnection],
|
||||
);
|
||||
|
||||
// We need to track connectionFailed in a ref to avoid stale closure issues
|
||||
// This is necessary because syncRemoteSessionDescription is a callback that captures
|
||||
// the connectionFailed value at creation time, but we need the latest value
|
||||
// when the function is actually called. Without this ref, the function would use
|
||||
// a stale value of connectionFailed in some conditions.
|
||||
//
|
||||
// We still need the state variable for UI rendering, so we sync the ref with the state.
|
||||
// This pattern is a workaround for what useEvent hook would solve more elegantly
|
||||
// (which would give us a callback that always has access to latest state without re-creation).
|
||||
const connectionFailedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const connectionAttemptsThreshold = 30;
|
||||
if (connectionAttempts > connectionAttemptsThreshold) {
|
||||
console.log(`Connection failed after ${connectionAttempts} attempts.`);
|
||||
setConnectionFailed(true);
|
||||
closePeerConnection();
|
||||
}
|
||||
}, [connectionAttempts, closePeerConnection]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if already connected
|
||||
if (connectedAt) return;
|
||||
|
||||
// Skip if connection is declared as failed
|
||||
if (connectionFailed) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
console.log("Checking connection status");
|
||||
|
||||
// Skip if connection hasn't started
|
||||
if (!startedConnectingAt) return;
|
||||
|
||||
const elapsedTime = Math.floor(
|
||||
new Date().getTime() - startedConnectingAt.getTime(),
|
||||
);
|
||||
|
||||
// Fail connection if it's been over X seconds since we started connecting
|
||||
if (elapsedTime > 60 * 1000) {
|
||||
console.error(`Connection failed after ${elapsedTime} ms.`);
|
||||
setConnectionFailed(true);
|
||||
closePeerConnection();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [closePeerConnection, connectedAt, connectionFailed, startedConnectingAt]);
|
||||
|
||||
const sdp = useCallback(
|
||||
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
|
||||
if (!pc) return;
|
||||
if (event.candidate !== null) return;
|
||||
connectionFailedRef.current = connectionFailed;
|
||||
}, [connectionFailed]);
|
||||
|
||||
const signalingAttempts = useRef(0);
|
||||
const syncRemoteSessionDescription = useCallback(
|
||||
async function syncRemoteSessionDescription(pc: RTCPeerConnection) {
|
||||
try {
|
||||
if (!pc) return;
|
||||
|
||||
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||
|
||||
const sessionUrl = isOnDevice
|
||||
? `${DEVICE_API}/webrtc/session`
|
||||
: `${CLOUD_API}/webrtc/session`;
|
||||
|
||||
console.log("Trying to get remote session description");
|
||||
setLoadingMessage(
|
||||
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
|
||||
);
|
||||
const res = await api.POST(sessionUrl, {
|
||||
sd,
|
||||
// When on device, we don't need to specify the device id, as it's already known
|
||||
|
@ -213,66 +193,109 @@ export default function KvmIdRoute() {
|
|||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
if (isOnDevice) {
|
||||
if (res.status === 401) {
|
||||
return navigate("/login-local");
|
||||
}
|
||||
if (res.status === 401) return navigate(isOnDevice ? "/login-local" : "/login");
|
||||
if (!res.ok) {
|
||||
console.error("Error getting SDP", { status: res.status, json });
|
||||
throw new Error("Error getting SDP");
|
||||
}
|
||||
|
||||
if (isInCloud) {
|
||||
// The cloud API returns a 401 if the user is not logged in
|
||||
// Most likely the session has expired
|
||||
if (res.status === 401) return navigate("/login");
|
||||
console.log("Successfully got Remote Session Description. Setting.");
|
||||
setLoadingMessage("Setting remote session description...");
|
||||
|
||||
// If can be a few things
|
||||
// - In cloud mode, the cloud api would return a 404, if the device hasn't contacted the cloud yet
|
||||
// - In device mode, the device api would timeout, the fetch would throw an error, therefore the catch block would be hit
|
||||
// Regardless, we should close the peer connection and let the useInterval handle reconnecting
|
||||
if (!res.ok) {
|
||||
closePeerConnection();
|
||||
console.error(`Error setting SDP - Status: ${res.status}}`, json);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const decodedSd = atob(json.sd);
|
||||
const parsedSd = JSON.parse(decodedSd);
|
||||
pc.setRemoteDescription(new RTCSessionDescription(parsedSd));
|
||||
|
||||
pc.setRemoteDescription(
|
||||
new RTCSessionDescription(JSON.parse(atob(json.sd))),
|
||||
).catch(e => console.log(`Error setting remote description: ${e}`));
|
||||
await new Promise((resolve, reject) => {
|
||||
console.log("Waiting for remote description to be set");
|
||||
const maxAttempts = 10;
|
||||
const interval = 1000;
|
||||
let attempts = 0;
|
||||
|
||||
const checkInterval = setInterval(() => {
|
||||
attempts++;
|
||||
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
|
||||
if (pc.sctp?.state === "connected") {
|
||||
console.log("Remote description set");
|
||||
clearInterval(checkInterval);
|
||||
resolve(true);
|
||||
} else if (attempts >= maxAttempts) {
|
||||
console.log(
|
||||
`Failed to get remote description after ${maxAttempts} attempts`,
|
||||
);
|
||||
closePeerConnection();
|
||||
clearInterval(checkInterval);
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to get remote description after ${maxAttempts} attempts`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log("Waiting for remote description to be set");
|
||||
}
|
||||
}, interval);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error setting SDP: ${error}`);
|
||||
closePeerConnection();
|
||||
console.error("Error getting SDP", { error });
|
||||
console.log("Connection failed", connectionFailedRef.current);
|
||||
if (connectionFailedRef.current) return;
|
||||
if (signalingAttempts.current < 5) {
|
||||
signalingAttempts.current++;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
console.log("Attempting to get SDP again", signalingAttempts.current);
|
||||
syncRemoteSessionDescription(pc);
|
||||
} else {
|
||||
closePeerConnection();
|
||||
}
|
||||
}
|
||||
},
|
||||
[closePeerConnection, navigate, params.id],
|
||||
);
|
||||
|
||||
const connectWebRTC = useCallback(async () => {
|
||||
console.log("Attempting to connect WebRTC");
|
||||
const setupPeerConnection = useCallback(async () => {
|
||||
console.log("Setting up peer connection");
|
||||
setConnectionFailed(false);
|
||||
setLoadingMessage("Connecting to device...");
|
||||
|
||||
// Track connection status to detect failures and show error overlay
|
||||
setConnectionAttempts(x => x + 1);
|
||||
setStartedConnectingAt(new Date());
|
||||
setConnectedAt(null);
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
// We only use STUN or TURN servers if we're in the cloud
|
||||
...(isInCloud && iceConfig?.iceServers
|
||||
? { iceServers: [iceConfig?.iceServers] }
|
||||
: {}),
|
||||
});
|
||||
let pc: RTCPeerConnection;
|
||||
try {
|
||||
console.log("Creating peer connection");
|
||||
setLoadingMessage("Creating peer connection...");
|
||||
pc = new RTCPeerConnection({
|
||||
// We only use STUN or TURN servers if we're in the cloud
|
||||
...(isInCloud && iceConfig?.iceServers
|
||||
? { iceServers: [iceConfig?.iceServers] }
|
||||
: {}),
|
||||
});
|
||||
console.log("Peer connection created", pc);
|
||||
setLoadingMessage("Peer connection created");
|
||||
} catch (e) {
|
||||
console.error(`Error creating peer connection: ${e}`);
|
||||
setTimeout(() => {
|
||||
closePeerConnection();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up event listeners and data channels
|
||||
pc.onconnectionstatechange = () => {
|
||||
// If the connection state is connected, we reset the connection attempts.
|
||||
if (pc.connectionState === "connected") {
|
||||
setConnectionAttempts(0);
|
||||
setConnectedAt(new Date());
|
||||
}
|
||||
setPeerConnectionState(pc.connectionState);
|
||||
console.log("Connection state changed", pc.connectionState);
|
||||
};
|
||||
|
||||
pc.onicecandidate = event => sdp(event, pc);
|
||||
pc.onicegatheringstatechange = event => {
|
||||
const pc = event.currentTarget as RTCPeerConnection;
|
||||
console.log("ICE Gathering State Changed", pc.iceGatheringState);
|
||||
if (pc.iceGatheringState === "complete") {
|
||||
console.log("ICE Gathering completed");
|
||||
setLoadingMessage("ICE Gathering completed");
|
||||
|
||||
// We can now start the https/ws connection to get the remote session description from the KVM device
|
||||
syncRemoteSessionDescription(pc);
|
||||
} else if (pc.iceGatheringState === "gathering") {
|
||||
console.log("ICE Gathering Started");
|
||||
setLoadingMessage("Gathering ICE candidates...");
|
||||
}
|
||||
};
|
||||
|
||||
pc.ontrack = function (event) {
|
||||
setMediaMediaStream(event.streams[0]);
|
||||
|
@ -290,60 +313,32 @@ export default function KvmIdRoute() {
|
|||
setDiskChannel(diskDataChannel);
|
||||
};
|
||||
|
||||
setPeerConnection(pc);
|
||||
|
||||
try {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
setPeerConnection(pc);
|
||||
} catch (e) {
|
||||
console.error(`Error creating offer: ${e}`);
|
||||
console.error(`Error creating offer: ${e}`, new Date().toISOString());
|
||||
closePeerConnection();
|
||||
}
|
||||
}, [
|
||||
closePeerConnection,
|
||||
iceConfig?.iceServers,
|
||||
sdp,
|
||||
setDiskChannel,
|
||||
setMediaMediaStream,
|
||||
setPeerConnection,
|
||||
setPeerConnectionState,
|
||||
setRpcDataChannel,
|
||||
setTransceiver,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Attempting to connect WebRTC");
|
||||
|
||||
// If we're in an other session, we don't need to connect
|
||||
if (location.pathname.includes("other-session")) return;
|
||||
|
||||
// If we're already connected or connecting, we don't need to connect
|
||||
if (
|
||||
["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In certain cases, we want to never connect again. This happens when we've tried for a long time and failed
|
||||
if (connectionFailed) {
|
||||
console.log("Connection failed. We won't attempt to connect again.");
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
connectWebRTC();
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [
|
||||
connectWebRTC,
|
||||
connectionFailed,
|
||||
location.pathname,
|
||||
peerConnection?.connectionState,
|
||||
syncRemoteSessionDescription,
|
||||
]);
|
||||
|
||||
// On boot, if the connection state is undefined, we connect to the WebRTC
|
||||
useEffect(() => {
|
||||
if (peerConnection?.connectionState === undefined) {
|
||||
connectWebRTC();
|
||||
setupPeerConnection();
|
||||
}
|
||||
}, [connectWebRTC, peerConnection?.connectionState]);
|
||||
}, [setupPeerConnection, peerConnection?.connectionState]);
|
||||
|
||||
// Cleanup effect
|
||||
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
|
||||
|
@ -597,7 +592,27 @@ export default function KvmIdRoute() {
|
|||
kvmName={deviceName || "JetKVM Device"}
|
||||
/>
|
||||
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
<div className="pointer-events-none fixed inset-0 isolate z-50 flex h-full w-full items-center justify-center">
|
||||
<div className="my-2 h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||
<LoadingConnectionOverlay
|
||||
show={
|
||||
!connectionFailed &&
|
||||
(["connecting", "new"].includes(
|
||||
peerConnection?.connectionState || "",
|
||||
) ||
|
||||
peerConnection === null) &&
|
||||
!location.pathname.includes("other-session")
|
||||
}
|
||||
text={loadingMessage}
|
||||
/>
|
||||
<ConnectionErrorOverlay
|
||||
show={connectionFailed && !location.pathname.includes("other-session")}
|
||||
setupPeerConnection={setupPeerConnection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WebRTCVideo />
|
||||
<SidebarContainer sidebarView={sidebarView} />
|
||||
</div>
|
||||
|
@ -614,7 +629,7 @@ export default function KvmIdRoute() {
|
|||
>
|
||||
<Modal open={outlet !== null} onClose={onModalClose}>
|
||||
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
|
||||
<Outlet context={{ connectWebRTC }} />
|
||||
<Outlet context={{ setupPeerConnection }} />
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
|
|
2
web.go
2
web.go
|
@ -16,6 +16,7 @@ import (
|
|||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
//nolint:typecheck
|
||||
//go:embed all:static
|
||||
var staticFiles embed.FS
|
||||
|
||||
|
@ -419,7 +420,6 @@ func handleSetup(c *gin.Context) {
|
|||
|
||||
// Set the cookie
|
||||
c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true)
|
||||
|
||||
} else {
|
||||
// For noPassword mode, ensure the password field is empty
|
||||
config.HashedPassword = ""
|
||||
|
|
Loading…
Reference in New Issue