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:
1 parent
10b45cae9e
commit
0b65c1ed6a
19 files changed
+2127
-268
No files matched your search
Generated
+30
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Vendored
+43
@@ -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
@@ -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
@@ -10,7 +10,7 @@
|
||||
pub mod config;
|
||||
pub mod rdp;
|
||||
|
||||
#[cfg(all(windows, feature = "clipboard"))]
|
||||
#[cfg(feature = "clipboard")]
|
||||
mod clipboard;
|
||||
|
||||
mod ws;
|
||||
+35
-1
@@ -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
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user