mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5f61efc1a | ||
|
|
3503141c56 | ||
|
|
a27e4654cf | ||
|
|
b9569f48d5 | ||
|
|
9694bb753d |
No files matched your search
@@ -39,7 +39,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "*.ts"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "*.js"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
tag: v0.0.2
|
||||
fileName: "ironrdp_web_bg.wasm"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { VerticalTabs } from "@components/VerticalTabs";
|
||||
import * as Tabs from "@radix-ui/react-tabs";
|
||||
import { Gauge, ScrollText } from "lucide-react";
|
||||
import { ExternalLinkIcon, Gauge, ScrollText, ServerIcon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { Suspense, useEffect, useState } from "react";
|
||||
import React, { lazy, Suspense, useEffect, useState } from "react";
|
||||
import AgentNetworkIcon from "@/assets/icons/AgentNetworkIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { useAgentNetworkMode } from "@/modules/agent-network/useAgentNetworkMode";
|
||||
import AgentAccountControlsCard from "@/modules/agent-network/AgentAccountControlsCard";
|
||||
import AgentBudgetRulesTable from "@/modules/agent-network/AgentBudgetRulesTable";
|
||||
import AIProvidersProvider from "@/modules/agent-network/AIProvidersProvider";
|
||||
|
||||
const ClustersTable = lazy(
|
||||
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
|
||||
);
|
||||
|
||||
const TAB_BUDGET_SETTINGS = "budget-settings";
|
||||
const TAB_LOG_SETTINGS = "log-settings";
|
||||
const TAB_CLUSTERS = "clusters";
|
||||
|
||||
// AgentNetworkConfigurationPage holds the configuration surfaces extracted from
|
||||
// Usage & Logs — global limits and log-collection controls — using the same
|
||||
@@ -27,6 +35,7 @@ const TAB_LOG_SETTINGS = "log-settings";
|
||||
// Settings page so it reads as "settings for the Agent Network".
|
||||
export default function AgentNetworkConfigurationPage() {
|
||||
const { permission } = usePermissions();
|
||||
const { only: agentNetworkOnly } = useAgentNetworkMode();
|
||||
const queryParams = useSearchParams();
|
||||
const queryTab = queryParams.get("tab");
|
||||
const [tab, setTab] = useState(queryTab ?? TAB_BUDGET_SETTINGS);
|
||||
@@ -47,6 +56,10 @@ export default function AgentNetworkConfigurationPage() {
|
||||
<ScrollText size={14} />
|
||||
Log Collection
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value={TAB_CLUSTERS}>
|
||||
<ServerIcon size={14} />
|
||||
Clusters
|
||||
</VerticalTabs.Trigger>
|
||||
</VerticalTabs.List>
|
||||
<RestrictedAccess
|
||||
page={"Configuration"}
|
||||
@@ -82,6 +95,27 @@ export default function AgentNetworkConfigurationPage() {
|
||||
<AgentAccountControlsCard />
|
||||
</Suspense>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value={TAB_CLUSTERS} className={"w-full"}>
|
||||
<ConfigTabHeader
|
||||
label={"Clusters"}
|
||||
href={"/agent-network/configuration?tab=clusters"}
|
||||
>
|
||||
{agentNetworkOnly
|
||||
? "Proxy clusters route your agents' traffic to AI providers and run on your own infrastructure. Add multiple clusters to scale your environment."
|
||||
: "Proxy clusters route inbound traffic to your services. Shared clusters are run by the platform; account clusters (self-hosted) run on your own infrastructure."}{" "}
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Learn more
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</ConfigTabHeader>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ClustersTable />
|
||||
</Suspense>
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</AIProvidersProvider>
|
||||
</PeersProvider>
|
||||
|
||||
@@ -7,6 +7,8 @@ import useFetchApi from "@utils/api";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import { RDPCertificateModal } from "@/modules/remote-access/rdp/RDPCertificateModal";
|
||||
import { RDPCredentialsModal } from "@/modules/remote-access/rdp/RDPCredentialsModal";
|
||||
import { useRDPQueryParams } from "@/modules/remote-access/rdp/useRDPQueryParams";
|
||||
@@ -138,6 +140,8 @@ function RDPSession({ peer }: Props) {
|
||||
domain: credentials.domain,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
dynamicResize:
|
||||
getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS,
|
||||
});
|
||||
if (result === RDPStatus.CONNECTED) {
|
||||
connected.current = true;
|
||||
@@ -182,7 +186,18 @@ function RDPSession({ peer }: Props) {
|
||||
if (client.error) {
|
||||
sendErrorNotification("NetBird Client Error", client.error);
|
||||
}
|
||||
}, [rdp, client]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rdp.error, client.error]);
|
||||
|
||||
/**
|
||||
* Return to the credentials screen when an established session drops with an
|
||||
* error, so the user can see what happened and retry.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (connected.current && rdp.error) {
|
||||
reset();
|
||||
}
|
||||
}, [rdp.error, reset]);
|
||||
|
||||
/**
|
||||
* Close credentials modal when RDP is connected
|
||||
|
||||
@@ -64,9 +64,11 @@ export default function PoliciesProvider({ children }: Props) {
|
||||
return created.id!;
|
||||
};
|
||||
|
||||
const sources = await Promise.all(
|
||||
(rule.sources ?? []).map(resolveGroup),
|
||||
).then((ids) => ids.filter(Boolean) as string[]);
|
||||
const sources = rule.sourceResource
|
||||
? undefined
|
||||
: await Promise.all(
|
||||
(rule.sources ?? []).map(resolveGroup),
|
||||
).then((ids) => ids.filter(Boolean) as string[]);
|
||||
|
||||
const destinations = rule.destinationResource
|
||||
? undefined
|
||||
|
||||
@@ -21,7 +21,7 @@ const ENDPOINT_EXISTS_ERROR_CODES = [401, 403, 405];
|
||||
* activation be picked up without forcing the user to clear storage.
|
||||
*/
|
||||
const LICENSE_CACHE_PREFIX = "netbird-licensed:";
|
||||
const LICENSE_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
const LICENSE_CACHE_TTL_MS = 60 * 10 * 1000;
|
||||
|
||||
const licenseCacheKey = () => `${LICENSE_CACHE_PREFIX}${loadConfig().apiOrigin}`;
|
||||
|
||||
|
||||
@@ -70,8 +70,8 @@ export function IntegrationCard<T>({
|
||||
)}
|
||||
>
|
||||
<div className={"flex flex-col gap-4"}>
|
||||
<div className={"flex justify-between"}>
|
||||
<div className={"flex gap-4"}>
|
||||
<div className={"flex justify-between gap-3"}>
|
||||
<div className={"flex gap-4 min-w-0"}>
|
||||
<div
|
||||
className={
|
||||
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70"
|
||||
@@ -84,25 +84,24 @@ export function IntegrationCard<T>({
|
||||
priority={true}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={""}>{name}</h3>
|
||||
<div className={"min-w-0"}>
|
||||
<h3 className={"truncate"}>{name}</h3>
|
||||
<InlineLink
|
||||
href={url.href}
|
||||
target={"_blank"}
|
||||
className={"text-sm font-light"}
|
||||
className={"text-sm font-light min-w-0 max-w-full"}
|
||||
variant={"faded"}
|
||||
>
|
||||
{url.title}
|
||||
<ExternalLinkIcon size={12} />
|
||||
<span className={"truncate"}>{url.title}</span>
|
||||
<ExternalLinkIcon size={12} className={"shrink-0"} />
|
||||
</InlineLink>
|
||||
</div>
|
||||
</div>
|
||||
{!hideSwitch && (
|
||||
<div className={"flex items-center"}>
|
||||
<div className={"flex items-center shrink-0"}>
|
||||
<ToggleSwitch
|
||||
checked={switchState}
|
||||
onCheckedChange={onEnabledChange}
|
||||
className={"grow"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,27 +3,31 @@ import { DropdownMenuItem } from "@components/DropdownMenu";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { CircleHelpIcon, MonitorIcon } 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 { RDPTooltip } from "@/modules/remote-access/rdp/RDPTooltip";
|
||||
import { SSHCredentialsModal } from "@/modules/remote-access/ssh/SSHCredentialsModal";
|
||||
|
||||
type Props = {
|
||||
peer: Peer;
|
||||
isDropdown?: boolean;
|
||||
};
|
||||
|
||||
const RDP_SUPPORTED_OS = new Set([
|
||||
OperatingSystem.WINDOWS,
|
||||
OperatingSystem.LINUX,
|
||||
OperatingSystem.FREEBSD,
|
||||
]);
|
||||
|
||||
export const RDPButton = ({ peer, isDropdown = false }: Props) => {
|
||||
const [modal, setModal] = useState(false);
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const os = getOperatingSystem(peer?.os);
|
||||
if (!RDP_SUPPORTED_OS.has(os)) return null;
|
||||
|
||||
const disabled = !peer.connected || !permission.peers.update;
|
||||
const hasPermission = permission.peers.update;
|
||||
|
||||
const isWindows = getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS;
|
||||
|
||||
const openRDPPage = () => {
|
||||
window.open(
|
||||
`/peer/rdp?id=${peer.id}`,
|
||||
@@ -33,40 +37,36 @@ export const RDPButton = ({ peer, isDropdown = false }: Props) => {
|
||||
};
|
||||
|
||||
return (
|
||||
isWindows && (
|
||||
<>
|
||||
<div>
|
||||
<RDPTooltip
|
||||
disabled={!disabled}
|
||||
hasPermission={hasPermission}
|
||||
side={isDropdown ? "left" : "top"}
|
||||
<div>
|
||||
<RDPTooltip
|
||||
disabled={!disabled}
|
||||
hasPermission={hasPermission}
|
||||
side={isDropdown ? "left" : "top"}
|
||||
>
|
||||
{isDropdown ? (
|
||||
<DropdownMenuItem
|
||||
onClick={openRDPPage}
|
||||
disabled={disabled}
|
||||
className={"w-full"}
|
||||
>
|
||||
{isDropdown ? (
|
||||
<DropdownMenuItem
|
||||
onClick={openRDPPage}
|
||||
disabled={disabled}
|
||||
className={"w-full"}
|
||||
>
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
<MonitorIcon size={14} className={"shrink-0"} />
|
||||
RDP
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={openRDPPage}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MonitorIcon size={16} />
|
||||
RDP
|
||||
{disabled && <CircleHelpIcon size={12} />}
|
||||
</Button>
|
||||
)}
|
||||
</RDPTooltip>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
<div className={"flex gap-3 items-center w-full"}>
|
||||
<MonitorIcon size={14} className={"shrink-0"} />
|
||||
RDP
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={openRDPPage}
|
||||
disabled={disabled}
|
||||
>
|
||||
<MonitorIcon size={16} />
|
||||
RDP
|
||||
{disabled && <CircleHelpIcon size={12} />}
|
||||
</Button>
|
||||
)}
|
||||
</RDPTooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,8 @@ import { useCallback, useMemo, useState } from "react";
|
||||
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import { getOperatingSystem } from "@hooks/useOperatingSystem";
|
||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
||||
import {
|
||||
ChevronsLeftRightEllipsis,
|
||||
ExternalLinkIcon,
|
||||
@@ -38,7 +40,11 @@ export const RDPCredentialsModal = ({
|
||||
error,
|
||||
loading,
|
||||
}: Props) => {
|
||||
const [username, setUsername] = useState("Administrator");
|
||||
const defaultUsername =
|
||||
getOperatingSystem(peer?.os) === OperatingSystem.WINDOWS
|
||||
? "Administrator"
|
||||
: "root";
|
||||
const [username, setUsername] = useState(defaultUsername);
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const [port, setPort] = useState("3389");
|
||||
|
||||
@@ -2,13 +2,18 @@ export interface IronRDPModule {
|
||||
SessionBuilder: new () => SessionBuilder;
|
||||
DesktopSize: new (width: number, height: number) => DesktopSize;
|
||||
ClipboardData?: new () => ClipboardData;
|
||||
Extension?: new (ident: string, value: unknown) => Extension;
|
||||
default?: () => Promise<void>;
|
||||
init?: () => Promise<void>;
|
||||
setup?: (logLevel: string) => void;
|
||||
}
|
||||
interface DesktopSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
interface Extension {
|
||||
free(): void;
|
||||
}
|
||||
interface SessionBuilder {
|
||||
username(user: string): SessionBuilder;
|
||||
password(pwd: string): SessionBuilder;
|
||||
@@ -18,6 +23,7 @@ interface SessionBuilder {
|
||||
renderCanvas(canvas: HTMLCanvasElement): SessionBuilder;
|
||||
proxyAddress(url: string): SessionBuilder;
|
||||
authToken(token: string): SessionBuilder;
|
||||
extension(ext: Extension): SessionBuilder;
|
||||
setCursorStyleCallback(cb: (style: string) => void): void;
|
||||
setCursorStyleCallbackContext(ctx: unknown): void;
|
||||
remoteClipboardChangedCallback(cb: (data: ClipboardData) => void): void;
|
||||
@@ -28,6 +34,13 @@ export interface RDPSession {
|
||||
run(): Promise<TerminationInfo>;
|
||||
shutdown(): void;
|
||||
sendInput(input: unknown): void;
|
||||
resize(
|
||||
width: number,
|
||||
height: number,
|
||||
scaleFactor?: number | null,
|
||||
physicalWidth?: number | null,
|
||||
physicalHeight?: number | null,
|
||||
): void;
|
||||
onClipboardPaste?(content: ClipboardData): Promise<void>;
|
||||
}
|
||||
interface TerminationInfo {
|
||||
@@ -67,6 +80,30 @@ declare global {
|
||||
|
||||
const IRON_RDP_PKG = "/ironrdp-pkg/ironrdp_web.js";
|
||||
|
||||
// IronErrorKind values as exposed by the IronRDP wasm module's kind() method,
|
||||
// indexed by the enum's numeric value.
|
||||
const IRON_ERROR_KIND_NAMES = [
|
||||
"General",
|
||||
"WrongPassword",
|
||||
"LogonFailure",
|
||||
"AccessDenied",
|
||||
"RDCleanPath",
|
||||
"ProxyConnect",
|
||||
"NegotiationFailure",
|
||||
] as const;
|
||||
|
||||
// User-facing message per error kind. "General" is intentionally absent: it
|
||||
// carries no structured detail, so its reason is read from the backtrace.
|
||||
const IRON_ERROR_KIND_MESSAGES: Record<string, string> = {
|
||||
WrongPassword: "Incorrect username or password.",
|
||||
LogonFailure: "Login failed. Check your username and password.",
|
||||
AccessDenied: "The remote host denied access.",
|
||||
RDCleanPath: "Could not establish the RDP connection to the host.",
|
||||
ProxyConnect: "Could not reach the remote host.",
|
||||
NegotiationFailure:
|
||||
"RDP negotiation failed. The host may not support the required security protocol.",
|
||||
};
|
||||
|
||||
export class IronRDPWASMBridge {
|
||||
private ironrdp: IronRDPModule | null = null;
|
||||
private initialized = false;
|
||||
@@ -91,6 +128,13 @@ export class IronRDPWASMBridge {
|
||||
await ironrdpModule.init();
|
||||
}
|
||||
}
|
||||
if (ironrdpModule.setup) {
|
||||
try {
|
||||
ironrdpModule.setup("info");
|
||||
} catch (e) {
|
||||
console.warn("IronRDP log setup failed:", e);
|
||||
}
|
||||
}
|
||||
this.ironrdp = ironrdpModule;
|
||||
this.initialized = true;
|
||||
if (window.onIronRDPReady) {
|
||||
@@ -112,6 +156,8 @@ export class IronRDPWASMBridge {
|
||||
netbirdClient?: {
|
||||
createRDPProxy: (hostname: string, port: string) => Promise<string>;
|
||||
},
|
||||
onSessionEnd?: (error: string | null, sessionId: string) => void,
|
||||
enableDisplayControl = false,
|
||||
): Promise<string> {
|
||||
if (!this.initialized) {
|
||||
await this.initialize();
|
||||
@@ -144,6 +190,15 @@ export class IronRDPWASMBridge {
|
||||
config.height,
|
||||
);
|
||||
builder.desktopSize(desktopSize);
|
||||
|
||||
// Enable the Display Control dynamic channel so the desktop can be
|
||||
// resized in-session (Session.resize) instead of reconnecting. Gated to
|
||||
// hosts known to handle it (Windows); xrdp mishandles the reactivation.
|
||||
if (enableDisplayControl && this.ironrdp.Extension) {
|
||||
builder.extension(
|
||||
new this.ironrdp.Extension("display_control", true),
|
||||
);
|
||||
}
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
@@ -172,7 +227,7 @@ export class IronRDPWASMBridge {
|
||||
if (enableClipboard) {
|
||||
this.startClipboardEventListeners();
|
||||
}
|
||||
this.startSession(session, sessionId);
|
||||
this.startSession(session, sessionId, onSessionEnd);
|
||||
return sessionId;
|
||||
} catch (error) {
|
||||
console.error(`IronRDP connection failed:`, error);
|
||||
@@ -193,16 +248,22 @@ export class IronRDPWASMBridge {
|
||||
});
|
||||
}
|
||||
|
||||
private startSession(session: RDPSession, sessionId: string): void {
|
||||
private startSession(
|
||||
session: RDPSession,
|
||||
sessionId: string,
|
||||
onSessionEnd?: (error: string | null, sessionId: string) => void,
|
||||
): void {
|
||||
session
|
||||
.run()
|
||||
.then((termInfo) => {
|
||||
this.cleanupSession(session, sessionId);
|
||||
onSessionEnd?.(null, sessionId);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("IronRDP session error:", err);
|
||||
this.logIronError(err);
|
||||
this.cleanupSession(session, sessionId);
|
||||
throw Error(err);
|
||||
onSessionEnd?.(this.getReadableError(err), sessionId);
|
||||
});
|
||||
}
|
||||
private cleanupSession(session: RDPSession, sessionId: string): void {
|
||||
@@ -282,11 +343,24 @@ export class IronRDPWASMBridge {
|
||||
|
||||
private getReadableError(error: unknown): string {
|
||||
const ironError = error as any;
|
||||
if (typeof ironError?.backtrace === "function") {
|
||||
if (ironError && ironError.__wbg_ptr) {
|
||||
try {
|
||||
const formatted = this.formatRDCleanPathError(ironError.backtrace());
|
||||
if (formatted.startsWith("Connection failed:")) {
|
||||
return formatted;
|
||||
if (typeof ironError.kind === "function") {
|
||||
const kindName = IRON_ERROR_KIND_NAMES[ironError.kind()];
|
||||
if (kindName && IRON_ERROR_KIND_MESSAGES[kindName]) {
|
||||
return IRON_ERROR_KIND_MESSAGES[kindName];
|
||||
}
|
||||
}
|
||||
if (typeof ironError.backtrace === "function") {
|
||||
const backtrace = ironError.backtrace();
|
||||
const formatted = this.formatRDCleanPathError(backtrace);
|
||||
if (formatted.startsWith("Connection failed:")) {
|
||||
return formatted;
|
||||
}
|
||||
const reason = this.extractIronReason(backtrace);
|
||||
if (reason) {
|
||||
return `RDP session error: ${reason}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to generic handling below.
|
||||
@@ -298,6 +372,19 @@ export class IronRDPWASMBridge {
|
||||
return "RDP connection failed";
|
||||
}
|
||||
|
||||
// extractIronReason pulls the human-readable reason out of an IronRDP
|
||||
// backtrace like `[IO channel] reason: unhandled PDU: "Update PDU"`. The
|
||||
// wasm error has no structured field for this, so General-kind errors can
|
||||
// only expose it as free text.
|
||||
private extractIronReason(backtrace: string): string {
|
||||
if (!backtrace) return "";
|
||||
const reasonMatch = backtrace.match(/reason:\s*(.+)/);
|
||||
if (reasonMatch) {
|
||||
return reasonMatch[1].trim();
|
||||
}
|
||||
return backtrace.replace(/^\[[^\]]*\]\s*/, "").trim();
|
||||
}
|
||||
|
||||
private logIronError(error: unknown): void {
|
||||
const ironError = error as any;
|
||||
if (!ironError || !ironError.__wbg_ptr) return;
|
||||
@@ -310,16 +397,7 @@ export class IronRDPWASMBridge {
|
||||
}
|
||||
if (ironError.kind) {
|
||||
const errorKind = ironError.kind();
|
||||
const errorKindNames = [
|
||||
"General",
|
||||
"WrongPassword",
|
||||
"LogonFailure",
|
||||
"AccessDenied",
|
||||
"RDCleanPath",
|
||||
"ProxyConnect",
|
||||
"NegotiationFailure",
|
||||
];
|
||||
const errorKindName = errorKindNames[errorKind] || "Unknown";
|
||||
const errorKindName = IRON_ERROR_KIND_NAMES[errorKind] || "Unknown";
|
||||
console.error("IronRDP error kind:", errorKindName, `(${errorKind})`);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -330,6 +408,12 @@ export class IronRDPWASMBridge {
|
||||
return this.sessions.get(sessionId) || null;
|
||||
}
|
||||
|
||||
resize(sessionId: string, width: number, height: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || typeof session.resize !== "function") return;
|
||||
session.resize(width, height);
|
||||
}
|
||||
|
||||
disconnect(sessionId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
@@ -13,6 +13,10 @@ interface RDPConfig {
|
||||
domain?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
// dynamicResize enables the Display Control channel and in-session resize.
|
||||
// Only reliable against Windows hosts; xrdp mishandles the reactivation, so
|
||||
// non-Windows peers fall back to reconnect-on-resize.
|
||||
dynamicResize?: boolean;
|
||||
}
|
||||
|
||||
export interface RDPCredentials {
|
||||
@@ -186,6 +190,14 @@ export const useRemoteDesktop = (client: any) => {
|
||||
canvas,
|
||||
true,
|
||||
client.client,
|
||||
(sessionError: string | null, endedSessionId: string) => {
|
||||
// Ignore terminations from superseded sessions (e.g. a resize
|
||||
// reconnect) so they can't tear down a newer connection.
|
||||
if (endedSessionId !== session.current?.id) return;
|
||||
resetState();
|
||||
if (sessionError) setError(sessionError);
|
||||
},
|
||||
rdpConfig.dynamicResize ?? false,
|
||||
);
|
||||
|
||||
// Store the ironrdp module and session for the input handler hook
|
||||
@@ -265,7 +277,9 @@ export const useRemoteDesktop = (client: any) => {
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle window resize events - reconnect with new dimensions
|
||||
* Handle window resize events. Hosts that support the Display Control channel
|
||||
* (Windows) resize the live session in place; others reconnect with the new
|
||||
* dimensions.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
@@ -279,6 +293,31 @@ export const useRemoteDesktop = (client: any) => {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (lastConnectedConfigRef.current.dynamicResize) {
|
||||
// In-session resize via Display Control, no reconnect.
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.width = width;
|
||||
canvasRef.current.height = height;
|
||||
}
|
||||
|
||||
if (session.current && client?.ironRDPBridge) {
|
||||
client.ironRDPBridge.resize(session.current.id, width, height);
|
||||
}
|
||||
|
||||
lastConnectedConfigRef.current = {
|
||||
...lastConnectedConfigRef.current!,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
canvasRef?.current?.focus();
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsResizing(true);
|
||||
|
||||
// Debounce resize handling for 1 second
|
||||
@@ -317,7 +356,7 @@ export const useRemoteDesktop = (client: any) => {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [status, connect]);
|
||||
}, [status, connect, client]);
|
||||
|
||||
/**
|
||||
* Auto accept certificate if previously accepted (for reconnects)
|
||||
|
||||
Reference in new issue
Block a user