feat(rdp): add RDP certificate verification dialog and related functionality

- Introduced a new `RdpCertificateVerifyDialog` component to handle RDP certificate verification requests.
- Updated the `App` component to manage RDP certificate requests and integrate the new dialog.
- Enhanced the `AppLayout` to include the RDP certificate verification dialog in the modal structure.
- Implemented state management for RDP display settings and integrated RDP session handling in the application.
- Added utility functions for RDP display management and clipboard handling, improving overall RDP session experience.
- Updated localization strings to support new RDP features and settings.
This commit is contained in:
Kang committed 2026-08-10 14:54:01 +08:00
1 parent 10b45cae9e
commit 0b65c1ed6a
19 files changed
+2127 -268

No files matched your search

+30
View File
@@ -3554,7 +3554,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f910b8dc8e7b8e001c61fd742be442e8604fb2499ded713cadf911e7645ade4"
dependencies = [
"ironrdp-client",
"ironrdp-cliprdr",
"ironrdp-connector",
"ironrdp-core",
"ironrdp-input",
"ironrdp-mstsgu",
"ironrdp-pdu",
@@ -3595,6 +3597,8 @@ dependencies = [
"anyhow",
"futures-util",
"ironrdp-cfg",
"ironrdp-cliprdr",
"ironrdp-cliprdr-native",
"ironrdp-connector",
"ironrdp-core",
"ironrdp-displaycontrol",
@@ -3617,6 +3621,31 @@ dependencies = [
"x509-cert",
]
[[package]]
name = "ironrdp-cliprdr"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb9050999a1e032f4313788ac5a6d06e897a4f672f1de2ed98683001fc1abf56"
dependencies = [
"bitflags 2.11.1",
"ironrdp-core",
"ironrdp-pdu",
"ironrdp-svc",
"tracing",
]
[[package]]
name = "ironrdp-cliprdr-native"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95cc7f249eb8a5c507fba3a5ee2ba57da716d0881c46421eab79e6fab1b51ae5"
dependencies = [
"ironrdp-cliprdr",
"ironrdp-core",
"tracing",
"windows 0.62.2",
]
[[package]]
name = "ironrdp-connector"
version = "0.10.0"
@@ -4943,6 +4972,7 @@ dependencies = [
"window-vibrancy",
"windows 0.61.3",
"windows-sys 0.61.2",
"x509-cert",
"zeroize",
"zip 8.2.0",
"zmodem2",
+2 -1
View File
@@ -157,10 +157,11 @@ encoding_rs = "0.8"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "gif", "webp"] }
rqrr = "0.9"
serialport = "4"
ironrdp = { version = "0.17", default-features = false, features = ["client", "connector", "rustls", "input", "pdu"] }
ironrdp = { version = "0.17", default-features = false, features = ["client", "client-clipboard", "cliprdr", "connector", "core", "rustls", "input", "pdu"] }
smallvec = "1"
strip-ansi-escapes = "0.2"
arboard = { version = "3", features = ["wayland-data-control"] }
x509-cert = { version = "0.2", default-features = false, features = ["std"] }
opendal = { version = "0.58", default-features = false, features = [
"executors-tokio",
"reqwest-rustls-tls",
+7 -5
View File
@@ -79,10 +79,12 @@ pub async fn close_rdp_session(
#[tauri::command]
pub async fn respond_rdp_certificate(
_state: tauri::State<'_, Arc<RdpSessionManager>>,
_request_id: String,
_accepted: bool,
_remember: bool,
state: tauri::State<'_, Arc<RdpSessionManager>>,
request_id: String,
accepted: bool,
remember: bool,
) -> AppResult<()> {
Ok(())
state
.respond_certificate(&request_id, accepted, remember)
.await
}
+1263 -248
View File
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
# Vendored RDP Dependencies
This directory contains local copies of upstream crates used by NyaTerm's RDP stack. Keep patches small and documented so the crates can be refreshed without turning them into long-lived forks.
## ironrdp-client
- Upstream: https://github.com/Devolutions/IronRDP
- Crate/version: `ironrdp-client` `0.1.0`
- Why vendored: NyaTerm needs runtime hooks that are not exposed by the published client API.
- NyaTerm changes:
- Adds an injectable server certificate verifier called after TLS/RDCleanPath certificate extraction and before RDP finalize.
- Adds an injectable CLIPRDR backend factory so NyaTerm can provide a text-only clipboard bridge instead of the native clipboard backend.
- Exposes the clipboard module for non-Windows builds when the clipboard feature is enabled.
## ironrdp-connector
- Upstream: https://github.com/Devolutions/IronRDP
- Crate/version: `ironrdp-connector` `0.10.0`
- Why vendored: It must stay in lockstep with the vendored IronRDP client and the current connector API used by NyaTerm.
- NyaTerm changes: no intentional feature patches in this round; keep local edits limited to compatibility fixes required by the client.
## picky
- Upstream: https://github.com/Devolutions/picky-rs
- Crate/version: `picky` `7.0.0-rc.25`
- Why vendored: Required by IronRDP/SSPI auth dependencies and pinned to match the vendored connector stack.
- NyaTerm changes: no intentional RDP behavior patches in this round.
## sspi
- Upstream: https://github.com/Devolutions/sspi-rs
- Crate/version: `sspi` `0.21.0`
- Why vendored: CredSSP/NLA support must remain compatible with the vendored IronRDP connector and pinned `picky` version.
- NyaTerm changes: no intentional RDP behavior patches in this round.
## Update Method
1. Record the current NyaTerm patches with `git diff -- src-tauri/vendor/<crate>`.
2. Replace the target crate from the matching upstream tag or revision.
3. Reapply only the documented NyaTerm patches, preferring upstream APIs if they now exist.
4. Run `cargo update --manifest-path src-tauri/Cargo.toml -p <crate>` if dependency metadata changed.
5. Run `cargo fmt`, `cargo test`, and `cargo clippy` for `src-tauri/Cargo.toml`.
6. Update this README with the new version/revision and any changed local patches.
+67
View File
@@ -1,4 +1,5 @@
use core::fmt;
use core::pin::Pin;
use core::str::FromStr;
use core::time::Duration;
#[cfg(all(windows, feature = "dvc-com-plugin"))]
@@ -15,6 +16,27 @@ use url::Url;
type StaticChannelFn = Arc<dyn Fn(&mut ironrdp_connector::ClientConnector, &PropertySet) + Send + Sync>;
type DvcChannelFn = Arc<dyn Fn(&mut ironrdp_dvc::DrdynvcClient, &PropertySet) + Send + Sync>;
#[cfg(feature = "clipboard")]
type CliprdrFactory = Arc<
dyn Fn(
Box<dyn ironrdp_cliprdr::backend::ClipboardMessageProxy>,
) -> Box<dyn ironrdp_cliprdr::backend::CliprdrBackendFactory + Send>
+ Send
+ Sync,
>;
pub type ServerCertificateVerifyFuture<'a> =
Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
pub trait ServerCertificateVerifier: fmt::Debug + Send + Sync {
fn verify_server_certificate<'a>(
&'a self,
host: &'a str,
port: u16,
der: Vec<u8>,
) -> ServerCertificateVerifyFuture<'a>;
}
/// Private registry of user-supplied static and dynamic virtual channel factories.
///
/// Cloneable via `Arc`; the factory closures are shared across reconnects.
@@ -61,6 +83,10 @@ pub struct Config {
pub(crate) kerberos_config: Option<ironrdp_connector::credssp::KerberosConfig>,
pub(crate) fake_events_interval: Option<Duration>,
pub(crate) channels: ChannelConfig,
pub(crate) certificate_verifier: Option<Arc<dyn ServerCertificateVerifier>>,
#[cfg(feature = "clipboard")]
pub(crate) cliprdr_factory: Option<CliprdrFactory>,
/// DVC channel ↔ named-pipe proxy configuration.
///
@@ -142,6 +168,15 @@ impl fmt::Debug for Config {
s.field("kerberos_config", &self.kerberos_config);
s.field("fake_events_interval", &self.fake_events_interval);
s.field("channels", &self.channels);
s.field(
"certificate_verifier",
&self.certificate_verifier.as_ref().map(|_| "<verifier>"),
);
#[cfg(feature = "clipboard")]
s.field(
"cliprdr_factory",
&self.cliprdr_factory.as_ref().map(|_| "<factory>"),
);
#[cfg(feature = "dvc-pipe-proxy")]
s.field("dvc_pipe_proxies", &self.dvc_pipe_proxies);
#[cfg(all(windows, feature = "dvc-com-plugin"))]
@@ -568,6 +603,9 @@ pub struct ConfigBuilder {
kerberos_config: Option<ironrdp_connector::credssp::KerberosConfig>,
fake_events_interval: Option<Duration>,
channels: ChannelConfig,
certificate_verifier: Option<Arc<dyn ServerCertificateVerifier>>,
#[cfg(feature = "clipboard")]
cliprdr_factory: Option<CliprdrFactory>,
#[cfg(feature = "dvc-pipe-proxy")]
dvc_pipe_proxies: Vec<DvcProxyInfo>,
#[cfg(all(windows, feature = "dvc-com-plugin"))]
@@ -921,6 +959,32 @@ impl ConfigBuilder {
self
}
/// Set a custom CLIPRDR backend factory.
#[cfg(feature = "clipboard")]
#[must_use]
pub fn with_cliprdr_factory<F>(mut self, factory: F) -> Self
where
F: Fn(
Box<dyn ironrdp_cliprdr::backend::ClipboardMessageProxy>,
) -> Box<dyn ironrdp_cliprdr::backend::CliprdrBackendFactory + Send>
+ Send
+ Sync
+ 'static,
{
self.cliprdr_factory = Some(Arc::new(factory));
self
}
/// Set a custom server-certificate verifier called after TLS upgrade and before RDP finalize.
#[must_use]
pub fn with_server_certificate_verifier(
mut self,
verifier: Arc<dyn ServerCertificateVerifier>,
) -> Self {
self.certificate_verifier = Some(verifier);
self
}
/// Enable or disable RDPDR (device redirection).
#[cfg(feature = "rdpdr")]
#[must_use]
@@ -1199,6 +1263,9 @@ impl ConfigBuilder {
kerberos_config,
fake_events_interval: self.fake_events_interval,
channels: self.channels,
certificate_verifier: self.certificate_verifier,
#[cfg(feature = "clipboard")]
cliprdr_factory: self.cliprdr_factory,
#[cfg(feature = "dvc-pipe-proxy")]
dvc_pipe_proxies: self.dvc_pipe_proxies,
#[cfg(all(windows, feature = "dvc-com-plugin"))]
+1 -1
View File
@@ -10,7 +10,7 @@
pub mod config;
pub mod rdp;
#[cfg(all(windows, feature = "clipboard"))]
#[cfg(feature = "clipboard")]
mod clipboard;
mod ws;
+35 -1
View File
@@ -151,6 +151,16 @@ impl RdpClient {
}
}
ClipboardType::Enable => {
if let Some(factory) = &self.config.cliprdr_factory {
use crate::clipboard::ClientClipboardMessageProxy;
cliprdr_factory = Some(factory(Box::new(ClientClipboardMessageProxy::new(
self.input_event_sender.clone(),
))));
#[cfg(windows)]
{
_win_clipboard = None;
}
} else {
#[cfg(windows)]
{
use crate::clipboard::ClientClipboardMessageProxy;
@@ -179,6 +189,7 @@ impl RdpClient {
let stub = StubClipboard::new();
cliprdr_factory = Some(stub.backend_factory());
}
}
}
}
}
@@ -514,7 +525,7 @@ async fn connect_rdcleanpath_transport(
let destination = config.destination.to_string();
let (upgraded, server_public_key) =
rdcleanpath_handshake(&mut framed, &mut connector, destination, rdcp.auth_token.clone(), None).await?;
rdcleanpath_handshake(config, &mut framed, &mut connector, destination, rdcp.auth_token.clone(), None).await?;
let connection_result = ironrdp_tokio::connect_finalize(
upgraded,
@@ -563,6 +574,10 @@ where
.ok_or_else(|| ironrdp_connector::general_err!("unable to extract tls server public key"))?
.to_owned();
verify_server_certificate(config, tls_cert)
.await
.map_err(|e| ironrdp_connector::custom_err!("server certificate verification", std::io::Error::other(e)))?;
let connection_result = ironrdp_tokio::connect_finalize(
upgraded,
connector,
@@ -577,9 +592,24 @@ where
Ok((connection_result, upgraded_framed))
}
async fn verify_server_certificate(config: &Config, cert: x509_cert::Certificate) -> Result<(), String> {
let Some(verifier) = config.certificate_verifier.as_ref() else {
return Ok(());
};
let der = {
use x509_cert::der::Encode as _;
cert.to_der()
.map_err(|error| format!("failed to encode server certificate: {error}"))?
};
verifier
.verify_server_certificate(config.destination.name(), config.destination.port(), der)
.await
}
// ── RDCleanPath handshake ─────────────────────────────────────────────────────
async fn rdcleanpath_handshake<S>(
config: &Config,
framed: &mut ironrdp_tokio::Framed<S>,
connector: &mut ironrdp_connector::ClientConnector,
destination: String,
@@ -712,6 +742,10 @@ where
.ok_or_else(|| ironrdp_connector::general_err!("subject public key BIT STRING is not aligned"))?
.to_owned();
verify_server_certificate(config, cert.clone())
.await
.map_err(|e| ironrdp_connector::custom_err!("server certificate verification", std::io::Error::other(e)))?;
let should_upgrade = ironrdp_tokio::skip_connect_begin(connector);
let upgraded = ironrdp_tokio::mark_as_upgraded(should_upgrade, connector);
+39
View File
@@ -9,6 +9,7 @@ import AppPanelContent from "./components/app/AppPanelContent";
import ExternalConnectionMatchDialog from "./components/dialog/connections/ExternalConnectionMatchDialog";
import type { HostKeyVerifyRequest } from "./components/dialog/connections/HostKeyVerifyDialog";
import type { OtpRequest } from "./components/dialog/connections/OtpDialog";
import type { RdpCertificateVerifyRequest } from "./components/dialog/connections/RdpCertificateVerifyDialog";
import type { SshAuthRequest } from "./components/dialog/connections/SshAuthDialog";
import TemporarySshLinkDialog from "./components/dialog/connections/TemporarySshLinkDialog";
import type { DockerSudoPasswordRequest } from "./components/dialog/docker/DockerSudoPasswordDialog";
@@ -153,6 +154,15 @@ function getConnectionSessionType(
return connection ? CONNECTION_SESSION_TYPES[connection.type] : "SSH";
}
function getRdpPaneDisplay(connection: SavedConnection | null | undefined) {
if (connection?.type !== "rdp") return undefined;
return {
remoteWidth: connection.display?.width ?? 1920,
remoteHeight: connection.display?.height ?? 1080,
scaleMode: connection.display?.mode === "fit-window" ? "fit" : "actual",
} as const;
}
function isSessionCreationCancelled(error: unknown) {
return getErrorMessage(error).toLowerCase().includes("session creation cancelled");
}
@@ -519,6 +529,9 @@ function App() {
const [hostKeyVerifyRequest, setHostKeyVerifyRequest] = useState<HostKeyVerifyRequest | null>(
null,
);
const [rdpCertificateRequests, setRdpCertificateRequests] = useState<
RdpCertificateVerifyRequest[]
>([]);
const lastCloudConflictRevisionRef = useRef<string | null>(null);
const modalChildWindowCount = useModalChildWindows();
@@ -633,6 +646,16 @@ function App() {
}),
);
unsubs.push(
listen<RdpCertificateVerifyRequest>("rdp-certificate-verify", (event) => {
if (!eventTargetsCurrentWindow(event.payload.targetWindowLabel)) return;
setRdpCertificateRequests((current) => {
if (current.some((item) => item.requestId === event.payload.requestId)) return current;
return [...current, event.payload];
});
}),
);
unsubs.push(
listen<{
requestId: string;
@@ -698,6 +721,7 @@ function App() {
name: connName,
type: sessionType,
connectionId,
display: getRdpPaneDisplay(conn),
});
} else {
const pending = addPendingTab(
@@ -706,6 +730,7 @@ function App() {
connectionId,
undefined,
anchorTabId ? { afterTabId: anchorTabId } : undefined,
{ display: getRdpPaneDisplay(conn) },
);
tabId = pending.tabId;
createRequestId = pending.createRequestId;
@@ -742,6 +767,12 @@ function App() {
createRequestId,
});
break;
case "rdp":
sessionId = await invoke<string>("create_rdp_session", {
connectionId,
createRequestId,
});
break;
default:
sessionId = await invoke<string>("create_ssh_session", {
connectionId,
@@ -989,6 +1020,9 @@ function App() {
connection.name,
getConnectionSessionType(connection),
connection.id,
undefined,
undefined,
{ display: getRdpPaneDisplay(connection) },
);
const { tabId, createRequestId } = pending;
@@ -3425,6 +3459,11 @@ function App() {
),
hostKeyVerifyRequest,
onHostKeyVerifyDone: () => setHostKeyVerifyRequest(null),
rdpCertificateVerifyRequest: rdpCertificateRequests[0] ?? null,
onRdpCertificateVerifyDone: (requestId) =>
setRdpCertificateRequests((current) =>
current.filter((item) => item.requestId !== requestId),
),
modalChildWindowCount,
locked: isLocked,
hasMasterPassword: !!appSettings.security.master_password,
+8
View File
@@ -10,6 +10,8 @@ import type { HostKeyVerifyRequest } from "@/components/dialog/connections/HostK
import { HostKeyVerifyDialog } from "@/components/dialog/connections/HostKeyVerifyDialog";
import type { OtpRequest } from "@/components/dialog/connections/OtpDialog";
import { OtpDialog } from "@/components/dialog/connections/OtpDialog";
import type { RdpCertificateVerifyRequest } from "@/components/dialog/connections/RdpCertificateVerifyDialog";
import { RdpCertificateVerifyDialog } from "@/components/dialog/connections/RdpCertificateVerifyDialog";
import type { SshAuthRequest } from "@/components/dialog/connections/SshAuthDialog";
import { SshAuthDialog } from "@/components/dialog/connections/SshAuthDialog";
import DockerSudoPasswordDialog, {
@@ -127,6 +129,8 @@ interface AppLayoutProps {
onDockerSudoPasswordDone: (requestId: string) => void;
hostKeyVerifyRequest: HostKeyVerifyRequest | null;
onHostKeyVerifyDone: () => void;
rdpCertificateVerifyRequest: RdpCertificateVerifyRequest | null;
onRdpCertificateVerifyDone: (requestId: string) => void;
modalChildWindowCount: number;
locked: boolean;
hasMasterPassword: boolean;
@@ -519,6 +523,10 @@ export default function AppLayout({
request={dialogs.hostKeyVerifyRequest}
onDone={dialogs.onHostKeyVerifyDone}
/>
<RdpCertificateVerifyDialog
request={dialogs.rdpCertificateVerifyRequest}
onDone={dialogs.onRdpCertificateVerifyDone}
/>
<TransferDuplicateDialog />
{dialogs.modalChildWindowCount > 0 && (
@@ -0,0 +1,155 @@
import { ShieldAlert, ShieldQuestion } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { invoke } from "@/lib/invoke";
import { logger } from "@/lib/logger";
export interface RdpCertificateVerifyRequest {
requestId: string;
sessionId: string;
host: string;
port: number;
fingerprint: string;
subject?: string | null;
issuer?: string | null;
validFrom?: string | null;
validTo?: string | null;
knownHostStatus: "match" | "changed" | "unknown" | string;
targetWindowLabel?: string | null;
}
interface RdpCertificateVerifyDialogProps {
request: RdpCertificateVerifyRequest | null;
onDone: (requestId: string) => void;
}
export function RdpCertificateVerifyDialog({ request, onDone }: RdpCertificateVerifyDialogProps) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const changed = request?.knownHostStatus === "changed";
const respond = async (accepted: boolean, remember: boolean) => {
if (!request || submitting) return;
setSubmitting(true);
try {
await invoke("respond_rdp_certificate", {
requestId: request.requestId,
accepted,
remember,
});
logger.info({
domain: "security.flow",
event: accepted ? "rdp_certificate.user_accepted" : "rdp_certificate.user_rejected",
message: accepted ? "User accepted RDP certificate" : "User rejected RDP certificate",
ids: { request_id: request.requestId, session_id: request.sessionId },
});
} catch (error) {
logger.error({
domain: "security.flow",
event: "rdp_certificate.response_failed",
message: "Failed to send RDP certificate response",
ids: { request_id: request.requestId, session_id: request.sessionId },
error,
});
}
setSubmitting(false);
onDone(request.requestId);
};
return (
<Dialog
disablePointerDismissal
open={!!request}
onOpenChange={(open) => {
if (!open) void respond(false, false);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
{changed ? (
<ShieldAlert className="h-4 w-4 text-destructive" />
) : (
<ShieldQuestion className="h-4 w-4 text-yellow-500" />
)}
{t("settings.rdpCertificateVerifyTitle")}
</DialogTitle>
<DialogDescription className="text-xs">
{changed
? t("settings.rdpCertificateVerifyChanged")
: t("settings.rdpCertificateVerifyNew")}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 overflow-hidden py-2 text-xs">
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1">
<span className="text-muted-foreground">{t("settings.hostKeyVerifyHost")}</span>
<span className="truncate font-mono" title={`${request?.host}:${request?.port}`}>
{request?.host}:{request?.port}
</span>
<span className="text-muted-foreground">{t("settings.hostKeyVerifyFingerprint")}</span>
<span className="break-all font-mono select-all">{request?.fingerprint}</span>
<span className="text-muted-foreground">{t("settings.rdpCertificateSubject")}</span>
<span className="truncate font-mono" title={request?.subject ?? ""}>
{request?.subject || t("common.unknown")}
</span>
<span className="text-muted-foreground">{t("settings.rdpCertificateIssuer")}</span>
<span className="truncate font-mono" title={request?.issuer ?? ""}>
{request?.issuer || t("common.unknown")}
</span>
<span className="text-muted-foreground">{t("settings.rdpCertificateValidity")}</span>
<span className="truncate font-mono">
{request?.validFrom || t("common.unknown")} -{" "}
{request?.validTo || t("common.unknown")}
</span>
</div>
{changed && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-2 text-[0.6875rem] text-destructive">
{t("settings.rdpCertificateVerifyWarning")}
</div>
)}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => void respond(false, false)}
disabled={submitting}
>
{t("settings.hostKeyVerifyReject")}
</Button>
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => void respond(true, false)}
disabled={submitting}
>
{t("settings.rdpCertificateAcceptOnce")}
</Button>
<Button
size="sm"
className="text-xs"
variant={changed ? "destructive" : "default"}
onClick={() => void respond(true, true)}
disabled={submitting}
>
{t("settings.rdpCertificateAcceptRemember")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+218 -3
View File
@@ -13,8 +13,15 @@ import {
} from "react";
import { Button } from "@/components/ui/button";
import { invoke } from "@/lib/invoke";
import {
buildRdpUnicodeInput,
rdpBeforeInputText,
rdpCompositionCommitText,
shouldUsePhysicalRdpKey,
} from "@/lib/rdpIme";
import { decodeRdpFramePatch, type RdpFramePatch } from "@/lib/rdpFrame";
import { buildRdpKeyEvent, type RdpInputEvent } from "@/lib/rdpInput";
import { decideFitWindowResize, keepDesktopSizeIfUnchanged } from "@/lib/rdpResize";
import { mapClientPointToRdpPixel } from "@/lib/rdpViewport";
import type { RdpSessionPane } from "@/types/global";
@@ -32,6 +39,29 @@ interface RdpStatePayload {
sessionId: string;
state: RdpSessionState;
message?: string | null;
errorKind?: string | null;
}
type RdpPointerPayload =
| { type: "default"; sessionId: string }
| { type: "hidden"; sessionId: string }
| { type: "position"; sessionId: string; x: number; y: number }
| {
type: "bitmap";
sessionId: string;
width: number;
height: number;
hotspotX: number;
hotspotY: number;
rgbaBase64: string;
};
interface RemoteCursorBitmap {
src: string;
width: number;
height: number;
hotspotX: number;
hotspotY: number;
}
interface RdpPaneHostProps {
@@ -80,6 +110,21 @@ function copyPatchToRgba(patch: RdpFramePatch, target: Uint8ClampedArray | Uint8
}
}
function rgbaBase64ToDataUrl(base64: string, width: number, height: number) {
const binary = atob(base64);
const bytes = new Uint8ClampedArray(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return "";
ctx.putImageData(new ImageData(bytes, width, height), 0, 0);
return canvas.toDataURL("image/png");
}
interface RdpCanvasRenderer {
draw(patch: RdpFramePatch): void;
dispose(): void;
@@ -301,10 +346,18 @@ function RdpPaneHost({
onConnectionError,
}: RdpPaneHostProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const imeRef = useRef<HTMLTextAreaElement | null>(null);
const cursorRef = useRef<HTMLDivElement | null>(null);
const rendererRef = useRef<RdpCanvasRenderer | null>(null);
const pressedKeysRef = useRef(new Set<string>());
const composingRef = useRef(false);
const pendingMouseMoveRef = useRef<{ x: number; y: number } | null>(null);
const mouseRafRef = useRef<number | null>(null);
const cursorRafRef = useRef<number | null>(null);
const pendingCursorRef = useRef<{ x: number; y: number } | null>(null);
const remoteCursorBitmapRef = useRef<RemoteCursorBitmap | null>(null);
const lastResizeRef = useRef<{ width: number; height: number } | null>(null);
const [state, setState] = useState<RdpSessionState>(pane.connectError ? "failed" : "connecting");
const [message, setMessage] = useState<string | null>(pane.connectError ?? null);
const [desktopSize, setDesktopSize] = useState({
@@ -329,7 +382,12 @@ function RdpPaneHost({
useEffect(() => {
const channel = new Channel<ArrayBuffer>((frame) => {
const patch = decodeRdpFramePatch(frame);
setDesktopSize({ width: patch.desktopWidth, height: patch.desktopHeight });
setDesktopSize((current) =>
keepDesktopSizeIfUnchanged(current, {
width: patch.desktopWidth,
height: patch.desktopHeight,
}),
);
const canvas = canvasRef.current;
if (!canvas) return;
rendererRef.current ??= createRdpRenderer(canvas);
@@ -364,10 +422,126 @@ function RdpPaneHost({
};
}, [onConnectionError, pane.sessionId]);
const applyCursorPosition = useCallback(() => {
cursorRafRef.current = null;
const cursor = cursorRef.current;
const canvas = canvasRef.current;
const container = containerRef.current;
const position = pendingCursorRef.current;
const bitmap = remoteCursorBitmapRef.current;
if (!cursor || !canvas || !container || !position || !bitmap) return;
const rect = canvas.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const scaleX = rect.width / Math.max(1, canvas.width);
const scaleY = rect.height / Math.max(1, canvas.height);
const x = rect.left - containerRect.left + position.x * scaleX - bitmap.hotspotX * scaleX;
const y = rect.top - containerRect.top + position.y * scaleY - bitmap.hotspotY * scaleY;
cursor.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
}, []);
useEffect(() => {
const unlisten = listen<RdpPointerPayload>(`rdp-pointer-${pane.sessionId}`, (event) => {
const canvas = canvasRef.current;
const cursor = cursorRef.current;
if (!canvas || !cursor) return;
if (event.payload.type === "default") {
remoteCursorBitmapRef.current = null;
cursor.replaceChildren();
cursor.style.display = "none";
canvas.style.cursor = "";
return;
}
canvas.style.cursor = "none";
if (event.payload.type === "hidden") {
cursor.style.display = "none";
return;
}
if (event.payload.type === "bitmap") {
const bitmap = {
src: rgbaBase64ToDataUrl(
event.payload.rgbaBase64,
event.payload.width,
event.payload.height,
),
width: event.payload.width,
height: event.payload.height,
hotspotX: event.payload.hotspotX,
hotspotY: event.payload.hotspotY,
};
remoteCursorBitmapRef.current = bitmap;
const img = document.createElement("img");
img.src = bitmap.src;
img.width = bitmap.width;
img.height = bitmap.height;
img.draggable = false;
cursor.replaceChildren(img);
cursor.style.display = "block";
return;
}
pendingCursorRef.current = { x: event.payload.x, y: event.payload.y };
cursor.style.display = remoteCursorBitmapRef.current ? "block" : "none";
if (cursorRafRef.current === null) {
cursorRafRef.current = requestAnimationFrame(applyCursorPosition);
}
});
return () => {
void unlisten.then((dispose) => dispose());
if (cursorRafRef.current !== null) cancelAnimationFrame(cursorRafRef.current);
cursorRafRef.current = null;
};
}, [applyCursorPosition, pane.sessionId]);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
if (pane.display?.scaleMode !== "fit") return;
let timer: number | null = null;
const syncResize = () => {
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
const rect = container.getBoundingClientRect();
const decision = decideFitWindowResize({
mode: "fit-window",
visible: active && visible && state === "active" && rect.width > 0 && rect.height > 0,
containerWidth: rect.width,
containerHeight: rect.height,
lastWidth: lastResizeRef.current?.width,
lastHeight: lastResizeRef.current?.height,
});
if (!decision.shouldResize) return;
lastResizeRef.current = { width: decision.width, height: decision.height };
void invoke("rdp_resize", {
sessionId: pane.sessionId,
width: decision.width,
height: decision.height,
}).catch(() => {});
}, 200);
};
const observer = new ResizeObserver(syncResize);
observer.observe(container);
syncResize();
return () => {
observer.disconnect();
if (timer !== null) window.clearTimeout(timer);
};
}, [active, pane.display?.scaleMode, pane.sessionId, state, visible]);
useEffect(() => {
if (!active || !visible) releaseAllKeys();
}, [active, releaseAllKeys, visible]);
useEffect(() => {
if (active && visible) {
imeRef.current?.focus({ preventScroll: true });
}
}, [active, visible]);
useEffect(() => {
window.addEventListener("blur", releaseAllKeys);
return () => {
@@ -377,7 +551,8 @@ function RdpPaneHost({
}, [releaseAllKeys]);
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
(event: ReactKeyboardEvent<HTMLElement>) => {
if (!shouldUsePhysicalRdpKey(event.nativeEvent)) return;
const inputEvent = buildRdpKeyEvent(event.nativeEvent, "key-down");
if (!inputEvent) return;
event.preventDefault();
@@ -389,7 +564,8 @@ function RdpPaneHost({
);
const handleKeyUp = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
(event: ReactKeyboardEvent<HTMLElement>) => {
if (!shouldUsePhysicalRdpKey(event.nativeEvent)) return;
const inputEvent = buildRdpKeyEvent(event.nativeEvent, "key-up");
if (!inputEvent) return;
event.preventDefault();
@@ -434,12 +610,45 @@ function RdpPaneHost({
return (
<div
ref={containerRef}
className="group relative flex h-full w-full min-h-0 min-w-0 items-center justify-center overflow-hidden bg-black outline-none"
tabIndex={active ? 0 : -1}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onBlur={releaseAllKeys}
onPointerDown={() => imeRef.current?.focus({ preventScroll: true })}
>
<textarea
ref={imeRef}
aria-hidden="true"
className="pointer-events-none absolute h-px w-px resize-none opacity-0"
tabIndex={active ? 0 : -1}
defaultValue=""
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={(event) => {
composingRef.current = false;
const text = rdpCompositionCommitText(event.data || event.currentTarget.value);
event.currentTarget.value = "";
if (text) void sendInputBatch(buildRdpUnicodeInput(text));
}}
onBeforeInput={(event) => {
const text = rdpBeforeInputText(event.nativeEvent as InputEvent);
if (!text || composingRef.current) return;
event.preventDefault();
event.currentTarget.value = "";
void sendInputBatch(buildRdpUnicodeInput(text));
}}
onKeyDown={(event) => {
if (!shouldUsePhysicalRdpKey(event.nativeEvent)) return;
handleKeyDown(event);
}}
onKeyUp={(event) => {
if (!shouldUsePhysicalRdpKey(event.nativeEvent)) return;
handleKeyUp(event);
}}
/>
<canvas
ref={canvasRef}
className="block object-contain"
@@ -473,6 +682,12 @@ function RdpPaneHost({
}}
/>
<div
ref={cursorRef}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-10 hidden"
/>
<div className="absolute left-2 top-2 flex items-center gap-1 rounded border border-white/15 bg-black/65 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100">
<Monitor className="h-3.5 w-3.5" />
<span className="max-w-40 truncate">{pane.name}</span>
+28 -2
View File
@@ -14,7 +14,12 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { invoke } from "@/lib/invoke";
import type { RdpCertificatePolicy, RdpClipboardMode, SavedPassword } from "@/types/global";
import type {
RdpCertificatePolicy,
RdpClipboardMode,
RdpDisplayMode,
SavedPassword,
} from "@/types/global";
interface RdpFormProps {
host: string;
@@ -39,6 +44,8 @@ interface RdpFormProps {
setDisplayWidth: (value: number) => void;
displayHeight: number;
setDisplayHeight: (value: number) => void;
displayMode: RdpDisplayMode;
setDisplayMode: (value: RdpDisplayMode) => void;
clipboardMode: RdpClipboardMode;
setClipboardMode: (value: RdpClipboardMode) => void;
reconnectEnabled: boolean;
@@ -74,6 +81,8 @@ export function RdpForm({
setDisplayWidth,
displayHeight,
setDisplayHeight,
displayMode,
setDisplayMode,
clipboardMode,
setClipboardMode,
reconnectEnabled,
@@ -226,7 +235,24 @@ export function RdpForm({
</div>
</div>
<div className="grid gap-3 sm:grid-cols-4">
<div className="grid gap-3 sm:grid-cols-5">
<div>
<Label className="text-xs font-medium text-foreground/80">
{t("dialog.rdpDisplayMode")}
</Label>
<Select
value={displayMode === "native" ? "fixed" : displayMode}
onValueChange={(value) => setDisplayMode(value as RdpDisplayMode)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fit-window">{t("dialog.rdpDisplayFitWindow")}</SelectItem>
<SelectItem value="fixed">{t("dialog.rdpDisplayFixed")}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs font-medium text-foreground/80">{t("dialog.rdpWidth")}</Label>
<NumberInput
+10 -6
View File
@@ -44,6 +44,7 @@ import type {
AppSettings,
Group,
PaneSplitDirection,
RdpSessionPane,
SavedConnection,
SessionPane,
SyncGroup,
@@ -56,6 +57,10 @@ import { logger, setLoggerLevel } from "../lib/logger";
import { DEFAULT_TERMINAL_FONT_SIZE } from "../lib/terminalFontSize";
import { isPrimaryMainWindow } from "../lib/windowManager";
type PaneConnectingUpdates = Partial<Pick<SessionPane, "name" | "type" | "connectionId">> & {
display?: RdpSessionPane["display"];
};
interface AppContextType {
// Tabs
tabs: Tab[];
@@ -76,6 +81,7 @@ interface AppContextType {
connectionId?: string,
extra?: Partial<Pick<Tab, "customName" | "tabColor">>,
options?: { afterTabId?: string },
paneOverrides?: Partial<SessionPane>,
) => PendingTabCreation;
/** Swap the active pane's temporary sessionId for the real one and clear the connecting flag. */
updateTabSession: (tabId: string, sessionId: string) => void;
@@ -89,7 +95,7 @@ interface AppContextType {
markPaneConnecting: (
tabId: string,
paneId: string,
updates?: Partial<Pick<SessionPane, "name" | "type" | "connectionId">>,
updates?: PaneConnectingUpdates,
) => string | null;
hasTab: (tabId: string) => boolean;
hasPane: (tabId: string, paneId: string) => boolean;
@@ -778,9 +784,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
connectionId?: string,
extra?: Partial<Pick<Tab, "customName" | "tabColor">>,
options?: { afterTabId?: string },
paneOverrides?: Partial<SessionPane>,
): PendingTabCreation => {
const createRequestId = createSessionRequestId();
const pane = createSessionPane(name, type, connectionId, {
...paneOverrides,
connecting: true,
createRequestId,
});
@@ -880,11 +888,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
);
const markPaneConnecting = useCallback(
(
tabId: string,
paneId: string,
updates?: Partial<Pick<SessionPane, "name" | "type" | "connectionId">>,
) => {
(tabId: string, paneId: string, updates?: PaneConnectingUpdates) => {
const createRequestId = createSessionRequestId();
const nextTabs = tabsRef.current.map((tab) =>
tab.id === tabId
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import {
buildRdpUnicodeInput,
rdpBeforeInputText,
rdpCompositionCommitText,
shouldUsePhysicalRdpKey,
} from "./rdpIme";
describe("rdpIme", () => {
it("emits committed composition text once", () => {
expect(rdpCompositionCommitText("你好")).toBe("你好");
expect(buildRdpUnicodeInput("你好")).toEqual([{ type: "unicode", text: "你好" }]);
});
it("uses beforeinput for plain text but ignores composing updates", () => {
expect(
rdpBeforeInputText({
data: "a",
inputType: "insertText",
isComposing: false,
}),
).toBe("a");
expect(
rdpBeforeInputText({
data: "ni",
inputType: "insertCompositionText",
isComposing: true,
}),
).toBeNull();
});
it("keeps shortcuts and navigation on the physical key path", () => {
expect(
shouldUsePhysicalRdpKey({
key: "c",
ctrlKey: true,
altKey: false,
metaKey: false,
}),
).toBe(true);
expect(
shouldUsePhysicalRdpKey({
key: "ArrowLeft",
ctrlKey: false,
altKey: false,
metaKey: false,
}),
).toBe(true);
expect(
shouldUsePhysicalRdpKey({
key: "a",
ctrlKey: false,
altKey: false,
metaKey: false,
}),
).toBe(false);
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { RdpInputEvent } from "./rdpInput";
const PHYSICAL_TEXT_KEYS = new Set([
"Backspace",
"Enter",
"Tab",
"Escape",
"Delete",
"Insert",
"Home",
"End",
"PageUp",
"PageDown",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
]);
export function shouldUsePhysicalRdpKey(
event: Pick<KeyboardEvent, "key" | "ctrlKey" | "altKey" | "metaKey">,
) {
return event.ctrlKey || event.altKey || event.metaKey || PHYSICAL_TEXT_KEYS.has(event.key);
}
export function buildRdpUnicodeInput(text: string): RdpInputEvent[] {
return text.length > 0 ? [{ type: "unicode", text }] : [];
}
export function rdpBeforeInputText(
event: Pick<InputEvent, "data" | "inputType" | "isComposing">,
): string | null {
if (event.isComposing) return null;
if (event.inputType !== "insertText" && event.inputType !== "insertCompositionText") return null;
return event.data || null;
}
export function rdpCompositionCommitText(text: string): string | null {
return text.length > 0 ? text : null;
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
decideFitWindowResize,
keepDesktopSizeIfUnchanged,
normalizeRdpDisplayMode,
} from "./rdpResize";
describe("rdpResize", () => {
it("normalizes unsupported display modes to fixed", () => {
expect(normalizeRdpDisplayMode("fit-window")).toBe("fit-window");
expect(normalizeRdpDisplayMode("native")).toBe("fixed");
expect(normalizeRdpDisplayMode("fixed")).toBe("fixed");
});
it("does not resize fixed or invisible sessions", () => {
expect(
decideFitWindowResize({
mode: "fixed",
visible: true,
containerWidth: 1200,
containerHeight: 800,
}).shouldResize,
).toBe(false);
expect(
decideFitWindowResize({
mode: "fit-window",
visible: false,
containerWidth: 1200,
containerHeight: 800,
}).shouldResize,
).toBe(false);
});
it("clamps fit-window resize and skips duplicate sizes", () => {
expect(
decideFitWindowResize({
mode: "fit-window",
visible: true,
containerWidth: 320,
containerHeight: 200,
}),
).toEqual({ shouldResize: true, width: 640, height: 480 });
expect(
decideFitWindowResize({
mode: "fit-window",
visible: true,
containerWidth: 640,
containerHeight: 480,
lastWidth: 640,
lastHeight: 480,
}).shouldResize,
).toBe(false);
});
it("keeps desktop object identity when the size is unchanged", () => {
const current = { width: 1920, height: 1080 };
expect(keepDesktopSizeIfUnchanged(current, { width: 1920, height: 1080 })).toBe(current);
expect(keepDesktopSizeIfUnchanged(current, { width: 1280, height: 720 })).toEqual({
width: 1280,
height: 720,
});
});
});
+50
View File
@@ -0,0 +1,50 @@
export const RDP_MIN_WIDTH = 640;
export const RDP_MIN_HEIGHT = 480;
export const RDP_MAX_WIDTH = 7680;
export const RDP_MAX_HEIGHT = 4320;
export interface RdpResizeDecisionInput {
mode?: string | null;
visible: boolean;
containerWidth: number;
containerHeight: number;
lastWidth?: number | null;
lastHeight?: number | null;
}
export interface RdpResizeDecision {
shouldResize: boolean;
width: number;
height: number;
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
}
export function normalizeRdpDisplayMode(mode?: string | null): "fit-window" | "fixed" {
return mode === "fit-window" ? "fit-window" : "fixed";
}
export function decideFitWindowResize(input: RdpResizeDecisionInput): RdpResizeDecision {
if (normalizeRdpDisplayMode(input.mode) !== "fit-window" || !input.visible) {
return { shouldResize: false, width: input.lastWidth ?? 0, height: input.lastHeight ?? 0 };
}
const width = clamp(Math.round(input.containerWidth), RDP_MIN_WIDTH, RDP_MAX_WIDTH);
const height = clamp(Math.round(input.containerHeight), RDP_MIN_HEIGHT, RDP_MAX_HEIGHT);
const sameSize = input.lastWidth === width && input.lastHeight === height;
return { shouldResize: !sameSize, width, height };
}
export interface RdpDesktopSize {
width: number;
height: number;
}
export function keepDesktopSizeIfUnchanged(
current: RdpDesktopSize,
next: RdpDesktopSize,
): RdpDesktopSize {
return current.width === next.width && current.height === next.height ? current : next;
}
+9 -1
View File
@@ -43,6 +43,7 @@ import type {
ProxyConfig,
RdpCertificatePolicy,
RdpClipboardMode,
RdpDisplayMode,
RecordingMode,
SavedConnection,
SftpSettings,
@@ -147,6 +148,7 @@ export default function NewSessionPage() {
const [currentTab, setCurrentTab] = useState("ssh");
const [rdpUseNla, setRdpUseNla] = useState(true);
const [rdpCertificatePolicy, setRdpCertificatePolicy] = useState<RdpCertificatePolicy>("prompt");
const [rdpDisplayMode, setRdpDisplayMode] = useState<RdpDisplayMode>("fit-window");
const [rdpDisplayWidth, setRdpDisplayWidth] = useState(1920);
const [rdpDisplayHeight, setRdpDisplayHeight] = useState(1080);
const [rdpClipboardMode, setRdpClipboardMode] = useState<RdpClipboardMode>("text-only");
@@ -300,6 +302,9 @@ export default function NewSessionPage() {
setHasPassword(found.auth?.has_password || false);
setRdpUseNla(found.security?.use_nla ?? true);
setRdpCertificatePolicy(found.security?.certificate_policy ?? "prompt");
setRdpDisplayMode(
found.display?.mode === "native" ? "fixed" : (found.display?.mode ?? "fit-window"),
);
setRdpDisplayWidth(found.display?.width ?? 1920);
setRdpDisplayHeight(found.display?.height ?? 1080);
setRdpClipboardMode(found.clipboard?.mode ?? "text-only");
@@ -383,6 +388,7 @@ export default function NewSessionPage() {
setTelnetSendSga(true);
setRdpUseNla(true);
setRdpCertificatePolicy("prompt");
setRdpDisplayMode("fit-window");
setRdpDisplayWidth(1920);
setRdpDisplayHeight(1080);
setRdpClipboardMode("text-only");
@@ -822,7 +828,7 @@ export default function NewSessionPage() {
certificate_policy: rdpCertificatePolicy,
},
display: {
mode: "fit-window",
mode: rdpDisplayMode,
width: rdpDisplayWidth,
height: rdpDisplayHeight,
color_depth: 32,
@@ -1303,6 +1309,8 @@ export default function NewSessionPage() {
setDisplayWidth={setRdpDisplayWidth}
displayHeight={rdpDisplayHeight}
setDisplayHeight={setRdpDisplayHeight}
displayMode={rdpDisplayMode}
setDisplayMode={setRdpDisplayMode}
clipboardMode={rdpClipboardMode}
setClipboardMode={setRdpClipboardMode}
reconnectEnabled={rdpReconnectEnabled}