diff --git a/config.json b/config.json index 2c40053..b040858 100644 --- a/config.json +++ b/config.json @@ -13,5 +13,6 @@ "dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS", "hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID", "googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID", - "googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID" + "googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID", + "wasmPath": "$NETBIRD_WASM_PATH" } \ No newline at end of file diff --git a/docker/init_react_envs.sh b/docker/init_react_envs.sh index 79a1bdd..1350cac 100644 --- a/docker/init_react_envs.sh +++ b/docker/init_react_envs.sh @@ -61,11 +61,12 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID} export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID} export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken} export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false} +export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH:-https://pkgs.netbird.io/wasm/client} echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}" # replace ENVs in the config -ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS" +ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH" OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js" envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS" diff --git a/src/app/(dashboard)/network/page.tsx b/src/app/(dashboard)/network/page.tsx index 5dd60d8..316f566 100644 --- a/src/app/(dashboard)/network/page.tsx +++ b/src/app/(dashboard)/network/page.tsx @@ -1,7 +1,15 @@ "use client"; import Breadcrumbs from "@components/Breadcrumbs"; +import Button from "@components/Button"; import Card from "@components/Card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@components/DropdownMenu"; import FullTooltip from "@components/FullTooltip"; import InlineLink from "@components/InlineLink"; import Separator from "@components/Separator"; @@ -12,12 +20,14 @@ import { cn } from "@utils/helpers"; import { ArrowUpRightIcon, HelpCircle, + MoreVertical, PencilLineIcon, ServerIcon, ShieldCheckIcon, ShieldXIcon, + Trash2, } from "lucide-react"; -import { useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import React, { useMemo, useState } from "react"; import { useSWRConfig } from "swr"; import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon"; @@ -25,8 +35,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; import { Network } from "@/interfaces/Network"; import PageContainer from "@/layouts/PageContainer"; import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare"; -import NetworkModal from "@/modules/networks/NetworkModal"; -import { NetworkProvider } from "@/modules/networks/NetworkProvider"; +import { + NetworkProvider, + useNetworksContext, +} from "@/modules/networks/NetworkProvider"; import { ResourcesSection } from "@/modules/networks/resources/ResourcesSection"; import { NetworkRoutingPeersSection } from "@/modules/networks/routing-peers/NetworkRoutingPeersSection"; @@ -77,35 +89,24 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
- - {permission.networks.update && ( - - )} - { - mutate(`/networks/${network.id}`); - }} - network={network} - /> +
+ +
+ + +
@@ -124,6 +125,56 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) { ); } +function NetworkActions() { + const { permission } = usePermissions(); + const { deleteNetwork, openEditNetworkModal, network } = useNetworksContext(); + const router = useRouter(); + + if (!network) return; + + return ( + + { + e.stopPropagation(); + e.preventDefault(); + }} + > + + + + openEditNetworkModal(network)} + disabled={!permission.networks.update} + > +
+ + Rename +
+
+ + + + + deleteNetwork(network).then(() => router.push("/networks")) + } + variant={"danger"} + disabled={!permission.networks.delete} + > +
+ + Delete +
+
+
+
+ ); +} + function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { const isHighlyAvailable = !!( network?.routing_peers_count && network?.routing_peers_count >= 2 @@ -154,7 +205,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { const policyCount = network.policies?.length ?? 0; return ( - + { let id = peer?.id ?? ""; - let ssh = peer?.ssh_enabled ? "1" : "0"; let expiration = peer?.login_expiration_enabled ? "1" : "0"; - return `${id}-${ssh}-${expiration}`; + return `${id}-${expiration}`; }, [peer]); if (isRestricted) { @@ -107,7 +103,7 @@ export default function PeerPage() { ); return peer && !isLoading ? ( - + ) : ( @@ -141,8 +137,7 @@ function PeerOverview() { const PeerGeneralInformation = () => { const router = useRouter(); const { mutate } = useSWRConfig(); - const { peer, user, peerGroups, openSSHDialog, update } = usePeer(); - const [ssh, setSsh] = useState(peer.ssh_enabled); + const { peer, user, peerGroups, update } = usePeer(); const [name, setName] = useState(peer.name); const [showEditNameModal, setShowEditNameModal] = useState(false); const [loginExpiration, setLoginExpiration] = useState( @@ -161,7 +156,6 @@ const PeerGeneralInformation = () => { * Detect if there are changes in the peer information, if there are changes, then enable the save button. */ const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([ - ssh, selectedGroups, loginExpiration, inactivityExpiration, @@ -174,7 +168,6 @@ const PeerGeneralInformation = () => { if (permission.peers.update) { const updateRequest = update({ name: newName ?? name, - ssh, loginExpiration, inactivityExpiration, }); @@ -190,7 +183,6 @@ const PeerGeneralInformation = () => { mutate("/peers/" + peer.id); mutate("/groups"); updateHasChangedRef([ - ssh, selectedGroups, loginExpiration, inactivityExpiration, @@ -314,41 +306,7 @@ const PeerGeneralInformation = () => { )} - - - - {`You don't have the required permissions to update this - setting.`} - - - } - interactive={false} - className={"w-full block"} - disabled={permission.peers.update} - > - - !set - ? setSsh(false) - : openSSHDialog().then((confirm) => setSsh(confirm)) - } - label={ - <> - - SSH Access - - } - helpText={ - "Enable the SSH server on this peer to access the machine via an secure shell." - } - /> - + {/* Remote Access Buttons */}
diff --git a/src/app/(remote-access)/peer/rdp/page.tsx b/src/app/(remote-access)/peer/rdp/page.tsx index f760fb5..0a40cfd 100644 --- a/src/app/(remote-access)/peer/rdp/page.tsx +++ b/src/app/(remote-access)/peer/rdp/page.tsx @@ -4,6 +4,7 @@ import { notify } from "@components/Notification"; import FullScreenLoading from "@components/ui/FullScreenLoading"; import { IconCircleX } from "@tabler/icons-react"; import useFetchApi from "@utils/api"; +import { cn } from "@utils/helpers"; import { Loader2Icon } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import type { Peer } from "@/interfaces/Peer"; @@ -19,7 +20,6 @@ import { NetBirdStatus, useNetBirdClient, } from "@/modules/remote-access/useNetBirdClient"; -import { cn } from "@utils/helpers"; export default function RDPPage() { const { peerId } = useRDPQueryParams(); @@ -55,7 +55,7 @@ function RDPSession({ peer }: Props) { useEffect(() => { document.title = `${peer.name} - ${peer.ip} - RDP`; - }, []); + }, [peer.ip, peer.name, connected, rdp]); const sendErrorNotification = (title: string, message: string) => { notify({ @@ -104,6 +104,7 @@ function RDPSession({ peer }: Props) { port: credentials.port, username: credentials.username, password: credentials.password, + domain: credentials.domain, width: window.innerWidth, height: window.innerHeight, }); diff --git a/src/app/(remote-access)/peer/ssh/page.tsx b/src/app/(remote-access)/peer/ssh/page.tsx index c1f73dc..23db5bc 100644 --- a/src/app/(remote-access)/peer/ssh/page.tsx +++ b/src/app/(remote-access)/peer/ssh/page.tsx @@ -2,6 +2,7 @@ import { PageNotFound } from "@components/ui/PageNotFound"; import useFetchApi, { ErrorResponse } from "@utils/api"; +import { isNativeSSHSupported } from "@utils/version"; import { CircleXIcon, InfoIcon, Loader2Icon } from "lucide-react"; import React, { useEffect, useRef } from "react"; import type { Peer } from "@/interfaces/Peer"; @@ -86,7 +87,8 @@ function SSHTerminal({ username, port, peer }: Props) { if (isSSHConnected || isSSHConnecting) return; connected.current = false; try { - const rules = [`tcp/${port}`]; + const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port; + const rules = [`tcp/${aclPort}`]; await client?.connectTemporary(peer.id, rules); await ssh({ hostname: peer.ip, @@ -107,7 +109,8 @@ function SSHTerminal({ username, port, peer }: Props) { if (connected.current) return; connected.current = true; try { - const rules = [`tcp/${port}`]; + const aclPort = isNativeSSHSupported(peer.version) ? "22022" : port; + const rules = [`tcp/${aclPort}`]; await client?.connectTemporary(peer.id, rules); const res = await ssh({ hostname: peer.ip, diff --git a/src/assets/ssh/ssh-client.png b/src/assets/ssh/ssh-client.png new file mode 100644 index 0000000..2d4bf1a Binary files /dev/null and b/src/assets/ssh/ssh-client.png differ diff --git a/src/components/PeerGroupSelector.tsx b/src/components/PeerGroupSelector.tsx index 4510fa6..c26323d 100644 --- a/src/components/PeerGroupSelector.tsx +++ b/src/components/PeerGroupSelector.tsx @@ -309,7 +309,7 @@ export function PeerGroupSelector({ "flex items-center gap-2 border-nb-gray-700 flex-wrap h-full" } > - {resource && showResources && ( + {resource && ( r.id === resource.id)} diff --git a/src/components/ui/PolicyDirection.tsx b/src/components/ui/PolicyDirection.tsx index 145184a..415c685 100644 --- a/src/components/ui/PolicyDirection.tsx +++ b/src/components/ui/PolicyDirection.tsx @@ -34,31 +34,34 @@ export default function PolicyDirection({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [disabled]); + const isNetworkResource = + !!destinationResource && destinationResource?.type !== "peer"; + const topBadgeClass = useMemo(() => { - if (destinationResource) return "blueDark"; + if (isNetworkResource) return "blueDark"; if (value === "bi") return "green"; if (value === "in") return "blueDark"; return "gray"; - }, [value, destinationResource]); + }, [value, isNetworkResource]); const topArrowClass = useMemo(() => { - if (destinationResource) return "fill-sky-500"; + if (isNetworkResource) return "fill-sky-500"; if (value === "bi") return "fill-green-500"; if (value === "in") return "fill-sky-500"; return "fill-gray-500"; - }, [value, destinationResource]); + }, [value, isNetworkResource]); const bottomBadgeClass = useMemo(() => { - if (destinationResource) return "gray"; + if (isNetworkResource) return "gray"; if (value === "bi") return "green"; return "gray"; - }, [value, destinationResource]); + }, [value, isNetworkResource]); const bottomArrowClass = useMemo(() => { - if (destinationResource) return "fill-gray-500"; + if (isNetworkResource) return "fill-gray-500"; if (value === "bi") return "fill-green-500"; return "fill-gray-500"; - }, [value, destinationResource]); + }, [value, isNetworkResource]); return (
-
+

) { ); return ( -

+
void; + onSuccess?: () => void; +}; + +export const PeerSSHInstructions = ({ + open, + onOpenChange, + onSuccess, +}: Props) => { + return ( + + + } + title={"Enable SSH Access"} + description={ + "Allow remote SSH access to this machine from other connected network participants. NetBird's embedded SSH server is running on port 44338." + } + color={"netbird"} + /> + + + +
+ + +

+ If you are using NetBird via CLI, you can enable SSH by running +

+ + {`netbird down # if NetBird is already running`} + + + {`netbird up --allow-server-ssh`} + +
+ + +

+ If you are using NetBird via the Desktop Client, click on the + NetBird tray icon, go to Settings and click{" "} + Allow SSH
+

+ +
+ + +

+ Once the NetBird SSH server is allowed on the client,
+ click Confirm & Enable below to finish the setup. +

+
+
+
+ + +
+ + Learn more about + + SSH + + + +
+
+ + + + + +
+
+
+
+ ); +}; diff --git a/src/modules/peer/PeerSSHToggle.tsx b/src/modules/peer/PeerSSHToggle.tsx new file mode 100644 index 0000000..38bf2c4 --- /dev/null +++ b/src/modules/peer/PeerSSHToggle.tsx @@ -0,0 +1,47 @@ +import FancyToggleSwitch from "@components/FancyToggleSwitch"; +import FullTooltip from "@components/FullTooltip"; +import { LockIcon, TerminalSquare } from "lucide-react"; +import * as React from "react"; +import { usePeer } from "@/contexts/PeerProvider"; +import { usePermissions } from "@/contexts/PermissionsProvider"; + +export const PeerSSHToggle = () => { + const { permission } = usePermissions(); + const { peer, toggleSSH, setSSHInstructionsModal } = usePeer(); + + return ( + <> + + + + {`You don't have the required permissions to update this + setting.`} + +
+ } + interactive={false} + className={"w-full block"} + disabled={permission.peers.update} + > + + enable ? setSSHInstructionsModal(true) : toggleSSH(false) + } + label={ + <> + + SSH Access + + } + helpText={ + "Enable the SSH server on this peer to access the machine via an secure shell." + } + /> + + + ); +}; diff --git a/src/modules/peers/PeerActionCell.tsx b/src/modules/peers/PeerActionCell.tsx index 02bdd46..aae26d7 100644 --- a/src/modules/peers/PeerActionCell.tsx +++ b/src/modules/peers/PeerActionCell.tsx @@ -24,7 +24,8 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; import { ExitNodeDropdownButton } from "@/modules/exit-node/ExitNodeDropdownButton"; export default function PeerActionCell() { - const { peer, deletePeer, update, openSSHDialog } = usePeer(); + const { peer, deletePeer, update, toggleSSH, setSSHInstructionsModal } = + usePeer(); const router = useRouter(); const { mutate } = useSWRConfig(); const { permission } = usePermissions(); @@ -48,21 +49,6 @@ export default function PeerActionCell() { }); }; - const toggleSSH = async () => { - const text = peer.ssh_enabled ? "disabled" : "enabled"; - notify({ - title: `SSH Server is ${text}`, - description: `The SSH Server for the peer ${peer.name} was successfully ${text}.`, - promise: update({ - ssh: !peer.ssh_enabled, - }).then(() => { - mutate("/peers"); - mutate("/groups"); - }), - loadingMessage: "Updating SSH access...", - }); - }; - return (
@@ -118,10 +104,8 @@ export default function PeerActionCell() { peer.ssh_enabled - ? toggleSSH() - : openSSHDialog().then((enable) => - enable ? toggleSSH() : null, - ) + ? toggleSSH(false) + : setSSHInstructionsModal(true) } disabled={!permission.peers.update} > diff --git a/src/modules/peers/PeerConnectButton.tsx b/src/modules/peers/PeerConnectButton.tsx index 96af981..05b60c8 100644 --- a/src/modules/peers/PeerConnectButton.tsx +++ b/src/modules/peers/PeerConnectButton.tsx @@ -6,12 +6,12 @@ import { import FullTooltip from "@components/FullTooltip"; import { getOperatingSystem } from "@hooks/useOperatingSystem"; import { IconChevronDown } from "@tabler/icons-react"; +import { cn } from "@utils/helpers"; import * as React from "react"; import { usePeer } from "@/contexts/PeerProvider"; import { OperatingSystem } from "@/interfaces/OperatingSystem"; import { RDPButton } from "@/modules/remote-access/rdp/RDPButton"; import { SSHButton } from "@/modules/remote-access/ssh/SSHButton"; -import { cn } from "@utils/helpers"; export const PeerConnectButton = () => { const { peer } = usePeer(); @@ -50,7 +50,7 @@ export const PeerConnectButton = () => { - Connecting via SSH or RDP is only available when the peer is online. + Connecting via SSH or RDP is only available when the peer is online.
} > diff --git a/src/modules/peers/PeerVersionCell.tsx b/src/modules/peers/PeerVersionCell.tsx index 154a868..bae7edd 100644 --- a/src/modules/peers/PeerVersionCell.tsx +++ b/src/modules/peers/PeerVersionCell.tsx @@ -9,7 +9,6 @@ import { import MemoizedNetBirdIcon from "@components/ui/MemoizedNetBirdIcon"; import { getOperatingSystem } from "@hooks/useOperatingSystem"; import { parseVersionString } from "@utils/version"; -import { trim } from "lodash"; import { ArrowRightIcon, ArrowUpCircleIcon } from "lucide-react"; import * as React from "react"; import { useMemo } from "react"; @@ -39,8 +38,6 @@ export default function PeerVersionCell({ version, os, serial }: Props) { return ; }, []); - const isWasmClient = trim(os) === "js"; - return (
{updateAvailable ? ( @@ -114,7 +111,7 @@ export default function PeerVersionCell({ version, os, serial }: Props) { > - {isWasmClient ? "Web Client" : os} + {os}
)} diff --git a/src/modules/peers/PeersTable.tsx b/src/modules/peers/PeersTable.tsx index 69735a7..96ac958 100644 --- a/src/modules/peers/PeersTable.tsx +++ b/src/modules/peers/PeersTable.tsx @@ -259,10 +259,17 @@ export default function PeersTable({ const [showBrowserPeers, setShowBrowserPeers] = useState(false); const withBrowserPeers = useCallback( - (condition: boolean) => - peers?.filter((peer) => - condition ? trim(peer.os) === "js" : trim(peer.os) !== "js", - ) ?? [], + (condition: boolean) => { + const isWebClient = (peer: Peer) => { + return trim(peer?.os) == "js" || peer.kernel_version === "wasm"; + }; + + return ( + peers?.filter((peer) => + condition ? isWebClient(peer) : !isWebClient(peer), + ) ?? [] + ); + }, [peers], ); diff --git a/src/modules/remote-access/rdp/RDPCredentialsModal.tsx b/src/modules/remote-access/rdp/RDPCredentialsModal.tsx index 1120798..c201e65 100644 --- a/src/modules/remote-access/rdp/RDPCredentialsModal.tsx +++ b/src/modules/remote-access/rdp/RDPCredentialsModal.tsx @@ -1,8 +1,13 @@ -import * as React from "react"; -import { useCallback, useMemo, useState } from "react"; +import Button from "@components/Button"; +import HelpText from "@components/HelpText"; +import InlineLink from "@components/InlineLink"; +import { Input } from "@components/Input"; +import { Label } from "@components/Label"; import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal"; import ModalHeader from "@components/modal/ModalHeader"; -import { Peer } from "@/interfaces/Peer"; +import Paragraph from "@components/Paragraph"; +import Separator from "@components/Separator"; +import { IconLoader2 } from "@tabler/icons-react"; import { ChevronsLeftRightEllipsis, ExternalLinkIcon, @@ -10,18 +15,13 @@ import { MonitorIcon, User2, } from "lucide-react"; -import Separator from "@components/Separator"; -import Paragraph from "@components/Paragraph"; -import InlineLink from "@components/InlineLink"; -import Button from "@components/Button"; -import { Label } from "@components/Label"; -import HelpText from "@components/HelpText"; -import { Input } from "@components/Input"; +import * as React from "react"; +import { useCallback, useMemo, useState } from "react"; +import { Peer } from "@/interfaces/Peer"; import { RDP_DOCS_LINK, RDPCredentials, } from "@/modules/remote-access/rdp/useRemoteDesktop"; -import { IconLoader2 } from "@tabler/icons-react"; type Props = { open: boolean; @@ -61,9 +61,31 @@ export const RDPCredentialsModal = ({ const handleConnect = useCallback(() => { if (hasAnyError || !onConnect) return; + + let parsedUsername = username; + let parsedDomain = ""; + + // Parse DOMAIN\username format + if (username.includes("\\")) { + const parts = username.split("\\"); + if (parts.length === 2) { + parsedDomain = parts[0]; + parsedUsername = parts[1]; + } + } + // Parse username@domain format + else if (username.includes("@")) { + const parts = username.split("@"); + if (parts.length === 2) { + parsedUsername = parts[0]; + parsedDomain = parts[1]; + } + } + onConnect({ - username, + username: parsedUsername, password, + domain: parsedDomain, port: Number(port), }); }, [hasAnyError, onConnect, username, password, port]); @@ -111,11 +133,12 @@ export const RDPCredentialsModal = ({ Enter the credentials required to authenticate with the remote - host. + host. For domain accounts, use DOMAIN\username or username@domain + format.
setUsername(e.target.value)} onKeyDown={handleKeyDown} diff --git a/src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts b/src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts index c4c5a5e..1f62517 100644 --- a/src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts +++ b/src/modules/remote-access/rdp/ironrdp-wasm-bridge.ts @@ -29,7 +29,6 @@ export interface RDPSession { shutdown(): void; sendInput(input: unknown): void; onClipboardPaste?(content: ClipboardData): Promise; - inputHandler?: IronRDPInputHandler; } interface TerminationInfo { reason(): string; @@ -57,11 +56,6 @@ interface RDPConfig { declare global { interface Window { IronRDPBridge: IronRDPWASMBridge; - IronRDPInputHandler?: new ( - ironrdp: IronRDPModule, - session: RDPSession, - canvas: HTMLCanvasElement, - ) => IronRDPInputHandler; initializeIronRDP: () => Promise; onIronRDPReady?: () => void; createRDCleanPathProxy?: ( @@ -70,9 +64,6 @@ declare global { ) => Promise; } } -interface IronRDPInputHandler { - destroy(): void; -} const IRON_RDP_PKG = "/ironrdp-pkg/ironrdp_web.js"; @@ -115,7 +106,8 @@ export class IronRDPWASMBridge { port: number, username: string, password: string, - canvas: HTMLCanvasElement, + domain?: string, + canvas?: HTMLCanvasElement, enableClipboard = true, netbirdClient?: { createRDPProxy: (hostname: string, port: string) => Promise; @@ -132,9 +124,9 @@ export class IronRDPWASMBridge { const config: RDPConfig = { username, password, - domain: "", - width: canvas.width || 1024, - height: canvas.height || 768, + domain: domain || "", + width: canvas?.width || 1024, + height: canvas?.height || 768, enable_tls: true, enable_credssp: true, enable_nla: true, @@ -177,9 +169,6 @@ export class IronRDPWASMBridge { builder.authToken(""); const session = await builder.connect(); this.sessions.set(sessionId, session); - if (canvas) { - this.attachInputHandler(session, canvas); - } if (enableClipboard) { this.startClipboardEventListeners(); } @@ -203,24 +192,7 @@ export class IronRDPWASMBridge { this.handleLocalClipboardRequest(); }); } - private attachInputHandler( - session: RDPSession, - canvas: HTMLCanvasElement, - ): void { - if (!window.IronRDPInputHandler) { - console.warn("IronRDPInputHandler not loaded - input will not work"); - return; - } - if (!this.ironrdp) { - console.warn("IronRDP module not available"); - return; - } - session.inputHandler = new window.IronRDPInputHandler( - this.ironrdp, - session, - canvas, - ); - } + private startSession(session: RDPSession, sessionId: string): void { session .run() @@ -234,9 +206,6 @@ export class IronRDPWASMBridge { }); } private cleanupSession(session: RDPSession, sessionId: string): void { - if (session.inputHandler) { - session.inputHandler.destroy(); - } this.sessions.delete(sessionId); // Stop clipboard event listeners if no active sessions @@ -244,12 +213,82 @@ export class IronRDPWASMBridge { this.stopClipboardEventListeners(); } } + private formatWSAError(wsaCode: number): string { + const wsaDescriptions: Record = { + 10004: "interrupted system call", + 10009: "bad file descriptor", + 10013: "permission denied", + 10014: "bad address", + 10022: "invalid argument", + 10024: "too many open files", + 10035: "resource temporarily unavailable", + 10036: "operation now in progress", + 10037: "operation already in progress", + 10038: "socket operation on nonsocket", + 10039: "destination address required", + 10040: "message too long", + 10041: "protocol wrong type for socket", + 10042: "bad protocol option", + 10043: "protocol not supported", + 10044: "socket type not supported", + 10045: "operation not supported", + 10046: "protocol family not supported", + 10047: "address family not supported by protocol family", + 10048: "address already in use", + 10049: "cannot assign requested address", + 10050: "network is down", + 10051: "network is unreachable", + 10052: "network dropped connection on reset", + 10053: "software caused connection abort", + 10054: "connection reset by peer", + 10055: "no buffer space available", + 10056: "socket is already connected", + 10057: "socket is not connected", + 10058: "cannot send after socket shutdown", + 10060: "connection timed out", + 10061: "connection refused", + 10064: "host is down", + 10065: "no route to host", + 10067: "too many processes", + 10091: "network subsystem is unavailable", + 10092: "Winsock version not supported", + 10093: "successful WSAStartup not yet performed", + 10101: "graceful shutdown in progress", + 10109: "class type not found", + 11001: "host not found", + 11002: "nonauthoritative host not found", + 11003: "this is a nonrecoverable error", + 11004: "valid name, no data record of requested type", + }; + + return wsaDescriptions[wsaCode] || "unknown error"; + } + + private formatRDCleanPathError(backtraceMsg: string): string { + const wsaMatch = backtraceMsg.match(/WSA last error = (\d+)/); + if (wsaMatch) { + const wsaCode = parseInt(wsaMatch[1], 10); + const description = this.formatWSAError(wsaCode); + return `Connection failed: ${description} (WSA ${wsaCode})`; + } + + const httpMatch = backtraceMsg.match(/HTTP status code = (\d+)/); + if (httpMatch) { + return `Connection failed: HTTP ${httpMatch[1]}`; + } + + return backtraceMsg; + } + private logIronError(error: unknown): void { const ironError = error as any; if (!ironError || !ironError.__wbg_ptr) return; try { if (ironError.backtrace) { - console.error("IronRDP backtrace:", ironError.backtrace()); + const backtraceMsg = ironError.backtrace(); + const formattedMsg = this.formatRDCleanPathError(backtraceMsg); + console.error("IronRDP error:", formattedMsg); + console.debug("IronRDP backtrace:", backtraceMsg); } if (ironError.kind) { const errorKind = ironError.kind(); @@ -269,13 +308,13 @@ export class IronRDPWASMBridge { console.error("Could not extract IronError details:", e); } } + getSession(sessionId: string): RDPSession | null { + return this.sessions.get(sessionId) || null; + } + disconnect(sessionId: string): void { const session = this.sessions.get(sessionId); if (!session) return; - if (session.inputHandler) { - session.inputHandler.destroy(); - session.inputHandler = undefined; - } if (session.shutdown) { session.shutdown(); } diff --git a/src/modules/remote-access/rdp/useIronRDPInputHandler.ts b/src/modules/remote-access/rdp/useIronRDPInputHandler.ts new file mode 100644 index 0000000..8b9ec9a --- /dev/null +++ b/src/modules/remote-access/rdp/useIronRDPInputHandler.ts @@ -0,0 +1,1089 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { IronRDPModule, RDPSession } from "./ironrdp-wasm-bridge"; + +interface DeviceEvent { + free?(): void; +} + +interface InputTransaction { + addEvent(event: DeviceEvent): void; + free?(): void; +} + +interface IronRDPAPI extends IronRDPModule { + DeviceEvent: { + mouseButtonPressed(button: number): DeviceEvent; + mouseButtonReleased(button: number): DeviceEvent; + mouseMove(x: number, y: number): DeviceEvent; + wheelRotations(isVertical: boolean, rotationUnits: number): DeviceEvent; + keyPressed(scancode: number): DeviceEvent; + keyReleased(scancode: number): DeviceEvent; + unicodePressed(unicode: string): DeviceEvent; + unicodeReleased(unicode: string): DeviceEvent; + }; + InputTransaction: new () => InputTransaction; +} + +interface ExtendedRDPSession extends RDPSession { + applyInputs(transaction: InputTransaction): void; +} + +interface CoordinateResult { + x: number; + y: number; +} + +interface UseIronRDPInputHandlerProps { + ironrdp: IronRDPModule | null; + session: RDPSession | null; + canvas: HTMLCanvasElement | null; + isConnected: boolean; +} + +declare global { + interface Window { + toggleFullscreen?: () => void; + } +} + +const activeHandlers = new Map void>(); + +export const useIronRDPInputHandler = ({ + ironrdp, + session, + canvas, + isConnected, +}: UseIronRDPInputHandlerProps) => { + const [isActive, setIsActive] = useState(false); + const mouseButtonStatesRef = useRef>({ + 0: false, + 1: false, + 2: false, + }); + const keyStatesRef = useRef(new Map()); + const currentMouseRef = useRef({ x: 0, y: 0 }); + const touchStateRef = useRef({ + lastX: 0, + lastY: 0, + touching: false, + touchId: null as number | null, + }); + + const codeToScancode: Record = useMemo( + () => ({ + KeyA: 0x1e, + KeyB: 0x30, + KeyC: 0x2e, + KeyD: 0x20, + KeyE: 0x12, + KeyF: 0x21, + KeyG: 0x22, + KeyH: 0x23, + KeyI: 0x17, + KeyJ: 0x24, + KeyK: 0x25, + KeyL: 0x26, + KeyM: 0x32, + KeyN: 0x31, + KeyO: 0x18, + KeyP: 0x19, + KeyQ: 0x10, + KeyR: 0x13, + KeyS: 0x1f, + KeyT: 0x14, + KeyU: 0x16, + KeyV: 0x2f, + KeyW: 0x11, + KeyX: 0x2d, + KeyY: 0x15, + KeyZ: 0x2c, + Digit0: 0x0b, + Digit1: 0x02, + Digit2: 0x03, + Digit3: 0x04, + Digit4: 0x05, + Digit5: 0x06, + Digit6: 0x07, + Digit7: 0x08, + Digit8: 0x09, + Digit9: 0x0a, + F1: 0x3b, + F2: 0x3c, + F3: 0x3d, + F4: 0x3e, + F5: 0x3f, + F6: 0x40, + F7: 0x41, + F8: 0x42, + F9: 0x43, + F10: 0x44, + F11: 0x57, + F12: 0x58, + Backspace: 0x0e, + Tab: 0x0f, + Enter: 0x1c, + ShiftLeft: 0x2a, + ShiftRight: 0x36, + ControlLeft: 0x1d, + ControlRight: 0x9d, + AltLeft: 0x38, + AltRight: 0xb8, + CapsLock: 0x3a, + Escape: 0x01, + Space: 0x39, + PageUp: 0xe049, + PageDown: 0xe051, + End: 0xe04f, + Home: 0xe047, + ArrowLeft: 0xe04b, + ArrowUp: 0xe048, + ArrowRight: 0xe04d, + ArrowDown: 0xe050, + Insert: 0xe052, + Delete: 0xe053, + MetaLeft: isMacOS() ? 0x1d : 0x5b, + MetaRight: isMacOS() ? 0x9d : 0x5c, + Semicolon: 0x27, + Equal: 0x0d, + Comma: 0x33, + Minus: 0x0c, + Period: 0x34, + Slash: 0x35, + Backquote: 0x29, + BracketLeft: 0x1a, + Backslash: 0x2b, + BracketRight: 0x1b, + Quote: 0x28, + Numpad0: 0x52, + Numpad1: 0x4f, + Numpad2: 0x50, + Numpad3: 0x51, + Numpad4: 0x4b, + Numpad5: 0x4c, + Numpad6: 0x4d, + Numpad7: 0x47, + Numpad8: 0x48, + Numpad9: 0x49, + NumpadDecimal: 0x53, + NumpadDivide: 0xe035, + NumpadMultiply: 0x37, + NumpadSubtract: 0x4a, + NumpadAdd: 0x4e, + NumpadEnter: 0xe01c, + NumLock: 0x45, + }), + [], + ); + + const mouseButtonMap: Record = useMemo(() => { + return { 0: 0, 1: 1, 2: 2 }; + }, []); + + /** + * Detect macOS + */ + function isMacOS(): boolean { + if ("userAgentData" in navigator && (navigator as any).userAgentData) { + return (navigator as any).userAgentData.platform === "macOS"; + } + return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); + } + + const getCanvasCoordinates = useCallback( + (clientX: number, clientY: number): CoordinateResult => { + if (!canvas) return { x: 0, y: 0 }; + + const rect = canvas.getBoundingClientRect(); + const canvasAspectRatio = canvas.width / canvas.height; + const containerAspectRatio = rect.width / rect.height; + + let renderWidth: number, + renderHeight: number, + offsetX: number, + offsetY: number; + + const isFullscreen = + document.fullscreenElement === canvas || + document.fullscreenElement === canvas.parentElement; + const hasLetterbox = isFullscreen && canvas.style.objectFit !== "fill"; + + if (hasLetterbox && canvasAspectRatio !== containerAspectRatio) { + if (canvasAspectRatio > containerAspectRatio) { + renderWidth = rect.width; + renderHeight = rect.width / canvasAspectRatio; + offsetX = 0; + offsetY = (rect.height - renderHeight) / 2; + } else { + renderWidth = rect.height * canvasAspectRatio; + renderHeight = rect.height; + offsetX = (rect.width - renderWidth) / 2; + offsetY = 0; + } + } else { + renderWidth = rect.width; + renderHeight = rect.height; + offsetX = 0; + offsetY = 0; + } + + const scaleX = canvas.width / renderWidth; + const scaleY = canvas.height / renderHeight; + + const relativeX = clientX - rect.left - offsetX; + const relativeY = clientY - rect.top - offsetY; + + const x = Math.max( + 0, + Math.min(canvas.width - 1, Math.round(relativeX * scaleX)), + ); + const y = Math.max( + 0, + Math.min(canvas.height - 1, Math.round(relativeY * scaleY)), + ); + + return { x, y }; + }, + [canvas], + ); + + const sendCopyKeyCombination = useCallback(() => { + if (!session || !ironrdp) return; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const transaction = new api.InputTransaction(); + + const ctrlScancode = isMacOS() + ? codeToScancode.MetaLeft + : codeToScancode.ControlLeft; + const cScancode = codeToScancode.KeyC; + + if (ctrlScancode && cScancode) { + const ctrlDown = api.DeviceEvent.keyPressed(ctrlScancode); + transaction.addEvent(ctrlDown); + + const cDown = api.DeviceEvent.keyPressed(cScancode); + transaction.addEvent(cDown); + + const cUp = api.DeviceEvent.keyReleased(cScancode); + transaction.addEvent(cUp); + + const ctrlUp = api.DeviceEvent.keyReleased(ctrlScancode); + transaction.addEvent(ctrlUp); + + extSession.applyInputs(transaction); + } + } catch (err) { + console.error("Error sending copy key combination:", err); + } + }, [session, ironrdp, codeToScancode]); + + const sendPasteKeyCombination = useCallback(() => { + if (!session || !ironrdp) return; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const transaction = new api.InputTransaction(); + + const ctrlScancode = isMacOS() + ? codeToScancode.MetaLeft + : codeToScancode.ControlLeft; + const vScancode = codeToScancode.KeyV; + + if (ctrlScancode && vScancode) { + const ctrlDown = api.DeviceEvent.keyPressed(ctrlScancode); + transaction.addEvent(ctrlDown); + + const vDown = api.DeviceEvent.keyPressed(vScancode); + transaction.addEvent(vDown); + + const vUp = api.DeviceEvent.keyReleased(vScancode); + transaction.addEvent(vUp); + + const ctrlUp = api.DeviceEvent.keyReleased(ctrlScancode); + transaction.addEvent(ctrlUp); + + extSession.applyInputs(transaction); + } + } catch (err) { + console.error("Error sending paste key combination:", err); + } + }, [session, ironrdp, codeToScancode]); + + const sendTextAsKeystrokes = useCallback( + (text: string) => { + if (!session || !ironrdp) return; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const transaction = new api.InputTransaction(); + + // Send each character as unicode event + for (let i = 0; i < text.length; i++) { + const char = text.charAt(i); + const deviceEvent = api.DeviceEvent.unicodePressed(char); + transaction.addEvent(deviceEvent); + } + + extSession.applyInputs(transaction); + } catch (err) { + console.error("Error sending paste text:", err); + } + }, + [session, ironrdp], + ); + + const releaseModifierKeys = useCallback(() => { + if (!session || !ironrdp) return; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const transaction = new api.InputTransaction(); + + // Release all common modifier keys + const modifierScancodes = [ + codeToScancode.ControlLeft, // Left Ctrl + codeToScancode.ControlRight, // Right Ctrl + codeToScancode.MetaLeft, // Left Meta/Windows + codeToScancode.MetaRight, // Right Meta/Windows + codeToScancode.ShiftLeft, // Left Shift + codeToScancode.ShiftRight, // Right Shift + codeToScancode.AltLeft, // Left Alt + codeToScancode.AltRight, // Right Alt + ]; + + for (const scancode of modifierScancodes) { + if (scancode !== undefined) { + const keyUpEvent = api.DeviceEvent.keyReleased(scancode); + transaction.addEvent(keyUpEvent); + } + } + + extSession.applyInputs(transaction); + } catch (err) { + console.error("Error releasing modifier keys:", err); + } + }, [session, ironrdp, codeToScancode]); + + const tryFallbackClipboardRead = useCallback(() => { + try { + // Create a temporary textarea to capture clipboard content + const textarea = document.createElement("textarea"); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + textarea.style.top = "-9999px"; + document.body.appendChild(textarea); + + textarea.focus(); + + // Check if 'paste' command is supported before using execCommand + if ( + typeof document.queryCommandSupported === "function" && + document.queryCommandSupported("paste") + ) { + if (document.execCommand("paste")) { + const text = textarea.value; + if (text) { + sendTextAsKeystrokes(text); + // Release all modifier keys after paste to prevent them from sticking + releaseModifierKeys(); + } + } + } else { + console.warn("Clipboard paste is not supported in this browser."); + } + + document.body.removeChild(textarea); + } catch (err) { + console.error("Fallback clipboard read failed:", err); + } + }, [sendTextAsKeystrokes, releaseModifierKeys]); + + const handleLocalClipboardPaste = useCallback(async () => { + if (!navigator.clipboard?.readText) { + console.warn("Clipboard API not available"); + return; + } + + try { + const clipboardText = await navigator.clipboard.readText(); + if (!clipboardText) return; + sendTextAsKeystrokes(clipboardText); + releaseModifierKeys(); + } catch (err) { + console.error("Failed to read from local clipboard:", err); + tryFallbackClipboardRead(); + } + }, [sendTextAsKeystrokes, releaseModifierKeys, tryFallbackClipboardRead]); + + const releaseAllKeys = useCallback(() => { + if (!session || !ironrdp) return; + + keyStatesRef.current.forEach((pressed, code) => { + if (!pressed) return; + const scancode = codeToScancode[code]; + if (scancode === undefined) return; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const deviceEvent = api.DeviceEvent.keyReleased(scancode); + const transaction = new api.InputTransaction(); + transaction.addEvent(deviceEvent); + extSession.applyInputs(transaction); + } catch (err) { + console.error("Error releasing key:", err); + } + }); + keyStatesRef.current.clear(); + }, [session, ironrdp, codeToScancode]); + + const releaseAllMouseButtons = useCallback(() => { + if (!session || !ironrdp) return; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const transaction = new api.InputTransaction(); + + // Release all mouse buttons that are currently pressed + Object.entries(mouseButtonStatesRef.current).forEach( + ([buttonIndex, pressed]) => { + if (pressed) { + const button = mouseButtonMap[parseInt(buttonIndex)]; + if (button !== undefined) { + const deviceEvent = api.DeviceEvent.mouseButtonReleased(button); + transaction.addEvent(deviceEvent); + mouseButtonStatesRef.current[parseInt(buttonIndex)] = false; + } + } + }, + ); + + if (transaction) { + extSession.applyInputs(transaction); + } + } catch (err) { + console.error("Error releasing mouse buttons:", err); + } + }, [session, ironrdp, mouseButtonMap]); + + const releaseAllInputs = useCallback(() => { + releaseAllKeys(); + releaseAllMouseButtons(); + // Release touch state + if (touchStateRef.current.touching) { + touchStateRef.current = { + lastX: 0, + lastY: 0, + touching: false, + touchId: null, + }; + } + }, [releaseAllKeys, releaseAllMouseButtons]); + + const requestClipboardSync = useCallback(() => { + if (!/Chrome/.test(navigator.userAgent)) return; + + if ( + window.IronRDPBridge && + (window.IronRDPBridge as any).checkAndSendClipboard + ) { + setTimeout(() => { + (window.IronRDPBridge as any).checkAndSendClipboard(); + }, 50); + } + }, []); + + // Event handlers + const handleMouseDown = useCallback( + (event: MouseEvent) => { + if (!canvas || !isActive || !session || !ironrdp) return; + + event.preventDefault(); + canvas.focus(); + + const { x, y } = getCanvasCoordinates(event.clientX, event.clientY); + const button = mouseButtonMap[event.button]; + if (button === undefined) return; + if (mouseButtonStatesRef.current[event.button]) return; + + mouseButtonStatesRef.current[event.button] = true; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const transaction = new api.InputTransaction(); + + const moveEvent = api.DeviceEvent.mouseMove(x, y); + const clickEvent = api.DeviceEvent.mouseButtonPressed(button); + + transaction.addEvent(moveEvent); + transaction.addEvent(clickEvent); + extSession.applyInputs(transaction); + + currentMouseRef.current = { x, y }; + } catch (err) { + console.error("Error sending mouse down:", err); + } + }, + [canvas, isActive, session, ironrdp, getCanvasCoordinates, mouseButtonMap], + ); + + const handleMouseUp = useCallback( + (event: MouseEvent) => { + if (!canvas || !isActive || !session || !ironrdp) return; + + event.preventDefault(); + const button = mouseButtonMap[event.button]; + if (button === undefined) return; + if (!mouseButtonStatesRef.current[event.button]) return; + + mouseButtonStatesRef.current[event.button] = false; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const deviceEvent = api.DeviceEvent.mouseButtonReleased(button); + const transaction = new api.InputTransaction(); + transaction.addEvent(deviceEvent); + extSession.applyInputs(transaction); + } catch (err) { + console.error("Error sending mouse up:", err); + } + }, + [canvas, isActive, session, ironrdp, mouseButtonMap], + ); + + const handleMouseMove = useCallback( + (event: MouseEvent) => { + if (!canvas || !session || !ironrdp) return; + + const { x, y } = getCanvasCoordinates(event.clientX, event.clientY); + + if (x === currentMouseRef.current.x && y === currentMouseRef.current.y) { + return; + } + + currentMouseRef.current = { x, y }; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const deviceEvent = api.DeviceEvent.mouseMove(x, y); + const transaction = new api.InputTransaction(); + transaction.addEvent(deviceEvent); + extSession.applyInputs(transaction); + } catch (err) { + console.error("Error sending mouse move:", err); + } + }, + [canvas, session, ironrdp, getCanvasCoordinates], + ); + + const handleWheel = useCallback( + (event: WheelEvent) => { + if (!isActive || !session || !ironrdp) return; + + event.preventDefault(); + const delta = event.deltaY > 0 ? -1 : 1; + const rotationUnits = delta * 120; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const deviceEvent = api.DeviceEvent.wheelRotations(true, rotationUnits); + const transaction = new api.InputTransaction(); + transaction.addEvent(deviceEvent); + extSession.applyInputs(transaction); + } catch (err) { + console.error("Error sending wheel event:", err); + } + }, + [isActive, session, ironrdp], + ); + + const handleTouchStart = useCallback( + (event: TouchEvent) => { + if (!canvas || !isActive || !session || !ironrdp) return; + + event.preventDefault(); + canvas.focus(); + + // Only handle single touch (first touch) + if (event.touches.length > 0 && !touchStateRef.current.touching) { + const touch = event.touches[0]; + const { x, y } = getCanvasCoordinates(touch.clientX, touch.clientY); + + touchStateRef.current = { + lastX: x, + lastY: y, + touching: true, + touchId: touch.identifier, + }; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const transaction = new api.InputTransaction(); + + const moveEvent = api.DeviceEvent.mouseMove(x, y); + const clickEvent = api.DeviceEvent.mouseButtonPressed(0); // Left click + + transaction.addEvent(moveEvent); + transaction.addEvent(clickEvent); + extSession.applyInputs(transaction); + + currentMouseRef.current = { x, y }; + } catch (err) { + console.error("Error sending touch start:", err); + } + } + }, + [canvas, isActive, session, ironrdp, getCanvasCoordinates], + ); + + const handleTouchEnd = useCallback( + (event: TouchEvent) => { + if (!canvas || !isActive || !session || !ironrdp) return; + + event.preventDefault(); + + // Check if our tracked touch ended + if (touchStateRef.current.touching) { + const touchEnded = Array.from(event.changedTouches).some( + (touch) => touch.identifier === touchStateRef.current.touchId, + ); + + if (touchEnded || event.touches.length === 0) { + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const deviceEvent = api.DeviceEvent.mouseButtonReleased(0); // Left click + const transaction = new api.InputTransaction(); + transaction.addEvent(deviceEvent); + extSession.applyInputs(transaction); + + touchStateRef.current = { + lastX: 0, + lastY: 0, + touching: false, + touchId: null, + }; + } catch (err) { + console.error("Error sending touch end:", err); + } + } + } + }, + [canvas, isActive, session, ironrdp], + ); + + const handleTouchMove = useCallback( + (event: TouchEvent) => { + if (!canvas || !session || !ironrdp || !touchStateRef.current.touching) return; + + event.preventDefault(); + + // Find our tracked touch + const currentTouch = Array.from(event.touches).find( + (touch) => touch.identifier === touchStateRef.current.touchId, + ); + + if (currentTouch) { + const { x, y } = getCanvasCoordinates(currentTouch.clientX, currentTouch.clientY); + + if (x === touchStateRef.current.lastX && y === touchStateRef.current.lastY) { + return; + } + + touchStateRef.current.lastX = x; + touchStateRef.current.lastY = y; + currentMouseRef.current = { x, y }; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const deviceEvent = api.DeviceEvent.mouseMove(x, y); + const transaction = new api.InputTransaction(); + transaction.addEvent(deviceEvent); + extSession.applyInputs(transaction); + } catch (err) { + console.error("Error sending touch move:", err); + } + } + }, + [canvas, session, ironrdp, getCanvasCoordinates], + ); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!isActive || !session || !ironrdp) return; + + const isLocalClipboardPaste = + (event.ctrlKey || event.metaKey) && + event.shiftKey && + event.key.toLowerCase() === "v"; + + if (isLocalClipboardPaste) { + event.preventDefault(); + handleLocalClipboardPaste(); + return; + } + + const isClipboardPaste = + (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v"; + const isClipboardCopy = + (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c"; + + // Handle copy directly in keydown as fallback if clipboard events don't work + if (isClipboardCopy && document.activeElement === canvas) { + event.preventDefault(); + sendCopyKeyCombination(); + return; + } + + // Handle paste directly in keydown as fallback if clipboard events don't work + if (isClipboardPaste && document.activeElement === canvas) { + event.preventDefault(); + sendPasteKeyCombination(); + return; + } + + if (!isClipboardPaste && !isClipboardCopy) { + event.preventDefault(); + } + + const isChromium = /Chrome/.test(navigator.userAgent); + if ((isClipboardPaste || isClipboardCopy) && isChromium) { + return; + } + + const scancode = codeToScancode[event.code]; + if (scancode !== undefined) { + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const deviceEvent = api.DeviceEvent.keyPressed(scancode); + const transaction = new api.InputTransaction(); + transaction.addEvent(deviceEvent); + extSession.applyInputs(transaction); + } catch (err) { + console.error("Error sending key down:", err); + } + } else if (event.key.length === 1) { + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const deviceEvent = api.DeviceEvent.unicodePressed(event.key); + const transaction = new api.InputTransaction(); + transaction.addEvent(deviceEvent); + extSession.applyInputs(transaction); + } catch (err) { + console.error("Error sending unicode char:", err); + } + } + }, + [ + isActive, + session, + ironrdp, + codeToScancode, + canvas, + sendCopyKeyCombination, + sendPasteKeyCombination, + handleLocalClipboardPaste, + ], + ); + + const handleKeyUp = useCallback( + (event: KeyboardEvent) => { + if (!isActive || !session || !ironrdp) return; + + const isLocalClipboardPaste = + (event.ctrlKey || event.metaKey) && + event.shiftKey && + event.key.toLowerCase() === "v"; + if (isLocalClipboardPaste) { + event.preventDefault(); + return; + } + + const isClipboardPaste = + (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "v"; + const isClipboardCopy = + (event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c"; + + if (!isClipboardPaste && !isClipboardCopy) { + event.preventDefault(); + } + + const isChromium = /Chrome/.test(navigator.userAgent); + if ((isClipboardPaste || isClipboardCopy) && isChromium) { + return; + } + + const scancode = codeToScancode[event.code]; + if (scancode === undefined) return; + + try { + const api = ironrdp as IronRDPAPI; + const extSession = session as ExtendedRDPSession; + const deviceEvent = api.DeviceEvent.keyReleased(scancode); + const transaction = new api.InputTransaction(); + transaction.addEvent(deviceEvent); + extSession.applyInputs(transaction); + } catch (err) { + console.error("Error sending key up:", err); + } + }, + [isActive, session, ironrdp, codeToScancode], + ); + + const handlePaste = useCallback( + (event: ClipboardEvent) => { + if (!isActive) return; + + const clipboardData = event.clipboardData; + if (!clipboardData) return; + + const text = clipboardData.getData("text/plain"); + if (!text) return; + + event.preventDefault(); + sendPasteKeyCombination(); + }, + [isActive, sendPasteKeyCombination], + ); + + const handleCopy = useCallback( + (event: ClipboardEvent) => { + if (!isActive) { + return; + } + sendCopyKeyCombination(); + }, + [isActive, sendCopyKeyCombination], + ); + + const handleFocus = useCallback(() => { + setIsActive(true); + requestClipboardSync(); + }, [requestClipboardSync]); + + const handleBlur = useCallback(() => { + setIsActive(false); + releaseAllInputs(); + }, [releaseAllInputs]); + + const handleClick = useCallback(() => { + if (canvas) { + canvas.focus(); + requestClipboardSync(); + + setTimeout(() => { + if (document.activeElement !== canvas) { + canvas.focus(); + } + }, 10); + } + }, [canvas, requestClipboardSync]); + + const handleGlobalKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isActive) return; + + if (e.key === "F11") { + e.preventDefault(); + if (window.toggleFullscreen) { + window.toggleFullscreen(); + } + } else if (e.ctrlKey && e.altKey && e.key === "Enter") { + e.preventDefault(); + if (window.toggleFullscreen) { + window.toggleFullscreen(); + } + } + }, + [isActive], + ); + + const preventContextMenu = useCallback((e: Event) => e.preventDefault(), []); + + const handleMouseLeave = useCallback(() => { + releaseAllInputs(); + }, [releaseAllInputs]); + + const handleWindowMouseLeave = useCallback( + (e: MouseEvent) => { + if ( + e.clientY <= 0 || + e.clientX <= 0 || + e.clientX >= window.innerWidth || + e.clientY >= window.innerHeight + ) { + releaseAllInputs(); + } + }, + [releaseAllInputs], + ); + + const handleVisibilityChange = useCallback(() => { + if (document.hidden) { + releaseAllInputs(); + } + }, [releaseAllInputs]); + + /** + * Setup all necessary event listeners on the canvas + */ + const setupEventListeners = useCallback(() => { + if (!canvas) return null; + + // Clean up any existing handler for this canvas first + const existingCleanup = activeHandlers.get(canvas); + if (existingCleanup) { + existingCleanup(); + } + + canvas.tabIndex = 1; + canvas.style.outline = "none"; + + // Add all event listeners + canvas.addEventListener("mousedown", handleMouseDown); + canvas.addEventListener("mouseup", handleMouseUp); + canvas.addEventListener("mousemove", handleMouseMove); + canvas.addEventListener("mouseenter", handleMouseMove); + canvas.addEventListener("mouseleave", handleMouseLeave); + canvas.addEventListener("wheel", handleWheel); + canvas.addEventListener("touchstart", handleTouchStart); + canvas.addEventListener("touchend", handleTouchEnd); + canvas.addEventListener("touchmove", handleTouchMove); + canvas.addEventListener("contextmenu", preventContextMenu); + canvas.addEventListener("keydown", handleKeyDown); + canvas.addEventListener("keyup", handleKeyUp); + canvas.addEventListener("paste", handlePaste); + canvas.addEventListener("copy", handleCopy); + + canvas.addEventListener("focus", handleFocus); + canvas.addEventListener("blur", handleBlur); + canvas.addEventListener("click", handleClick); + + document.addEventListener("keydown", handleGlobalKeyDown); + + // Window-level event listeners for input cleanup + document.addEventListener("mouseleave", handleWindowMouseLeave); + document.addEventListener("visibilitychange", handleVisibilityChange); + + // Create cleanup function + const cleanup = () => { + canvas.removeEventListener("mousedown", handleMouseDown); + canvas.removeEventListener("mouseup", handleMouseUp); + canvas.removeEventListener("mousemove", handleMouseMove); + canvas.removeEventListener("mouseenter", handleMouseMove); + canvas.removeEventListener("mouseleave", handleMouseLeave); + canvas.removeEventListener("wheel", handleWheel); + canvas.removeEventListener("touchstart", handleTouchStart); + canvas.removeEventListener("touchend", handleTouchEnd); + canvas.removeEventListener("touchmove", handleTouchMove); + canvas.removeEventListener("contextmenu", preventContextMenu); + canvas.removeEventListener("keydown", handleKeyDown); + canvas.removeEventListener("keyup", handleKeyUp); + canvas.removeEventListener("paste", handlePaste); + canvas.removeEventListener("copy", handleCopy); + + canvas.removeEventListener("focus", handleFocus); + canvas.removeEventListener("blur", handleBlur); + canvas.removeEventListener("click", handleClick); + document.removeEventListener("keydown", handleGlobalKeyDown); + + // Remove window-level listeners + document.removeEventListener("mouseleave", handleWindowMouseLeave); + document.removeEventListener("visibilitychange", handleVisibilityChange); + + releaseAllInputs(); + // Don't set isActive false here - let blur event handle it + activeHandlers.delete(canvas); + }; + + // Register this handler + activeHandlers.set(canvas, cleanup); + + return cleanup; + }, [ + canvas, + handleMouseDown, + handleMouseUp, + handleMouseMove, + handleMouseLeave, + handleWheel, + handleTouchStart, + handleTouchEnd, + handleTouchMove, + preventContextMenu, + handleKeyDown, + handleKeyUp, + handlePaste, + handleCopy, + handleFocus, + handleBlur, + handleClick, + handleGlobalKeyDown, + handleWindowMouseLeave, + handleVisibilityChange, + releaseAllInputs, + ]); + + /** + * Auto-focus canvas when connection is established + */ + useEffect(() => { + if (isConnected && canvas) { + const timeoutId = setTimeout(() => { + if (document.activeElement !== canvas) { + canvas.focus(); + } + }, 300); + return () => clearTimeout(timeoutId); + } + }, [isConnected, canvas]); + + /** + * Setup event listeners when connected and canvas/session are available + */ + useEffect(() => { + if (isConnected && ironrdp && session && canvas) { + setupEventListeners(); + } + }, [isConnected, ironrdp, session, canvas, setupEventListeners]); + + /** + * Cleanup event listeners when canvas changes or component unmounts + */ + useEffect(() => { + return () => { + if (canvas) { + const existingCleanup = activeHandlers.get(canvas); + if (existingCleanup) { + existingCleanup(); + } + } + }; + }, [canvas]); + + /** + * Function to manually focus the canvas and ensure it receives input + */ + const focusCanvas = useCallback(() => { + if (canvas) canvas.focus(); + }, [canvas]); + + return { + isActive, + focusCanvas, + }; +}; diff --git a/src/modules/remote-access/rdp/useRemoteDesktop.ts b/src/modules/remote-access/rdp/useRemoteDesktop.ts index fad247d..1541be5 100644 --- a/src/modules/remote-access/rdp/useRemoteDesktop.ts +++ b/src/modules/remote-access/rdp/useRemoteDesktop.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useIronRDPInputHandler } from "./useIronRDPInputHandler"; import { CertificatePromptInfo, useRDPCertificateHandler, @@ -14,6 +15,7 @@ interface RDPConfig { port: number; username: string; password: string; + domain?: string; width?: number; height?: number; } @@ -21,6 +23,7 @@ interface RDPConfig { export interface RDPCredentials { username: string; password: string; + domain?: string; port: number; } @@ -38,8 +41,7 @@ export enum RDPStatus { CONNECTING = 2, } -export const RDP_DOCS_LINK = - "https://docs.netbird.io/how-to/browser-client#rdp-connection"; +export const RDP_DOCS_LINK = "https://docs.netbird.io/how-to/browser-client"; export const useRemoteDesktop = (client: any) => { const [status, setStatus] = useState(RDPStatus.DISCONNECTED); @@ -59,10 +61,20 @@ export const useRemoteDesktop = (client: any) => { reject: (reason?: any) => void; } | null>(null); + const [rdpSession, setRdpSession] = useState(null); + const [ironrdpModule, setIronrdpModule] = useState(null); + const { handleRDCleanPathResponse, acceptCertificate } = useRDPCertificateHandler(); const certificateAccepted = useRef(false); + const { isActive, focusCanvas } = useIronRDPInputHandler({ + ironrdp: ironrdpModule, + session: rdpSession, + canvas: canvasRef.current, + isConnected: status === RDPStatus.CONNECTED, + }); + /** * Reset the RDP state, optionally preserving config and/or certificate state */ @@ -75,6 +87,9 @@ export const useRemoteDesktop = (client: any) => { ) => { session.current = null; setStatus(RDPStatus.DISCONNECTED); + setRdpSession(null); + setIronrdpModule(null); + if (!options.preserveConfig) { setConfig(null); } @@ -172,11 +187,17 @@ export const useRemoteDesktop = (client: any) => { rdpConfig.port, rdpConfig.username, rdpConfig.password, + rdpConfig.domain, canvas, true, client.client, ); + // Store the ironrdp module and session for the input handler hook + setIronrdpModule((client.ironRDPBridge as any).ironrdp || null); + const actualSession = client.ironRDPBridge.getSession(sessionId); + setRdpSession(actualSession); + session.current = { id: sessionId, disconnect: (options = {}) => { @@ -192,6 +213,7 @@ export const useRemoteDesktop = (client: any) => { }; setStatus(RDPStatus.CONNECTED); lastConnectedConfigRef.current = rdpConfig; + canvasRef?.current?.focus(); return RDPStatus.CONNECTED; } catch (err) { const ironError = err as IronError; @@ -232,6 +254,7 @@ export const useRemoteDesktop = (client: any) => { setPendingCertificate(null); certificatePromiseRef.current = null; certificateAccepted.current = true; + canvasRef?.current?.focus(); }, [pendingCertificate, acceptCertificate], ); @@ -287,6 +310,7 @@ export const useRemoteDesktop = (client: any) => { await connect(newConfig); } finally { setIsResizing(false); + canvasRef?.current?.focus(); } }, 1000); }; @@ -318,6 +342,10 @@ export const useRemoteDesktop = (client: any) => { session: session.current, canvasRef, + // Input handler + inputHandlerActive: isActive, + focusCanvas, + // Certificate handling pendingCertificate, acceptCertificatePrompt, diff --git a/src/modules/remote-access/ssh/SSHButton.tsx b/src/modules/remote-access/ssh/SSHButton.tsx index cef1514..d2c198d 100644 --- a/src/modules/remote-access/ssh/SSHButton.tsx +++ b/src/modules/remote-access/ssh/SSHButton.tsx @@ -1,14 +1,14 @@ import Button from "@components/Button"; import { DropdownMenuItem } from "@components/DropdownMenu"; +import { getOperatingSystem } from "@hooks/useOperatingSystem"; import { CircleHelpIcon, TerminalIcon } from "lucide-react"; import * as React from "react"; import { useState } from "react"; import { usePermissions } from "@/contexts/PermissionsProvider"; +import { OperatingSystem } from "@/interfaces/OperatingSystem"; import { Peer } from "@/interfaces/Peer"; import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal"; import { SSHTooltip } from "@/modules/remote-access/ssh/SSHTooltip"; -import { getOperatingSystem } from "@hooks/useOperatingSystem"; -import { OperatingSystem } from "@/interfaces/OperatingSystem"; type Props = { peer: Peer; @@ -41,7 +41,8 @@ export const SSHButton = ({ peer, isDropdown = false }: Props) => { )}
diff --git a/src/modules/remote-access/ssh/SSHTooltip.tsx b/src/modules/remote-access/ssh/SSHTooltip.tsx index 8f67aee..93a18b9 100644 --- a/src/modules/remote-access/ssh/SSHTooltip.tsx +++ b/src/modules/remote-access/ssh/SSHTooltip.tsx @@ -1,57 +1,99 @@ import FullTooltip from "@components/FullTooltip"; import InlineLink from "@components/InlineLink"; -import { ExternalLinkIcon } from "lucide-react"; +import { ArrowUpRightIcon } from "lucide-react"; import * as React from "react"; +import { useState } from "react"; +import { usePeer } from "@/contexts/PeerProvider"; type Props = { - disabled?: boolean; children?: React.ReactNode; - hasPermission?: boolean; + hasPermission: boolean; + isOnline?: boolean; + isSSHEnabled?: boolean; side?: "top" | "right" | "bottom" | "left"; }; export const SSHTooltip = ({ - disabled, children, hasPermission, + isOnline, + isSSHEnabled, side = "top", }: Props) => { + const [showTooltip, setShowTooltip] = useState(false); + + const tooltipContent = () => { + if (!hasPermission) { + return ; + } + if (!isSSHEnabled) { + return ; + } + if (!isOnline) { + return ; + } + return null; + }; + return ( - {hasPermission ? ( - <> -
- This peer is either offline or SSH access is not enabled. -
-
- Please enable SSH access for this peer in the dashboard and make - sure SSH is allowed in the NetBird Client under{" "} - Settings → Allow SSH. -
-
- Learn more about{" "} - - SSH - -
- - ) : ( -
- You do not have permission to launch the SSH console. Please - contact your administrator. -
- )} -
- } - disabled={disabled} + content={tooltipContent()} + disabled={isOnline && isSSHEnabled && hasPermission} > {children} ); }; + +const NoPermissionText = () => { + return ( +
+
+ You do not have permission to launch the SSH console. Please contact + your administrator. +
+
+ ); +}; + +const IsOfflineText = () => { + return ( +
+
Connecting via SSH is only available when the peer is online.
+
+ ); +}; + +const SSHDisabledText = ({ + setShowTooltip, +}: { + setShowTooltip: (show: boolean) => void; +}) => { + const { setSSHInstructionsModal } = usePeer(); + + return ( +
+
+ SSH Access is currently disabled for this peer. Please enable SSH access + for this peer and make sure SSH is allowed in the NetBird Client. +
+
+ { + e.preventDefault(); + e.stopPropagation(); + setShowTooltip(false); + setSSHInstructionsModal(true); + }} + href={"#"} + target={"_blank"} + > + Enable SSH Access + +
+
+ ); +}; diff --git a/src/modules/remote-access/ssh/useSSH.ts b/src/modules/remote-access/ssh/useSSH.ts index e26ae3b..e76922c 100644 --- a/src/modules/remote-access/ssh/useSSH.ts +++ b/src/modules/remote-access/ssh/useSSH.ts @@ -1,3 +1,4 @@ +import { useOidcAccessToken } from "@axa-fr/react-oidc"; import { useCallback, useRef, useState } from "react"; interface SSHConfig { @@ -28,6 +29,7 @@ export const useSSH = (client: any) => { const [config, setConfig] = useState(null); const session = useRef(null); const [error, setError] = useState(""); + const { accessToken } = useOidcAccessToken(); const connect = useCallback( async (config: SSHConfig): Promise => { diff --git a/src/modules/remote-access/useNetBirdClient.ts b/src/modules/remote-access/useNetBirdClient.ts index 08c9090..08cea74 100644 --- a/src/modules/remote-access/useNetBirdClient.ts +++ b/src/modules/remote-access/useNetBirdClient.ts @@ -4,7 +4,6 @@ import { getBrowserInfo } from "@utils/helpers"; import { generateKeypair } from "@utils/wireguard"; import { trim } from "lodash"; import { useCallback, useEffect, useRef, useSyncExternalStore } from "react"; -import { IronRDPInputHandler } from "@/modules/remote-access/rdp/ironrdp-input-handler"; import { IronRDPWASMBridge } from "@/modules/remote-access/rdp/ironrdp-wasm-bridge"; import { RDPCertificateHandler } from "@/modules/remote-access/rdp/rdp-certificate-handler"; import { installWebSocketProxy } from "@/modules/remote-access/rdp/websocket-proxy"; @@ -13,7 +12,7 @@ const config = loadConfig(); const WASM_CONFIG = { SCRIPT_PATH: "/wasm_exec.js", - WASM_PATH: "https://pkgs.netbird.io/wasm/client", + WASM_PATH: config.wasmPath, INIT_TIMEOUT: 10000, RETRY_DELAY: 100, } as const; @@ -73,9 +72,8 @@ export const useNetBirdClient = () => { const rdpComponents = useRef<{ bridge: IronRDPWASMBridge | null; - inputHandler: typeof IronRDPInputHandler | null; certificateHandler: typeof RDPCertificateHandler | null; - }>({ bridge: null, inputHandler: null, certificateHandler: null }); + }>({ bridge: null, certificateHandler: null }); const loadWASMRuntime = useCallback((): Promise => { if (document.querySelector(`script[src="${WASM_CONFIG.SCRIPT_PATH}"]`)) { @@ -117,7 +115,6 @@ export const useNetBirdClient = () => { installWebSocketProxy(); rdpComponents.current = { bridge: new IronRDPWASMBridge(), - inputHandler: IronRDPInputHandler, certificateHandler: RDPCertificateHandler, }; }, []); @@ -209,8 +206,23 @@ export const useNetBirdClient = () => { return Promise.resolve(); }, []); + const detectSSHServerType = useCallback( + async (host: string, port: number): Promise => { + if (!netBirdClient.current?.detectSSHServerType) { + throw new Error("NetBird client not ready"); + } + return netBirdClient.current.detectSSHServerType(host, port); + }, + [], + ); + const createSSHConnection = useCallback( - async (host: string, port: number, username: string): Promise => { + async ( + host: string, + port: number, + username: string, + jwtToken?: string, + ): Promise => { if (!netBirdClient.current?.createSSHConnection) { throw new Error("Go client not ready"); } @@ -268,7 +280,7 @@ export const useNetBirdClient = () => { { name, wg_pub_key: keyPairs.publicKey, - rules: rules ?? ["tcp/22", "tcp/3389", "tcp/44338"], + rules: rules ?? ["tcp/22022", "tcp/3389", "tcp/44338"], }, `/${peerId}/temporary-access`, ); @@ -289,15 +301,15 @@ export const useNetBirdClient = () => { status, wasmStatus, error, - client: netBirdClient.current, // Expose the raw NetBird client + client: netBirdClient.current, ironRDPBridge: rdpComponents.current.bridge, - ironRDPInputHandler: rdpComponents.current.inputHandler, rdpCertificateHandler: rdpComponents.current.certificateHandler, initialize, initializeIronRDP, connect, connectTemporary, disconnect, + detectSSHServerType, createSSHConnection, makeRequest, proxyRequest, diff --git a/src/modules/setup-netbird-modal/MacOSTab.tsx b/src/modules/setup-netbird-modal/MacOSTab.tsx index 34e1956..f8c3047 100644 --- a/src/modules/setup-netbird-modal/MacOSTab.tsx +++ b/src/modules/setup-netbird-modal/MacOSTab.tsx @@ -79,23 +79,23 @@ export default function MacOSTab({
diff --git a/src/modules/setup-netbird-modal/WindowsTab.tsx b/src/modules/setup-netbird-modal/WindowsTab.tsx index fbaf06b..74337c1 100644 --- a/src/modules/setup-netbird-modal/WindowsTab.tsx +++ b/src/modules/setup-netbird-modal/WindowsTab.tsx @@ -1,11 +1,12 @@ import Button from "@components/Button"; import Code from "@components/Code"; +import { SelectDropdown } from "@components/select/SelectDropdown"; import Steps from "@components/Steps"; import TabsContentPadding, { TabsContent } from "@components/Tabs"; import { getNetBirdUpCommand, GRPC_API_ORIGIN } from "@utils/netbird"; import { DownloadIcon, PackageOpenIcon } from "lucide-react"; import Link from "next/link"; -import React from "react"; +import React, { useState } from "react"; import { OperatingSystem } from "@/interfaces/OperatingSystem"; import { HostnameParameter, @@ -24,6 +25,9 @@ export default function WindowsTab({ showSetupKeyInfo, hostname, }: Readonly) { + const [windowsUrl, setWindowsUrl] = useState( + "https://pkgs.netbird.io/windows/x64", + ); return ( @@ -35,10 +39,35 @@ export default function WindowsTab({

Download and run Windows Installer

+