Files
kvm/ui/src/layout/index.pc.tsx
2026-05-16 16:40:04 +08:00

881 lines
32 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Outlet,
redirect,
useLoaderData,
useLocation,
useOutlet,
} from "react-router-dom";
import { useInterval } from "usehooks-ts";
import { FocusTrap } from "focus-trap-react";
import useWebSocket from "react-use-websocket";
import { useReactAt } from 'i18n-auto-extractor/react';
import semver from "semver";
import {
HidState,
KeyboardLedState,
NetworkState,
UpdateState,
useDeviceStore,
useHidStore,
useMountMediaStore,
useNetworkStateStore,
User,
useRTCStore,
useUiStore,
useUpdateStore,
useVideoStore,
VideoState,
useSettingsStore,
useVpnStore } from "@/hooks/stores";
import { JsonRpcRequest, useJsonRpc, resetHttpSessionId } from "@/hooks/useJsonRpc";
import { DEVICE_API } from "@/ui.config";
import api from "@/api";
import Modal from "@components/Modal";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import {
ConnectionFailedOverlay,
LoadingConnectionOverlay,
PeerConnectionDisconnectedOverlay,
} from "@components/VideoOverlay";
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
import notifications from "@/notifications";
import BarTop from "@/layout/core/bar_top/index";
import BottomBar from "@/layout/core/bar_bottom/index";
import { DeviceStatus } from "@routes/login_page/index";
import { LocalVersionInfo } from "@/layout/components_setting/version/VersionContent";
import Desktop from "@/layout/core/desktop/index";
import { dark_bg_style_fun } from "@/layout/theme_color";
import SidebarContainer from "@/layout/core/bar_side";
import { useTheme } from "@/layout/contexts/ThemeContext";
import enJSON from '../locales/en.json';
import zhJSON from '../locales/zh.json';
interface LocalLoaderResp {
authMode: "password" | "noPassword" | null;
}
interface CloudLoaderResp {
deviceName: string;
user: User | null;
iceConfig: {
iceServers: { credential?: string; urls: string | string[]; username?: string };
} | null;
}
export type AuthMode = "password" | "noPassword" | null;
export interface LocalDevice {
authMode: AuthMode;
deviceId: string;
}
interface TailScaleResponse {
state: string;
loginUrl: string;
ip: string;
xEdge: boolean;
}
interface ZeroTierResponse {
state: string;
networkID: string;
ip: string;
}
const deviceLoader = async () => {
const res = await api
.GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/mode");
const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice;
return { authMode: device.authMode };
}
throw new Error("Error fetching device");
};
export default function PCHome() {
const { $at } = useReactAt();
const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp;
// Depending on the mode, we set the appropriate variables
const iceConfig = "iceConfig" in loaderResp ? loaderResp.iceConfig : null;
const sidebarView = useUiStore(state => state.sidebarView);
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
const peerConnection = useRTCStore(state => state.peerConnection);
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
const setPeerConnection = useRTCStore(state => state.setPeerConnection);
const setDiskChannel = useRTCStore(state => state.setDiskChannel);
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
const setTransceiver = useRTCStore(state => state.setTransceiver);
const setAudioTransceiver = useRTCStore(state => state.setAudioTransceiver);
const location = useLocation();
const isLegacySignalingEnabled = useRef(false);
const [connectionFailed, setConnectionFailed] = useState(false);
const forceHttp = useSettingsStore(state => state.forceHttp);
const { setOtaState } = useUpdateStore();
const [isFullscreen, setIsFullscreen] = useState(0);
const handleRequestFullscreen = async () => {
setIsFullscreen(prevCount => prevCount + 1);
};
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
const cleanupAndStopReconnecting = useCallback(
function cleanupAndStopReconnecting() {
console.log("Closing peer connection");
setConnectionFailed(true);
if (peerConnection) {
setPeerConnectionState(peerConnection.connectionState);
}
connectionFailedRef.current = true;
peerConnection?.close();
signalingAttempts.current = 0;
},
[peerConnection, setPeerConnectionState],
);
// 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(() => {
connectionFailedRef.current = connectionFailed;
}, [connectionFailed]);
const signalingAttempts = useRef(0);
const setRemoteSessionDescription = useCallback(
async function setRemoteSessionDescription(
pc: RTCPeerConnection,
remoteDescription: RTCSessionDescriptionInit,
) {
if (useSettingsStore.getState().forceHttp) {
console.log("[setRemoteSessionDescription] Skipping due to HTTP fallback/force mode");
return;
}
setLoadingMessage("Setting remote description");
try {
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
console.log("[setRemoteSessionDescription] Remote description set successfully");
setLoadingMessage("Establishing secure connection...");
} catch (error) {
console.error(
"[setRemoteSessionDescription] Failed to set remote description:",
error,
);
cleanupAndStopReconnecting();
return;
}
// Replace the interval-based check with a more reliable approach
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("[setRemoteSessionDescription] Remote description set");
clearInterval(checkInterval);
setLoadingMessage("Connection established");
} else if (attempts >= 10) {
console.log(
"[setRemoteSessionDescription] Failed to establish connection after 10 attempts",
{
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
},
);
cleanupAndStopReconnecting();
clearInterval(checkInterval);
} else {
console.log("[setRemoteSessionDescription] Waiting for connection, state:", {
connectionState: pc.connectionState,
iceConnectionState: pc.iceConnectionState,
});
}
}, 1000);
},
[cleanupAndStopReconnecting],
);
const ignoreOffer = useRef(false);
const isSettingRemoteAnswerPending = useRef(false);
const makingOffer = useRef(false);
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const { sendMessage, getWebSocket } = useWebSocket(
//`${wsProtocol}//${window.location.host}/webrtc/signaling/client?id=${params.id}`,
`${wsProtocol}//${window.location.host}/webrtc/signaling/client`,
{
heartbeat: true,
retryOnError: true,
reconnectAttempts: 15,
reconnectInterval: 1000,
onReconnectStop: () => {
console.log("Reconnect stopped");
cleanupAndStopReconnecting();
},
shouldReconnect(event) {
console.log("[Websocket] shouldReconnect", event);
// TODO: Why true?
return true;
},
onClose(event) {
console.log("[Websocket] onClose", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onError(event) {
console.log("[Websocket] onError", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onOpen() {
console.log("[Websocket] onOpen");
},
onMessage: message => {
if (message.data === "pong") return;
/*
Currently the signaling process is as follows:
After open, the other side will send a `device-metadata` message with the device version
If the device version is not set, we can assume the device is using the legacy signaling
Otherwise, we can assume the device is using the new signaling
If the device is using the legacy signaling, we close the websocket connection
and use the legacy HTTPSignaling function to get the remote session description
If the device is using the new signaling, we don't need to do anything special, but continue to use the websocket connection
to chat with the other peer about the connection
*/
const parsedMessage = JSON.parse(message.data);
if (parsedMessage.type === "device-metadata") {
const { deviceVersion } = parsedMessage.data;
console.log("[Websocket] Received device-metadata message");
console.log("[Websocket] Device version", deviceVersion);
// If the device version is not set, we can assume the device is using the legacy signaling
if (!deviceVersion) {
console.log("[Websocket] Device is using legacy signaling");
// Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling
// which does everything over HTTP(at least from the perspective of the client)
isLegacySignalingEnabled.current = true;
getWebSocket()?.close();
} else {
console.log("[Websocket] Device is using new signaling");
isLegacySignalingEnabled.current = false;
}
setupPeerConnection();
}
if (!peerConnection) return;
if (parsedMessage.type === "answer") {
console.log("[Websocket] Received answer");
const readyForOffer =
// If we're making an offer, we don't want to accept an answer
!makingOffer &&
// If the peer connection is stable or we're SettingsModal the remote answer pending, we're ready for an offer
(peerConnection?.signalingState === "stable" ||
isSettingRemoteAnswerPending.current);
// If we're not ready for an offer, we don't want to accept an offer
ignoreOffer.current = parsedMessage.type === "offer" && !readyForOffer;
if (ignoreOffer.current) return;
// Set so we don't accept an answer while we're SettingsModal the remote description
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
console.log(
"[Websocket] Setting remote answer pending",
isSettingRemoteAnswerPending.current,
);
const sd = atob(parsedMessage.data);
const remoteSessionDescription = JSON.parse(sd);
setRemoteSessionDescription(
peerConnection,
new RTCSessionDescription(remoteSessionDescription),
);
// Reset the remote answer pending flag
isSettingRemoteAnswerPending.current = false;
} else if (parsedMessage.type === "new-ice-candidate") {
console.log("[Websocket] Received new-ice-candidate");
const candidate = parsedMessage.data;
peerConnection.addIceCandidate(candidate);
}
},
},
// Don't even retry once we declare failure
!connectionFailed && isLegacySignalingEnabled.current === false,
);
const sendWebRTCSignal = useCallback(
(type: string, data: unknown) => {
// Second argument tells the library not to queue the message, and send it once the connection is established again.
// We have event handlers that handle the connection set up, so we don't need to queue the message.
sendMessage(JSON.stringify({ type, data }), false);
},
[sendMessage],
);
const setupPeerConnection = useCallback(async () => {
if (useSettingsStore.getState().forceHttp) {
console.log("[setupPeerConnection] Skipping due to HTTP fallback/force mode");
return;
}
console.log("[setupPeerConnection] Setting up peer connection");
setConnectionFailed(false);
setLoadingMessage("Connecting to device...");
let pc: RTCPeerConnection;
try {
console.log("[setupPeerConnection] Creating peer connection");
setLoadingMessage("Creating peer connection...");
let fetchedIceServers: RTCIceServer[] = [];
if (!iceConfig?.iceServers) {
try {
const res = await api.GET("/api/ice-servers");
const data = await res.json();
fetchedIceServers = data.iceServers ?? [];
} catch (e) {
console.error("failed to fetch ICE servers, fallback", e);
fetchedIceServers = [{ urls: ["stun:stun.l.google.com:19302"] }];
}
}
pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud
//...(isInCloud && iceConfig?.iceServers
// ? { iceServers: [iceConfig?.iceServers] }
// : {}),
...(iceConfig?.iceServers
? { iceServers: [iceConfig?.iceServers] }
: { iceServers: fetchedIceServers }),
});
setPeerConnectionState(pc.connectionState);
console.log("[setupPeerConnection] Peer connection created", pc);
setLoadingMessage("Setting up connection to device...");
} catch (e) {
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
setTimeout(() => {
cleanupAndStopReconnecting();
}, 1000);
return;
}
// Set up event listeners and data channels
pc.onconnectionstatechange = () => {
console.log("[setupPeerConnection] Connection state changed", pc.connectionState);
setPeerConnectionState(pc.connectionState);
};
pc.onnegotiationneeded = async () => {
try {
console.log("[setupPeerConnection] Creating offer");
makingOffer.current = true;
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const sd = btoa(JSON.stringify(pc.localDescription));
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
if (isNewSignalingEnabled) {
sendWebRTCSignal("offer", { sd: sd });
} else {
console.log("Legacy signanling. Waiting for ICE Gathering to complete...");
}
} catch (e) {
console.error(
`[setupPeerConnection] Error creating offer: ${e}`,
new Date().toISOString(),
);
cleanupAndStopReconnecting();
} finally {
makingOffer.current = false;
}
};
pc.onicecandidate = async ({ candidate }) => {
if (!candidate) return;
if (candidate.candidate === "") return;
sendWebRTCSignal("new-ice-candidate", candidate);
};
pc.onicegatheringstatechange = event => {
const pc = event.currentTarget as RTCPeerConnection;
if (pc.iceGatheringState === "complete") {
console.log("ICE Gathering completed");
setLoadingMessage("ICE Gathering completed");
} else if (pc.iceGatheringState === "gathering") {
console.log("ICE Gathering Started");
setLoadingMessage("Gathering ICE candidates...");
}
};
pc.ontrack = function (event) {
setMediaMediaStream(event.streams[0]);
};
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
pc.addTransceiver("audio", { direction: "recvonly" });
const rpcDataChannel = pc.createDataChannel("rpc");
rpcDataChannel.onopen = () => {
setRpcDataChannel(rpcDataChannel);
};
const diskDataChannel = pc.createDataChannel("disk");
diskDataChannel.onopen = () => {
setDiskChannel(diskDataChannel);
};
setPeerConnection(pc);
}, [
forceHttp,
cleanupAndStopReconnecting,
iceConfig?.iceServers,
sendWebRTCSignal,
setDiskChannel,
setMediaMediaStream,
setPeerConnection,
setPeerConnectionState,
setRpcDataChannel,
setTransceiver,
setAudioTransceiver,
]);
useEffect(() => {
if (peerConnectionState === "failed") {
console.log("Connection failed, closing peer connection");
cleanupAndStopReconnecting();
}
}, [peerConnectionState, cleanupAndStopReconnecting]);
// Cleanup effect
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
const clearCandidatePairStats = useRTCStore(state => state.clearCandidatePairStats);
const setSidebarView = useUiStore(state => state.setSidebarView);
useEffect(() => {
return () => {
peerConnection?.close();
};
}, [peerConnection]);
// For some reason, we have to have this unmount separate from the cleanup effect above
useEffect(() => {
return () => {
clearInboundRtpStats();
clearCandidatePairStats();
setSidebarView(null);
setPeerConnection(null);
};
}, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]);
// TURN server usage detection
useEffect(() => {
if (peerConnectionState !== "connected") return;
const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState();
const lastLocalStat = Array.from(localCandidateStats).pop();
if (!lastLocalStat?.length) return;
const localCandidateIsUsingTurn = lastLocalStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
const lastRemoteStat = Array.from(remoteCandidateStats).pop();
if (!lastRemoteStat?.length) return;
const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
}, [peerConnectionState, setIsTurnServerInUse]);
// Vpn State Update
const tailScaleConnectionState = useVpnStore(state => state.tailScaleConnectionState);
const setTailScaleConnectionState = useVpnStore(state => state.setTailScaleConnectionState);
const setTailScaleXEdge = useVpnStore(state => state.setTailScaleXEdge);
const setTailScaleLoginUrl = useVpnStore(state => state.setTailScaleLoginUrl);
const setTailScaleIP = useVpnStore(state => state.setTailScaleIP);
const zeroTierConnectionState = useVpnStore(state => state.zeroTierConnectionState);
const setZeroTierConnectionState = useVpnStore(state => state.setZeroTierConnectionState);
const setZeroTierNetworkID = useVpnStore(state => state.setZeroTierNetworkID);
const setZeroTierIP = useVpnStore(state => state.setZeroTierIP);
const updateVpnStates = () => {
// TailScaleState
if (tailScaleConnectionState !== "connecting" && tailScaleConnectionState !== "closed") {
send("getTailScaleSettings", {}, resp => {
if ("error" in resp) return;
const result = resp.result as TailScaleResponse;
const validState = ["closed", "connecting", "connected", "disconnected", "logined"].includes(result.state)
? result.state as "closed" | "connecting" | "connected" | "disconnected" | "logined"
: "closed";
if(tailScaleConnectionState !== "disconnected" ) {
setTailScaleXEdge(result.xEdge);
}
setTailScaleConnectionState(validState);
setTailScaleLoginUrl(result.loginUrl);
setTailScaleIP(result.ip);
});
}
// ZeroTier
if (zeroTierConnectionState !== "connecting" && zeroTierConnectionState !== "closed") {
send("getZeroTierSettings", {}, resp => {
if ("error" in resp) return;
const result = resp.result as ZeroTierResponse;
const validState = ["closed", "connecting", "connected", "disconnected", "logined"].includes(result.state)
? result.state as "closed" | "connecting" | "connected" | "disconnected" | "logined"
: "closed";
setZeroTierConnectionState(validState);
setZeroTierNetworkID(result.networkID);
setZeroTierIP(result.ip);
});
}
}
useInterval(updateVpnStates, 5000);
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
const setUsbState = useHidStore(state => state.setUsbState);
const setHdmiState = useVideoStore(state => state.setHdmiState);
const keyboardLedState = useHidStore(state => state.keyboardLedState);
const setKeyboardLedState = useHidStore(state => state.setKeyboardLedState);
const setKeyboardLedStateSyncAvailable = useHidStore(state => state.setKeyboardLedStateSyncAvailable);
const [hasUpdated, setHasUpdated] = useState(false);
const [sessionInvalidated, setSessionInvalidated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
function onJsonRpcRequest(resp: JsonRpcRequest) {
if (resp.method === "otherSessionConnected") {
navigateTo("/other-session");
}
if (resp.method === "sessionInvalidated") {
resetHttpSessionId();
setSessionInvalidated(true);
return;
}
if (resp.method === "usbState") {
setUsbState(resp.params as unknown as HidState["usbState"]);
}
if (resp.method === "videoInputState") {
setHdmiState(resp.params as Parameters<VideoState["setHdmiState"]>[0]);
}
if (resp.method === "networkState") {
console.log("Setting network state", resp.params);
setNetworkState(resp.params as NetworkState);
}
if (resp.method === "keyboardLedState") {
const ledState = resp.params as KeyboardLedState;
console.log("Setting keyboard led state", ledState);
setKeyboardLedState(ledState);
setKeyboardLedStateSyncAvailable(true);
}
if (resp.method === "otaState") {
const otaState = resp.params as UpdateState["otaState"];
setOtaState(otaState);
if (otaState.updating === true) {
setHasUpdated(true);
}
}
}
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const [send] = useJsonRpc(onJsonRpcRequest);
const updateUsbState = useCallback(() => {
send("getUSBState", {}, resp => {
if ("error" in resp) return;
setUsbState(resp.result as HidState["usbState"]);
});
}, [send, setUsbState]);
const updateVideoState = useCallback(() => {
send("getVideoState", {}, resp => {
if ("error" in resp) return;
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
});
}, [send, setHdmiState]);
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
updateVideoState();
updateVpnStates();
}, [rpcDataChannel?.readyState, updateVideoState]);
useEffect(() => {
if (!forceHttp) return;
updateVideoState();
updateUsbState();
}, [forceHttp, updateUsbState, updateVideoState]);
useInterval(() => {
updateVideoState();
updateUsbState();
}, forceHttp ? 1000 : null);
// request keyboard led state from the device
useEffect(() => {
if (rpcDataChannel?.readyState !== "open") return;
if (keyboardLedState !== undefined) return;
send("getKeyboardLedState", {}, resp => {
if ("error" in resp) {
// -32601 means the method is not supported
if (resp.error.code === -32601) {
setKeyboardLedStateSyncAvailable(false);
console.error("Failed to get keyboard led state, disabling sync", resp.error);
} else {
console.error("Failed to get keyboard led state", resp.error);
}
return;
}
console.log("Keyboard led state", resp.result);
setKeyboardLedState(resp.result as KeyboardLedState);
setKeyboardLedStateSyncAvailable(true);
});
}, [rpcDataChannel?.readyState, send, setKeyboardLedState, setKeyboardLedStateSyncAvailable, keyboardLedState]);
const diskChannel = useRTCStore(state => state.diskChannel)!;
const file = useMountMediaStore(state => state.localFile)!;
useEffect(() => {
if (!diskChannel || !file) return;
diskChannel.onmessage = async e => {
console.log("Received", e.data);
const data = JSON.parse(e.data);
const blob = file.slice(data.start, data.end);
const buf = await blob.arrayBuffer();
const header = new ArrayBuffer(16);
const headerView = new DataView(header);
headerView.setBigUint64(0, BigInt(data.start), false); // start offset, big-endian
headerView.setBigUint64(8, BigInt(buf.byteLength), false); // length, big-endian
const fullData = new Uint8Array(header.byteLength + buf.byteLength);
fullData.set(new Uint8Array(header), 0);
fullData.set(new Uint8Array(buf), header.byteLength);
diskChannel.send(fullData);
};
}, [diskChannel, file]);
// System update
const disableKeyboardFocusTrap = useUiStore(state => state.disableVideoFocusTrap);
// const [kvmTerminal, setKvmTerminal] = useState<RTCDataChannel | null>(null);
// const [serialConsole, setSerialConsole] = useState<RTCDataChannel | null>(null);
const outlet = useOutlet();
const onModalClose = useCallback(() => {
if (location.pathname !== "/other-session") navigateTo("/");
}, [navigateTo, location.pathname]);
const appVersion = useDeviceStore(state => state.appVersion);
const systemVersion = useDeviceStore(state => state.systemVersion);
const setAppVersion = useDeviceStore(state => state.setAppVersion);
const setSystemVersion = useDeviceStore(state => state.setSystemVersion);
const [lowSystemVersionPromptDismissed, setLowSystemVersionPromptDismissed] = useState(false);
useEffect(() => {
if (appVersion && systemVersion) return;
send("getLocalUpdateStatus", {}, async resp => {
if ("error" in resp) {
notifications.error(`Failed to get device version: ${resp.error}`);
return
}
const result = resp.result as LocalVersionInfo;
setAppVersion(result.appVersion);
setSystemVersion(result.systemVersion);
});
}, [appVersion, send, setAppVersion, setSystemVersion, systemVersion]);
const isSystemVersionTooLow = useMemo(() => {
const baseCurrentVersion = semver.coerce(systemVersion ?? "")?.version;
const baseMinVersion = semver.coerce("0.1.4")?.version;
if (!baseCurrentVersion || !baseMinVersion) return false;
return semver.lt(baseCurrentVersion, baseMinVersion);
}, [systemVersion]);
const ConnectionStatusElement = useMemo(() => {
const hasConnectionFailed =
connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
const isPeerConnectionLoading =
["connecting", "new"].includes(peerConnectionState ?? "") ||
peerConnection === null;
const isDisconnected = peerConnectionState === "disconnected" && !forceHttp;
const isOtherSession = location.pathname.includes("other-session");
if (isOtherSession) return null;
if (peerConnectionState === "connected") return null;
if (isDisconnected) {
return <PeerConnectionDisconnectedOverlay show={true} />;
}
if (hasConnectionFailed)
return (
<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />
);
if (forceHttp) return null;
if (isPeerConnectionLoading) {
return <LoadingConnectionOverlay show={true} text={loadingMessage} />;
}
return null;
}, [
connectionFailed,
loadingMessage,
location.pathname,
peerConnection,
peerConnectionState,
setupPeerConnection,
]);
const { isDark } = useTheme();
const language = useSettingsStore(state => state.language);
const { setCurrentLang } = useReactAt();
// Initialize Language
useEffect(() => {
setCurrentLang(language, language === 'en' ? enJSON : zhJSON);
}, [language, setCurrentLang]);
return (
<FeatureFlagProvider appVersion={appVersion}>
<div className="relative h-full overflow-hidden">
{!lowSystemVersionPromptDismissed && isSystemVersionTooLow && (
<div className="absolute inset-0 z-[19999] flex items-center justify-center bg-black/60">
<div className="rounded-md bg-white px-6 py-4 text-center shadow-lg dark:bg-[#1a1a1a]">
<p className="mb-2 text-base font-semibold text-slate-900 dark:text-white">
{$at("Your system version is outdated (< 0.1.4)")}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{$at("Please upgrade to the latest firmware as soon as possible.")}
</p>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{$at("Current system version")}: {systemVersion}
</p>
<button
className="mt-4 rounded bg-[rgba(22,152,217,1)] dark:bg-[rgba(45,106,229,1)] px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={() => setLowSystemVersionPromptDismissed(true)}
>
{$at("I understand")}
</button>
</div>
</div>
)}
{sessionInvalidated && (
<div className="absolute inset-0 z-[20000] flex items-center justify-center bg-black/60">
<div className="rounded-md bg-white px-6 py-4 text-center shadow-lg dark:bg-slate-800">
<p className="mb-2 text-base font-semibold text-slate-900 dark:text-white">
{$at("The current page has been launched")}
</p>
<p className="text-sm text-slate-600 dark:text-slate-300">
{$at("Please close this page or continue using the device in a new page.")}
</p>
</div>
</div>
)}
<FocusTrap
paused={disableKeyboardFocusTrap}
focusTrapOptions={{
allowOutsideClick: true,
escapeDeactivates: false,
fallbackFocus: "#videoFocusTrap",
}}
>
<div className="absolute top-0">
<button className="absolute top-0 bg-fuchsia-300" tabIndex={-1} id="videoFocusTrap" />
</div>
</FocusTrap>
<div className={`grid h-full grid-rows-(--grid-headerBody) select-none ${dark_bg_style_fun(isDark)}`}>
<BarTop requestFullscreen={handleRequestFullscreen} />
<div className="relative flex h-full w-full overflow-hidden">
<Desktop isFullscreen={isFullscreen} />
<div
style={{ animationDuration: "500ms" }}
className="animate-slideUpFade pointer-events-none absolute inset-0 z-20 flex items-center justify-center"
>
<div className="relative h-full w-full">
{!!ConnectionStatusElement && ConnectionStatusElement}
{/*<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />*/}
</div>
</div>
<SidebarContainer sidebarView={sidebarView} />
</div>
<BottomBar />
</div>
</div>
<div
className="z-50"
onClick={e => e.stopPropagation()}
onMouseUp={e => e.stopPropagation()}
onMouseDown={e => e.stopPropagation()}
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => {
e.stopPropagation();
if (e.key === "Escape") navigateTo("/");
}}
>
<Modal open={outlet !== null} onClose={onModalClose}>
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
<Outlet context={{ setupPeerConnection }} />
</Modal>
</div>
</FeatureFlagProvider>
);
}