migrate runtime and packaging to Rust WebRTC
ci / rust (push) Canceled after 0s
ci / native-client (push) Canceled after 0s
ci / package-windows-client (push) Canceled after 0s
ci / linux-agent (push) Canceled after 0s
ci / edge-service (push) Canceled after 0s
ci / coturn-pop (push) Canceled after 0s
ci / package-windows-host (push) Canceled after 0s

This commit is contained in:
曾志威
2026-08-14 16:08:28 +08:00
parent 19a8e03a83
commit 5ef362ba22
81 changed files with 1426 additions and 14314 deletions
+15 -50
View File
@@ -16,70 +16,35 @@ jobs:
with:
components: clippy,rustfmt
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo clippy --locked -p remotedesk-webrtc-core -p remotedesk-native-gui --all-targets -- -D warnings
- run: cargo test --workspace
web:
runs-on: windows-latest
defaults:
run:
working-directory: client/web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: client/web/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm run build
package-preview:
native-client:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: client/web/package-lock.json
- run: npm ci
working-directory: client/web
- name: Build preview package
- run: cargo test --locked -p remotedesk-webrtc-core
- run: cargo build --locked --release -p remotedesk-native-gui
package-windows-client:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: Build native portable preview
shell: pwsh
run: ./packaging/windows/package-preview.ps1
- uses: actions/upload-artifact@v4
with:
name: remotedesk-m0-windows-preview
path: |
artifacts/*.zip
artifacts/SHA256SUMS.txt
if-no-files-found: error
package-installer:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: client/web/package-lock.json
- run: npm ci
working-directory: client/web
- name: Test update manifest generator contract
run: node ./packaging/windows/test-update-manifest.mjs
- name: Build Windows installer
- name: Build native Windows installer
shell: pwsh
run: ./packaging/windows/package-installer.ps1
- uses: actions/upload-artifact@v4
with:
name: remotedesk-m0-windows-installer
name: remotedesk-native-windows-packages
path: |
artifacts/*.zip
artifacts/*.msi
artifacts/SHA256SUMS.txt
artifacts/INSTALLER-SHA256SUMS.txt
if-no-files-found: error
-8
View File
@@ -23,14 +23,6 @@ jobs:
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: client/web/package-lock.json
- name: Install web dependencies
working-directory: client/web
run: npm ci
- name: Validate release tag and repository version
id: version
shell: pwsh
Generated
+237 -2324
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -4,9 +4,9 @@ members = [
"agent/agent-core",
"agent/agent-runtime",
"agent/windows-agent",
"client/app-shell",
"client/native-gui",
"client/crates/client-core",
"client/crates/webrtc-core",
"client/helpers/control-service",
"client/helpers/credential-store",
"client/helpers/native-video",
+18 -35
View File
@@ -679,7 +679,7 @@ mod tests {
}
#[test]
fn xorg_shm_is_rejected_by_strict_policy_and_allowed_by_compatibility() {
fn xorg_shm_is_rejected_by_the_only_policy() {
let capture = CaptureMemoryPath::CpuMemory;
let candidates = [candidate(
"renderD128",
@@ -700,15 +700,6 @@ mod tests {
path: CaptureMemoryPath::CpuMemory
}
);
let selected = select_encoder(
DesktopBackend::XorgShmCompatibility,
ZeroCopyPolicy::RequiredEndToEnd,
&capture,
&candidates,
)
.unwrap();
assert_eq!(selected.transfer_mode, TransferMode::CpuUpload);
}
#[test]
@@ -774,7 +765,7 @@ mod tests {
}
#[test]
fn compatibility_policy_explicitly_allows_cross_adapter_copy() {
fn cross_adapter_copy_is_always_rejected() {
let capture = CaptureMemoryPath::DmaBuf {
adapter: adapter("gpu-0"),
};
@@ -786,23 +777,23 @@ mod tests {
},
)];
let selected = select_encoder(
let error = select_encoder(
DesktopBackend::WaylandPipeWire,
ZeroCopyPolicy::RequiredEndToEnd,
&capture,
&candidates,
)
.unwrap();
assert_eq!(selected.transfer_mode, TransferMode::CrossAdapterCopy);
.unwrap_err();
assert_eq!(
selected.protocol_status(),
MemoryPathStatus::CrossAdapterCopy
error,
SelectionError::NoVerifiedSameAdapterEncoder {
capture_adapter: Some(adapter("gpu-0"))
}
);
}
#[test]
fn opaque_capture_is_only_available_in_compatibility_mode() {
fn opaque_capture_is_always_rejected() {
let capture = CaptureMemoryPath::OpaqueGpu {
adapter: Some(adapter("gpu-0")),
};
@@ -825,16 +816,6 @@ mod tests {
path: capture.clone()
}
);
let selected = select_encoder(
DesktopBackend::WaylandPipeWire,
ZeroCopyPolicy::RequiredEndToEnd,
&capture,
&candidates,
)
.unwrap();
assert_eq!(selected.transfer_mode, TransferMode::OpaqueGpuPath);
assert!(!selected.transfer_mode.is_verified_zero_copy());
}
#[test]
@@ -929,7 +910,7 @@ mod tests {
}
#[test]
fn compatibility_revalidation_accepts_software_selection() {
fn revalidation_rejects_software_selection() {
let zero_copy = EncoderSelection {
adapter: adapter("gpu-0"),
encoder_name: "vaapi-h264".into(),
@@ -943,18 +924,20 @@ mod tests {
.unwrap();
assert!(pipeline.resize(Resolution::new(1280, 720).unwrap()));
pipeline
let error = pipeline
.finish_revalidation(EncoderSelection {
adapter: adapter("cpu"),
encoder_name: "software-h264".into(),
transfer_mode: TransferMode::Software,
})
.unwrap();
assert_eq!(pipeline.state(), PipelineState::Active);
.unwrap_err();
assert_eq!(
pipeline.selection().unwrap().protocol_status(),
MemoryPathStatus::Software
error,
PipelineTransitionError::SelectionRejectedByPolicy {
policy: ZeroCopyPolicy::RequiredEndToEnd,
status: MemoryPathStatus::Software,
}
);
assert_eq!(pipeline.state(), PipelineState::Revalidating);
}
}
+16 -46
View File
@@ -971,7 +971,7 @@ mod tests {
}
#[test]
fn compatibility_reports_each_cross_adapter_downgrade() {
fn cross_adapter_multi_display_plan_is_rejected() {
let topology = two_display_topology(7);
let sources = [source(1, "gpu-0"), source(2, "gpu-1")];
let candidate = candidate(
@@ -987,28 +987,16 @@ mod tests {
],
);
let plan = MultiDisplayCapturePlan::create(
DesktopBackend::WaylandPipeWire,
ZeroCopyPolicy::RequiredEndToEnd,
&topology,
&sources,
&DisplaySelection::all(7),
&[candidate],
)
.unwrap();
assert_eq!(
plan.compliance,
MultiDisplayCompliance::CompatibilityDegraded
);
assert_eq!(
plan.paths[0].transfer_mode,
TransferMode::SameAdapterZeroCopy
);
assert_eq!(plan.paths[1].transfer_mode, TransferMode::CrossAdapterCopy);
assert_eq!(
plan.paths[1].transfer_mode.protocol_status(),
MemoryPathStatus::CrossAdapterCopy
MultiDisplayCapturePlan::create(
DesktopBackend::WaylandPipeWire,
ZeroCopyPolicy::RequiredEndToEnd,
&topology,
&sources,
&DisplaySelection::all(7),
&[candidate],
),
Err(MultiDisplayPlanError::NoVerifiedSingleAdapterEncoder)
);
}
@@ -1044,17 +1032,14 @@ mod tests {
}
#[test]
fn strict_rejects_degraded_imports_while_compatibility_reports_them() {
fn degraded_imports_are_always_rejected() {
let topology = topology(7, vec![descriptor(1, 0, 0, 1920, 1080, true)]);
let sources = [source(1, "gpu-0")];
for (probe_result, expected_mode) in [
(ImportProbeResult::CpuUpload, TransferMode::CpuUpload),
(ImportProbeResult::Software, TransferMode::Software),
(
ImportProbeResult::OpaqueGpuPath,
TransferMode::OpaqueGpuPath,
),
for probe_result in [
ImportProbeResult::CpuUpload,
ImportProbeResult::Software,
ImportProbeResult::OpaqueGpuPath,
] {
let candidate = candidate("gpu-0", vec![probe(1, probe_result)]);
assert_eq!(
@@ -1068,21 +1053,6 @@ mod tests {
),
Err(MultiDisplayPlanError::NoVerifiedSingleAdapterEncoder)
);
let plan = MultiDisplayCapturePlan::create(
DesktopBackend::WaylandPipeWire,
ZeroCopyPolicy::RequiredEndToEnd,
&topology,
&sources,
&DisplaySelection::all(7),
&[candidate],
)
.unwrap();
assert_eq!(
plan.compliance,
MultiDisplayCompliance::CompatibilityDegraded
);
assert_eq!(plan.paths[0].transfer_mode, expected_mode);
}
}
@@ -1104,7 +1074,7 @@ mod tests {
&DisplaySelection::all(7),
&[incomplete],
),
Err(MultiDisplayPlanError::NoCommonEncoder)
Err(MultiDisplayPlanError::NoVerifiedSingleAdapterEncoder)
);
}
+1
View File
@@ -41,6 +41,7 @@ futures-util = "0.3"
rand = "0.9"
rcgen = "0.14"
remotedesk-protocol = { path = "../../protocol" }
remotedesk-webrtc-core = { path = "../../client/crates/webrtc-core" }
reqwest = { version = "0.12.28", default-features = false, features = ["json", "rustls-tls"] }
rtc = "0.20.1"
rustls = { version = "0.23", default-features = false, features = ["logging", "ring", "std", "tls12"] }
+2
View File
@@ -13,6 +13,7 @@ mod linux_desktop;
mod opus_capture;
mod pairing;
mod protocol;
mod quality;
mod state;
#[cfg(all(target_os = "linux", feature = "wayland-eis"))]
mod wayland_eis;
@@ -100,6 +101,7 @@ pub use protocol::{
OPUS_AUDIO_PROTOCOL_MINOR, PROTOCOL_MAJOR, PROTOCOL_MINOR, ServerEvent, ShellCommand,
ShellEvent,
};
pub use quality::{QualityController, QualityLadderEntry, QualitySample, QualityState};
pub use state::{
AgentState, ClientGrant, DeviceIdentity, EdgePresenceStatus, PairingGrant, StateError,
unix_timestamp,
+236
View File
@@ -0,0 +1,236 @@
use std::time::Duration;
/// Network/encoder health buckets used by the session quality policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum QualityState {
Good,
Fair,
Poor,
Critical,
}
/// One bounded sample collected from WebRTC/RDP diagnostics.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct QualitySample {
pub rtt: Duration,
pub loss_fraction: f32,
pub jitter: Duration,
pub available_bitrate_bps: u64,
pub encode_queue: Duration,
}
/// Output ladder entry. The controller only moves between entries and never
/// exceeds the user supplied ceiling.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct QualityLadderEntry {
pub width: u16,
pub height: u16,
pub frames_per_second: u8,
pub bitrate_bps: u64,
}
const DEFAULT_LADDER: [QualityLadderEntry; 6] = [
QualityLadderEntry {
width: 3840,
height: 2160,
frames_per_second: 60,
bitrate_bps: 20_000_000,
},
QualityLadderEntry {
width: 3840,
height: 2160,
frames_per_second: 30,
bitrate_bps: 12_000_000,
},
QualityLadderEntry {
width: 2560,
height: 1440,
frames_per_second: 30,
bitrate_bps: 8_000_000,
},
QualityLadderEntry {
width: 1920,
height: 1080,
frames_per_second: 30,
bitrate_bps: 5_000_000,
},
QualityLadderEntry {
width: 1280,
height: 720,
frames_per_second: 30,
bitrate_bps: 2_500_000,
},
QualityLadderEntry {
width: 1280,
height: 720,
frames_per_second: 15,
bitrate_bps: 1_000_000,
},
];
/// Adaptive policy with fast degradation and hysteretic recovery.
#[derive(Clone, Debug)]
pub struct QualityController {
ladder: Vec<QualityLadderEntry>,
ceiling: usize,
current: usize,
state: QualityState,
short_loss: f32,
short_rtt: Duration,
short_jitter: Duration,
bad_samples: u8,
good_samples: u8,
}
impl QualityController {
/// Creates a controller using the documented six-step ladder.
pub fn new(max_width: u16, max_height: u16, max_fps: u8, max_bitrate_bps: u64) -> Self {
let mut ladder = DEFAULT_LADDER
.into_iter()
.filter(|entry| {
entry.width <= max_width
&& entry.height <= max_height
&& entry.frames_per_second <= max_fps
&& entry.bitrate_bps <= max_bitrate_bps
})
.collect::<Vec<_>>();
if ladder.is_empty() {
ladder.push(QualityLadderEntry {
width: max_width.max(200),
height: max_height.max(200),
frames_per_second: max_fps.clamp(1, 60),
bitrate_bps: max_bitrate_bps.max(128_000),
});
}
let ceiling = ladder.len() - 1;
Self {
ladder,
ceiling,
current: 0,
state: QualityState::Good,
short_loss: 0.0,
short_rtt: Duration::ZERO,
short_jitter: Duration::ZERO,
bad_samples: 0,
good_samples: 0,
}
}
#[must_use]
pub fn state(&self) -> QualityState {
self.state
}
#[must_use]
pub fn current(&self) -> QualityLadderEntry {
self.ladder[self.current]
}
/// Records a sample and returns true when the active ladder entry changed.
pub fn observe(&mut self, sample: QualitySample) -> bool {
const ALPHA: f32 = 0.35;
self.short_loss =
self.short_loss * (1.0 - ALPHA) + sample.loss_fraction.clamp(0.0, 1.0) * ALPHA;
self.short_rtt = ewma_duration(self.short_rtt, sample.rtt, ALPHA);
self.short_jitter = ewma_duration(self.short_jitter, sample.jitter, ALPHA);
let next_state = if self.short_loss > 0.12
|| self.short_rtt > Duration::from_millis(500)
|| sample.encode_queue > Duration::from_millis(250)
{
QualityState::Critical
} else if self.short_loss >= 0.05
|| self.short_rtt > Duration::from_millis(250)
|| self.short_jitter > Duration::from_millis(80)
{
QualityState::Poor
} else if self.short_loss >= 0.01
|| self.short_rtt > Duration::from_millis(120)
|| self.short_jitter > Duration::from_millis(30)
{
QualityState::Fair
} else {
QualityState::Good
};
self.state = next_state;
if next_state == QualityState::Good {
self.bad_samples = 0;
self.good_samples = self.good_samples.saturating_add(1);
if self.good_samples >= 10 && self.current > 0 {
self.current -= 1;
self.good_samples = 0;
return true;
}
} else {
self.good_samples = 0;
self.bad_samples = self.bad_samples.saturating_add(1);
let threshold = if next_state == QualityState::Critical {
1
} else {
2
};
if self.bad_samples >= threshold && self.current < self.ceiling {
self.current += 1;
self.bad_samples = 0;
return true;
}
}
false
}
}
fn ewma_duration(previous: Duration, current: Duration, alpha: f32) -> Duration {
let value =
previous.as_secs_f64() * f64::from(1.0 - alpha) + current.as_secs_f64() * f64::from(alpha);
Duration::from_secs_f64(value)
}
#[cfg(test)]
mod tests {
use super::*;
fn good() -> QualitySample {
QualitySample {
rtt: Duration::from_millis(20),
loss_fraction: 0.0,
jitter: Duration::from_millis(2),
available_bitrate_bps: 20_000_000,
encode_queue: Duration::ZERO,
}
}
#[test]
fn degrades_quickly_and_recovers_with_hysteresis() {
let mut controller = QualityController::new(3840, 2160, 60, 20_000_000);
let poor = QualitySample {
rtt: Duration::from_millis(300),
loss_fraction: 0.10,
jitter: Duration::from_millis(100),
available_bitrate_bps: 1_000_000,
encode_queue: Duration::ZERO,
};
assert!(!controller.observe(poor));
assert!(controller.observe(poor));
assert_eq!(controller.state(), QualityState::Poor);
let degraded = controller.current();
for _ in 0..100 {
controller.observe(good());
}
assert_eq!(controller.state(), QualityState::Good);
assert!(controller.current().bitrate_bps >= degraded.bitrate_bps);
}
#[test]
fn ceiling_is_respected() {
let mut controller = QualityController::new(1280, 720, 15, 1_000_000);
let critical = QualitySample {
rtt: Duration::from_secs(1),
loss_fraction: 0.5,
jitter: Duration::from_millis(200),
available_bitrate_bps: 1,
encode_queue: Duration::from_secs(1),
};
for _ in 0..20 {
controller.observe(critical);
}
assert_eq!(controller.current().width, 1280);
assert_eq!(controller.current().frames_per_second, 15);
}
}
+29 -43
View File
@@ -38,7 +38,11 @@ use webrtc::rtp_transceiver::RtpSender;
use crate::protocol::DESKTOP_MAX_OPUS_PACKET_BYTES;
use crate::{EdgeNegotiationClient, EdgeNegotiationError};
use remotedesk_protocol::{EdgeNegotiationKind, MAX_ICE_CANDIDATE_BYTES, MAX_SDP_BYTES};
use remotedesk_protocol::{EdgeNegotiationKind, MAX_SDP_BYTES};
use remotedesk_webrtc_core::{
IceServerConfig as SharedIceServerConfig, SignalKind as SharedSignalKind,
SignalMessage as SharedSignalMessage, validate_ice_servers,
};
const H264_PAYLOAD_TYPE: u8 = 102;
const H264_RTX_PAYLOAD_TYPE: u8 = 103;
@@ -48,10 +52,6 @@ const TRACK_QUEUE_CAPACITY: usize = 4;
const POLL_INTERVAL: Duration = Duration::from_millis(100);
const MIN_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_CONNECT_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_ICE_SERVERS: usize = 8;
const MAX_ICE_URLS_PER_SERVER: usize = 8;
const MAX_ICE_URL_BYTES: usize = 2_048;
const MAX_CREDENTIAL_BYTES: usize = 1_024;
const MAX_H264_ACCESS_UNIT_BYTES: usize = 16 * 1024 * 1024;
const DEFAULT_VIDEO_FRAME_DURATION: Duration = Duration::from_micros(33_333);
@@ -142,32 +142,19 @@ impl WebRtcSessionConfig {
if self.bind_address.port() != 0
|| self.connect_timeout < MIN_CONNECT_TIMEOUT
|| self.connect_timeout > MAX_CONNECT_TIMEOUT
|| self.ice_servers.len() > MAX_ICE_SERVERS
{
return Err(WebRtcSessionError::InvalidConfiguration);
}
for server in &self.ice_servers {
if server.urls.is_empty()
|| server.urls.len() > MAX_ICE_URLS_PER_SERVER
|| server.username.len() > MAX_CREDENTIAL_BYTES
|| server.credential.len() > MAX_CREDENTIAL_BYTES
|| server.username.is_empty() != server.credential.is_empty()
{
return Err(WebRtcSessionError::InvalidConfiguration);
}
for url in &server.urls {
if url.is_empty()
|| url.len() > MAX_ICE_URL_BYTES
|| url.chars().any(char::is_control)
|| !matches!(
url.split_once(':').map(|(scheme, _)| scheme),
Some("stun" | "stuns" | "turn" | "turns")
)
{
return Err(WebRtcSessionError::InvalidConfiguration);
}
}
}
let servers = self
.ice_servers
.iter()
.map(|server| SharedIceServerConfig {
urls: server.urls.clone(),
username: server.username.clone(),
credential: server.credential.clone(),
})
.collect::<Vec<_>>();
validate_ice_servers(&servers).map_err(|_| WebRtcSessionError::InvalidConfiguration)?;
Ok(())
}
}
@@ -1278,22 +1265,21 @@ fn validate_signal_payload(
kind: EdgeNegotiationKind,
payload: &str,
) -> Result<(), WebRtcSessionError> {
let valid = match kind {
EdgeNegotiationKind::Offer | EdgeNegotiationKind::Answer => {
!payload.is_empty() && payload.len() <= MAX_SDP_BYTES && !payload.contains('\0')
let shared_kind = match kind {
EdgeNegotiationKind::Offer => SharedSignalKind::Offer,
EdgeNegotiationKind::Answer => SharedSignalKind::Answer,
EdgeNegotiationKind::IceCandidate => SharedSignalKind::IceCandidate,
EdgeNegotiationKind::IceEnd => SharedSignalKind::IceEnd,
EdgeNegotiationKind::Restart => {
return payload
.is_empty()
.then_some(())
.ok_or(WebRtcSessionError::InvalidSignal);
}
EdgeNegotiationKind::IceCandidate => {
!payload.is_empty()
&& payload.len() <= MAX_ICE_CANDIDATE_BYTES
&& !payload.contains('\0')
}
EdgeNegotiationKind::IceEnd | EdgeNegotiationKind::Restart => payload.is_empty(),
};
if valid {
Ok(())
} else {
Err(WebRtcSessionError::InvalidSignal)
}
SharedSignalMessage::new(shared_kind, payload)
.map(|_| ())
.map_err(|_| WebRtcSessionError::InvalidSignal)
}
#[cfg(test)]
@@ -1353,7 +1339,7 @@ mod tests {
assert!(
WebRtcSignalMessage::new(
EdgeNegotiationKind::IceCandidate,
"x".repeat(MAX_ICE_CANDIDATE_BYTES + 1),
"x".repeat(remotedesk_protocol::MAX_ICE_CANDIDATE_BYTES + 1),
)
.is_err()
);
+1 -71
View File
@@ -1,15 +1,8 @@
//! Windows Desktop Duplication compatibility capture.
//!
//! The staging/readback path is deliberately reported as software/cpu_upload.
//! It is a compatibility path, not an end-to-end GPU zero-copy pipeline. An
//! IDD swap-chain consumer can replace this module without changing the agent
//! control protocol or frame envelope.
//! Windows Desktop Duplication capture feeding the hardware encoder probe.
#[cfg(windows)]
mod windows_capture {
use anyhow::Context as _;
use flate2::{Compression, write::ZlibEncoder};
use std::io::Write as _;
use std::time::{Duration, Instant};
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
use windows::Win32::Graphics::Direct3D11::{
@@ -24,19 +17,9 @@ mod windows_capture {
};
use windows::core::Interface as _;
const FRAME_MAGIC: &[u8; 4] = b"RDWF";
const FRAME_VERSION: u8 = 2;
const FRAME_HEADER_LEN: usize = 40;
const CODEC_BGRA_ZLIB: u8 = 1;
const MAX_DIMENSION: u32 = 16_384;
const MAX_RAW_FRAME_BYTES: usize = 256 * 1024 * 1024;
pub struct EncodedFrame {
pub bytes: Vec<u8>,
pub capture_latency: Duration,
pub encode_latency: Duration,
}
pub struct CapturedFrame {
pub width: u32,
pub height: u32,
@@ -150,23 +133,6 @@ mod windows_capture {
}
}
pub fn capture_frame(&mut self) -> anyhow::Result<EncodedFrame> {
let captured = self.capture_bgra_frame()?;
let encode_started = Instant::now();
let bytes = encode_frame(
captured.width,
captured.height,
&captured.pixels,
captured.capture_latency,
Duration::ZERO,
)?;
Ok(EncodedFrame {
bytes,
capture_latency: captured.capture_latency,
encode_latency: encode_started.elapsed(),
})
}
pub fn capture_bgra_frame(&mut self) -> anyhow::Result<CapturedFrame> {
let capture_started = Instant::now();
let mut info = DXGI_OUTDUPL_FRAME_INFO::default();
@@ -246,42 +212,6 @@ mod windows_capture {
anyhow::ensure!(bytes <= MAX_RAW_FRAME_BYTES, "DDA frame exceeds 256 MiB");
Ok(())
}
fn encode_frame(
width: u32,
height: u32,
pixels: &[u8],
capture: Duration,
encode: Duration,
) -> anyhow::Result<Vec<u8>> {
let mut compressor = ZlibEncoder::new(Vec::new(), Compression::fast());
compressor.write_all(pixels)?;
let compressed = compressor.finish()?;
let raw_len = u32::try_from(pixels.len()).context("DDA raw frame is too large")?;
let compressed_len =
u32::try_from(compressed.len()).context("DDA compressed frame is too large")?;
let mut frame = Vec::with_capacity(FRAME_HEADER_LEN + compressed.len());
frame.extend_from_slice(FRAME_MAGIC);
frame.push(FRAME_VERSION);
frame.push(CODEC_BGRA_ZLIB);
frame.extend_from_slice(&0_u16.to_le_bytes());
frame.extend_from_slice(&width.to_le_bytes());
frame.extend_from_slice(&height.to_le_bytes());
frame.extend_from_slice(&raw_len.to_le_bytes());
frame.extend_from_slice(&compressed_len.to_le_bytes());
frame.extend_from_slice(
&u64::try_from(capture.as_micros())
.unwrap_or(u64::MAX)
.to_le_bytes(),
);
frame.extend_from_slice(
&u64::try_from(encode.as_micros())
.unwrap_or(u64::MAX)
.to_le_bytes(),
);
frame.extend_from_slice(&compressed);
Ok(frame)
}
}
#[cfg(windows)]
+32 -155
View File
@@ -22,11 +22,8 @@ mod media_ring;
mod windows_security;
const PREFERRED_CAPTURE_BACKEND: &str = "idd-headless-sdr";
const SOFTWARE_FALLBACK_BACKEND: &str = "gdi-bgra-zlib";
const CURRENT_MEDIA_TRANSPORT: &str = "rdwf_tcp_compatibility";
const TARGET_MEDIA_TRANSPORT: &str = "hysteria2_video_datagram_plus_encoded_ring";
const DEFAULT_FALLBACK_FPS: u8 = 10;
const MAX_FALLBACK_FPS: u8 = 15;
const CURRENT_MEDIA_TRANSPORT: &str = "unavailable_until_str0m_driver";
const TARGET_MEDIA_TRANSPORT: &str = "str0m_webrtc_srtp";
const DEFAULT_VIDEO_CODEC: &str = "h264";
const DEFAULT_CAPTURE_MODE: &str = "strict_hardware";
const NO_LATENCY_SAMPLE: u64 = u64::MAX;
@@ -71,17 +68,15 @@ struct Status {
platform: &'static str,
transport: &'static str,
capture_backend: &'static str,
compatibility_capture_backend: &'static str,
video_codecs: [&'static str; 3],
audio_codec: &'static str,
media_transport: &'static str,
input_backend: &'static str,
session_runtime: &'static str,
strict_gpu_pipeline: &'static str,
software_fallback_backend: &'static str,
software_fallback_available: bool,
media_pipeline_state: &'static str,
supported_capture_modes: [&'static str; 2],
supported_capture_modes: [&'static str; 1],
desktop_duplication: bool,
hardware_h264_mft: bool,
hardware_h264_encoder_verified: bool,
@@ -101,17 +96,15 @@ fn main() -> anyhow::Result<()> {
platform: "windows",
transport: "native-agent",
capture_backend: capabilities.capture_backend(),
compatibility_capture_backend: capabilities.capture_backend(),
video_codecs: ["h264", "hevc", "av1"],
audio_codec: "opus",
media_transport: CURRENT_MEDIA_TRANSPORT,
input_backend: "windows-send-input",
session_runtime: "native runtime not started",
strict_gpu_pipeline: "not_implemented",
software_fallback_backend: SOFTWARE_FALLBACK_BACKEND,
software_fallback_available: cfg!(windows),
software_fallback_available: false,
media_pipeline_state: capabilities.pipeline_state(),
supported_capture_modes: ["strict_hardware", "compatibility"],
supported_capture_modes: ["strict_hardware"],
desktop_duplication: capabilities.desktop_duplication,
hardware_h264_mft: capabilities.hardware_h264_mft,
hardware_h264_encoder_verified: capabilities.hardware_h264_encoder_verified,
@@ -126,7 +119,6 @@ fn main() -> anyhow::Result<()> {
println!("RemoteDesk Windows Agent");
println!("Transport: native Agent protocol");
println!("Capture: IDD Headless SDR (driver integration pending)");
println!("Compatibility capture: {}", capabilities.capture_backend());
println!(
"Video codecs: H.264={} HEVC={} AV1={}",
capabilities.hardware_h264_mft,
@@ -139,7 +131,7 @@ fn main() -> anyhow::Result<()> {
);
println!("Input: SendInput");
println!("Session runtime: not started");
println!("Software fallback: GDI / BGRA / zlib available");
println!("Software fallback: disabled");
}
}
Command::Run => run_server("0.0.0.0:39501".parse()?)?,
@@ -233,8 +225,6 @@ struct Hello<'a> {
protocol_minor: u16,
capture_backend: &'a str,
preferred_capture_backend: &'a str,
compatibility_capture_backend: &'a str,
software_fallback_backend: &'a str,
video_codecs: [&'a str; 3],
audio_codec: &'a str,
media_transport: &'a str,
@@ -243,7 +233,7 @@ struct Hello<'a> {
input_backend: &'a str,
authenticated: bool,
media_pipeline_state: &'a str,
supported_capture_modes: [&'a str; 2],
supported_capture_modes: [&'a str; 1],
desktop_duplication: bool,
hardware_h264_mft: bool,
hardware_h264_encoder_verified: bool,
@@ -310,16 +300,12 @@ fn requested_capture_mode(command: &AgentCommand) -> &str {
command
.capture_mode
.as_deref()
.unwrap_or(if command.allow_software_fallback {
"compatibility"
} else {
DEFAULT_CAPTURE_MODE
})
.unwrap_or(DEFAULT_CAPTURE_MODE)
}
fn validate_capture_mode(mode: &str) -> anyhow::Result<()> {
anyhow::ensure!(
matches!(mode, "strict_hardware" | "compatibility"),
mode == "strict_hardware",
"unsupported capture mode: {mode}"
);
Ok(())
@@ -340,43 +326,14 @@ fn requested_video_codec(command: &AgentCommand) -> &str {
.unwrap_or(DEFAULT_VIDEO_CODEC)
}
fn software_desktop_response_for_backend(
mode: &str,
codec: &str,
fps: u8,
capture_backend: &str,
) -> serde_json::Value {
serde_json::json!({
"kind": "desktop_opened",
"capture_backend": capture_backend,
"pixel_format": "bgra8",
"compression": "zlib",
"video_codec": "bgra-zlib",
"requested_video_codec": codec,
"capture_mode": mode,
"frames_per_second": fps,
"surface_type": "cpu_readback",
"cpu_map_count": 1,
"hardware_path_verified": false,
"degraded": true,
"media_transport": CURRENT_MEDIA_TRANSPORT,
"target_media_transport": TARGET_MEDIA_TRANSPORT
})
}
fn unavailable_media_response(mode: &str, codec: &str) -> serde_json::Value {
let error = if mode == "strict_hardware" {
"HARDWARE_CAPTURE_UNAVAILABLE"
} else {
"HARDWARE_ENCODER_UNAVAILABLE"
};
serde_json::json!({
"kind": "desktop_unavailable",
"error": error,
"error": "HARDWARE_CAPTURE_UNAVAILABLE",
"capture_mode": mode,
"video_codec": codec,
"media_pipeline_state": "hardware_probe_failed",
"software_fallback_available": cfg!(windows),
"software_fallback_available": false,
"degraded": false
})
}
@@ -398,17 +355,15 @@ where
protocol_minor: 0,
capture_backend: capabilities.capture_backend(),
preferred_capture_backend: PREFERRED_CAPTURE_BACKEND,
compatibility_capture_backend: capabilities.capture_backend(),
software_fallback_backend: SOFTWARE_FALLBACK_BACKEND,
video_codecs: ["h264", "hevc", "av1"],
audio_codec: "opus",
media_transport: CURRENT_MEDIA_TRANSPORT,
target_media_transport: TARGET_MEDIA_TRANSPORT,
software_fallback_available: cfg!(windows),
software_fallback_available: false,
input_backend: "windows-send-input",
authenticated,
media_pipeline_state: capabilities.pipeline_state(),
supported_capture_modes: ["strict_hardware", "compatibility"],
supported_capture_modes: ["strict_hardware"],
desktop_duplication: capabilities.desktop_duplication,
hardware_h264_mft: capabilities.hardware_h264_mft,
hardware_h264_encoder_verified: capabilities.hardware_h264_encoder_verified,
@@ -514,25 +469,12 @@ where
writer.write_all(b"\n").await?;
continue;
}
if !command.allow_software_fallback && capture_mode != "compatibility" {
let response = serde_json::json!({
"kind": "desktop_unavailable",
"error": "COMPATIBILITY_MODE_REQUIRED",
"degraded": false
});
writer
.write_all(serde_json::to_string(&response)?.as_bytes())
.await?;
writer.write_all(b"\n").await?;
continue;
}
if let Some(previous) = encoded_session.take() {
drop(previous);
}
match start_encoded_session(
session_id.to_owned(),
command.frames_per_second.unwrap_or(30).clamp(1, 120),
command.allow_software_fallback,
) {
Ok((info, session)) => {
let response = serde_json::json!({
@@ -632,10 +574,6 @@ where
.write_all(b"{\"kind\":\"input_failed\",\"error\":\"windows_only\"}\n")
.await?;
} else if command.kind == "open_application" {
if !command.allow_software_fallback {
writer.write_all(b"{\"kind\":\"application_error\",\"error\":\"software_fallback_required\"}\n").await?;
continue;
}
match command
.application
.as_deref()
@@ -643,22 +581,7 @@ where
.transpose()
{
Ok(Some(_application)) => {
let fps = command
.frames_per_second
.unwrap_or(DEFAULT_FALLBACK_FPS)
.clamp(1, MAX_FALLBACK_FPS);
let response = software_desktop_response_for_backend(
capture_mode,
codec,
fps,
capabilities.capture_backend(),
);
writer
.write_all(serde_json::to_string(&response)?.as_bytes())
.await?;
writer.write_all(b"\n").await?;
stream_software_desktop(&mut writer, fps).await?;
return Ok(());
writer.write_all(b"{\"kind\":\"application_error\",\"error\":\"HARDWARE_APPLICATION_CAPTURE_UNAVAILABLE\"}\n").await?;
}
Ok(None) => {
writer.write_all(b"{\"kind\":\"application_error\",\"error\":\"application_id_required\"}\n").await?;
@@ -675,29 +598,10 @@ where
continue;
}
} else if command.kind == "open_desktop" {
if capture_mode == "strict_hardware" || !command.allow_software_fallback {
let response =
serde_json::to_string(&unavailable_media_response(capture_mode, codec))?;
writer.write_all(response.as_bytes()).await?;
writer.write_all(b"\n").await?;
continue;
}
let fps = command
.frames_per_second
.unwrap_or(DEFAULT_FALLBACK_FPS)
.clamp(1, MAX_FALLBACK_FPS);
let response = software_desktop_response_for_backend(
capture_mode,
codec,
fps,
capabilities.capture_backend(),
);
writer
.write_all(serde_json::to_string(&response)?.as_bytes())
.await?;
let response = serde_json::to_string(&unavailable_media_response(capture_mode, codec))?;
writer.write_all(response.as_bytes()).await?;
writer.write_all(b"\n").await?;
stream_software_desktop(&mut writer, fps).await?;
return Ok(());
continue;
} else if command.kind == "status" {
let response = serde_json::to_string(&serde_json::json!({
"kind": "status",
@@ -707,9 +611,7 @@ where
"media_transport": CURRENT_MEDIA_TRANSPORT,
"target_media_transport": TARGET_MEDIA_TRANSPORT,
"preferred_capture_backend": PREFERRED_CAPTURE_BACKEND,
"compatibility_capture_backend": capabilities.capture_backend(),
"software_fallback_backend": SOFTWARE_FALLBACK_BACKEND,
"software_fallback_available": cfg!(windows),
"software_fallback_available": false,
"desktop_duplication": capabilities.desktop_duplication,
"hardware_h264_mft": capabilities.hardware_h264_mft,
"hardware_h264_encoder_verified": capabilities.hardware_h264_encoder_verified,
@@ -717,7 +619,7 @@ where
"hardware_av1_mft": capabilities.hardware_av1_mft,
"hardware_pipeline_verified": capabilities.hardware_pipeline_verified,
"degraded": !capabilities.hardware_pipeline_verified,
"supported_capture_modes": ["strict_hardware", "compatibility"],
"supported_capture_modes": ["strict_hardware"],
"capture_latency_ms": latency_ms(&CAPTURE_LATENCY_US),
"encode_latency_ms": latency_ms(&ENCODE_LATENCY_US),
"frame_processing_latency_ms": latency_ms(&FRAME_PROCESSING_LATENCY_US)
@@ -751,7 +653,6 @@ fn validate_session_id(session_id: &str) -> anyhow::Result<()> {
fn start_encoded_session(
session_id: String,
fps: u8,
allow_software_fallback: bool,
) -> anyhow::Result<(EncodedSessionInfo, EncodedSession)> {
let generation = ENCODED_GENERATION.fetch_add(1, Ordering::Relaxed).max(1);
let session_started = Instant::now();
@@ -779,16 +680,6 @@ fn start_encoded_session(
let (mut encoder, encoder_backend, hardware_path_verified) =
match encoder::H264Encoder::new(width, height, u32::from(fps), true) {
Ok(encoder) => (encoder, "hardware-mft", true),
Err(error) if allow_software_fallback => {
match encoder::H264Encoder::new(width, height, u32::from(fps), false) {
Ok(encoder) => (encoder, "software-mft", false),
Err(fallback) => {
let _ = ready_sender
.send(Err(format!("hardware: {error}; software: {fallback}")));
return;
}
}
}
Err(error) => {
let _ = ready_sender
.send(Err(format!("hardware H.264 encoder unavailable: {error}")));
@@ -1104,8 +995,6 @@ fn record_latency(value: &AtomicU64, elapsed: Duration) {
struct AgentCommand {
kind: String,
#[serde(default)]
allow_software_fallback: bool,
#[serde(default)]
capture_mode: Option<String>,
#[serde(default)]
video_codec: Option<String>,
@@ -1195,7 +1084,7 @@ fn apply_input(command: &AgentCommand) -> anyhow::Result<()> {
Ok(())
}
#[cfg(windows)]
#[cfg(any())]
async fn stream_software_desktop<W>(writer: &mut W, fps: u8) -> anyhow::Result<()>
where
W: AsyncWrite + Unpin,
@@ -1226,7 +1115,7 @@ where
Ok(())
}
#[cfg(windows)]
#[cfg(any())]
fn capture_compatibility(
fps: u8,
sender: &tokio::sync::mpsc::Sender<anyhow::Result<Vec<u8>>>,
@@ -1270,7 +1159,7 @@ fn capture_compatibility(
}
}
#[cfg(not(windows))]
#[cfg(any())]
fn capture_compatibility(
_fps: u8,
_sender: &tokio::sync::mpsc::Sender<anyhow::Result<Vec<u8>>>,
@@ -1278,7 +1167,7 @@ fn capture_compatibility(
anyhow::bail!("gdi_software_fallback_unavailable")
}
#[cfg(not(windows))]
#[cfg(any())]
async fn stream_software_desktop<W>(_writer: &mut W, _fps: u8) -> anyhow::Result<()>
where
W: AsyncWrite + Unpin,
@@ -1286,7 +1175,7 @@ where
anyhow::bail!("gdi_software_fallback_unavailable")
}
#[cfg(windows)]
#[cfg(any())]
mod fallback {
use anyhow::Context as _;
use flate2::{Compression, write::ZlibEncoder};
@@ -1496,27 +1385,23 @@ mod tests {
use super::*;
#[test]
fn software_fallback_is_opt_in() {
fn legacy_software_fallback_field_does_not_enable_compatibility() {
let default: AgentCommand = serde_json::from_str(r#"{"kind":"open_desktop"}"#).unwrap();
assert!(!default.allow_software_fallback);
let enabled: AgentCommand = serde_json::from_str(
r#"{"kind":"open_desktop","allow_software_fallback":true,"frames_per_second":12}"#,
)
.unwrap();
assert!(enabled.allow_software_fallback);
assert_eq!(enabled.frames_per_second, Some(12));
assert_eq!(requested_capture_mode(&default), "strict_hardware");
assert_eq!(requested_capture_mode(&enabled), "compatibility");
assert_eq!(requested_capture_mode(&enabled), "strict_hardware");
}
#[test]
fn explicit_capture_mode_overrides_legacy_fallback_flag() {
let command: AgentCommand = serde_json::from_str(
r#"{"kind":"open_desktop","allow_software_fallback":true,"capture_mode":"strict_hardware"}"#,
)
.unwrap();
assert_eq!(requested_capture_mode(&command), "strict_hardware");
assert!(validate_capture_mode(requested_capture_mode(&command)).is_ok());
fn compatibility_capture_mode_is_rejected() {
let command: AgentCommand =
serde_json::from_str(r#"{"kind":"open_desktop","capture_mode":"compatibility"}"#)
.unwrap();
assert!(validate_capture_mode(requested_capture_mode(&command)).is_err());
}
#[test]
@@ -1527,17 +1412,9 @@ mod tests {
}
#[test]
fn codec_and_degraded_response_are_explicit() {
fn supported_codec_is_explicit() {
assert!(validate_video_codec("h264").is_ok());
assert!(validate_video_codec("vp9").is_err());
let response = software_desktop_response_for_backend(
"compatibility",
"h264",
10,
SOFTWARE_FALLBACK_BACKEND,
);
assert_eq!(response["degraded"], true);
assert_eq!(response["hardware_path_verified"], false);
}
#[test]
-21
View File
@@ -1,21 +0,0 @@
[package]
name = "remotedesk-app"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[[bin]]
name = "remotedesk"
path = "src/main.rs"
[target.'cfg(windows)'.dependencies]
getrandom = "0.3.3"
sha2 = "0.10"
tauri = { version = "2.8.5", default-features = false, features = ["wry"] }
[target.'cfg(windows)'.build-dependencies]
tauri-build = { version = "2.4.1", features = [] }
[lints]
workspace = true
-4
View File
@@ -1,4 +0,0 @@
fn main() {
#[cfg(windows)]
tauri_build::build();
}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
{}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#5ed39a"/>
<g fill="none" stroke="#101b16" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 8.5v3.2M8.4 10.1h3.2"/>
<path d="M21.8 8.5v3.2M20.2 10.1h3.2"/>
<path d="M10 18.5v3.2M8.4 20.1h3.2"/>
<path d="M21.8 18.5v3.2M20.2 20.1h3.2"/>
<path d="M12.5 14.2h7M12.5 16.4h7"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 444 B

-318
View File
@@ -1,318 +0,0 @@
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
#[cfg(not(windows))]
fn main() {
eprintln!("RemoteDesk desktop shell is available only on Windows");
}
#[cfg(windows)]
mod windows_app {
use std::env;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read as _, Write as _};
use std::net::{Ipv4Addr, SocketAddrV4, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, RecvTimeoutError, Sender};
use std::thread;
use std::time::{Duration, Instant};
use sha2::{Digest as _, Sha256};
use std::os::windows::process::CommandExt as _;
use tauri::{Manager as _, WebviewUrl, WebviewWindowBuilder};
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const PERSISTENT_WEB_PORT: u16 = 4173;
const STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
struct ControlServiceHandle {
shutdown: Sender<()>,
}
impl Drop for ControlServiceHandle {
fn drop(&mut self) {
let _ = self.shutdown.send(());
}
}
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let (control_service, port) = start_control_service(app.handle().clone())?;
app.manage(control_service);
// WebView2 treats localhost as a trusted loopback origin more
// consistently than a numeric loopback URL across enterprise
// policies and older runtime builds.
let url = tauri::Url::parse(&format!("http://localhost:{port}/"))?;
WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url))
.title("RemoteDesk")
.inner_size(1280.0, 800.0)
.min_inner_size(960.0, 600.0)
.devtools(cfg!(debug_assertions))
.build()?;
Ok(())
})
.run(tauri::generate_context!())
.expect("RemoteDesk desktop runtime failed");
}
fn start_control_service(
app_handle: tauri::AppHandle,
) -> Result<(ControlServiceHandle, u16), Box<dyn std::error::Error>> {
let web_root = resolve_web_root()?;
let service_path = resolve_control_service()?;
// Keep the WebView origin stable so its localStorage (host profiles and
// preferences) survives application restarts and MSI upgrades. Fall back
// to an ephemeral port only when another local service owns the port.
let port = reserve_persistent_or_ephemeral_port()?;
let shell_token = new_shell_token()?;
let (ready_tx, ready_rx) = mpsc::sync_channel(1);
let (shutdown_tx, shutdown_rx) = mpsc::channel();
thread::Builder::new()
.name("remotedesk-control-service-manager".to_owned())
.spawn(move || {
let mut child =
match spawn_control_service(&service_path, &web_root, port, &shell_token) {
Ok(child) => child,
Err(error) => {
let _ = ready_tx.send(Err(error.to_string()));
return;
}
};
if let Err(error) = wait_for_control_service(&mut child, port, &shell_token) {
let _ = ready_tx.send(Err(error.to_string()));
stop_child(&mut child);
return;
}
if ready_tx.send(Ok(port)).is_err() {
stop_child(&mut child);
return;
}
loop {
match shutdown_rx.recv_timeout(Duration::from_millis(250)) {
Ok(()) | Err(RecvTimeoutError::Disconnected) => {
stop_child(&mut child);
return;
}
Err(RecvTimeoutError::Timeout) => {}
}
match child.try_wait() {
Ok(Some(status)) => {
app_handle.exit(if status.success() { 0 } else { 1 });
return;
}
Ok(None) => {}
Err(_) => {
stop_child(&mut child);
app_handle.exit(1);
return;
}
}
}
})?;
let handle = ControlServiceHandle {
shutdown: shutdown_tx,
};
match ready_rx.recv_timeout(STARTUP_TIMEOUT) {
Ok(Ok(ready_port)) => Ok((handle, ready_port)),
Ok(Err(error)) => Err(io::Error::other(error).into()),
Err(_) => Err(io::Error::new(
io::ErrorKind::TimedOut,
"control service did not become ready within 10 seconds",
)
.into()),
}
}
fn reserve_persistent_or_ephemeral_port() -> io::Result<u16> {
if TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, PERSISTENT_WEB_PORT)).is_ok() {
return Ok(PERSISTENT_WEB_PORT);
}
reserve_loopback_port()
}
fn resolve_web_root() -> io::Result<PathBuf> {
if let Some(path) = environment_path("REMOTEDESK_WEB_ROOT") {
return validate_web_root(path);
}
let executable = env::current_exe()?;
let executable_dir = executable
.parent()
.ok_or_else(|| io::Error::other("desktop executable has no parent directory"))?;
if let Some(install_root) = executable_dir.parent() {
let installed = install_root.join("web");
if installed.join("index.html").is_file() {
return validate_web_root(installed);
}
}
validate_web_root(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("web")
.join("dist"),
)
}
fn validate_web_root(path: PathBuf) -> io::Result<PathBuf> {
let canonical = path.canonicalize()?;
if !canonical.join("index.html").is_file() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"RemoteDesk web assets do not contain index.html",
));
}
Ok(canonical)
}
fn resolve_control_service() -> io::Result<PathBuf> {
let path = if let Some(path) = environment_path("REMOTEDESK_CONTROL_SERVICE") {
path
} else {
env::current_exe()?
.parent()
.ok_or_else(|| io::Error::other("desktop executable has no parent directory"))?
.join("remotedesk-control-service.exe")
};
let canonical = path.canonicalize()?;
if !canonical.is_file() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"RemoteDesk control service executable is missing",
));
}
Ok(canonical)
}
fn environment_path(name: &str) -> Option<PathBuf> {
env::var_os(name)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn reserve_loopback_port() -> io::Result<u16> {
let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))?;
let port = listener.local_addr()?.port();
drop(listener);
if port < 1_024 {
return Err(io::Error::other("Windows selected a reserved local port"));
}
Ok(port)
}
fn new_shell_token() -> io::Result<String> {
let mut bytes = [0_u8; 32];
getrandom::fill(&mut bytes).map_err(|error| io::Error::other(error.to_string()))?;
let mut token = String::with_capacity(bytes.len() * 2);
const HEX: &[u8; 16] = b"0123456789abcdef";
for byte in bytes {
token.push(char::from(HEX[usize::from(byte >> 4)]));
token.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
Ok(token)
}
fn spawn_control_service(
service: &Path,
web_root: &Path,
port: u16,
shell_token: &str,
) -> io::Result<Child> {
let mut command = Command::new(service);
command
.arg("--web-root")
.arg(web_root)
.arg("--port")
.arg(port.to_string())
.arg("--exit-on-stdin-close")
.env("REMOTEDESK_DESKTOP_SHELL", "tauri-2")
.env("REMOTEDESK_SHELL_TOKEN", shell_token)
.stdin(Stdio::piped())
.creation_flags(CREATE_NO_WINDOW);
if let Some(log) = open_control_service_log() {
command.stdout(Stdio::from(log.try_clone()?));
command.stderr(Stdio::from(log));
} else {
command.stdout(Stdio::null());
command.stderr(Stdio::null());
}
command.spawn()
}
fn open_control_service_log() -> Option<File> {
let root = env::var_os("LOCALAPPDATA")
.filter(|value| !value.is_empty())
.map(PathBuf::from)?
.join("RemoteDesk")
.join("logs");
fs::create_dir_all(&root).ok()?;
OpenOptions::new()
.create(true)
.append(true)
.open(root.join("control-service.log"))
.ok()
}
fn wait_for_control_service(child: &mut Child, port: u16, shell_token: &str) -> io::Result<()> {
let deadline = Instant::now() + STARTUP_TIMEOUT;
let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port);
let expected_proof = format!("{:x}", Sha256::digest(shell_token.as_bytes()));
let expected_body = format!(r#"{{"ready":true,"proof":"{expected_proof}"}}"#).into_bytes();
while Instant::now() < deadline {
if let Some(status) = child.try_wait()? {
return Err(io::Error::other(format!(
"control service exited during startup with {status}"
)));
}
if let Ok(mut stream) =
TcpStream::connect_timeout(&address.into(), Duration::from_millis(100))
{
stream.set_read_timeout(Some(Duration::from_millis(250)))?;
stream.set_write_timeout(Some(Duration::from_millis(250)))?;
let request = format!(
"GET /api/v1/shell/ready HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
);
if stream.write_all(request.as_bytes()).is_ok() {
let mut response = Vec::new();
if stream.take(4_096).read_to_end(&mut response).is_ok()
&& response.starts_with(b"HTTP/1.1 200 ")
&& response
.windows(expected_body.len())
.any(|window| window == expected_body)
{
return Ok(());
}
}
}
thread::sleep(Duration::from_millis(50));
}
Err(io::Error::new(
io::ErrorKind::TimedOut,
"control service did not open its loopback listener",
))
}
fn stop_child(child: &mut Child) {
drop(child.stdin.take());
let deadline = Instant::now() + SHUTDOWN_TIMEOUT;
while Instant::now() < deadline {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) => thread::sleep(Duration::from_millis(50)),
Err(_) => break,
}
}
let _ = child.kill();
let _ = child.wait();
}
}
#[cfg(windows)]
fn main() {
windows_app::run();
}
-21
View File
@@ -1,21 +0,0 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "RemoteDesk",
"version": "0.2.19",
"identifier": "com.remotedesk.client",
"build": {
"frontendDist": "../web/dist"
},
"app": {
"windows": [],
"security": {
"capabilities": []
}
},
"bundle": {
"active": false,
"icon": [
"icons/icon.ico"
]
}
}
+8 -5
View File
@@ -1056,7 +1056,7 @@ mod tests {
}
#[test]
fn compatibility_marks_cross_adapter_plan_without_claiming_zero_copy() {
fn cross_adapter_display_plan_is_rejected() {
let mut planner = ClientDisplayPlanner::new();
let mut request = request(
ClientDisplayBackend::WindowsRdpDisplayControl,
@@ -1068,9 +1068,12 @@ mod tests {
zero_copy_policy: ZeroCopyPolicy::RequiredEndToEnd,
};
request.decoder_adapter = adapter("gpu-1");
let plan = planner.plan(request).unwrap();
assert!(!plan.gpu_pipeline.is_zero_copy_candidate());
assert_eq!(plan.gpu_pipeline.display_adapter, adapter("gpu-0"));
assert!(matches!(
planner.plan(request),
Err(DisplayPlanError::GpuPipeline(
PipelinePlanError::CrossAdapterRejected { .. }
))
));
assert_eq!(planner.generation(), 0);
}
}
+14 -14
View File
@@ -216,8 +216,8 @@ mod tests {
}
#[test]
fn compatibility_policy_marks_cross_adapter_copy() {
let plan = plan_d3d11_pipeline(PipelineRequest {
fn the_only_policy_rejects_cross_adapter_copy() {
let error = plan_d3d11_pipeline(PipelineRequest {
policy: GpuPolicy {
selection: GpuSelection::WindowDisplayAdapter,
zero_copy_policy: ZeroCopyPolicy::RequiredEndToEnd,
@@ -226,16 +226,17 @@ mod tests {
decoder_adapter: adapter("decode"),
available_adapters: vec![adapter("render"), adapter("decode")],
})
.unwrap();
.unwrap_err();
assert_eq!(plan.planned_memory_path, MemoryPathStatus::CrossAdapterCopy);
assert_eq!(plan.zero_copy_policy, ZeroCopyPolicy::RequiredEndToEnd);
assert!(!plan.is_zero_copy_candidate());
assert!(matches!(
error,
PipelinePlanError::CrossAdapterRejected { .. }
));
}
#[test]
fn compatibility_allows_manual_render_adapter_to_differ_from_display() {
let plan = plan_d3d11_pipeline(PipelineRequest {
fn manual_render_adapter_must_match_display() {
let error = plan_d3d11_pipeline(PipelineRequest {
policy: GpuPolicy {
selection: GpuSelection::Manual(adapter("manual-gpu")),
zero_copy_policy: ZeroCopyPolicy::RequiredEndToEnd,
@@ -244,12 +245,11 @@ mod tests {
decoder_adapter: adapter("manual-gpu"),
available_adapters: vec![adapter("display-gpu"), adapter("manual-gpu")],
})
.unwrap();
.unwrap_err();
assert_eq!(plan.decode_adapter, adapter("manual-gpu"));
assert_eq!(plan.render_adapter, adapter("manual-gpu"));
assert_eq!(plan.display_adapter, adapter("display-gpu"));
assert_eq!(plan.planned_memory_path, MemoryPathStatus::CrossAdapterCopy);
assert!(!plan.is_zero_copy_candidate());
assert!(matches!(
error,
PipelinePlanError::CrossAdapterRejected { .. }
));
}
}
+9 -12
View File
@@ -228,7 +228,7 @@ impl SecurePipeServer {
.checked_add(timeout)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid pipe timeout"))?;
windows_platform::wait_for_client(&self.pipe, timeout)?;
windows_platform::set_blocking(&self.pipe)?;
windows_platform::set_nonblocking(&self.pipe)?;
let pipe_pid = windows_platform::pipe_client_pid(&self.pipe)?;
if pipe_pid != expected.pid {
return Err(io::Error::new(
@@ -654,12 +654,9 @@ fn read_exact_until(
while offset < buffer.len() {
ensure_handshake_deadline(deadline)?;
match reader.read(&mut buffer[offset..]) {
Ok(0) => {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"secure pipe peer closed during handshake",
));
}
// In PIPE_NOWAIT mode Windows can report a successful zero-byte
// read while the connected peer has not produced data yet.
Ok(0) => std::thread::sleep(CONNECT_POLL_INTERVAL),
Ok(count) => offset += count,
Err(error) if error.kind() == io::ErrorKind::Interrupted => {}
Err(error) if windows_platform::read_is_pending(&error) => {
@@ -712,7 +709,7 @@ mod windows_platform {
System::{
Pipes::{
ConnectNamedPipe, CreateNamedPipeW, GetNamedPipeClientProcessId, PIPE_NOWAIT,
PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT,
PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE,
SetNamedPipeHandleState,
},
Threading::{
@@ -807,7 +804,7 @@ mod windows_platform {
loop {
match OpenOptions::new().read(true).write(true).open(pipe_name) {
Ok(pipe) => {
set_blocking(&pipe)?;
set_nonblocking(&pipe)?;
return Ok(pipe);
}
Err(error)
@@ -823,8 +820,8 @@ mod windows_platform {
}
}
pub fn set_blocking(pipe: &File) -> io::Result<()> {
let mode = PIPE_READMODE_BYTE | PIPE_WAIT;
pub fn set_nonblocking(pipe: &File) -> io::Result<()> {
let mode = PIPE_READMODE_BYTE | PIPE_NOWAIT;
// SAFETY: handle is a live Named Pipe client and mode points to initialized storage.
if unsafe {
SetNamedPipeHandleState(
@@ -1169,7 +1166,7 @@ mod tests {
let _pipe = windows_platform::connect_client(&pipe_name, Duration::from_secs(2))
.expect("test client connects");
connected_sender.send(()).unwrap();
std::thread::sleep(Duration::from_millis(500));
std::thread::sleep(Duration::from_secs(2));
});
connected_receiver
.recv_timeout(Duration::from_secs(2))
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "remotedesk-webrtc-core"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Shared bounded WebRTC signaling and media policy for RemoteDesk"
[dependencies]
bytes = "1.12"
remotedesk-protocol = { path = "../../../protocol" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
str0m = "0.23"
[lints]
workspace = true
+673
View File
@@ -0,0 +1,673 @@
//! Shared WebRTC policy boundary.
//!
//! `str0m` is intentionally kept behind this crate so Agent and Client do not
//! exchange library-specific types. Signaling and media payloads remain
//! bounded before they reach the Sans-IO WebRTC engine.
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use remotedesk_protocol::{MAX_ICE_CANDIDATE_BYTES, MAX_SDP_BYTES};
pub const MAX_SIGNAL_MESSAGES: usize = 256;
pub const MAX_DATA_CHANNEL_MESSAGE_BYTES: usize = 64 * 1024;
pub const MAX_ICE_SERVERS: usize = 8;
pub const MAX_ICE_URLS_PER_SERVER: usize = 8;
pub const MAX_ICE_URL_BYTES: usize = 2_048;
pub const MAX_ICE_CREDENTIAL_BYTES: usize = 1_024;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IceServerConfig {
pub urls: Vec<String>,
pub username: String,
pub credential: String,
}
impl IceServerConfig {
pub fn validate(&self) -> Result<(), SignalError> {
if self.urls.is_empty()
|| self.urls.len() > MAX_ICE_URLS_PER_SERVER
|| self.username.len() > MAX_ICE_CREDENTIAL_BYTES
|| self.credential.len() > MAX_ICE_CREDENTIAL_BYTES
|| self.username.is_empty() != self.credential.is_empty()
{
return Err(SignalError::InvalidIceServer);
}
for url in &self.urls {
if url.is_empty()
|| url.len() > MAX_ICE_URL_BYTES
|| url.chars().any(char::is_control)
|| !matches!(
url.split_once(':').map(|(scheme, _)| scheme),
Some("stun" | "stuns" | "turn" | "turns")
)
{
return Err(SignalError::InvalidIceServer);
}
}
Ok(())
}
}
pub fn validate_ice_servers(servers: &[IceServerConfig]) -> Result<(), SignalError> {
if servers.len() > MAX_ICE_SERVERS {
return Err(SignalError::InvalidIceServer);
}
servers.iter().try_for_each(IceServerConfig::validate)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum WebRtcRole {
Offerer,
Answerer,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SignalKind {
Offer,
Answer,
IceCandidate,
IceEnd,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SignalMessage {
pub kind: SignalKind,
pub payload: String,
}
impl SignalMessage {
/// Creates a bounded signaling message.
pub fn new(kind: SignalKind, payload: impl Into<String>) -> Result<Self, SignalError> {
let payload = payload.into();
let valid = match kind {
SignalKind::Offer | SignalKind::Answer => {
!payload.is_empty() && payload.len() <= MAX_SDP_BYTES
}
SignalKind::IceCandidate => {
!payload.is_empty() && payload.len() <= MAX_ICE_CANDIDATE_BYTES
}
SignalKind::IceEnd => payload.is_empty(),
};
if !valid || payload.contains('\0') {
return Err(SignalError::InvalidPayload);
}
Ok(Self { kind, payload })
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SessionLimits {
pub connect_timeout: Duration,
pub max_data_channel_message_bytes: usize,
}
impl Default for SessionLimits {
fn default() -> Self {
Self {
connect_timeout: Duration::from_secs(30),
max_data_channel_message_bytes: MAX_DATA_CHANNEL_MESSAGE_BYTES,
}
}
}
impl SessionLimits {
pub fn validate(self) -> Result<(), SignalError> {
if !(Duration::from_secs(5)..=Duration::from_secs(60)).contains(&self.connect_timeout)
|| self.max_data_channel_message_bytes == 0
|| self.max_data_channel_message_bytes > MAX_DATA_CHANNEL_MESSAGE_BYTES
{
return Err(SignalError::InvalidLimits);
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SignalError {
InvalidPayload,
InvalidLimits,
InvalidState,
InvalidIceServer,
Engine,
}
impl std::fmt::Display for SignalError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(match self {
Self::InvalidPayload => "invalid or oversized WebRTC signaling payload",
Self::InvalidLimits => "WebRTC session limits are outside the supported bounds",
Self::InvalidState => "WebRTC endpoint is not in the required state",
Self::InvalidIceServer => "STUN/TURN configuration is outside the supported bounds",
Self::Engine => "str0m rejected the WebRTC operation",
})
}
}
impl std::error::Error for SignalError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NetworkDatagram {
pub source: SocketAddr,
pub destination: SocketAddr,
pub contents: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum EndpointEvent {
Connected,
ChannelOpen {
kind: DataChannelKind,
label: String,
},
ChannelData {
kind: DataChannelKind,
data: Vec<u8>,
},
ChannelClosed(DataChannelKind),
Media {
kind: MediaKind,
data: Vec<u8>,
contiguous: bool,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DataChannelKind {
Control,
Pointer,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MediaKind {
H264,
Opus,
}
pub enum EndpointOutput {
Timeout(Instant),
Transmit(NetworkDatagram),
Event(EndpointEvent),
}
/// A Sans-IO str0m endpoint with no str0m types in its public interface.
pub struct WebRtcEndpoint {
rtc: str0m::Rtc,
role: WebRtcRole,
limits: SessionLimits,
pending_offer: Option<str0m::change::SdpPendingOffer>,
control_channel_id: Option<str0m::channel::ChannelId>,
pointer_channel_id: Option<str0m::channel::ChannelId>,
video_mid: Option<str0m::media::Mid>,
audio_mid: Option<str0m::media::Mid>,
}
impl WebRtcEndpoint {
pub fn new(role: WebRtcRole, limits: SessionLimits, now: Instant) -> Result<Self, SignalError> {
limits.validate()?;
Ok(Self {
rtc: str0m::Rtc::new(now),
role,
limits,
pending_offer: None,
control_channel_id: None,
pointer_channel_id: None,
video_mid: None,
audio_mid: None,
})
}
pub fn add_host_candidate(&mut self, address: SocketAddr) -> Result<(), SignalError> {
let candidate = str0m::Candidate::host(address, "udp").map_err(|_| SignalError::Engine)?;
self.rtc
.add_local_candidate(candidate)
.ok_or(SignalError::InvalidState)?;
Ok(())
}
/// Adds an address learned by an external STUN binding client.
pub fn add_server_reflexive_candidate(
&mut self,
address: SocketAddr,
base: SocketAddr,
) -> Result<(), SignalError> {
let candidate = str0m::Candidate::server_reflexive(address, base, "udp")
.map_err(|_| SignalError::InvalidPayload)?;
self.rtc
.add_local_candidate(candidate)
.ok_or(SignalError::InvalidState)?;
Ok(())
}
/// Adds an allocation learned by an external TURN client.
pub fn add_relay_candidate(
&mut self,
address: SocketAddr,
local: SocketAddr,
protocol: RelayProtocol,
) -> Result<(), SignalError> {
let protocol = match protocol {
RelayProtocol::Udp => "udp",
RelayProtocol::Tcp | RelayProtocol::Tls => "tcp",
};
let candidate = str0m::Candidate::relayed(address, local, protocol)
.map_err(|_| SignalError::InvalidPayload)?;
self.rtc
.add_local_candidate(candidate)
.ok_or(SignalError::InvalidState)?;
Ok(())
}
pub fn create_offer(&mut self, channel_label: &str) -> Result<SignalMessage, SignalError> {
if self.role != WebRtcRole::Offerer
|| self.pending_offer.is_some()
|| channel_label.is_empty()
|| channel_label.len() > 64
{
return Err(SignalError::InvalidState);
}
let mut change = self.rtc.sdp_api();
self.control_channel_id = Some(change.add_channel(channel_label.to_owned()));
self.pointer_channel_id = Some(change.add_channel_with_config(
str0m::channel::ChannelConfig {
label: "pointer".to_owned(),
ordered: false,
reliability: str0m::channel::Reliability::MaxRetransmits { retransmits: 0 },
..Default::default()
},
));
self.video_mid = Some(change.add_media(
str0m::media::MediaKind::Video,
str0m::media::Direction::SendOnly,
None,
None,
None,
));
self.audio_mid = Some(change.add_media(
str0m::media::MediaKind::Audio,
str0m::media::Direction::SendOnly,
None,
None,
None,
));
let (offer, pending) = change.apply().ok_or(SignalError::InvalidState)?;
self.pending_offer = Some(pending);
let payload = serde_json::to_string(&offer).map_err(|_| SignalError::Engine)?;
SignalMessage::new(SignalKind::Offer, payload)
}
pub fn accept_offer(&mut self, offer: &SignalMessage) -> Result<SignalMessage, SignalError> {
if self.role != WebRtcRole::Answerer || offer.kind != SignalKind::Offer {
return Err(SignalError::InvalidState);
}
let offer =
serde_json::from_str(&offer.payload).map_err(|_| SignalError::InvalidPayload)?;
let answer = self
.rtc
.sdp_api()
.accept_offer(offer)
.map_err(|_| SignalError::Engine)?;
let payload = serde_json::to_string(&answer).map_err(|_| SignalError::Engine)?;
SignalMessage::new(SignalKind::Answer, payload)
}
pub fn accept_answer(&mut self, answer: &SignalMessage) -> Result<(), SignalError> {
if self.role != WebRtcRole::Offerer || answer.kind != SignalKind::Answer {
return Err(SignalError::InvalidState);
}
let pending = self.pending_offer.take().ok_or(SignalError::InvalidState)?;
let answer =
serde_json::from_str(&answer.payload).map_err(|_| SignalError::InvalidPayload)?;
self.rtc
.sdp_api()
.accept_answer(pending, answer)
.map_err(|_| SignalError::Engine)
}
pub fn handle_timeout(&mut self, now: Instant) -> Result<(), SignalError> {
self.rtc
.handle_input(str0m::Input::Timeout(now))
.map_err(|_| SignalError::Engine)
}
pub fn handle_datagram(
&mut self,
now: Instant,
datagram: &NetworkDatagram,
) -> Result<(), SignalError> {
let receive = str0m::net::Receive::new(
str0m::net::Protocol::Udp,
datagram.source,
datagram.destination,
&datagram.contents,
)
.map_err(|_| SignalError::InvalidPayload)?;
self.rtc
.handle_input(str0m::Input::Receive(now, receive))
.map_err(|_| SignalError::Engine)
}
pub fn poll_output(&mut self) -> Result<EndpointOutput, SignalError> {
loop {
match self.rtc.poll_output().map_err(|_| SignalError::Engine)? {
str0m::Output::Timeout(at) => return Ok(EndpointOutput::Timeout(at)),
str0m::Output::Transmit(packet) => {
return Ok(EndpointOutput::Transmit(NetworkDatagram {
source: packet.source,
destination: packet.destination,
contents: packet.contents.to_vec(),
}));
}
str0m::Output::Event(event) => {
let mapped = match event {
str0m::Event::Connected => Some(EndpointEvent::Connected),
str0m::Event::ChannelOpen(id, label) => {
let kind = if label == "pointer" {
self.pointer_channel_id.get_or_insert(id);
DataChannelKind::Pointer
} else {
self.control_channel_id.get_or_insert(id);
DataChannelKind::Control
};
Some(EndpointEvent::ChannelOpen { kind, label })
}
str0m::Event::ChannelData(data) => {
let kind = self.channel_kind(data.id)?;
Some(EndpointEvent::ChannelData {
kind,
data: data.data,
})
}
str0m::Event::ChannelClose(id) => {
Some(EndpointEvent::ChannelClosed(self.channel_kind(id)?))
}
str0m::Event::MediaData(media) => {
let kind = match media.params.spec().codec {
str0m::format::Codec::H264 => MediaKind::H264,
str0m::format::Codec::Opus => MediaKind::Opus,
_ => continue,
};
Some(EndpointEvent::Media {
kind,
data: media.data.to_vec(),
contiguous: media.contiguous,
})
}
_ => None,
};
if let Some(event) = mapped {
return Ok(EndpointOutput::Event(event));
}
}
}
}
}
pub fn write_channel(&mut self, data: &[u8]) -> Result<(), SignalError> {
self.write_data_channel(DataChannelKind::Control, true, data)
}
pub fn write_pointer(&mut self, data: &[u8]) -> Result<(), SignalError> {
self.write_data_channel(DataChannelKind::Pointer, true, data)
}
fn write_data_channel(
&mut self,
kind: DataChannelKind,
binary: bool,
data: &[u8],
) -> Result<(), SignalError> {
if data.is_empty() || data.len() > self.limits.max_data_channel_message_bytes {
return Err(SignalError::InvalidPayload);
}
let id = match kind {
DataChannelKind::Control => self.control_channel_id,
DataChannelKind::Pointer => self.pointer_channel_id,
}
.ok_or(SignalError::InvalidState)?;
self.rtc
.channel(id)
.ok_or(SignalError::InvalidState)?
.write(binary, data)
.map(|_| ())
.map_err(|_| SignalError::Engine)
}
fn channel_kind(&self, id: str0m::channel::ChannelId) -> Result<DataChannelKind, SignalError> {
if self.control_channel_id == Some(id) {
Ok(DataChannelKind::Control)
} else if self.pointer_channel_id == Some(id) {
Ok(DataChannelKind::Pointer)
} else {
Err(SignalError::InvalidState)
}
}
pub fn write_h264(
&mut self,
wallclock: Instant,
media_time: Duration,
access_unit: &[u8],
) -> Result<(), SignalError> {
if access_unit.is_empty()
|| access_unit.len() > 16 * 1024 * 1024
|| !(access_unit.starts_with(&[0, 0, 1]) || access_unit.starts_with(&[0, 0, 0, 1]))
{
return Err(SignalError::InvalidPayload);
}
self.write_media(MediaKind::H264, wallclock, media_time, access_unit)
}
pub fn write_opus(
&mut self,
wallclock: Instant,
media_time: Duration,
packet: &[u8],
) -> Result<(), SignalError> {
if packet.is_empty() || packet.len() > 4 * 1024 {
return Err(SignalError::InvalidPayload);
}
self.write_media(MediaKind::Opus, wallclock, media_time, packet)
}
fn write_media(
&mut self,
kind: MediaKind,
wallclock: Instant,
media_time: Duration,
data: &[u8],
) -> Result<(), SignalError> {
let (mid, codec) = match kind {
MediaKind::H264 => (self.video_mid, str0m::format::Codec::H264),
MediaKind::Opus => (self.audio_mid, str0m::format::Codec::Opus),
};
let mid = mid.ok_or(SignalError::InvalidState)?;
let pt = self
.rtc
.codec_config()
.find(|params| params.spec().codec == codec)
.map(str0m::format::PayloadParams::pt)
.ok_or(SignalError::InvalidState)?;
self.rtc
.writer(mid)
.ok_or(SignalError::InvalidState)?
.write(pt, wallclock, media_time.into(), data.to_vec())
.map_err(|_| SignalError::Engine)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RelayProtocol {
Udp,
Tcp,
Tls,
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::VecDeque;
use std::sync::Once;
#[test]
fn signal_messages_are_bounded() {
assert!(SignalMessage::new(SignalKind::Offer, "offer").is_ok());
assert!(SignalMessage::new(SignalKind::IceEnd, "").is_ok());
assert!(SignalMessage::new(SignalKind::IceEnd, "unexpected").is_err());
assert!(
SignalMessage::new(
SignalKind::Offer,
vec!['x'; MAX_SDP_BYTES + 1].into_iter().collect::<String>()
)
.is_err()
);
}
#[test]
fn limits_reject_unbounded_sessions() {
assert!(SessionLimits::default().validate().is_ok());
assert!(
SessionLimits {
connect_timeout: Duration::from_secs(61),
..Default::default()
}
.validate()
.is_err()
);
}
#[test]
fn ice_servers_are_bounded_and_require_credentials_in_pairs() {
let valid = IceServerConfig {
urls: vec!["stun:stun.example.invalid:3478".to_owned()],
username: String::new(),
credential: String::new(),
};
assert!(validate_ice_servers(&[valid]).is_ok());
let invalid = IceServerConfig {
urls: vec!["turn:turn.example.invalid:3478".to_owned()],
username: "session".to_owned(),
credential: String::new(),
};
assert!(validate_ice_servers(&[invalid]).is_err());
}
#[test]
fn agent_client_sdp_ice_and_data_channel_loopback() {
static CRYPTO: Once = Once::new();
CRYPTO.call_once(|| str0m::crypto::from_feature_flags().install_process_default());
let start = Instant::now();
let mut client =
WebRtcEndpoint::new(WebRtcRole::Offerer, SessionLimits::default(), start).unwrap();
let mut agent =
WebRtcEndpoint::new(WebRtcRole::Answerer, SessionLimits::default(), start).unwrap();
client
.add_host_candidate("127.0.0.1:41000".parse().unwrap())
.unwrap();
agent
.add_host_candidate("127.0.0.1:41001".parse().unwrap())
.unwrap();
let offer = client.create_offer("control").unwrap();
let answer = agent.accept_offer(&offer).unwrap();
client.accept_answer(&answer).unwrap();
let mut to_client = VecDeque::new();
let mut to_agent = VecDeque::new();
let mut client_open = false;
let mut agent_open = false;
let mut received = false;
let mut pointer_received = false;
let mut media_received = Vec::new();
for step in 0..2_000_u64 {
let now = start + Duration::from_millis(step * 5);
while let Some(packet) = to_client.pop_front() {
client.handle_datagram(now, &packet).unwrap();
}
while let Some(packet) = to_agent.pop_front() {
agent.handle_datagram(now, &packet).unwrap();
}
client.handle_timeout(now).unwrap();
agent.handle_timeout(now).unwrap();
drain(
&mut client,
&mut to_agent,
&mut client_open,
&mut received,
&mut pointer_received,
&mut media_received,
);
drain(
&mut agent,
&mut to_client,
&mut agent_open,
&mut received,
&mut pointer_received,
&mut media_received,
);
if client_open && agent_open && !received {
client.write_channel(b"loopback-ping").unwrap();
client.write_pointer(b"pointer-move").unwrap();
}
if received && media_received.is_empty() {
client
.write_h264(now, Duration::from_millis(33), &[0, 0, 0, 1, 0x65, 1, 2, 3])
.unwrap();
client
.write_opus(now, Duration::from_millis(20), &[0x78, 1, 2, 3])
.unwrap();
}
if media_received.len() == 2 {
break;
}
}
assert!(client_open && agent_open);
assert!(received);
assert!(pointer_received);
assert!(media_received.contains(&MediaKind::H264));
assert!(media_received.contains(&MediaKind::Opus));
}
fn drain(
endpoint: &mut WebRtcEndpoint,
packets: &mut VecDeque<NetworkDatagram>,
opened: &mut bool,
received: &mut bool,
pointer_received: &mut bool,
media_received: &mut Vec<MediaKind>,
) {
loop {
match endpoint.poll_output().unwrap() {
EndpointOutput::Transmit(packet) => packets.push_back(packet),
EndpointOutput::Event(EndpointEvent::ChannelOpen { kind, label }) => match kind {
DataChannelKind::Control => {
assert_eq!(label, "control");
*opened = true;
}
DataChannelKind::Pointer => assert_eq!(label, "pointer"),
},
EndpointOutput::Event(EndpointEvent::ChannelData { kind, data }) => match kind {
DataChannelKind::Control => {
assert_eq!(data, b"loopback-ping");
*received = true;
}
DataChannelKind::Pointer => {
assert_eq!(data, b"pointer-move");
*pointer_received = true;
}
},
EndpointOutput::Event(EndpointEvent::Media {
kind,
data,
contiguous,
}) => {
assert!(!data.is_empty());
assert!(contiguous);
if !media_received.contains(&kind) {
media_received.push(kind);
}
}
EndpointOutput::Event(_) | EndpointOutput::Timeout(_) => break,
}
}
}
}
+4 -19
View File
@@ -738,17 +738,12 @@ fn capabilities(settings: &PersistentSettings) -> CapabilitiesResponse {
let update_configured =
settings.update_manifest_url.is_some() && settings.update_public_key.is_some();
let d3d11_available = d3d11_device_available();
let desktop_shell = env::var("REMOTEDESK_DESKTOP_SHELL").as_deref() == Ok("tauri-2");
CapabilitiesResponse {
version: env!("CARGO_PKG_VERSION"),
platform: env::consts::OS,
application: ApplicationCapabilities {
desktop_shell,
shell_kind: if desktop_shell {
"tauri-2"
} else {
"standalone"
},
desktop_shell: true,
shell_kind: "winit-wgpu-egui",
},
rdp: RdpCapabilities {
probe: true,
@@ -3330,18 +3325,8 @@ mod tests {
assert_eq!(response.version, env!("CARGO_PKG_VERSION"));
assert_eq!(response.platform, env::consts::OS);
assert_eq!(
response.application.desktop_shell,
env::var("REMOTEDESK_DESKTOP_SHELL").as_deref() == Ok("tauri-2")
);
assert_eq!(
response.application.shell_kind,
if response.application.desktop_shell {
"tauri-2"
} else {
"standalone"
}
);
assert!(response.application.desktop_shell);
assert_eq!(response.application.shell_kind, "winit-wgpu-egui");
assert!(response.rdp.probe);
assert_eq!(
response.rdp.protocol_probe,
+3 -30
View File
@@ -809,44 +809,17 @@ mod tests {
}
#[test]
fn compatibility_reports_manual_to_display_cross_adapter_copy() {
let args = parse_args(strings(&[
"--dry-run",
"--window-adapter",
"gpu-0",
"--manual-adapter",
"gpu-1",
"--required-end-to-end",
]))
.unwrap();
let plan = build_plan(args).unwrap();
assert_eq!(plan.decode_adapter, AdapterId::new("gpu-1"));
assert_eq!(plan.render_adapter, AdapterId::new("gpu-1"));
assert_eq!(plan.display_adapter, AdapterId::new("gpu-0"));
assert_eq!(
plan.planned_memory_path,
remotedesk_client_core::MemoryPathStatus::CrossAdapterCopy
);
assert!(!plan.is_zero_copy_candidate());
}
#[test]
fn required_end_to_end_is_default_and_compatibility_is_explicit() {
fn required_end_to_end_is_the_only_policy() {
let required = parse_args(strings(&["--dry-run", "--window-adapter", "gpu-0"])).unwrap();
assert_eq!(required.zero_copy_policy, ZeroCopyPolicy::RequiredEndToEnd);
let compatibility = parse_args(strings(&[
let explicit = parse_args(strings(&[
"--dry-run",
"--window-adapter",
"gpu-0",
"--required-end-to-end",
]))
.unwrap();
assert_eq!(
compatibility.zero_copy_policy,
ZeroCopyPolicy::RequiredEndToEnd
);
assert_eq!(explicit.zero_copy_policy, ZeroCopyPolicy::RequiredEndToEnd);
}
#[test]
@@ -42,15 +42,15 @@ impl MediaClock {
if is_forward {
if let Some(last_pts) = self.last_audio_pts {
let delta = pts.saturating_sub(last_pts);
let expected_duration = u64::from(duration_ms.max(1)).saturating_mul(10_000);
let error = i64::try_from(delta).unwrap_or(i64::MAX)
- i64::try_from(expected_duration).unwrap_or(i64::MAX);
let expected_duration = u64::from(duration_ms.max(1)).saturating_mul(10_000);
let error = i64::try_from(delta).unwrap_or(i64::MAX)
- i64::try_from(expected_duration).unwrap_or(i64::MAX);
if error.abs() <= AUDIO_SYNC_DEADBAND_100NS {
self.source_rate_adjustment_ppm =
self.source_rate_adjustment_ppm.saturating_mul(7) / 8;
} else {
let target = (error.saturating_mul(1_000_000)
/ i64::try_from(expected_duration.max(1)).unwrap_or(1))
let target = (error.saturating_mul(1_000_000)
/ i64::try_from(expected_duration.max(1)).unwrap_or(1))
.clamp(
-i64::from(AUDIO_RATE_LIMIT_PPM),
i64::from(AUDIO_RATE_LIMIT_PPM),
@@ -595,15 +595,12 @@ mod tests {
let mut clock = MediaClock::new();
clock.observe_audio(1_000_000, 20);
for sequence in 1..128 {
clock.observe_audio(1_000_000 + sequence * 300_000, 30);
clock.observe_audio(1_000_000 + sequence * 300_000, 20);
}
assert!(clock.rate_adjustment_ppm().abs() <= AUDIO_RATE_LIMIT_PPM);
let corrected = clock.rate_adjustment_ppm();
for sequence in 128..160 {
clock.observe_audio(
1_000_000 + 127 * 300_000 + (sequence - 127) * 200_000,
20,
);
clock.observe_audio(1_000_000 + 127 * 300_000 + (sequence - 127) * 200_000, 20);
}
assert!(clock.rate_adjustment_ppm().abs() < corrected.abs());
}
+1
View File
@@ -14,6 +14,7 @@ egui = "0.31"
egui-wgpu = "0.31"
egui-winit = "0.31"
pollster = "0.4"
remotedesk-webrtc-core = { path = "../crates/webrtc-core" }
wgpu = "24"
winit = "0.30"
+47 -14
View File
@@ -5,6 +5,7 @@ use std::time::Instant;
use egui_wgpu::Renderer;
use egui_winit::State;
use remotedesk_webrtc_core::{SessionLimits, WebRtcEndpoint, WebRtcRole};
use wgpu::SurfaceError;
use winit::application::ApplicationHandler;
use winit::dpi::PhysicalSize;
@@ -20,9 +21,11 @@ struct App {
config: Option<wgpu::SurfaceConfiguration>,
renderer: Option<Renderer>,
egui_state: Option<State>,
egui_context: egui::Context,
started: Instant,
endpoint: String,
status: String,
webrtc_endpoint: Option<WebRtcEndpoint>,
}
impl Default for App {
@@ -35,9 +38,11 @@ impl Default for App {
config: None,
renderer: None,
egui_state: None,
egui_context: egui::Context::default(),
started: Instant::now(),
endpoint: String::new(),
status: "Disconnected".to_owned(),
webrtc_endpoint: None,
}
}
}
@@ -67,7 +72,7 @@ impl ApplicationHandler for App {
}))
.expect("find a compatible GPU adapter");
let (device, queue) =
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default(), None))
.expect("create GPU device");
let size = window.inner_size();
let capabilities = surface.get_capabilities(&adapter);
@@ -84,6 +89,7 @@ impl ApplicationHandler for App {
};
surface.configure(&device, &config);
self.egui_state = Some(State::new(
self.egui_context.clone(),
egui::ViewportId::ROOT,
event_loop,
None,
@@ -149,7 +155,7 @@ impl App {
return;
};
let raw_input = state.take_egui_input(window);
let full_output = egui::Context::default().run(raw_input, |ctx| {
let full_output = self.egui_context.run(raw_input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("RemoteDesk");
ui.label("Native cross-platform client");
@@ -158,7 +164,17 @@ impl App {
ui.label("Server");
ui.text_edit_singleline(&mut self.endpoint);
if ui.button("Connect").clicked() {
self.status = "Connecting".to_owned();
match WebRtcEndpoint::new(
WebRtcRole::Offerer,
SessionLimits::default(),
Instant::now(),
) {
Ok(endpoint) => {
self.webrtc_endpoint = Some(endpoint);
self.status = "Waiting for signaling".to_owned();
}
Err(error) => self.status = error.to_string(),
}
}
});
ui.label(format!("Status: {}", self.status));
@@ -183,8 +199,12 @@ impl App {
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("egui"),
});
let clipped =
egui::Context::default().tessellate(full_output.shapes, full_output.pixels_per_point);
let clipped = self
.egui_context
.tessellate(full_output.shapes, full_output.pixels_per_point);
for (texture_id, delta) in &full_output.textures_delta.set {
renderer.update_texture(device, queue, *texture_id, delta);
}
renderer.update_buffers(
device,
queue,
@@ -195,17 +215,30 @@ impl App {
pixels_per_point: full_output.pixels_per_point,
},
);
renderer.render(
&mut encoder,
&view,
&clipped,
&egui_wgpu::ScreenDescriptor {
size_in_pixels: [config.width, config.height],
pixels_per_point: full_output.pixels_per_point,
},
);
let screen = egui_wgpu::ScreenDescriptor {
size_in_pixels: [config.width, config.height],
pixels_per_point: full_output.pixels_per_point,
};
let render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("egui"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
renderer.render(&mut render_pass.forget_lifetime(), &clipped, &screen);
queue.submit([encoder.finish()]);
output.present();
for texture_id in &full_output.textures_delta.free {
renderer.free_texture(texture_id);
}
window.request_redraw();
}
}
-15
View File
@@ -1,15 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<link rel="icon" type="image/svg+xml" href="./remotedesk-icon.svg" />
<link rel="alternate icon" type="image/x-icon" href="./icon.ico" />
<title>RemoteDesk</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-1189
View File
File diff suppressed because it is too large Load Diff
-24
View File
@@ -1,24 +0,0 @@
{
"name": "remotedesk-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --configLoader runner",
"control": "cargo run --manifest-path ../helpers/control-service/Cargo.toml -- --web-root dist --port 4174",
"build": "npm run lint && vite build --configLoader runner",
"build:check": "npm run lint && node scripts/build-check.mjs",
"lint": "tsc --noEmit -p tsconfig.app.json --pretty false && tsc --noEmit -p tsconfig.node.json --pretty false"
},
"dependencies": {
"react": "19.2.6",
"react-dom": "19.2.6",
"lucide-react": "0.577.0"
},
"devDependencies": {
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"typescript": "5.9.3",
"vite": "6.4.3"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#5ed39a"/>
<g fill="none" stroke="#101b16" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 8.5v3.2M8.4 10.1h3.2"/>
<path d="M21.8 8.5v3.2M20.2 10.1h3.2"/>
<path d="M10 18.5v3.2M8.4 20.1h3.2"/>
<path d="M21.8 18.5v3.2M20.2 20.1h3.2"/>
<path d="M12.5 14.2h7M12.5 16.4h7"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 444 B

-10
View File
@@ -1,10 +0,0 @@
import { build } from 'vite'
import { fileURLToPath } from 'node:url'
await build({
root: fileURLToPath(new URL('..', import.meta.url)),
configFile: false,
build: {
write: false,
},
})
File diff suppressed because it is too large Load Diff
-549
View File
@@ -1,549 +0,0 @@
export type RdpProbeStatus =
| 'reachable'
| 'dns_failed'
| 'timeout'
| 'refused'
| 'unreachable'
export type RdpProbeResult = {
status: RdpProbeStatus
reachable: boolean
latency_ms: number | null
port: number
}
export type LinuxAgentProbeResult = {
ok: boolean
latency_ms: number
protocol_major: number
protocol_minor: number
terminal: boolean
desktop: boolean
files: boolean
edge_presence_configured: boolean
edge_presence_online: boolean
edge_signaling_configured: boolean
edge_signaling_online: boolean
}
export type RdpProtocolProbeResult = {
ok: boolean
stage: 'input' | 'dns' | 'tcp' | 'rdp_negotiation' | 'tls'
security_protocol?: 'hybrid' | 'hybrid_extended'
tcp_latency_ms?: number
total_latency_ms?: number
certificate_sha256?: string
error_code?: string
}
export type LaunchRdpRequest = {
address: string
username: string
resolution: RdpResolution
use_multimon: boolean
monitor_indices?: number[]
client: RdpClientMode
fullscreen: boolean
certificate_sha256: string | null
credential_ref: string | null
redirect_clipboard: boolean
}
export type RdpResolution =
| { mode: 'follow_window' }
| { mode: 'fixed'; width: number; height: number }
export type RdpClientMode = 'system' | 'native'
export type LaunchRdpResult = {
launched: boolean
client?: RdpClientMode
session_id?: string
}
export type NativeRdpSessionState =
| 'waiting_for_credentials'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'failed'
| 'terminated'
export type NativeRdpResizeState =
| 'idle'
| 'pending'
| 'reconnect_required'
| 'reconnecting'
| 'confirmed'
| 'cancelled'
export type NativeRdpSessionDiagnostics = {
schema_version: 1
session_id: string
state: NativeRdpSessionState
started_at_unix_ms: number
updated_at_unix_ms: number
frame_count: number
frames_per_second: number | null
desktop_width: number
desktop_height: number
monitor_count: number
multi_monitor: boolean
network_latency_ms: number | null
base_network_latency_ms: number | null
bandwidth_kbps: number | null
decode_latency_ms: number | null
presentation_latency_ms: number | null
renderer?: string
frame_conversion_pixels: number
frame_upload_mode?: 'pending' | 'full_frame' | 'dirty_rect' | 'cached_frame'
frame_upload_pixels: number
reconnect_attempt: number
resize_generation: number
resize_state: NativeRdpResizeState
resize_requested_width: number | null
resize_requested_height: number | null
resize_failure: 'unsupported' | 'timeout' | null
error_code: string | null
}
export type LinuxDesktopSessionDiagnostics = {
schema_version: 1
session_id: string
state: 'connecting' | 'connected' | 'reconnecting' | 'failed' | 'terminated'
updated_at_unix_ms: number
frame_count: number
frames_per_second: number | null
desktop_width: number
desktop_height: number
network_latency_ms: number | null
capture_latency_ms: number | null
encode_latency_ms: number | null
compression_level: number | null
compression_ratio_percent: number | null
decode_latency_ms: number | null
presentation_latency_ms: number | null
clipboard_read: boolean
clipboard_write: boolean
reconnect_attempt: number
error_code: string | null
}
function compatibleResolution(resolution: RdpResolution): string {
return resolution.mode === 'follow_window'
? 'Follow window'
: `${resolution.width} x ${resolution.height}`
}
export type ControlCapabilities = {
version: string
platform: string
application: {
desktop_shell: boolean
shell_kind: 'tauri-2' | 'standalone'
}
rdp: {
probe: boolean
protocol_probe: boolean
external_launch: boolean
native_launch: boolean
native_diagnostics: boolean
native_multimon: boolean
session_network_metrics: boolean
session_decode_metrics: boolean
region_pixel_conversion: boolean
secure_named_pipe: boolean
secure_pipe_config_ack: boolean
secure_pipe_handshake_timeout: boolean
job_object_lifecycle: boolean
certificate_pinning: boolean
automatic_reconnect: boolean
resize_confirmation: boolean
clipboard: boolean
audio_playback: boolean
native_renderer: 'software_framebuffer' | 'd3d11_cpu_upload'
d3d11_device_available: boolean
d3d11_dirty_rect_upload: boolean
credential_manager: boolean
credential_store: boolean
}
linux: {
agent_session: boolean
agent_probe: boolean
terminal_session: boolean
desktop_session: boolean
x11_desktop: boolean
relative_pointer: boolean
adaptive_frame_pacing: boolean
adaptive_compression: boolean
desktop_session_resume: boolean
desktop_session_crash_resume: boolean
direct_webrtc_signaling: boolean
opus_audio: boolean
clipboard_read: boolean
clipboard_write: boolean
wayland_desktop: boolean
native_video: boolean
native_video_decoder_probe: boolean
native_video_surface_presenter: boolean
file_transfer: boolean
agent_identity_store: boolean
}
edge: {
relay: boolean
signed_intent: boolean
linux_session_relay: boolean
linux_known_device_rendezvous: boolean
remote_pairing: boolean
}
update: {
configured: boolean
check: boolean
install: boolean
}
}
export type ControlSettings = {
edge_api_url: string | null
update_manifest_url: string | null
update_public_key: string | null
auto_check_updates: boolean
}
export type ControlUpdateRelease = {
version: string
channel: string
published_at: string
notes?: string
size_bytes: number
}
export type ControlUpdateCheck = {
current_version: string
status: 'up_to_date' | 'available'
release?: ControlUpdateRelease
}
type ApiError = {
error?: string
}
export class ControlApiError extends Error {
readonly status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ControlApiError'
this.status = status
}
}
async function readJson<T>(response: Response): Promise<T> {
const body = await response.json() as T & ApiError
if (!response.ok) {
throw new ControlApiError(
body.error || `control service returned HTTP ${response.status}`,
response.status,
)
}
return body
}
export async function probeRdp(address: string): Promise<RdpProbeResult> {
const response = await fetch('/api/v1/rdp/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address }),
})
return readJson<RdpProbeResult>(response)
}
export async function negotiateRdp(address: string): Promise<RdpProtocolProbeResult> {
const response = await fetch('/api/v1/rdp/negotiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address }),
})
return readJson<RdpProtocolProbeResult>(response)
}
export async function getControlCapabilities(): Promise<ControlCapabilities> {
const response = await fetch('/api/v1/capabilities', {
headers: { Accept: 'application/json' },
})
return readJson<ControlCapabilities>(response)
}
export async function getControlSettings(): Promise<ControlSettings> {
const response = await fetch('/api/v1/settings', {
headers: { Accept: 'application/json' },
})
return readJson<ControlSettings>(response)
}
export async function updateControlSettings(settings: ControlSettings): Promise<ControlSettings> {
const response = await fetch('/api/v1/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings),
})
return readJson<ControlSettings>(response)
}
export async function getControlHosts(): Promise<unknown> {
const response = await fetch('/api/v1/hosts', {
headers: { Accept: 'application/json' },
})
return readJson<unknown>(response)
}
export async function updateControlHosts(hosts: unknown): Promise<void> {
const response = await fetch('/api/v1/hosts', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hosts),
})
await readJson<{ saved: boolean }>(response)
}
export async function checkControlUpdate(): Promise<ControlUpdateCheck> {
const response = await fetch('/api/v1/update/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
return readJson<ControlUpdateCheck>(response)
}
export async function installControlUpdate(version: string): Promise<void> {
const response = await fetch('/api/v1/update/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version }),
})
await readJson<{ accepted: boolean; version: string }>(response)
}
export async function launchRdp(request: LaunchRdpRequest): Promise<LaunchRdpResult> {
// Keep a reloaded UI compatible with a control service that has not restarted yet.
const compatibleRequest = {
...request,
resolution: compatibleResolution(request.resolution),
}
const response = await fetch('/api/v1/rdp/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(compatibleRequest),
})
return readJson<LaunchRdpResult>(response)
}
export async function getNativeRdpSession(
sessionId: string,
): Promise<NativeRdpSessionDiagnostics> {
const response = await fetch(`/api/v1/rdp/session/${encodeURIComponent(sessionId)}`, {
headers: { Accept: 'application/json' },
})
return readJson<NativeRdpSessionDiagnostics>(response)
}
export async function openCredentialManager(): Promise<void> {
const response = await fetch('/api/v1/windows/credential-manager', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
})
await readJson<{ launched: boolean }>(response)
}
export type RdpCredentialStatus = 'missing' | 'ready'
async function credentialRequest(
operation: 'status' | 'set' | 'delete',
credentialRef: string,
): Promise<Response> {
return fetch(`/api/v1/rdp/credential/${operation}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential_ref: credentialRef }),
})
}
export async function getRdpCredentialStatus(credentialRef: string): Promise<RdpCredentialStatus> {
const response = await credentialRequest('status', credentialRef)
return (await readJson<{ status: RdpCredentialStatus }>(response)).status
}
export async function configureRdpCredential(credentialRef: string): Promise<void> {
const response = await credentialRequest('set', credentialRef)
await readJson<{ launched: boolean }>(response)
}
export async function saveRdpCredential(credentialRef: string, account: string, password: string): Promise<void> {
const response = await fetch('/api/v1/rdp/credential/set', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credential_ref: credentialRef, account, password }),
})
await readJson<{ launched: boolean }>(response)
}
export async function deleteRdpCredential(credentialRef: string): Promise<void> {
const response = await credentialRequest('delete', credentialRef)
await readJson<{ status: 'missing' }>(response)
}
export async function launchLinuxTerminal(request: {
address: string
user: string
certificate_sha256: string
pair: boolean
agent_public_key?: string | null
edge_api_url?: string | null
}): Promise<void> {
const response = await fetch('/api/v1/linux/terminal/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
await readJson<{ launched: boolean }>(response)
}
export async function probeLinuxAgent(
address: string,
certificateSha256: string,
): Promise<LinuxAgentProbeResult> {
const response = await fetch('/api/v1/linux/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, certificate_sha256: certificateSha256 }),
})
return readJson<LinuxAgentProbeResult>(response)
}
export type WindowsAgentProbeResult = {
ok: boolean
latency_ms: number
protocol_major: number
protocol_minor: number
capture_backend: string
preferred_capture_backend: string
software_fallback_backend?: string | null
software_fallback_available: boolean
input_backend: string
authenticated: boolean
desktop_session: boolean
capture_latency_ms?: number | null
encode_latency_ms?: number | null
frame_processing_latency_ms?: number | null
applications: Array<{ id: string; name: string }>
}
export async function probeWindowsAgent(address: string): Promise<WindowsAgentProbeResult> {
const response = await fetch('/api/v1/windows-agent/probe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address }),
})
return readJson<WindowsAgentProbeResult>(response)
}
export type WindowsAgentSessionDiagnostics = {
schema_version: 1
session_id: string
state: 'connecting' | 'connected' | 'failed' | 'terminated'
updated_at_unix_ms: number
frame_count: number
frames_per_second: number | null
desktop_width: number
desktop_height: number
capture_latency_ms: number | null
encode_latency_ms: number | null
frame_processing_latency_ms: number | null
decode_latency_ms: number | null
presentation_latency_ms: number | null
renderer: string
error_code: string | null
}
export async function launchWindowsAgentDesktop(request: { address: string; frames_per_second: number; fullscreen: boolean; mode?: 'desktop' | 'application'; application?: string }): Promise<{ launched: boolean; session_id: string }> {
const response = await fetch('/api/v1/windows-agent/desktop/launch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request) })
return readJson<{ launched: boolean; session_id: string }>(response)
}
export async function getWindowsAgentSession(sessionId: string): Promise<WindowsAgentSessionDiagnostics> {
const response = await fetch(`/api/v1/windows-agent/session/${encodeURIComponent(sessionId)}`, { headers: { Accept: 'application/json' } })
return readJson<WindowsAgentSessionDiagnostics>(response)
}
export async function launchLinuxDesktop(request: {
address: string
user: string
certificate_sha256: string
width: number
height: number
frames_per_second: number
fullscreen: boolean
follow_window: boolean
capture_input: boolean
clipboard_read: boolean
clipboard_write: boolean
edge_api_url?: string | null
}): Promise<{ launched: boolean; session_id: string }> {
const response = await fetch('/api/v1/linux/desktop/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
return readJson<{ launched: boolean; session_id: string }>(response)
}
export async function getLinuxDesktopSession(
sessionId: string,
): Promise<LinuxDesktopSessionDiagnostics> {
const response = await fetch(
`/api/v1/linux/desktop/session/${encodeURIComponent(sessionId)}`,
{ headers: { Accept: 'application/json' } },
)
return readJson<LinuxDesktopSessionDiagnostics>(response)
}
export async function launchLinuxFileTransfer(request: {
address: string
user: string
certificate_sha256: string
direction: 'upload' | 'download'
local_path: string
remote_path: string
edge_api_url?: string | null
}): Promise<void> {
const response = await fetch('/api/v1/linux/files/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
})
await readJson<{ launched: boolean }>(response)
}
async function linuxAgentIdentityRequest(
operation: 'status' | 'delete',
certificateSha256: string,
): Promise<RdpCredentialStatus> {
const response = await fetch(`/api/v1/linux/identity/${operation}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ certificate_sha256: certificateSha256 }),
})
return (await readJson<{ status: RdpCredentialStatus }>(response)).status
}
export async function getLinuxAgentIdentityStatus(
certificateSha256: string,
): Promise<RdpCredentialStatus> {
return linuxAgentIdentityRequest('status', certificateSha256)
}
export async function deleteLinuxAgentIdentity(certificateSha256: string): Promise<void> {
const status = await linuxAgentIdentityRequest('delete', certificateSha256)
if (status !== 'missing') throw new Error('控制服务未删除 Linux Agent 身份')
}
-202
View File
@@ -1,202 +0,0 @@
import type {
LinuxAgentProbeResult,
NativeRdpSessionState,
RdpClientMode,
RdpProbeResult,
RdpProtocolProbeResult,
RdpResolution,
} from './backend'
export type HostKind = 'windows_rdp' | 'windows_agent' | 'linux_agent'
export type HostStatus = 'ready' | 'offline' | 'connecting' | 'probing' | 'unknown'
export type NetworkPolicy = 'Smart' | 'Direct preferred' | 'CDN preferred'
export type DisplayMode = 'primary' | 'all' | 'custom'
export type CredentialState = 'missing' | 'ready' | 'unknown'
export type WindowsAgentApplication = { id: string; name: string }
export type HostDisplay = {
id: string
name: string
resolution: string
primary: boolean
}
export type Host = {
id: number
name: string
address: string
kind: HostKind
mode: string
agentApplication?: string
agentApplications?: WindowsAgentApplication[]
tags?: string[]
status: HostStatus
favorite: boolean
lastUsed: string
latency?: number
sessionNetworkLatency?: boolean
networkBaseLatency?: number
bandwidthKbps?: number
captureLatency?: number
encodeLatency?: number
frameProcessingLatency?: number
compressionLevel?: number
compressionRatio?: number
decodeLatency?: number
presentationLatency?: number
nativeMonitorCount?: number
frameRate?: number
nativeSessionState?: NativeRdpSessionState
nativeRenderer?: string
frameUploadMode?: string
frameUploadPixels?: number
frameConversionPixels?: number
qualityUpdatedAt?: number
path: string
networkPolicy: NetworkPolicy
resolution: string
customWidth: string
customHeight: string
gpu: string
memoryPath: string
recent: boolean
strictZeroCopy: boolean
displayMode: DisplayMode
displays: HostDisplay[]
selectedDisplayIds: string[]
username: string
credentialState: CredentialState
agentIdentityState: CredentialState
credentialRef: string
rdpClient: RdpClientMode
nativeFullscreen: boolean
captureInput: boolean
redirectClipboard: boolean
clipboardRead: boolean
clipboardWrite: boolean
sessionClipboardRead?: boolean
sessionClipboardWrite?: boolean
agentPublicKey?: string
probeDetail?: string
}
export const minRdpDimension = 200
export const maxRdpDimension = 8192
const credentialRefPrefix = 'RemoteDesk/RDP/'
function createRef(prefix: string) {
const profileKey = typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: Array.from(crypto.getRandomValues(new Uint8Array(16)), (value) => value.toString(16).padStart(2, '0')).join('')
return `${prefix}${profileKey}`
}
export function createCredentialRef() { return createRef(credentialRefPrefix) }
export function validCredentialRef(value: unknown): value is string {
return typeof value === 'string' && new RegExp(`^${credentialRefPrefix}[A-Za-z0-9_.:@-]{1,128}$`).test(value)
}
export function isStoredHost(value: unknown): value is Host {
if (!value || typeof value !== 'object') return false
const host = value as Partial<Host>
return typeof host.id === 'number'
&& typeof host.name === 'string'
&& typeof host.address === 'string'
&& (host.kind === 'windows_rdp' || host.kind === 'windows_agent' || host.kind === 'linux_agent')
&& typeof host.mode === 'string'
&& Array.isArray(host.displays)
&& Array.isArray(host.selectedDisplayIds)
}
export function normalizeTags(value: unknown): string[] {
if (!Array.isArray(value)) return []
const tags: string[] = []
for (const item of value) {
if (typeof item !== 'string') continue
const tag = item.trim().slice(0, 32)
if (tag && !tags.includes(tag)) tags.push(tag)
if (tags.length >= 20) break
}
return tags
}
export function parseTags(value: string): string[] {
return normalizeTags(value.split(','))
}
export function normalizeStoredHost(host: Host): Host {
return {
...host,
tags: normalizeTags(host.tags),
mode: host.kind === 'windows_agent' && host.mode !== 'Application' ? 'Desktop' : host.mode,
status: 'unknown', latency: undefined, sessionNetworkLatency: undefined, networkBaseLatency: undefined,
bandwidthKbps: undefined, captureLatency: undefined, encodeLatency: undefined, frameProcessingLatency: undefined, decodeLatency: undefined,
presentationLatency: undefined, frameRate: undefined, nativeSessionState: undefined,
sessionClipboardRead: undefined, sessionClipboardWrite: undefined, qualityUpdatedAt: undefined,
credentialState: host.kind === 'windows_rdp' ? 'unknown' : 'missing',
agentIdentityState: host.kind === 'linux_agent' || host.kind === 'windows_agent' ? 'unknown' : 'missing',
credentialRef: validCredentialRef(host.credentialRef) ? host.credentialRef : createCredentialRef(),
rdpClient: host.rdpClient === 'native' ? 'native' : 'system', nativeFullscreen: host.nativeFullscreen === true,
captureInput: host.captureInput === true, redirectClipboard: host.redirectClipboard !== false,
clipboardRead: host.clipboardRead ?? host.redirectClipboard !== false,
clipboardWrite: host.clipboardWrite ?? host.redirectClipboard !== false,
customWidth: typeof host.customWidth === 'string' ? host.customWidth : '1600',
customHeight: typeof host.customHeight === 'string' ? host.customHeight : '900',
probeDetail: '尚未检测',
}
}
export function statusLabel(host: Host) {
if (host.status === 'ready') return host.kind === 'linux_agent' || host.kind === 'windows_agent' ? 'Agent 在线' : '端口可达'
if (host.status === 'connecting') return '连接中'
if (host.status === 'probing') return '检测中'
if (host.status === 'unknown') return '待检测'
return '离线'
}
export function probeResultDetail(result: RdpProbeResult) {
if (result.status === 'reachable') return `TCP ${result.port} 可达,账号尚未验证`
if (result.status === 'dns_failed') return '域名解析失败'
if (result.status === 'timeout') return `TCP ${result.port} 连接超时`
if (result.status === 'refused') return `TCP ${result.port} 拒绝连接`
return `TCP ${result.port} 不可达`
}
export function linuxProbeDetail(result: LinuxAgentProbeResult) {
const capabilities = [result.terminal ? '终端' : null, result.desktop ? 'X11 桌面' : null, result.files ? '文件' : null]
.filter((value): value is string => value !== null).join('、') || '无会话能力'
const edge = result.edge_presence_configured
? result.edge_presence_online && result.edge_signaling_online ? 'Edge 在线' : 'Edge 离线' : ''
return `Agent v${result.protocol_major}.${result.protocol_minor} 可用,${capabilities}${edge}`
}
export function protocolProbeDetail(result: RdpProtocolProbeResult) {
if (result.ok) {
const protocol = result.security_protocol === 'hybrid_extended' ? 'NLA Hybrid Extended' : 'NLA Hybrid'
const latency = result.total_latency_ms === undefined ? '' : `${result.total_latency_ms} ms`
return `RDP 协商成功(${protocol}${latency}),账号尚未验证`
}
if (result.error_code === 'timeout') return 'TCP 可达,但 RDP 协商超时'
if (result.error_code === 'rdp_negotiation_failed') return 'TCP 可达,但目标未通过 RDP 协商'
return `RDP 协商未完成(${result.error_code ?? result.stage}`
}
export function parseRdpDimension(value: string) {
if (!/^\d+$/.test(value)) return null
const dimension = Number(value)
return Number.isSafeInteger(dimension) && dimension >= minRdpDimension && dimension <= maxRdpDimension ? dimension : null
}
export function rdpResolution(host: Host): RdpResolution | null {
if (host.resolution === 'Follow window') return { mode: 'follow_window' }
if (host.resolution === 'Custom') {
const width = parseRdpDimension(host.customWidth)
const height = parseRdpDimension(host.customHeight)
return width && height ? { mode: 'fixed', width, height } : null
}
const match = /^(\d+) x (\d+)$/.exec(host.resolution)
if (!match) return null
const width = parseRdpDimension(match[1])
const height = parseRdpDimension(match[2])
return width && height ? { mode: 'fixed', width, height } : null
}
-10
View File
@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
-283
View File
@@ -1,283 +0,0 @@
:root {
color: #dce2e7;
background: #101315;
color-scheme: dark;
font-family: Inter, "Segoe UI", "Microsoft YaHei", sans-serif;
font-synthesis: none;
letter-spacing: 0;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; overflow: hidden; }
button, input, select { font: inherit; letter-spacing: 0; }
button { color: inherit; }
button:focus-visible, .host-row:focus-visible { outline: 2px solid #69d6a0; outline-offset: -2px; }
.app-shell { min-height: 100vh; background: #101315; }
.topbar { height: 56px; display: grid; grid-template-columns: 220px minmax(260px, 560px) minmax(142px, 1fr) auto; align-items: center; gap: 16px; padding: 0 16px; border-bottom: 1px solid #2a3034; background: #15191c; }
.brand { display: flex; align-items: center; gap: 10px; font-size: 15px; font-weight: 700; color: #f1f4f5; }
.brand-mark { width: 30px; height: 30px; display: grid; place-items: center; color: #0f1512; background: #5ed39a; border-radius: 6px; overflow: hidden; }
.brand-mark img { display: block; width: 30px; height: 30px; }
.search-field { height: 34px; display: flex; align-items: center; gap: 9px; padding: 0 10px; border: 1px solid #343b40; border-radius: 6px; background: #0e1113; color: #7f8a91; }
.search-field:focus-within { border-color: #5d8f77; box-shadow: 0 0 0 2px #244435; }
.search-field input { flex: 1; min-width: 0; border: 0; outline: 0; color: #e8ecee; background: transparent; font-size: 13px; }
.search-field kbd { border: 1px solid #343b40; border-radius: 4px; padding: 2px 5px; color: #78838a; font-size: 10px; }
.top-actions { justify-self: end; display: flex; align-items: center; gap: 8px; }
.topbar-clock { min-width: 142px; display: inline-flex; align-items: center; justify-content: flex-end; gap: 7px; color: #98a39d; font-size: 11px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.icon-button, .row-icon { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #343b40; border-radius: 6px; background: #1a1f22; cursor: pointer; }
.icon-button:hover, .row-icon:hover { background: #252b2f; border-color: #475158; }
.icon-button.compact { width: 30px; height: 30px; }
.icon-button:disabled { opacity: .45; cursor: not-allowed; }
.icon-button.danger { color: #dd7d7d; }
.icon-button.update-available { color: #65d69d; border-color: #3d775b; }
.command-button, .connect-button, .secondary-button { min-height: 34px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border-radius: 6px; padding: 0 13px; border: 1px solid #4fbf87; background: #4fbf87; color: #0d1712; font-weight: 700; font-size: 13px; cursor: pointer; }
.secondary-button { border-color: #3b4449; background: #20262a; color: #dce2e7; }
.command-button:disabled, .secondary-button:disabled { opacity: .45; cursor: not-allowed; }
.workspace { height: calc(100vh - 56px); display: grid; grid-template-columns: 188px minmax(520px, 1fr) 310px; }
.sidebar { display: flex; flex-direction: column; padding: 18px 12px 12px; border-right: 1px solid #272d31; background: #121619; }
.sidebar nav { display: grid; gap: 3px; }
.sidebar-label { margin: 18px 10px 5px; color: #69757a; font-size: 10px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
.tag-filter { min-height: 31px; }
.tag-filter-name { min-width: 0; display: inline-flex; align-items: center; gap: 7px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-dot { width: 6px; height: 6px; flex: 0 0 auto; border-radius: 50%; background: #63c894; }
.nav-item { height: 35px; display: flex; align-items: center; justify-content: space-between; padding: 0 10px; border: 0; border-radius: 5px; background: transparent; color: #aab3b8; cursor: pointer; font-size: 13px; text-align: left; }
.nav-item:hover { background: #1b2023; color: #e3e7e9; }
.nav-item.active { color: #edf2ef; background: #22362d; }
.nav-count { color: #728078; font-size: 11px; font-variant-numeric: tabular-nums; }
.sidebar-status { margin-top: auto; display: flex; align-items: center; gap: 8px; padding: 10px; border-top: 1px solid #272d31; color: #8d999f; font-size: 11px; }
.host-area { min-width: 0; padding: 22px 22px 30px; overflow: auto; }
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 16px; }
.section-heading h1 { margin: 0 0 4px; font-size: 19px; line-height: 1.2; font-weight: 700; }
.section-heading span { color: #7f8a91; font-size: 12px; }
.path-summary { display: flex; align-items: center; gap: 7px; color: #93a19a; font-size: 12px; }
.host-table { min-width: 690px; border: 1px solid #2a3135; border-radius: 6px; overflow: hidden; background: #15191c; }
.table-row { width: 100%; display: grid; grid-template-columns: minmax(190px, 1.5fr) 90px 100px 110px 100px 78px; align-items: center; min-height: 54px; padding: 0 12px; border: 0; border-bottom: 1px solid #272d31; background: transparent; color: #adb7bc; text-align: left; font-size: 12px; }
.table-header { min-height: 34px; color: #758087; background: #111517; font-size: 10px; text-transform: uppercase; }
.host-row { cursor: pointer; }
.host-row:hover { background: #1a2023; }
.host-row.selected { background: #1d2924; box-shadow: inset 3px 0 #57c890; }
.host-row:last-child { border-bottom: 0; }
.host-identity { display: flex; align-items: center; gap: 10px; min-width: 0; }
.host-icon { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid #30383d; border-radius: 5px; color: #92a19a; background: #171c1f; }
.host-identity strong, .host-identity small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.host-identity strong { color: #e2e7e9; font-size: 13px; }
.host-identity small { margin-top: 2px; color: #707b82; font-size: 10px; }
.host-tags, .tag-editor-preview { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; min-width: 0; }
.tag-chip { display: inline-flex; align-items: center; max-width: 110px; min-height: 18px; padding: 1px 6px; overflow: hidden; border: 1px solid #345344; border-radius: 4px; background: #1b3026; color: #8edbb1; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; line-height: 1.3; }
.tag-editor-preview { margin: -4px 0 10px; }
.host-type-label { display: block; font-size: 11px; font-weight: 600; }
.host-mode-label { display: block; margin-top: 3px; color: #7f8b90; font-size: 10px; }
.add-host-note { padding: 8px 10px; border: 1px solid #344942; border-radius: 5px; background: #17251f; }
.status-cell { display: flex; align-items: center; gap: 7px; }
.status-dot { width: 7px; height: 7px; display: inline-block; flex: 0 0 auto; border-radius: 50%; background: #657078; }
.status-dot.ready { background: #59d394; box-shadow: 0 0 0 3px #1d3c2e; }
.status-dot.connecting { background: #e4b660; box-shadow: 0 0 0 3px #453820; }
.status-dot.probing { background: #e4b660; box-shadow: 0 0 0 3px #453820; animation: pulse 1s ease-in-out infinite; }
.status-dot.offline { background: #667078; }
.status-dot.unknown { background: #87939a; box-shadow: 0 0 0 3px #293136; }
.row-actions { display: flex; justify-content: flex-end; gap: 5px; }
.row-icon { width: 28px; height: 28px; }
.row-icon.primary { color: #65d69d; }
.row-icon:disabled { opacity: .32; cursor: not-allowed; }
.empty-state { padding: 42px; color: #778289; text-align: center; font-size: 13px; }
.inspector-empty { display: grid; align-content: center; justify-items: center; gap: 8px; color: #778289; }
.inspector-empty h2 { margin: 0; font-size: 13px; }
.inspector { padding: 20px 18px; border-left: 1px solid #272d31; background: #14181b; overflow-y: auto; }
.inspector-title { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 10px; padding-bottom: 16px; }
.inspector-title > div:first-child { min-width: 0; }
.inspector-actions { display: grid; grid-template-columns: repeat(2, 30px); grid-auto-rows: 30px; justify-content: end; gap: 6px; min-width: 66px; }
.inspector-actions .icon-button { width: 30px; height: 30px; min-width: 30px; }
.inspector-title > div { display: grid; grid-template-columns: 12px 1fr; align-items: center; }
.inspector-title h2 { margin: 0; font-size: 16px; line-height: 1.4; }
.inspector-title p { grid-column: 2; margin: 2px 0 0; color: #78838a; font-size: 11px; }
.inspector-section { padding: 16px 0; border-top: 1px solid #2a3034; }
.inspector-section h3 { margin: 0 0 12px; color: #8d989e; font-size: 11px; font-weight: 700; text-transform: uppercase; }
.inspector-section label, .modal label { display: grid; gap: 6px; margin-bottom: 11px; color: #8d989e; font-size: 11px; }
.rdp-client-field { display: grid; gap: 6px; margin-bottom: 11px; color: #8d989e; font-size: 11px; }
.segmented-control { width: 100%; min-height: 34px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid #333b40; border-radius: 5px; overflow: hidden; background: #0f1315; }
.segmented-control button { min-width: 0; display: flex; align-items: center; justify-content: center; gap: 4px; padding: 0 5px; border: 0; border-right: 1px solid #333b40; background: transparent; color: #8e999f; cursor: pointer; font-size: 10px; white-space: nowrap; }
.segmented-control button:last-child { border-right: 0; }
.segmented-control button:hover:not(:disabled) { color: #e2e7e9; background: #22292c; }
.segmented-control button.active { color: #bcebd2; background: #244133; }
.segmented-control button:disabled { color: #596268; background: #151a1d; cursor: not-allowed; }
.segmented-control.file-direction { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 11px; }
.connection-note { min-height: 28px; display: block; color: #717c82; line-height: 1.4; }
select, .modal input, .settings-drawer input { width: 100%; height: 32px; padding: 0 9px; border: 1px solid #333b40; border-radius: 5px; outline: 0; color: #dce2e5; background: #0f1315; font-size: 12px; }
select:focus, .modal input:focus, .settings-drawer input:focus { border-color: #548b70; }
select:disabled { color: #727d83; background: #151a1d; cursor: not-allowed; }
.resolution-custom { display: grid; grid-template-columns: minmax(0, 1fr) 12px minmax(0, 1fr); align-items: end; gap: 6px; margin-bottom: 11px; }
.resolution-custom label { min-width: 0; margin: 0; }
.resolution-custom span { padding-bottom: 8px; color: #727d83; text-align: center; font-size: 11px; }
.resolution-custom input { width: 100%; min-width: 0; height: 32px; padding: 0 8px; border: 1px solid #333b40; border-radius: 5px; outline: 0; color: #dce2e5; background: #0f1315; font-size: 12px; }
.resolution-custom input:focus { border-color: #548b70; }
.display-picker { display: grid; gap: 5px; margin: 0 0 12px; padding: 0; border: 0; }
.display-picker legend { margin-bottom: 6px; color: #8d989e; font-size: 11px; }
.display-option { min-height: 38px; grid-template-columns: 18px minmax(0, 1fr) !important; align-items: center; gap: 8px !important; margin: 0 !important; padding: 5px 8px; border: 1px solid #30383d; border-radius: 5px; background: #111517; cursor: pointer; }
.display-option input { width: 15px; height: 15px; margin: 0; accent-color: #3a9b6b; }
.display-option span, .display-option strong, .display-option small { display: block; min-width: 0; }
.display-option strong { overflow: hidden; color: #cbd2d5; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.display-option small { margin-top: 2px; color: #707b82; font-size: 10px; }
.metric-row { display: flex; justify-content: space-between; align-items: center; gap: 14px; min-height: 30px; color: #879299; font-size: 11px; }
.metric-row span { display: inline-flex; align-items: center; gap: 6px; }
.metric-row strong { max-width: 150px; overflow: hidden; color: #cfd6d9; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.good-text { color: #63d99d !important; }
.toggle-row { grid-template-columns: 1fr auto !important; align-items: center; margin-top: 8px; }
.toggle-row span, .toggle-row strong, .toggle-row small { display: block; }
.toggle-row strong { color: #cbd2d5; font-size: 11px; }
.toggle-row small { margin-top: 3px; color: #707b82; font-size: 10px; }
.toggle-row input { appearance: none; width: 30px; height: 17px; border-radius: 10px; background: #3a4247; cursor: pointer; position: relative; }
.toggle-row input::after { content: ''; position: absolute; width: 13px; height: 13px; left: 2px; top: 2px; border-radius: 50%; background: #b5bec2; transition: transform .15s; }
.toggle-row input:checked { background: #3a9b6b; }
.toggle-row input:checked::after { transform: translateX(13px); background: white; }
.toggle-row input:disabled { opacity: .55; cursor: not-allowed; }
.quality-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.quality-heading h3 { margin: 0; }
.quality-heading span, .quality-heading time { color: #717c82; font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.quality-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.quality-metrics > div { min-width: 0; padding: 1px 7px; border-left: 1px solid #2a3034; }
.quality-metrics > div:first-child { padding-left: 0; border-left: 0; }
.quality-metrics > div:last-child { padding-right: 0; }
.quality-metrics span, .quality-metrics strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.quality-metrics span { color: #7e898f; font-size: 10px; }
.quality-metrics strong { min-height: 19px; margin-top: 4px; color: #cfd5d8; font-size: 13px; font-variant-numeric: tabular-nums; }
.quality-details { margin-top: 13px; padding-top: 7px; border-top: 1px solid #252b2f; }
.quality-details > div { display: flex; justify-content: space-between; gap: 12px; margin: 7px 0; color: #7e898f; font-size: 11px; }
.quality-details strong { max-width: 180px; overflow: hidden; color: #cfd5d8; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; }
.connect-button { width: 100%; margin-top: 8px; }
.connection-blockers { margin-top: 14px; padding: 11px 12px; border: 1px solid #6b4c3f; border-radius: 6px; background: #2d2523; color: #f2c7b4; }
.connection-blockers h3 { margin: 0 0 7px; color: #f0a98c; font-size: 11px; font-weight: 700; }
.connection-blockers ul { margin: 0; padding-left: 18px; font-size: 12px; line-height: 1.55; }
.credential-button { width: 100%; margin-top: 10px; }
.certificate-input { width: 100%; min-width: 0; height: 32px; padding: 0 9px; border: 1px solid #333b40; border-radius: 5px; outline: 0; color: #dce2e5; background: #0f1315; font: 11px Consolas, "Cascadia Mono", monospace; }
.certificate-input:focus { border-color: #548b70; }
.certificate-observed { display: grid; gap: 4px; margin: -2px 0 9px; color: #7e898f; font-size: 10px; }
.certificate-observed strong { overflow: hidden; color: #cfd5d8; text-overflow: ellipsis; white-space: nowrap; font: 10px Consolas, "Cascadia Mono", monospace; }
.certificate-actions { display: grid; grid-template-columns: minmax(0, 1fr); grid-auto-flow: column; grid-auto-columns: 30px; gap: 7px; }
.connect-button:disabled { border-color: #394147; background: #252b2f; color: #78838a; cursor: not-allowed; }
.modal-backdrop, .drawer-backdrop { position: fixed; inset: 0; display: grid; place-items: center; background: #050708b8; z-index: 20; }
.modal { width: min(430px, calc(100vw - 32px)); padding: 20px; border: 1px solid #394248; border-radius: 7px; background: #171c1f; box-shadow: 0 24px 80px #000a; }
.modal-heading { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }
.modal-heading h2 { margin: 0; font-size: 17px; }
.modal-heading p { margin: 5px 0 0; color: #7c878d; font-size: 11px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
.drawer-backdrop { place-items: stretch end; }
.settings-drawer { width: min(390px, 100vw); height: 100%; overflow-y: auto; padding: 20px; border-left: 1px solid #394248; background: #171c1f; box-shadow: -20px 0 70px #0008; }
.drawer-save { width: 100%; }
.settings-error { margin: 10px 0 0; color: #ef8f8f; font-size: 11px; line-height: 1.4; overflow-wrap: anywhere; }
.shortcut-settings h3 { display: flex; align-items: center; gap: 6px; }
.settings-hint { margin: 8px 0 0; color: #9ca9ad; font-size: 11px; line-height: 1.45; }
.update-key-input { font-family: Consolas, "Cascadia Mono", monospace; }
.update-status { min-height: 38px; margin-top: 10px; padding: 9px 0; border-top: 1px solid #2a3034; border-bottom: 1px solid #2a3034; color: #879299; font-size: 11px; }
.update-status p { margin: 0; line-height: 1.45; overflow-wrap: anywhere; white-space: pre-line; }
.update-release > div { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.update-release strong { color: #d7dddf; font-size: 12px; }
.update-release span { color: #77838a; font-size: 10px; font-variant-numeric: tabular-nums; white-space: nowrap; }
.update-release > p { margin-top: 7px; color: #9ca6ab; }
.update-actions { display: flex; gap: 8px; margin-top: 10px; }
.update-actions > button { flex: 1; padding: 0 8px; }
.spin { animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 50% { opacity: .45; } }
:root[data-theme='light'] { color: #25302b; background: #f5f7f8; color-scheme: light; }
[data-theme='light'].app-shell { color: #25302b; background: #f5f7f8; }
[data-theme='light'] .topbar { border-color: #dbe1de; background: #ffffff; }
[data-theme='light'] .brand { color: #111713; }
[data-theme='light'] .topbar-clock { color: #5f6e66; }
[data-theme='light'] .search-field { border-color: #cbd3cf; background: #f5f7f8; color: #65736c; }
[data-theme='light'] .search-field:focus-within { border-color: #36845e; box-shadow: 0 0 0 2px #d7eee2; }
[data-theme='light'] .search-field input { color: #1f2924; }
[data-theme='light'] .search-field kbd { border-color: #cbd3cf; color: #66746d; background: #ffffff; }
[data-theme='light'] .icon-button, [data-theme='light'] .row-icon { border-color: #cbd3cf; background: #ffffff; }
[data-theme='light'] .icon-button:hover, [data-theme='light'] .row-icon:hover { border-color: #94a49c; background: #edf2ef; }
[data-theme='light'] .secondary-button { border-color: #c4cec9; background: #f1f4f2; color: #26322c; }
[data-theme='light'] .sidebar { border-color: #dfe5e2; background: #f8faf9; }
[data-theme='light'] .nav-item { color: #526159; }
[data-theme='light'] .nav-item:hover { color: #1f2924; background: #edf2ef; }
[data-theme='light'] .nav-item.active { color: #17452f; background: #dff1e7; }
[data-theme='light'] .nav-count { color: #68776f; }
[data-theme='light'] .sidebar-status { border-color: #dfe5e2; color: #65736c; }
[data-theme='light'] .section-heading span, [data-theme='light'] .path-summary { color: #65736c; }
[data-theme='light'] .host-table { border-color: #d7dfdb; background: #ffffff; }
[data-theme='light'] .table-row { border-color: #e1e6e3; color: #526159; }
[data-theme='light'] .table-header { color: #68776f; background: #f2f5f4; }
[data-theme='light'] .host-row:hover { background: #f3f7f5; }
[data-theme='light'] .host-row.selected { background: #e8f5ee; box-shadow: inset 3px 0 #2b9a65; }
[data-theme='light'] .host-icon { border-color: #d5ddd9; color: #65736c; background: #f4f7f5; }
[data-theme='light'] .host-identity strong { color: #1d2822; }
[data-theme='light'] .host-identity small, [data-theme='light'] .inspector-title p { color: #6c7a73; }
[data-theme='light'] .sidebar-label { color: #7a8981; }
[data-theme='light'] .tag-chip { border-color: #b9d8c6; background: #edf8f1; color: #28734b; }
[data-theme='light'] .status-dot.ready { background: #209a63; box-shadow: 0 0 0 3px #d7f0e3; }
[data-theme='light'] .status-dot.connecting { background: #b97817; box-shadow: 0 0 0 3px #f4e6ca; }
[data-theme='light'] .status-dot.probing { background: #b97817; box-shadow: 0 0 0 3px #f4e6ca; }
[data-theme='light'] .status-dot.offline { background: #8a9690; }
[data-theme='light'] .status-dot.unknown { background: #718078; box-shadow: 0 0 0 3px #e1e7e4; }
[data-theme='light'] .row-icon.primary, [data-theme='light'] .good-text { color: #167a4c !important; }
[data-theme='light'] .empty-state { color: #69776f; }
[data-theme='light'] .inspector { border-color: #dfe5e2; background: #ffffff; }
[data-theme='light'] .inspector-section { border-color: #dfe5e2; }
[data-theme='light'] .inspector-section h3, [data-theme='light'] .inspector-section label, [data-theme='light'] .modal label { color: #5f6e66; }
[data-theme='light'] .rdp-client-field { color: #5f6e66; }
[data-theme='light'] .segmented-control { border-color: #cbd3cf; background: #ffffff; }
[data-theme='light'] .segmented-control button { border-color: #cbd3cf; color: #5f6e66; }
[data-theme='light'] .segmented-control button:hover:not(:disabled) { color: #26322c; background: #edf2ef; }
[data-theme='light'] .segmented-control button.active { color: #175c3b; background: #dff1e7; }
[data-theme='light'] .segmented-control button:disabled { color: #96a19b; background: #eef1f0; }
[data-theme='light'] .connection-note { color: #69776f; }
[data-theme='light'] select, [data-theme='light'] .modal input, [data-theme='light'] .settings-drawer input { border-color: #cbd3cf; color: #25302b; background: #ffffff; }
[data-theme='light'] select:focus, [data-theme='light'] .modal input:focus, [data-theme='light'] .settings-drawer input:focus { border-color: #36845e; }
[data-theme='light'] select:disabled { color: #77857e; background: #eef1f0; }
[data-theme='light'] .resolution-custom span { color: #77857e; }
[data-theme='light'] .resolution-custom input { border-color: #cbd3cf; color: #25302b; background: #ffffff; }
[data-theme='light'] .resolution-custom input:focus { border-color: #36845e; }
[data-theme='light'] .certificate-input { border-color: #cbd3cf; color: #25302b; background: #ffffff; }
[data-theme='light'] .certificate-input:focus { border-color: #36845e; }
[data-theme='light'] .certificate-observed strong { color: #28342e; }
[data-theme='light'] .display-picker legend { color: #5f6e66; }
[data-theme='light'] .display-option { border-color: #d5ddd9; background: #f7f9f8; }
[data-theme='light'] .display-option strong { color: #28342e; }
[data-theme='light'] .display-option small { color: #69776f; }
[data-theme='light'] .metric-row, [data-theme='light'] .quality-details > div { color: #5f6e66; }
[data-theme='light'] .metric-row strong, [data-theme='light'] .quality-metrics strong, [data-theme='light'] .quality-details strong, [data-theme='light'] .toggle-row strong { color: #28342e; }
[data-theme='light'] .quality-heading span, [data-theme='light'] .quality-heading time, [data-theme='light'] .quality-metrics span { color: #69776f; }
[data-theme='light'] .quality-metrics > div, [data-theme='light'] .quality-details { border-color: #dfe5e2; }
[data-theme='light'] .toggle-row small, [data-theme='light'] .modal-heading p { color: #69776f; }
[data-theme='light'] .toggle-row input { background: #b7c1bc; }
[data-theme='light'] .toggle-row input::after { background: #ffffff; }
[data-theme='light'] .toggle-row input:checked { background: #26865a; }
[data-theme='light'] .connect-button:disabled { border-color: #d1d8d4; color: #77857e; background: #e8ecea; }
[data-theme='light'] .connection-blockers { border-color: #e5b9a7; background: #fff5f0; color: #7a3928; }
[data-theme='light'] .connection-blockers h3 { color: #a3472e; }
[data-theme='light'] .modal-backdrop, [data-theme='light'] .drawer-backdrop { background: #10181447; }
[data-theme='light'] .modal, [data-theme='light'] .settings-drawer { border-color: #c9d1cd; background: #ffffff; box-shadow: 0 24px 70px #19251f2e; }
[data-theme='light'] .settings-error { color: #aa3030; }
[data-theme='light'] .settings-hint { color: #5e6b65; }
[data-theme='light'] .icon-button.update-available { color: #167a4c; border-color: #78aa90; }
[data-theme='light'] .update-status { border-color: #dfe5e2; color: #5f6e66; }
[data-theme='light'] .update-release strong { color: #28342e; }
[data-theme='light'] .update-release span, [data-theme='light'] .update-release > p { color: #69776f; }
@media (max-width: 1040px) {
.workspace { grid-template-columns: 160px minmax(520px, 1fr); }
.inspector { display: none; }
.topbar { grid-template-columns: 140px minmax(150px, 1fr) 136px auto; gap: 8px; padding: 0 12px; }
}
@media (max-width: 900px) {
.top-actions .command-button { width: 34px; padding: 0; font-size: 0; }
}
@media (max-width: 720px) {
body { overflow: auto; }
.topbar { height: auto; min-height: 56px; grid-template-columns: 1fr auto; gap: 8px; padding: 10px 12px; }
.top-actions { gap: 4px; }
.topbar-clock { grid-column: 1 / -1; grid-row: 2; justify-self: end; min-width: 0; }
.search-field { grid-column: 1 / -1; grid-row: 3; }
.workspace { height: auto; min-height: calc(100vh - 132px); display: block; }
.sidebar { padding: 8px; border-right: 0; border-bottom: 1px solid #272d31; }
.sidebar nav { display: flex; overflow-x: auto; }
.nav-item { min-width: 82px; }
.sidebar-status { display: none; }
.host-area { padding: 16px 12px; overflow-x: auto; }
}
-22
View File
@@ -1,22 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
-15
View File
@@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
-15
View File
@@ -1,15 +0,0 @@
import { defineConfig } from 'vite'
export default defineConfig({
base: './',
build: {
emptyOutDir: true,
},
server: {
host: '127.0.0.1',
port: 4173,
proxy: {
'/api': 'http://127.0.0.1:4174',
},
},
})
+10 -10
View File
@@ -13,7 +13,7 @@
## 阶段 1:删除 Go/Hysteria2 构建链
状态:未开始
状态:已完成(源码与构建链)
工作:
@@ -27,7 +27,7 @@
## 阶段 2:新增共享 WebRTC Rust crate
状态:未开始
状态:部分完成。共享 crate 已锁定 `str0m 0.23`Windows/Linux workspace 已编译并有边界测试;macOS 目标尚未验证
工作:
@@ -41,7 +41,7 @@
## 阶段 3Agent/Client Loopback
状态:未开始
状态:部分完成。自动测试已覆盖 offer/answer、host ICE、DTLS/SCTP、可靠 control 与不可靠 pointer DataChanneltrickle ICE、ICE restart 和恶意网络测试尚缺
工作:
@@ -56,7 +56,7 @@
## 阶段 4:接入 H.264 RTP
状态:未开始
状态:部分完成。`str0m` loopback 已发送和接收 H.264 access unit,现有 Agent 测试覆盖单 NAL、STAP-A、FU-A、丢包与关键帧恢复;尚缺真实网络压力测试
工作:
@@ -70,7 +70,7 @@
## 阶段 5:平台硬件编码与解码
状态:未开始
状态:部分完成。Windows Media Foundation/D3D11 与 Linux GStreamer 硬件编码路径已有源码和自动构建;尚无三平台硬件端到端及 GPU 工具验证
工作:
@@ -85,7 +85,7 @@ surface 类型和 Adapter;硬件能力不足时明确拒绝会话,不静默
## 阶段 6:接入 wgpu 呈现
状态:未开始
状态:部分完成。原生 Client 已使用 `winit``wgpu``egui` 并进入 Windows CI;解码 surface 的跨平台 GPU interop 与呈现验收尚缺
工作:
@@ -99,7 +99,7 @@ surface 类型和 Adapter;硬件能力不足时明确拒绝会话,不静默
## 阶段 7:接入 Opus
状态:未开始
状态:部分完成。共享 `str0m` loopback 已承载 OpusLinux 捕获编码和 Windows 播放路径已有源码;PLC、漂移控制和真实设备验收尚缺
工作:
@@ -113,7 +113,7 @@ surface 类型和 Adapter;硬件能力不足时明确拒绝会话,不静默
## 阶段 8:接入 STUN/TURN
状态:未开始
状态:部分完成。Edge 已签发短期 TURN 凭据,共享层校验 STUN/TURN 配置并接受外部 srflx/relay 候选;真实 NAT、TURN/UDP 和 TURN/TLS 会话尚未验收
工作:
@@ -127,7 +127,7 @@ surface 类型和 Adapter;硬件能力不足时明确拒绝会话,不静默
## 阶段 9:更新安装包和 CI
状态:未开始
状态:部分完成。Windows/Linux 打包与 CI 已移除 Go、Node Web 前端和 Tauri 构建入口,原生 Client 与安装包已加入 CI;macOS 包、升级矩阵和完整产物白名单尚缺
工作:
@@ -141,7 +141,7 @@ Node、Tauri CLI 或 WebView 构建工具;发布产物具备签名、校验和
## 阶段 10:删除旧 Tauri/WebView 与兼容路径
状态:未开始
状态:部分完成。旧 `client/web``client/app-shell` 及正式构建引用已删除;历史设计文档仍待归档改写,旧异步 WebRTC 驱动仍待由共享 `str0m` socket 驱动替换
工作:
+22 -6
View File
@@ -8,7 +8,26 @@
- **部分**:只有策略模型、界面或实验入口,尚不能完成端到端会话。
- **未实现**:目前只有设计或路线规划。
## 当前可用
## 全 Rust 迁移快照
| 阶段 | 状态 | 已验证边界 |
|---|---|---|
| 删除 Go/Hysteria2 | 完成 | 源码、CI、Windows/Linux 安装入口均已删除 |
| 共享 `str0m` crate | 部分 | `str0m 0.23` 已锁定;共享边界测试和 Agent 复用校验通过 |
| SDP/ICE/DataChannel | 部分 | host ICE loopback 覆盖 DTLS/SCTP、control 与 pointer 通道 |
| H.264 RTP | 部分 | loopback 媒体事件及 RFC 6184 重组/丢包测试通过 |
| 硬件编解码 | 部分 | Windows/Linux 源码与编译路径存在,尚无三平台实机证明 |
| `wgpu` 呈现 | 部分 | 原生窗口和 surface 编译通过,解码 surface interop 尚未完成 |
| Opus | 部分 | loopback、Linux 编码和 Windows 播放源码存在,尚无设备端到端 |
| STUN/TURN | 部分 | 有界配置、外部 srflx/relay 注入和 Edge 凭据已接入,尚无 NAT 矩阵 |
| 安装包/CI | 部分 | Windows/Linux 改用 Rust 原生产物,macOS 发布产物尚缺 |
| 删除 Tauri/WebView | 部分 | 源码和正式构建入口已删除;历史设计文档保留作迁移依据 |
以下长表保留了迁移前各子系统的实现边界,其中出现的 Tauri、WebView、Hysteria2、
软件回退或 `webrtc-rs` 只描述历史实现,不再是正式构建入口。当前状态以上述迁移快照
和 [全 Rust 迁移计划](all-rust-migration.md) 为准。
## 迁移前能力明细(历史基线)
| 能力 | 状态 | 当前边界 |
|---|---|---|
@@ -19,7 +38,7 @@
| IronRDP 协议与证书探测 | 可用 | 独立 helper 解析 X.224/RDP Negotiation Confirm,确认 HYBRID/HYBRID_EX 后完成无凭据 TLS 握手并返回服务器证书 SHA-256;不进入 CredSSP/NLA 或账号验证 |
| Windows `mstsc` 启动 | 可用 | 生成不含密码的临时 `.rdp` 文件并启动系统客户端 |
| RDP 检测旁路 | 可用 | TCP/协议检测仅用于诊断;待检测、检测中或检测失败均不阻止直接启动客户端 |
| 控制端时钟与质量面板 | 部分 | 顶栏显示每秒更新的本地时间;Windows 启动前网络延迟使用 TCP/RDP 探针实测值,原生会话接入 vendored IronRDP 已解析的 `NetworkCharacteristicsResult`,服务器提供时回传会话平均 RTT、基础 RTT 和估算带宽;Linux X11 源码通过当前直连 WSS 或 Edge relay 内的 Ping/Pong 测量会话 RTTIronRDP 原生会话另回传状态、累计帧数、最近一秒实际帧率、尺寸、本地解码处理耗时和本地呈现耗时。解码计时从网络 PDU 进入 `active_stage.process` 开始,覆盖图形处理与 framebuffer 像素转换,本地输入或 resize 产生的更新不计入;Linux X11 桌面源码另回传真实采集、zlib 编码、解码/完整性校验、呈现耗时和帧率,并显示当前 zlib level 1..6 与压缩后/未压缩字节比例。RDP 服务端编码耗时仍无数据;新增指标链路按要求尚未编译 |
| 控制端时钟与质量面板 | 部分 | 顶栏显示每秒更新的本地时间;Windows 启动前网络延迟使用 TCP/RDP 探针实测值,原生会话接入 vendored IronRDP 已解析的 `NetworkCharacteristicsResult`,服务器提供时回传会话平均 RTT、基础 RTT 和估算带宽;Linux X11 源码通过当前直连 WSS 或 Edge relay 内的 Ping/Pong 测量会话 RTTIronRDP 原生会话另回传状态、累计帧数、最近一秒实际帧率、尺寸、本地解码处理耗时和本地呈现耗时。解码计时从网络 PDU 进入 `active_stage.process` 开始,覆盖图形处理与 framebuffer 像素转换,本地输入或 resize 产生的更新不计入;Linux X11 桌面源码另回传真实采集、zlib 编码、解码/完整性校验、呈现耗时和帧率,并显示当前 zlib level 1..6 与压缩后/未压缩字节比例。RDP 服务端编码耗时仍无数据;Agent runtime 新增有界 `QualityController`,将 RTT、丢包、抖动、编码队列 EWMA 映射到 Good/Fair/Poor/Critical,并按 2160p60 到 720p15 阶梯快速降档、迟滞升档,单元测试覆盖用户上限和恢复行为;尚未接入完整 UI/实机指标采集 |
| 客户端内 RDP | 部分 | 独立 IronRDP 原生窗口可发起单屏会话、跟随窗口动态调整远程桌面、启动/切换全屏和主动退出;原生多屏源码通过 Win32 枚举 2..16 个真实本地显示器,规范化主屏相对坐标,在服务端 RDPEDISP capabilities 到达后发送多 monitor layout PDU,并以无边框组合窗口覆盖本地虚拟桌面;退出组合全屏后可缩放查看,重连复用同一布局,单屏 resize 不会覆盖多屏配置,诊断回传实际 monitor countTLS 探针返回证书 SHA-256,用户确认后原生启动强制携带指纹,实际 TLS 握手固定完整证书 DER 并验证握手签名;窗口聚焦时转发物理键盘扫描码、五键鼠标、缩放后的绝对坐标及水平/垂直滚轮,失焦或退出时释放远端输入状态;`Ctrl+Alt+Home` 发送远端 `Ctrl+Alt+End` 安全界面序列;viewer 通过 opaque 引用直接读取 Windows Credential Manager 凭据;控制服务源码已改用当前用户 SID ACL、拒绝远程客户端且单实例的 Windows Named Pipe,按 OS 返回的 pipe 客户端 PID、进程创建时间、镜像路径与 SHA-256 build hash 校验 helper,并通过一次性 32 字节密钥和 HMAC-SHA256 challenge 后才交付严格有界的连接配置,命令行只保留随机 pipe nameviewer 严格校验 schema 和全部配置字段后返回绑定载荷 SHA-256 与 challenge 的 HMAC ACK,服务端只有验证 ACK 版本、摘要和 MAC 后才确认配置已接受;连接、认证、配置与 ACK 共用 10 秒绝对 deadline,双端非阻塞有界读取避免已连接 helper 永久停滞握手线程;每个 viewer 启动后立即加入带 `KILL_ON_JOB_CLOSE` 的独立 Job Object,控制服务退出时由内核回收 helper,握手失败则终止整个 Job 并向现有诊断 API 写入 `secure_pipe_failed`;当前构建显式启用 Windows CLIPRDR 双向剪贴板并默认启用 RDPSND 本地音频;异常断线后释放输入并按 1/2/4 秒执行三次有界重连;Windows 优先创建硬件 D3D11 device、持久 CPU framebuffer、完整 GPU frame texture 与 DXGI swap chainvendored IronRDP 只转换裁剪后的 `GraphicsUpdate` region 紧凑像素块,viewer 严格验证并合并到持久 framebuffer,再合并 redraw 前的多个待呈现区域,只对变化像素执行 CPU texture uploaddiscard swap chain 下每帧仍执行完整 GPU resource copy,失败时回退 software framebuffer;诊断回传本帧转换像素数、`full_frame``dirty_rect``cached_frame` 及实际 CPU 上传像素数;新增多屏/Named Pipe/Job/配置 ACK/全握手超时/dirty-region 转换与上传链路按要求尚未编译/实机验收,且尚非硬件解码或零拷贝,剪贴板和音频仍待真实远端专项验收 |
| 分辨率和全部显示器 | 可用 | UI 提供常用档位及 200..8192 自定义宽高;`mstsc` 支持动态/固定分辨率和 `use multimon`IronRDP 单屏窗口发送带 generation 的动态 resize、以实际远端图像确认,并在不支持/超时时经用户确认重连;原生“全部本地显示器”源码发送真实 RDPEDISP 多屏布局;自定义本地显示器子集尚未开放 |
| Windows 凭据适配器 | 可用 | 每个主机保存 `RemoteDesk/RDP/<profile-id>` opaque 引用;独立本地遮罩窗口写入/覆盖 Windows Credential ManagerUI 可查询状态和删除;密码不进入 React、HTTP、控制服务、argv、日志或主机配置;已在 Windows 实测缺失条目查询,真实远端 NLA 登录仍待环境验收 |
@@ -33,15 +52,12 @@
| Linux 文件传输 | 可用 | 与终端共用证书固定、Ed25519 配对身份、Edge opaque relay 和独立 `files` 权限;上传/下载限制为授权普通用户主目录内相对路径和 2 GiB,使用 45 KiB 分块、声明大小及 SHA-256 端到端校验;协议 minor 4 支持双向断点续传:上传 transfer ID 绑定 user/remote path/size/hash,降权 helper 从同 ID 的普通文件实测 offset、保留未完成 partial 并在整文件校验后原子替换;下载由本地隐藏 partial 长度请求 offset,Agent 按远端真实大小校验,只发送剩余字节,Client 重新哈希已有前缀和新增数据后才改名,最终哈希不符会删除 partial;Ubuntu CI 脚本实际中断 4/10 字节后续传并验证上传/下载剩余段;UI 可启动独立传输窗口,仍待真实 Linux 主机专项验收 |
| Linux X11 桌面 | 部分 | 协议 minor 10 源码包含 `desktop` 权限、严格 zlib BGRA 分块/哈希/呈现 ACK、RTT、X11 `GetImage`/XTEST、绝对及有界相对鼠标、五键/滚轮/键盘、失焦释放、全屏/退出、跟随窗口 resize、三次有界重连和完整质量诊断;Windows helper 可通过 Raw Input 合并相对位移并显式抓取/释放光标。用户会话使用真实呈现 ACK 的 1..30 FPS 有界自适应 pacer,并依据真实编码预算与 ACK 在 zlib level 1..6 内调整压缩策略。Edge 桌面会话复用同一次 BGRA 捕获,以 GStreamer 按 VA/VAAPI/NVENC/QSV/OpenH264/x264 顺序建立 H.264 Annex-B AU 编码器;16 MiB AU 上限、45 KiB 分块、严格顺序、canonical base64、精确长度和 SHA-256 经 root/user IPC 验证后交给 WebRTC sender。Windows helper 已接入远端 H.264 RTP 到 Media Foundation/D3D11 原生窗口;第一帧呈现后双方在完整帧边界切换至 H.264-only,以呈现 ACK 驱动 pacer,不再并行传输完整 zlib 帧。RTP 序号中断立即触发回退;积压、重连、解码失败、媒体终止或三秒 H.264 ACK 超时也会恢复 zlib。协议 minor 14 源码增加默认关闭的 `opus_audio` 协商:只有同版本 Client 明确请求且已绑定 WebRTC sender 时,用户会话才从 Linux 输出 monitor 捕获 48 kHz 双声道 Opus,经 4 KiB 有界 IPC/SRTP 发送并由 Windows WASAPI 播放;音频与视频失败相互独立,旧客户端不接收音频事件。协议 minor 15 源码增加独立 `clipboard_read`/`clipboard_write` 权限、X11 selection 与 Windows `CF_UNICODETEXT` 双向纯文本剪贴板、Offer/Request/Data 按需传输、32 KiB/UTF-8/NUL/canonical Base64/SHA-256 校验和摘要防回环;root agentd 只授权和转发结构化消息。WSS/Edge 异常断开时立即释放输入并保留 15 秒 X11 租约、帧序列、pacer 和压缩控制器;32 字节随机恢复令牌绑定 agentd 注入的已认证 Client 指纹与 Linux 用户。新增输入/媒体/音频/剪贴板链路尚未解析依赖、编译或在真实 Windows/Xorg 主机验收,因此仍不标记为原生视频可用;Wayland 及 Wayland 剪贴板仍关闭。 |
| CDN Edge 独立服务 | 部分 | 独立 Rust 进程已实现管理员与 Agent Presence 独立作用域的 Bearer 认证 API、短 TTL Presence、Direct/Single/Dual 路径评分、coturn REST 凭据和一次性 HMAC relay 票据;签名 Session Intent 队列最多全局 1024、每设备 32、每设备/Client 8 个,最长 120 秒,request ID 幂等、15 秒投递租约、Agent proof nonce 防重放并绑定当前 Presence connection;普通请求验证本地已配对 Client/permission/user,首次 `pairing` 请求只在 Agent 本机配对窗口有效且允许目标用户/terminal 时接受;Edge 分别交付 Client/Agent 角色票据,并在双方各自的签名授权响应中下发绑定相同 session/expiry 的 coturn REST 短期凭据;已接受桌面会话的 Agent/Client 使用 `webrtc-rs` 0.20.1 创建真实 PeerConnection,注册 H.264/RTX/Opus、NACK/PLI/transport-cc、控制 DataChannel,并经有界签名 mailbox 交换 SDP/ICE。Agent 侧媒体 sender 以 session、过期时间、目标用户和 Client 指纹注册,单次消费且在 PeerConnection 关闭时撤销;Linux 捕获帧已接入 H.264 trackWindows 接收端也已接入有界 RTP 重组和原生解码/呈现源码。真实 coturn POP、UDP/TLS、双 POP、跨网络媒体和 RDP Edge 尚未验收,因此不开放通用 CDN 策略;新增接线按要求尚未解析依赖、编译或运行。 |
| Hysteria2 Agent transport | 已有入口,未完成原生会话迁移 | `transport/hysteria2-agent` 使用官方 Hysteria2 Go `core/v2` 提供密码认证、TLS、UDP/QUIC-only server/client 和本机 UDP 适配端口;服务端明确拒绝 TCP proxy,未引入 SOCKS5 或 TCP bridge。Windows/Linux Rust Agent 现有 TCP/WSS 会话协议尚未改为 UDP 数据报协议,因此该入口目前是可部署的传输适配层,不宣称桌面会话已直接接通。 |
IronRDP 单屏 resize 源码现已补齐单调 generation、调整后尺寸回传、匹配远端图像确认、5 秒确认超时、不支持/超时诊断和用户确认重连。vendored client 不再因缺少 Display Control 静默重连;取消确认会保持当前会话,确认后的尺寸也会保留给后续有界故障重连。该新增链路仍按要求等待编译和 Windows 真实服务端验收。
Linux 用户会话现已将 X11 与 Wayland 生命周期收敛到统一 Agent 后端。Wayland 默认编译 `wayland-eis`,通过用户 D-Bus 实际查询 RemoteDesktop/ScreenCast Portal,按 `CreateSession -> SelectDevices -> SelectSources -> Start` 顺序授权,持有 PipeWire remote 与 EIS fd,并以 DMABUF 严格硬件 H.264 管线发送;Portal/EIS/VA 能力不足时返回明确错误,不伪装成 X11 或 CPU 回退。控制服务的 `linux.wayland_desktop` 现在表示 Linux Agent helper 已包含该能力,具体桌面是否可用仍由 Agent status 的 Portal probe 和会话启动结果决定。
Windows Headless 方案状态:架构已确定,实现在尚未实现表中跟踪。目标是 IDD/IddCx SDR 8-bit 虚拟显示器、Desktop Duplication 首阶段兼容捕获或后续 IDD GPU frame、NVENC/AMF/VCE/VCN/Quick Sync 硬件优先、x264/x265/SVT-AV1 软件兜底、独立 Opus 音频、输入优先控制流和 Hysteria2 视频 datagram。IDD 不提供 HDR10/10-bit 能力本方案不承诺 HDR10。
Windows Headless compatibility 模式允许无硬件 GPU 主机使用可用的 Desktop Duplication/IDD 输出、WARP 或 CPU 编码,但只报告 software/degraded,并根据实测 CPU 能力降低分辨率、帧率和并发上限。该模式不保证多路 4K/120;strict 性能模式在硬件编码器不可用时拒绝请求。当前仍未完成真实无 GPU 主机验收。
Windows Headless 方案只接受可验证的硬件路径:IDD/IddCx SDR 8-bit 虚拟显示器、GPU surface、硬件编码器和 `str0m` SRTP。软件编码、CPU bitmap、跨 Adapter 和 opaque 路径不再作为运行时回退;硬件能力或证明不足时明确拒绝会话。IDD 不提供 HDR10/10-bit 能力本方案不承诺 HDR10。
## 尚未实现
-11
View File
@@ -2,7 +2,6 @@
param(
[switch]$SkipClient,
[switch]$SkipHost,
[switch]$SkipHysteria2,
[switch]$CleanArtifacts,
[ValidateSet('zh-cn', 'en-us')]
[string]$Culture = 'zh-cn'
@@ -14,7 +13,6 @@ $artifacts = Join-Path $repoRoot 'artifacts'
$windowsScript = Join-Path $repoRoot 'packaging/windows/package-installer.ps1'
$hostScript = Join-Path $repoRoot 'packaging/windows-host/package-host.ps1'
$portableHostScript = Join-Path $repoRoot 'packaging/windows-host/package-portable-host.ps1'
$hysteriaScript = Join-Path $repoRoot 'packaging/windows-host/build-hysteria2-agent.ps1'
$previousRustFlags = $env:RUSTFLAGS
$staticRustFlags = '-C target-feature=+crt-static'
@@ -53,11 +51,6 @@ try {
Invoke-ChildBuild -Name 'Windows Host portable package' -Script $portableHostScript -Arguments @('-Culture', $Culture)
}
# Hysteria2 is an independent UDP/QUIC transport package and must not be
# skipped merely because the Windows Host MSI is disabled.
if (-not $SkipHysteria2) {
Invoke-ChildBuild -Name 'Hysteria2 transport' -Script $hysteriaScript
}
} finally {
if ($null -eq $previousRustFlags) {
Remove-Item Env:RUSTFLAGS -ErrorAction SilentlyContinue
@@ -74,10 +67,6 @@ $portableHost = Join-Path $artifacts 'RemoteDesk-Host-portable'
if (Test-Path -LiteralPath $portableHost) {
$outputFiles += @(Get-ChildItem -LiteralPath $portableHost -File -Include '*.exe', 'manifest.json' -ErrorAction SilentlyContinue)
}
$hysteriaOutput = Join-Path $artifacts 'hysteria2'
if (Test-Path -LiteralPath $hysteriaOutput) {
$outputFiles += @(Get-ChildItem -LiteralPath $hysteriaOutput -File -ErrorAction SilentlyContinue)
}
$outputFiles |
Sort-Object FullName -Unique |
+3 -5
View File
@@ -1,9 +1,7 @@
# RemoteDesk Linux controlled endpoint
The Linux package installs four Rust processes. Packages that include the
optional `remotedesk-hysteria2-agent` binary also install a pure Hysteria2
QUIC/UDP transport service on UDP 39502. It requires a TLS certificate and a
long random password; it does not expose TCP, a TCP proxy, or SOCKS5.
The Linux package installs four Rust processes. Real-time desktop sessions use
the Rust WebRTC stack; the package contains no Go transport sidecar.
- `remotedesk-agentd`: root system service for device identity, WSS pairing/authentication, authorization, user-session registration, and terminal creation.
- `remotedesk-agent-session`: per-user service that detects Wayland/X11/PipeWire state and registers through authenticated Unix IPC.
@@ -29,7 +27,7 @@ sudo remotedesk-agentd revoke CLIENT_FINGERPRINT
The daemon listens on TCP 39500 by default. Edit `/etc/remotedesk/agent.env` to change it and restart `remotedesk-agentd`. The package deliberately does not modify nftables, firewalld, UFW, SELinux, or AppArmor policy.
Edge Presence and signed session-intent polling are optional and disabled by default. To publish the Agent in a separately deployed Edge directory, set all four `REMOTEDESK_EDGE_API_URL`, `REMOTEDESK_EDGE_PRESENCE_TOKEN`, `REMOTEDESK_EDGE_REGION`, and `REMOTEDESK_EDGE_GATEWAY_ID` values in `agent.env`. The public API URL must use HTTPS; loopback HTTP is accepted only for local testing. The token must be a dedicated Presence token of at least 32 bytes, not the Edge administrator token or relay ticket secret. Restart the service and inspect `sudo remotedesk-agentd status --json`; graceful service shutdown removes the connection-bound registration. While online, the Agent signs each poll/ack with its device key and accepts an intent only after verifying its Client signature, local pairing grant, permission, and allowed user. Accepted terminal/file requests use an Agent-only one-use ticket to open an outbound relay tunnel to the local TLS listener. Accepted desktop requests start an Agent-side `webrtc-rs` PeerConnection, receive session-bound TURN credentials, and bind its sender to the authorized Linux user and Client fingerprint. Native DEB/RPM builds use GStreamer hardware encoders when available and bounded software fallbacks otherwise; the static musl compatibility package excludes this dynamically linked media feature. Windows remote H.264 RTP consumption and native D3D11 presentation are now wired in source with zlib/WSS fallback, but remain disabled as a runtime capability until rebuilt and validated; this is not an RDP relay.
Edge Presence and signed session-intent polling are optional and disabled by default. To publish the Agent in a separately deployed Edge directory, set all four `REMOTEDESK_EDGE_API_URL`, `REMOTEDESK_EDGE_PRESENCE_TOKEN`, `REMOTEDESK_EDGE_REGION`, and `REMOTEDESK_EDGE_GATEWAY_ID` values in `agent.env`. The public API URL must use HTTPS; loopback HTTP is accepted only for local testing. The token must be a dedicated Presence token of at least 32 bytes, not the Edge administrator token or relay ticket secret. Restart the service and inspect `sudo remotedesk-agentd status --json`; graceful service shutdown removes the connection-bound registration. While online, the Agent signs each poll/ack with its device key and accepts an intent only after verifying its Client signature, local pairing grant, permission, and allowed user. Accepted terminal/file requests use an Agent-only one-use ticket to open an outbound relay tunnel to the local TLS listener. Accepted desktop requests receive session-bound TURN credentials and bind the sender to the authorized Linux user and Client fingerprint. The shared Rust `str0m` boundary owns bounded SDP/ICE, DataChannel, H.264 RTP, and Opus events; the existing async Agent socket driver is being migrated to that boundary. Native DEB/RPM builds use GStreamer hardware encoders when available; the static musl package excludes this dynamically linked media feature. Windows remote H.264 RTP consumption and native D3D11 presentation are wired in source, but remain disabled as a runtime capability until rebuilt and validated; this is not an RDP relay.
On the Windows controller, configure the same public origin under Global Settings > Edge session authorization. For first pairing, enter both `device_public_key` and `tls_certificate_sha256` printed by the Agent's local `pairing-code` command. While that local pairing window is valid, the Client submits a signed `pairing` intent and completes Agent-key verification, pinned TLS/WSS, Client challenge authentication, and the eight-digit code inside the opaque relay; the code is never sent to the Edge API. The certificate-bound Agent public key is then stored in Windows Credential Manager, and subsequent terminal/file requests submit directly to Edge without a Client-to-Agent connection. Direct pairing remains a fallback when Edge or the public key is not configured. No Edge administrator or Presence token is stored on Windows.
@@ -1,5 +0,0 @@
REMOTEDESK_HYSTERIA2_LISTEN=0.0.0.0:39502
REMOTEDESK_HYSTERIA2_PASSWORD=replace-with-a-long-random-password
REMOTEDESK_HYSTERIA2_CERT=/etc/remotedesk/server.crt
REMOTEDESK_HYSTERIA2_KEY=/etc/remotedesk/server.key
REMOTEDESK_AGENT_UDP=127.0.0.1:39502
-7
View File
@@ -13,13 +13,6 @@ install -m 0755 "$package_root/bin/remotedesk-agentd" /usr/bin/remotedesk-agentd
install -m 0755 "$package_root/bin/remotedesk-agent-session" /usr/bin/remotedesk-agent-session
install -m 0755 "$package_root/bin/remotedesk-shell-session" /usr/libexec/remotedesk-shell-session
install -m 0755 "$package_root/bin/remotedesk-file-session" /usr/libexec/remotedesk-file-session
if [ -f "$package_root/bin/remotedesk-hysteria2-agent" ]; then
install -m 0755 "$package_root/bin/remotedesk-hysteria2-agent" /usr/bin/remotedesk-hysteria2-agent
install -m 0644 "$package_root/systemd/remotedesk-hysteria2-agent.service" /usr/lib/systemd/system/
if [ ! -e /etc/remotedesk/hysteria2-agent.env ]; then
install -m 0600 "$package_root/config/hysteria2-agent.env" /etc/remotedesk/hysteria2-agent.env
fi
fi
install -m 0644 "$package_root/systemd/remotedesk-agentd.service" /usr/lib/systemd/system/
install -m 0644 "$package_root/systemd/remotedesk-agent-session.service" /usr/lib/systemd/user/
install -m 0644 "$package_root/README.md" /usr/share/doc/remotedesk-agent/README.md
-5
View File
@@ -20,18 +20,13 @@ mkdir -p "$package_root/DEBIAN" "$package_root/usr/bin" "$package_root/usr/libex
"$package_root/etc/remotedesk" "$package_root/usr/share/doc/remotedesk-agent" "$artifacts_root"
cargo build --locked --release --manifest-path "$repo_root/Cargo.toml" -p remotedesk-agent-runtime
CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags '-s -w' \
-o "$package_root/usr/bin/remotedesk-hysteria2-agent" \
"$repo_root/transport/hysteria2-agent"
install -m 0755 "$repo_root/target/release/remotedesk-agentd" "$package_root/usr/bin/"
install -m 0755 "$repo_root/target/release/remotedesk-agent-session" "$package_root/usr/bin/"
install -m 0755 "$repo_root/target/release/remotedesk-shell-session" "$package_root/usr/libexec/"
install -m 0755 "$repo_root/target/release/remotedesk-file-session" "$package_root/usr/libexec/"
install -m 0644 "$repo_root/packaging/linux/remotedesk-agentd.service" "$package_root/usr/lib/systemd/system/"
install -m 0644 "$repo_root/packaging/linux/remotedesk-agent-session.service" "$package_root/usr/lib/systemd/user/"
install -m 0644 "$repo_root/packaging/linux/remotedesk-hysteria2-agent.service" "$package_root/usr/lib/systemd/system/"
install -m 0644 "$repo_root/packaging/linux/agent.env" "$package_root/etc/remotedesk/"
install -m 0600 "$repo_root/packaging/linux/hysteria2-agent.env.example" "$package_root/etc/remotedesk/hysteria2-agent.env"
install -m 0644 "$repo_root/packaging/linux/README.md" "$package_root/usr/share/doc/remotedesk-agent/README.md"
sed -e "s/@VERSION@/$version/g" -e "s/@ARCH@/$arch/g" \
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
set -eu
repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
output_dir=${1:-"$repo_root/artifacts/hysteria2-linux"}
mkdir -p "$output_dir"
CGO_ENABLED=0 go build -buildvcs=false -trimpath -ldflags '-s -w' \
-o "$output_dir/remotedesk-hysteria2-agent" \
"$repo_root/transport/hysteria2-agent"
install -m 0644 "$repo_root/packaging/linux/remotedesk-hysteria2-agent.service" "$output_dir/"
install -m 0644 "$repo_root/packaging/linux/hysteria2-agent.env.example" "$output_dir/"
install -m 0644 "$repo_root/transport/hysteria2-agent/README.md" "$output_dir/README-hysteria2.md"
sha256sum "$output_dir/remotedesk-hysteria2-agent" > "$output_dir/SHA256SUMS.txt"
printf 'Hysteria2 package: %s\n' "$output_dir"
+4 -19
View File
@@ -62,14 +62,6 @@ try {
Invoke-Checked -Description 'Static Linux Agent build' -Command {
cargo build --locked --release --jobs 2 --target x86_64-unknown-linux-musl -p remotedesk-agent-runtime --no-default-features --features gstreamer-h264,wayland-eis
}
Invoke-Checked -Description 'Static Hysteria2 transport build' -Command {
$env:CGO_ENABLED = '0'
$env:GOOS = 'linux'
$env:GOARCH = 'amd64'
go build -buildvcs=false -trimpath -ldflags '-s -w' -o (Join-Path $targetRoot 'remotedesk-hysteria2-agent-linux') (Join-Path $repoRoot 'transport/hysteria2-agent')
Remove-Item Env:CGO_ENABLED, Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue
}
Assert-PathWithin -Path $buildRoot -Root $targetRoot
if (Test-Path -LiteralPath $buildRoot) { Remove-Item -LiteralPath $buildRoot -Recurse -Force }
New-Item -ItemType Directory -Force -Path $buildRoot, $artifactsRoot | Out-Null
@@ -82,11 +74,7 @@ try {
Copy-Item -LiteralPath (Join-Path $targetRoot "x86_64-unknown-linux-musl\release\$binary") -Destination $destination
Invoke-Checked -Description "Strip $binary" -Command { & $llvmStrip $destination }
}
Copy-Item -LiteralPath (Join-Path $targetRoot 'remotedesk-hysteria2-agent-linux') -Destination (Join-Path $portableRoot 'bin/remotedesk-hysteria2-agent')
Invoke-Checked -Description 'Strip remotedesk-hysteria2-agent' -Command { & $llvmStrip (Join-Path $portableRoot 'bin/remotedesk-hysteria2-agent') }
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'remotedesk-agentd.service') -Destination (Join-Path $portableRoot 'systemd')
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'remotedesk-hysteria2-agent.service') -Destination (Join-Path $portableRoot 'systemd')
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'hysteria2-agent.env.example') -Destination (Join-Path $portableRoot 'config/hysteria2-agent.env')
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'remotedesk-agent-session.service') -Destination (Join-Path $portableRoot 'systemd')
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'agent.env') -Destination (Join-Path $portableRoot 'config')
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'install.sh') -Destination $portableRoot
@@ -103,8 +91,8 @@ try {
$portableTar = Join-Path $buildRoot "$portableName.tar"
$portableGzip = "$portableTar.gz"
$portableDirectories = @($portableName, "$portableName/bin", "$portableName/config", "$portableName/systemd")
$portableExecutables = @("$portableName/install.sh", "$portableName/uninstall.sh", "$portableName/bin/remotedesk-agentd", "$portableName/bin/remotedesk-agent-session", "$portableName/bin/remotedesk-shell-session", "$portableName/bin/remotedesk-file-session", "$portableName/bin/remotedesk-hysteria2-agent")
$portableData = @("$portableName/README.md", "$portableName/SHA256SUMS.txt", "$portableName/config/agent.env", "$portableName/config/hysteria2-agent.env", "$portableName/systemd/remotedesk-agentd.service", "$portableName/systemd/remotedesk-hysteria2-agent.service", "$portableName/systemd/remotedesk-agent-session.service")
$portableExecutables = @("$portableName/install.sh", "$portableName/uninstall.sh", "$portableName/bin/remotedesk-agentd", "$portableName/bin/remotedesk-agent-session", "$portableName/bin/remotedesk-shell-session", "$portableName/bin/remotedesk-file-session")
$portableData = @("$portableName/README.md", "$portableName/SHA256SUMS.txt", "$portableName/config/agent.env", "$portableName/systemd/remotedesk-agentd.service", "$portableName/systemd/remotedesk-agent-session.service")
Push-Location $buildRoot
try {
Invoke-Checked -Description 'Portable directory archive' -Command { & $gnuTar --force-local --no-recursion --format=gnu --owner=0 --group=0 --mode=0755 -cf $portableTar @portableDirectories }
@@ -124,12 +112,9 @@ try {
Copy-Item -LiteralPath (Join-Path $portableRoot 'bin\remotedesk-agent-session') -Destination (Join-Path $debianRoot 'usr\bin')
Copy-Item -LiteralPath (Join-Path $portableRoot 'bin\remotedesk-shell-session') -Destination (Join-Path $debianRoot 'usr\libexec')
Copy-Item -LiteralPath (Join-Path $portableRoot 'bin\remotedesk-file-session') -Destination (Join-Path $debianRoot 'usr\libexec')
Copy-Item -LiteralPath (Join-Path $portableRoot 'bin\remotedesk-hysteria2-agent') -Destination (Join-Path $debianRoot 'usr\bin')
Copy-Item -LiteralPath (Join-Path $portableRoot 'systemd\remotedesk-agentd.service') -Destination (Join-Path $debianRoot 'usr\lib\systemd\system')
Copy-Item -LiteralPath (Join-Path $portableRoot 'systemd\remotedesk-agent-session.service') -Destination (Join-Path $debianRoot 'usr\lib\systemd\user')
Copy-Item -LiteralPath (Join-Path $portableRoot 'systemd\remotedesk-hysteria2-agent.service') -Destination (Join-Path $debianRoot 'usr\lib\systemd\system')
Copy-Item -LiteralPath (Join-Path $portableRoot 'config\agent.env') -Destination (Join-Path $debianRoot 'etc\remotedesk')
Copy-Item -LiteralPath (Join-Path $portableRoot 'config\hysteria2-agent.env') -Destination (Join-Path $debianRoot 'etc\remotedesk')
Copy-Item -LiteralPath (Join-Path $portableRoot 'README.md') -Destination (Join-Path $debianRoot 'usr\share\doc\remotedesk-agent\README.md')
$control = (Get-Content -LiteralPath (Join-Path $PSScriptRoot 'debian\control') -Raw).Replace('@VERSION@', $version).Replace('@ARCH@', 'amd64')
$control = $control.Replace(', gstreamer1.0-plugins-base, gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-plugins-ugly', '')
@@ -149,8 +134,8 @@ try {
} finally { Pop-Location }
Invoke-Checked -Description 'DEB control compression' -Command { & $gzip -n -f $controlTar }
$dataTar = Join-Path $debParts 'data.tar'
$execFiles = @('./usr/bin/remotedesk-agentd', './usr/bin/remotedesk-agent-session', './usr/bin/remotedesk-hysteria2-agent', './usr/libexec/remotedesk-shell-session', './usr/libexec/remotedesk-file-session')
$dataFiles = @('./usr/lib/systemd/system/remotedesk-agentd.service', './usr/lib/systemd/system/remotedesk-hysteria2-agent.service', './usr/lib/systemd/user/remotedesk-agent-session.service', './etc/remotedesk/agent.env', './etc/remotedesk/hysteria2-agent.env', './usr/share/doc/remotedesk-agent/README.md')
$execFiles = @('./usr/bin/remotedesk-agentd', './usr/bin/remotedesk-agent-session', './usr/libexec/remotedesk-shell-session', './usr/libexec/remotedesk-file-session')
$dataFiles = @('./usr/lib/systemd/system/remotedesk-agentd.service', './usr/lib/systemd/user/remotedesk-agent-session.service', './etc/remotedesk/agent.env', './usr/share/doc/remotedesk-agent/README.md')
Push-Location $debianRoot
try {
Invoke-Checked -Description 'DEB executable payload' -Command { & $gnuTar --force-local --format=gnu --owner=0 --group=0 --mode=0755 -cf $dataTar @execFiles }
@@ -1,20 +0,0 @@
[Unit]
Description=RemoteDesk Hysteria2 UDP transport
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/remotedesk/hysteria2-agent.env
ExecStart=/usr/bin/remotedesk-hysteria2-agent
Restart=on-failure
RestartSec=3s
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/lib/remotedesk
RestrictAddressFamilies=AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
@@ -26,8 +26,6 @@
<ComponentRef Id="HostManifestComponent" />
<ComponentRef Id="HostShortcuts" />
<ComponentRef Id="WindowsAgentComponent" />
<ComponentRef Id="Hysteria2AgentComponent" />
<ComponentRef Id="Hysteria2AgentEnvComponent" />
</Feature>
<CustomAction
@@ -60,12 +58,6 @@
<Component Id="WindowsAgentComponent" Guid="*" Bitness="always64">
<File Id="WindowsAgent" Source="$(var.WindowsAgentBinary)" KeyPath="yes" />
</Component>
<Component Id="Hysteria2AgentComponent" Guid="*" Bitness="always64">
<File Id="Hysteria2Agent" Source="$(var.Hysteria2Binary)" KeyPath="yes" />
</Component>
<Component Id="Hysteria2AgentEnvComponent" Guid="*" Bitness="always64">
<File Id="Hysteria2AgentEnv" Source="$(var.SourceRoot)\hysteria2-agent.env.example" />
</Component>
</DirectoryRef>
</Fragment>
@@ -1,16 +0,0 @@
[CmdletBinding()]
param([string]$OutputDirectory)
$ErrorActionPreference = 'Stop'
$root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..\..'))
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
$OutputDirectory = Join-Path $root 'artifacts\hysteria2'
}
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
Push-Location (Join-Path $root 'transport\hysteria2-agent')
try {
& go.exe build -buildvcs=false -trimpath -ldflags '-s -w' -o (Join-Path $OutputDirectory 'remotedesk-hysteria2-agent.exe') .
if ($LASTEXITCODE -ne 0) { throw 'Go Hysteria2 build failed.' }
} finally { Pop-Location }
Copy-Item (Join-Path $root 'packaging\windows-host\hysteria2-agent.env.example') (Join-Path $OutputDirectory 'hysteria2-agent.env.example') -Force
Write-Output "Hysteria2 package: $OutputDirectory"
@@ -1,5 +0,0 @@
REMOTEDESK_HYSTERIA2_LISTEN=0.0.0.0:39502
REMOTEDESK_HYSTERIA2_PASSWORD=replace-with-a-long-random-password
REMOTEDESK_HYSTERIA2_CERT=server.crt
REMOTEDESK_HYSTERIA2_KEY=server.key
REMOTEDESK_AGENT_UDP=127.0.0.1:39502
+1 -8
View File
@@ -87,16 +87,9 @@ try {
if ($LASTEXITCODE -ne 0) { throw "Windows Agent build failed with exit code $LASTEXITCODE" }
$agentBinary = Join-Path $repoRoot 'target\release\remotedesk-windows-agent.exe'
if (-not (Test-Path -LiteralPath $agentBinary -PathType Leaf)) { throw "Windows Agent binary is missing: $agentBinary" }
$hysteriaBinary = Join-Path $buildRoot 'remotedesk-hysteria2-agent.exe'
Push-Location (Join-Path $repoRoot 'transport\hysteria2-agent')
try {
& go.exe build -buildvcs=false -trimpath -ldflags '-s -w' -o $hysteriaBinary .
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $hysteriaBinary -PathType Leaf)) { throw 'Hysteria2 transport build failed.' }
} finally { Pop-Location }
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'Configure-RemoteDeskHost.ps1') -Destination $payloadRoot
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'RemoteDeskHost.psm1') -Destination $payloadRoot
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'README.md') -Destination $payloadRoot
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'hysteria2-agent.env.example') -Destination $payloadRoot
$manifest = [ordered]@{
product = 'RemoteDesk Host'
@@ -130,7 +123,7 @@ try {
$temporaryInstaller = Join-Path $buildRoot $installerName
$installerPath = Join-Path $artifactsRoot $installerName
$env:DOTNET_ROLL_FORWARD = 'Major'
& $wix build -arch x64 -ext $uiExtension -culture $Culture -d "ProductVersion=$version" -d "SourceRoot=$payloadRoot" -d "WindowsAgentBinary=$agentBinary" -d "Hysteria2Binary=$hysteriaBinary" -d "IconPath=$(Join-Path $PSScriptRoot 'icon.ico')" `
& $wix build -arch x64 -ext $uiExtension -culture $Culture -d "ProductVersion=$version" -d "SourceRoot=$payloadRoot" -d "WindowsAgentBinary=$agentBinary" -d "IconPath=$(Join-Path $PSScriptRoot 'icon.ico')" `
-intermediateFolder $intermediateRoot -pdbtype none -out $temporaryInstaller `
(Join-Path $PSScriptRoot 'RemoteDeskHost.wxs')
if ($LASTEXITCODE -ne 0) { throw "WiX build failed with exit code $LASTEXITCODE" }
@@ -24,14 +24,6 @@ $agentBinary = Join-Path $repoRoot 'target\release\remotedesk-windows-agent.exe'
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $agentBinary)) { throw 'Unable to build Windows Agent.' }
Copy-Item -LiteralPath $agentBinary -Destination $OutputDirectory -Force
$hysteriaBinary = Join-Path $OutputDirectory 'remotedesk-hysteria2-agent.exe'
Push-Location (Join-Path $repoRoot 'transport\hysteria2-agent')
try {
& go.exe build -buildvcs=false -trimpath -ldflags '-s -w' -o $hysteriaBinary .
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $hysteriaBinary)) { throw 'Unable to build Hysteria2 Agent transport.' }
} finally { Pop-Location }
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'hysteria2-agent.env.example') -Destination $OutputDirectory -Force
$csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
if (-not (Test-Path -LiteralPath $csc)) { $csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework\v4.0.30319\csc.exe' }
if (-not (Test-Path -LiteralPath $csc)) { throw 'Microsoft C# compiler (csc.exe) is required to create the portable EXE.' }
+2 -2
View File
@@ -2,11 +2,11 @@
此 MSI 将 RemoteDesk 安装到当前用户的 `%LOCALAPPDATA%\Programs\RemoteDesk`,不需要管理员权限。
安装后从开始菜单打开 `RemoteDesk`Tauri 2 原生窗口会启动仅监听随机 `127.0.0.1` 端口的本地控制服务;窗口退出或控制服务异常结束时,两者会一起关闭。可以在 Windows“已安装的应用”中卸载。
安装后从开始菜单打开 `RemoteDesk`程序使用 Rust `winit``wgpu``egui` 原生窗口。可以在 Windows“已安装的应用”中卸载。
## 当前包含
- Tauri 2 + React 管理界面;壳以一次性 256 位令牌的 SHA-256 proof 验证随机 loopback 控制服务后才创建 WebView,令牌本身不进入 HTTP 请求;WebView 只允许导航到本次 origin,且不向页面开放 Tauri command
- Rust 原生管理界面;会话凭据只交给受限本地 helper,不进入窗口标题、命令行或日志
- RDP TCP 与 pre-NLA IronRDP 协议探测。
- Windows 系统 `mstsc` 启动回退。
- 单显示器 IronRDP 原生兼容窗口,支持固定/自定义初始尺寸、窗口动态分辨率、启动/切换全屏、退出会话、三次有界自动重连、Windows 双向剪贴板、RDPSND 本地音频,以及焦点绑定的键盘、五键鼠标和滚轮远程输入;源码中的启动配置通过当前用户 ACL、OS 进程身份和 challenge MAC 认证的 Named Pipe 交付,并用 `KILL_ON_JOB_CLOSE` Job Object 约束 helper 生命周期。
+3 -6
View File
@@ -4,17 +4,14 @@ This archive is a portable technical preview, not an installer or a finished rem
## Start
Run `bin/remotedesk.exe`. The Tauri desktop shell starts the Rust control service on a random loopback port, verifies its one-time startup proof, and opens the management UI only inside the RemoteDesk window. Closing the window also stops the control service. The package does not launch or support a system-browser UI.
Run `bin/remotedesk.exe`. The Rust-native `winit`/`wgpu`/`egui` application is the only supported management UI. The package does not launch or support a browser UI.
The server does not listen on LAN interfaces. Its JSON API probes TCP and the pre-NLA RDP negotiation, then can start `mstsc.exe` or a native IronRDP window. The probe does not submit credentials or prove that NLA authentication succeeds.
## Included
- Production-built React management UI.
- `bin/remotedesk.exe`, providing the Tauri 2 desktop shell and the only supported management UI entry point.
- `bin/remotedesk-control-service.exe`, providing static UI hosting and RDP launch orchestration.
- `bin/remotedesk.exe`, providing the native Rust management UI and the only supported UI entry point.
- `bin/remotedesk-credential-store.exe`, providing masked local credential entry and direct Windows Credential Manager access.
- `bin/remotedesk-linux-terminal.exe`, providing pinned-WSS Linux Agent pairing, certificate-bound known-device Edge rendezvous, file transfer, and an interactive terminal window.
- `bin/remotedesk-rdp-session.exe`, providing the secret-free IronRDP negotiation probe.
- `bin/remotedesk-rdp-viewer.exe`, providing a single-monitor native IronRDP compatibility window with fixed/custom initial sizing, dynamic resize, fullscreen, bounded automatic reconnect, Windows clipboard redirection, RDPSND local audio, session exit controls, and focus-bound keyboard/five-button mouse/wheel forwarding.
- `bin/remotedesk-native-video.exe`, providing GPU pipeline planning and a real hardware D3D11 device/context probe; rendering remains software.
@@ -29,6 +26,6 @@ The server does not listen on LAN interfaces. Its JSON API probes TCP and the pr
The IronRDP window forwards keyboard, mouse buttons, movement, and wheel input while focused, and releases remote input state when focus is lost or the session closes. Use `F11` or `Alt+Enter` to toggle fullscreen, `Esc` to leave fullscreen, and `Ctrl+Shift+Q` to close the session; triggering these commands releases remote input and does not forward the command key itself.
Passwords remain in Windows Credential Manager after entry through the local masked helper. The native viewer reads its referenced credential directly. Passwords are never written to WebView storage, HTTP, argv, logs, or temporary `.rdp` files. Do not use this build as an unattended-access or security product.
Passwords remain in Windows Credential Manager after entry through the local masked helper. The native viewer reads its referenced credential directly. Passwords are never written to UI state, argv, logs, or temporary `.rdp` files. Do not use this build as an unattended-access or security product.
Paired Linux Agent public keys are stored as integrity-sensitive, non-secret mappings at `RemoteDesk/Linux/agent/<certificate-sha256>`. The Linux Terminal panel can query or explicitly forget a mapping. With a configured Edge service and the Agent-local `device_public_key` plus certificate fingerprint, initial pairing and subsequent terminal/file sessions can all be established through Agent-outbound relay connections; direct pairing remains the fallback.
+5 -19
View File
@@ -210,16 +210,8 @@ function New-WixPayloadSource {
Push-Location $repoRoot
try {
Invoke-Checked -Description 'Web production build' -Command {
Push-Location (Join-Path $repoRoot 'client\web')
try {
& npm.cmd run build
} finally {
Pop-Location
}
}
Invoke-Checked -Description 'Native helpers release build' -Command {
& cargo.exe build --release --jobs 2 -p remotedesk-app -p remotedesk-control-service -p remotedesk-credential-store -p remotedesk-linux-terminal -p remotedesk-native-video -p remotedesk-rdp-session -p remotedesk-rdp-viewer -p remotedesk-windows-agent-viewer
Invoke-Checked -Description 'Native Rust client release build' -Command {
& cargo.exe build --release --jobs 2 -p remotedesk-native-gui -p remotedesk-credential-store -p remotedesk-native-video -p remotedesk-rdp-session -p remotedesk-rdp-viewer -p remotedesk-windows-agent-viewer
}
$metadata = (& cargo.exe metadata --no-deps --format-version 1 | ConvertFrom-Json)
@@ -259,7 +251,6 @@ try {
}
$buildRoot = Join-Path $artifactsRoot ".package-installer-$PID-$([guid]::NewGuid().ToString('N'))"
$payloadRoot = Join-Path $buildRoot 'payload'
$webRoot = Join-Path $payloadRoot 'web'
$binRoot = Join-Path $payloadRoot 'bin'
$docsRoot = Join-Path $payloadRoot 'docs'
$intermediateRoot = Join-Path $buildRoot 'wixobj'
@@ -271,12 +262,9 @@ try {
Assert-PathWithin -Path $installerPath -Root $artifactsRoot
New-Item -ItemType Directory -Force -Path $artifactsRoot | Out-Null
New-Item -ItemType Directory -Force -Path $webRoot, $binRoot, $docsRoot, $intermediateRoot | Out-Null
Copy-Item -Path (Join-Path $repoRoot 'client\web\dist\*') -Destination $webRoot -Recurse -Force
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk.exe') -Destination $binRoot
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-control-service.exe') -Destination $binRoot
New-Item -ItemType Directory -Force -Path $binRoot, $docsRoot, $intermediateRoot | Out-Null
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-native.exe') -Destination (Join-Path $binRoot 'remotedesk.exe')
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-credential-store.exe') -Destination $binRoot
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-linux-terminal.exe') -Destination $binRoot
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-native-video.exe') -Destination $binRoot
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-rdp-session.exe') -Destination $binRoot
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-rdp-viewer.exe') -Destination $binRoot
@@ -297,10 +285,8 @@ try {
install_scope = 'per-user'
target = $hostTarget
runnable_ui = 'bin/remotedesk.exe'
desktop_shell = 'tauri-2'
control_service = 'bin/remotedesk-control-service.exe'
desktop_shell = 'winit-wgpu-egui'
credential_store = 'bin/remotedesk-credential-store.exe'
linux_terminal = 'bin/remotedesk-linux-terminal.exe'
native_helper = 'bin/remotedesk-native-video.exe'
rdp_probe_helper = 'bin/remotedesk-rdp-session.exe'
rdp_viewer = 'bin/remotedesk-rdp-viewer.exe'
+5 -19
View File
@@ -34,16 +34,8 @@ function Assert-ArtifactPath {
Push-Location $repoRoot
try {
Invoke-Checked -Description 'Web production build' -Command {
Push-Location (Join-Path $repoRoot 'client\web')
try {
& npm.cmd run build
} finally {
Pop-Location
}
}
Invoke-Checked -Description 'Native helpers release build' -Command {
& cargo.exe build --release --jobs 2 -p remotedesk-app -p remotedesk-control-service -p remotedesk-credential-store -p remotedesk-linux-terminal -p remotedesk-native-video -p remotedesk-rdp-session -p remotedesk-rdp-viewer -p remotedesk-windows-agent-viewer
Invoke-Checked -Description 'Native Rust client release build' -Command {
& cargo.exe build --release --jobs 2 -p remotedesk-native-gui -p remotedesk-credential-store -p remotedesk-native-video -p remotedesk-rdp-session -p remotedesk-rdp-viewer -p remotedesk-windows-agent-viewer
}
$metadata = (& cargo.exe metadata --no-deps --format-version 1 | ConvertFrom-Json)
@@ -85,16 +77,12 @@ try {
Remove-Item -LiteralPath $zipPath -Force
}
$webRoot = Join-Path $stagingRoot 'web'
$binRoot = Join-Path $stagingRoot 'bin'
$docsRoot = Join-Path $stagingRoot 'docs'
New-Item -ItemType Directory -Force -Path $webRoot, $binRoot, $docsRoot | Out-Null
New-Item -ItemType Directory -Force -Path $binRoot, $docsRoot | Out-Null
Copy-Item -Path (Join-Path $repoRoot 'client\web\dist\*') -Destination $webRoot -Recurse -Force
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk.exe') -Destination $binRoot
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-control-service.exe') -Destination $binRoot
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-native.exe') -Destination (Join-Path $binRoot 'remotedesk.exe')
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-credential-store.exe') -Destination $binRoot
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-linux-terminal.exe') -Destination $binRoot
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-native-video.exe') -Destination $binRoot
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-rdp-session.exe') -Destination $binRoot
Copy-Item -LiteralPath (Join-Path $repoRoot 'target\release\remotedesk-rdp-viewer.exe') -Destination $binRoot
@@ -114,10 +102,8 @@ try {
distribution = 'portable'
target = $hostTarget
runnable_ui = 'bin/remotedesk.exe'
desktop_shell = 'tauri-2'
control_service = 'bin/remotedesk-control-service.exe'
desktop_shell = 'winit-wgpu-egui'
credential_store = 'bin/remotedesk-credential-store.exe'
linux_terminal = 'bin/remotedesk-linux-terminal.exe'
native_helper = 'bin/remotedesk-native-video.exe'
rdp_probe_helper = 'bin/remotedesk-rdp-session.exe'
rdp_viewer = 'bin/remotedesk-rdp-viewer.exe'
-122
View File
@@ -1,122 +0,0 @@
# RemoteDesk Hysteria2 transport
This helper uses the official `github.com/apernet/hysteria/core/v2` Go server.
It exposes only authenticated Hysteria2 QUIC/UDP sessions. The core's TCP
proxy hook is deliberately rejected, so the public transport never falls back
to TCP or SOCKS5. Authenticated datagrams are delivered to the local Agent UDP
endpoint (`127.0.0.1:39502` by default).
Build with `go build -buildvcs=false -trimpath -ldflags "-s -w" -o remotedesk-hysteria2-agent ./`.
The certificate and key must be a real TLS pair; do not run without them.
Example:
```sh
REMOTEDESK_HYSTERIA2_PASSWORD='change-me' \
REMOTEDESK_HYSTERIA2_CERT=/etc/remotedesk/server.crt \
REMOTEDESK_HYSTERIA2_KEY=/etc/remotedesk/server.key \
./remotedesk-hysteria2-agent --listen 0.0.0.0:39502
```
The same binary provides the official Hysteria2 client path. It listens on a
local UDP socket and forwards datagrams through one authenticated Hysteria2
UDP session:
```sh
./remotedesk-hysteria2-agent --mode client --listen 127.0.0.1:39503 \
--server relay.example:39502 --password 'change-me' \
--server-name relay.example --agent-udp 127.0.0.1:39502
```
`--insecure` is intentionally opt-in and should only be used for a local test
certificate. No TCP listener, TCP proxy, or SOCKS5 endpoint is created.
## Rust encoded ring bridge
The Windows Agent compatibility encoder can publish H.264 Annex-B access units
to its session-bound memory-mapped ring. The Hysteria2 client can consume that
ring directly and send RDV1 video datagrams on the same authenticated UDP
session:
```text
remotedesk-hysteria2-agent --mode client \
--listen 127.0.0.1:39503 \
--server relay.example:39502 --password 'change-me' \
--server-name relay.example \
--agent-udp 127.0.0.1:39502 \
--encoded-ring C:\Users\user\AppData\Local\Temp\remotedesk-session-...ring \
--ring-stream-id 1 --ring-generation 7
```
`--encoded-ring` is optional and is intended for the authenticated session
orchestrator, which obtains the path, stream ID, and generation from the Rust
control response. The Go consumer validates the ring header and geometry,
copies each encoded access unit before releasing the slot, fragments it into
RDV1, and drops video on cancellation or transport failure. It never maps or
transmits raw BGRA/NV12 surfaces.
For production local orchestration, start the Agent and bridge with the same
random 32-byte base64url bootstrap. The Agent removes its inherited copy from
the environment after startup, restricts the pipe and ring DACL to the current
Windows user, rejects remote pipe clients, and authenticates each connection
with a nonce-bound HMAC-SHA256 challenge:
```powershell
$token = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Fill($token)
$env:REMOTEDESK_AGENT_PIPE_TOKEN = [Convert]::ToBase64String($token).TrimEnd('=').Replace('+','-').Replace('/','_')
Start-Process -WindowStyle Hidden remotedesk-windows-agent -ArgumentList 'pipe'
remotedesk-hysteria2-agent --mode client `
--server relay.example:39502 --password 'change-me' `
--server-name relay.example --agent-udp 127.0.0.1:39502 `
--windows-agent-pipe '\\.\pipe\RemoteDesk\windows-agent' `
--session-id session-1 --ring-fps 120 --ring-stream-id 1
Remove-Item Env:REMOTEDESK_AGENT_PIPE_TOKEN
```
`--windows-agent-control` remains available only for a same-machine development
bridge. Its address must be a literal loopback IP:
```text
remotedesk-hysteria2-agent --mode client \
--server relay.example:39502 --password 'change-me' \
--server-name relay.example --agent-udp 127.0.0.1:39502 \
--windows-agent-control 127.0.0.1:39501 \
--session-id session-1 --ring-fps 120 --ring-stream-id 1
```
The receiver-side Windows viewer binds only loopback UDP, pins the first valid
RDV1 source, uses bounded reassembly and a one-frame UI mailbox, and presents
H.264 through Media Foundation/D3D11:
```text
remotedesk-windows-agent-viewer --target 10.0.0.20:39501 \
--session-id session-1 --fps 120 --media-udp 127.0.0.1:39502
```
RDWF remains the default when `--media-udp` is absent. The authenticated Named
Pipe is the production descriptor path; loopback TCP and manual `--encoded-ring`
are diagnostic/development paths and do not prove descriptor authenticity.
## RemoteDesk media datagrams
Packets beginning with RDV1 are RemoteDesk H.264/HEVC/AV1 video fragments.
The relay enforces the complete datagram limit (58-byte header plus at most
1200 bytes of payload) and otherwise forwards the bytes without decoding them.
Audio and control packets use their own framing and remain opaque to this
adapter. A missing video fragment is not retransmitted by this helper; the
receiver drops the incomplete access unit after its bounded reassembly timeout.
The transport is an unreliable media path. Input and control traffic must use a
separate reliable path in the Agent session protocol; this UDP adapter does not
provide ordering or delivery guarantees for those messages.
RemoteDesk Opus packets use RDA1: a 40-byte header followed by one bounded
Opus packet (maximum 4 KiB). Valid durations are 10/20/40/60 ms. The adapter
validates the framing and forwards the packet without reordering or waiting for
video. With an authenticated Windows Agent descriptor it independently drains
the Opus mmap ring and sends RDA1 datagrams. The Windows viewer uses a bounded
jitter queue plus Opus FEC/PLC and WASAPI playback; audio failure does not stop
video or input. Real-device validation and precise shared-QPC drift correction
remain release requirements.
-26
View File
@@ -1,26 +0,0 @@
module github.com/remotedesk/remotedesk/transport/hysteria2-agent
go 1.25.0
require (
github.com/Microsoft/go-winio v0.6.2
github.com/apernet/hysteria/core/v2 v2.12.1
golang.org/x/sys v0.47.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/testify v1.11.1 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/text v0.40.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
-49
View File
@@ -1,49 +0,0 @@
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/apernet/hysteria/core/v2 v2.12.1 h1:7jA/jSC+1tFP8Z9COzI0AdT1p9c0SY7D8vngRd1YX7s=
github.com/apernet/hysteria/core/v2 v2.12.1/go.mod h1:YVOel66fPGf9dO2rz/9Bsu6yChyMe7gSmYagAYfPOGE=
github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg=
github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-339
View File
@@ -1,339 +0,0 @@
// Command remotedesk-hysteria2-agent exposes a RemoteDesk datagram endpoint
// through the official Hysteria2 QUIC server. No TCP proxy or SOCKS5 mode is
// enabled: every authenticated Hysteria2 session is delivered to the local
// Agent UDP endpoint.
package main
import (
"context"
"crypto/tls"
"errors"
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
"github.com/apernet/hysteria/core/v2/client"
"github.com/apernet/hysteria/core/v2/server"
)
type passwordAuth struct{ password string }
func (a passwordAuth) Authenticate(_ net.Addr, auth string, _ uint64) (bool, string) {
return auth != "" && auth == a.password, "remotedesk"
}
// localDatagramOutbound maps a Hysteria2 UDP request to a local UDP Agent.
// The public side remains QUIC/UDP; this socket is local-only and never a
// fallback transport for the remote connection.
type localDatagramOutbound struct{ target *net.UDPAddr }
func (o localDatagramOutbound) TCP(string) (net.Conn, error) {
return nil, fmt.Errorf("TCP proxy is disabled for RemoteDesk Hysteria2")
}
func (o localDatagramOutbound) CheckUDP(string) error { return nil }
func (o localDatagramOutbound) UDP(_ string) (server.UDPConn, error) {
conn, err := net.DialUDP("udp", nil, o.target)
if err != nil {
return nil, err
}
return &udpConn{conn: conn}, nil
}
type udpConn struct{ conn *net.UDPConn }
type datagramKind uint8
const (
datagramOpaque datagramKind = iota
datagramVideo
)
// classifyDatagram keeps the relay payload opaque. Only the RemoteDesk video
// framing has a local size contract; control and audio packets are forwarded
// without parsing so they can evolve independently.
func classifyDatagram(data []byte) (datagramKind, error) {
if len(data) >= len(videoDatagramMagic) && string(data[:len(videoDatagramMagic)]) == videoDatagramMagic {
if err := validateVideoDatagramSize(data); err != nil {
return datagramVideo, err
}
return datagramVideo, nil
}
if len(data) >= len(audioDatagramMagic) && string(data[:len(audioDatagramMagic)]) == audioDatagramMagic {
if err := validateAudioDatagramSize(data); err != nil {
return datagramOpaque, err
}
if _, err := decodeAudioDatagram(data); err != nil {
return datagramOpaque, err
}
}
return datagramOpaque, nil
}
func prepareDatagram(data []byte) ([]byte, datagramKind, error) {
if len(data) == 0 {
return nil, datagramOpaque, fmt.Errorf("empty RemoteDesk datagram")
}
kind, err := classifyDatagram(data)
if err != nil {
return nil, kind, err
}
// Always copy a UDP read buffer before handing it to Hysteria2.
return append([]byte(nil), data...), kind, nil
}
func (c *udpConn) ReadFrom(b []byte) (int, string, error) {
n, addr, err := c.conn.ReadFromUDP(b)
if addr == nil {
return n, "", err
}
return n, addr.String(), err
}
func (c *udpConn) WriteTo(b []byte, _ string) (int, error) { return c.conn.Write(b) }
func (c *udpConn) Close() error { return c.conn.Close() }
func main() {
mode := flag.String("mode", "server", "transport mode: server or client")
listen := flag.String("listen", env("REMOTEDESK_HYSTERIA2_LISTEN", "0.0.0.0:39502"), "Hysteria2 UDP listen address")
serverAddr := flag.String("server", env("REMOTEDESK_HYSTERIA2_SERVER", "127.0.0.1:39502"), "Hysteria2 server address (client mode)")
password := flag.String("password", os.Getenv("REMOTEDESK_HYSTERIA2_PASSWORD"), "Hysteria2 password")
certFile := flag.String("cert", os.Getenv("REMOTEDESK_HYSTERIA2_CERT"), "TLS certificate PEM")
keyFile := flag.String("key", os.Getenv("REMOTEDESK_HYSTERIA2_KEY"), "TLS private key PEM")
agent := flag.String("agent-udp", env("REMOTEDESK_AGENT_UDP", "127.0.0.1:39502"), "local Agent UDP endpoint")
serverName := flag.String("server-name", env("REMOTEDESK_HYSTERIA2_SERVER_NAME", "localhost"), "TLS server name (client mode)")
insecure := flag.Bool("insecure", false, "disable TLS certificate verification (client mode only)")
ringPath := flag.String("encoded-ring", os.Getenv("REMOTEDESK_ENCODED_RING"), "local Rust encoded-access-unit ring path (optional)")
ringStreamID := flag.Uint("ring-stream-id", 0, "RemoteDesk video stream ID for the encoded ring")
ringGeneration := flag.Uint64("ring-generation", 0, "RemoteDesk encoded ring generation")
agentControl := flag.String("windows-agent-control", os.Getenv("REMOTEDESK_WINDOWS_AGENT_CONTROL"), "loopback Windows Agent TCP control endpoint (optional)")
agentPipe := flag.String("windows-agent-pipe", os.Getenv("REMOTEDESK_WINDOWS_AGENT_PIPE"), "authenticated local Windows Agent Named Pipe (recommended)")
sessionID := flag.String("session-id", os.Getenv("REMOTEDESK_SESSION_ID"), "encoded desktop session ID")
ringFPS := flag.Uint("ring-fps", 30, "encoded desktop frame rate (1-120)")
allowSoftwareFallback := flag.Bool("allow-software-fallback", true, "allow Media Foundation software H.264 fallback")
flag.Parse()
if *mode == "client" {
if *agentControl != "" && *agentPipe != "" {
log.Fatal("--windows-agent-control and --windows-agent-pipe are mutually exclusive")
}
controlEndpoint := *agentPipe
if controlEndpoint == "" {
controlEndpoint = *agentControl
}
if uint64(*ringStreamID) > uint64(^uint32(0)) {
log.Fatal("--ring-stream-id exceeds uint32")
}
if *ringFPS == 0 || *ringFPS > 120 {
log.Fatal("--ring-fps must be between 1 and 120")
}
runClient(*listen, *serverAddr, *password, *agent, *serverName, *insecure, *ringPath, uint32(*ringStreamID), *ringGeneration, controlEndpoint, *sessionID, uint8(*ringFPS), *allowSoftwareFallback)
return
}
if *mode != "server" {
log.Fatalf("invalid --mode %q (must be server or client)", *mode)
}
if *password == "" || *certFile == "" || *keyFile == "" {
log.Fatal("password, cert and key are required; refusing unauthenticated Hysteria2")
}
target, err := net.ResolveUDPAddr("udp", *agent)
if err != nil {
log.Fatalf("invalid --agent-udp: %v", err)
}
cert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
if err != nil {
log.Fatalf("load TLS certificate: %v", err)
}
packetConn, err := net.ListenPacket("udp", *listen)
if err != nil {
log.Fatalf("listen Hysteria2 UDP: %v", err)
}
srv, err := server.NewServer(&server.Config{
TLSConfig: server.TLSConfig{Certificates: []tls.Certificate{cert}},
Conn: packetConn,
Authenticator: passwordAuth{password: *password},
Outbound: localDatagramOutbound{target: target},
DisableUDP: false,
})
if err != nil {
log.Fatalf("create Hysteria2 server: %v", err)
}
log.Printf("RemoteDesk Hysteria2 server listening on %s (UDP/QUIC only)", *listen)
log.Printf("authenticated datagrams delivered to local Agent UDP %s", target)
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
done := make(chan error, 1)
go func() { done <- srv.Serve() }()
select {
case sig := <-stop:
log.Printf("shutting down on %s", sig)
_ = srv.Close()
case err := <-done:
if err != nil && !strings.Contains(err.Error(), "closed") {
log.Fatal(err)
}
}
// Allow the QUIC listener to finish releasing its packet socket.
time.Sleep(50 * time.Millisecond)
}
func runClient(localListen, serverAddr, password, remoteAgent, serverName string, insecure bool, ringPath string, ringStreamID uint32, ringGeneration uint64, agentControl, sessionID string, ringFPS uint8, allowSoftwareFallback bool) {
if password == "" {
log.Fatal("--password is required in client mode")
}
serverUDP, err := net.ResolveUDPAddr("udp", serverAddr)
if err != nil {
log.Fatalf("invalid --server: %v", err)
}
localUDP, err := net.ResolveUDPAddr("udp", localListen)
if err != nil {
log.Fatalf("invalid --listen: %v", err)
}
localConn, err := net.ListenUDP("udp", localUDP)
if err != nil {
log.Fatalf("listen local UDP: %v", err)
}
defer localConn.Close()
hy, _, err := client.NewClient(&client.Config{
ServerAddr: serverUDP,
Auth: password,
TLSConfig: client.TLSConfig{
ServerName: serverName,
InsecureSkipVerify: insecure,
},
})
if err != nil {
log.Fatalf("connect Hysteria2 server: %v", err)
}
defer hy.Close()
stream, err := hy.UDP()
if err != nil {
log.Fatalf("open Hysteria2 UDP session: %v", err)
}
defer stream.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var sendMu sync.Mutex
log.Printf("RemoteDesk Hysteria2 client listening on %s (UDP only)", localConn.LocalAddr())
log.Printf("authenticated Hysteria2 server %s; remote Agent %s", serverAddr, remoteAgent)
var agentControlConnection net.Conn
var audioDescriptor *audioRingDescriptor
if ringPath == "" && agentControl != "" {
connection, descriptor, err := openWindowsEncodedSession(agentControl, sessionID, ringFPS, allowSoftwareFallback)
if err != nil {
log.Fatalf("start Windows encoded desktop: %v", err)
}
agentControlConnection = connection
ringPath = descriptor.RingPath
ringGeneration = descriptor.Generation
if ringStreamID == 0 {
ringStreamID = 1
}
audioDescriptor = descriptor.AudioRing
log.Printf("Windows encoded desktop session %s generation %d opened", sessionID, ringGeneration)
}
if agentControlConnection != nil {
defer agentControlConnection.Close()
}
if ringPath != "" {
if ringStreamID == 0 || ringGeneration == 0 {
log.Fatal("--ring-stream-id and --ring-generation are required with --encoded-ring")
}
ring, err := openEncodedRing(ringPath)
if err != nil {
log.Fatalf("open encoded media ring: %v", err)
}
go func() {
defer ring.Close()
err := pumpEncodedRing(ctx, ring, ringStreamID, ringGeneration, func(data []byte) error {
sendMu.Lock()
defer sendMu.Unlock()
return stream.Send(data, remoteAgent)
})
if err != nil && !errors.Is(err, context.Canceled) {
log.Printf("encoded media ring stopped: %v", err)
}
cancel()
if agentControlConnection != nil {
_ = agentControlConnection.Close()
}
_ = localConn.Close()
}()
}
if audioDescriptor != nil {
audioRing, err := openAudioRing(audioDescriptor.RingPath)
if err != nil {
log.Printf("audio ring unavailable, continuing without audio: %v", err)
} else {
go func() {
defer audioRing.Close()
err := pumpAudioRing(ctx, audioRing, ringStreamID, audioDescriptor.Generation, func(data []byte) error {
sendMu.Lock()
defer sendMu.Unlock()
return stream.Send(data, remoteAgent)
})
if err != nil && !errors.Is(err, context.Canceled) {
log.Printf("audio media ring stopped: %v", err)
}
}()
}
}
var peerMu sync.RWMutex
var peer *net.UDPAddr
go func() {
for {
data, _, err := stream.Receive()
if err != nil {
cancel()
_ = localConn.Close()
return
}
prepared, _, err := prepareDatagram(data)
if err != nil {
log.Printf("drop invalid datagram from Hysteria2: %v", err)
continue
}
peerMu.RLock()
currentPeer := peer
if currentPeer != nil {
_, _ = localConn.WriteToUDP(prepared, currentPeer)
}
peerMu.RUnlock()
}
}()
buf := make([]byte, 64*1024)
for {
n, sourcePeer, err := localConn.ReadFromUDP(buf)
if err != nil {
return
}
peerMu.Lock()
peer = sourcePeer
peerMu.Unlock()
prepared, _, err := prepareDatagram(buf[:n])
if err != nil {
log.Printf("drop invalid local datagram from %s: %v", sourcePeer, err)
continue
}
// The Hysteria2 destination is metadata only. The server's outbound
// implementation pins delivery to its configured local Agent UDP port.
sendMu.Lock()
err = stream.Send(prepared, remoteAgent)
sendMu.Unlock()
if err != nil {
log.Printf("send Hysteria2 datagram from %s: %v", sourcePeer, err)
return
}
}
}
func env(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
-352
View File
@@ -1,352 +0,0 @@
package main
import (
"encoding/binary"
"errors"
"fmt"
"time"
)
const (
videoDatagramMagic = "RDV1"
videoDatagramVersion = 1
videoDatagramHeaderBytes = 58
videoDatagramPayloadMax = 1200
videoDatagramMaxBytes = videoDatagramHeaderBytes + videoDatagramPayloadMax
videoFrameMaxBytes = 16 * 1024 * 1024
videoFrameMaxFragments = 4096
videoReassemblyMaxFrames = 8
videoReassemblyTimeout = 100 * time.Millisecond
)
const (
audioDatagramMagic = "RDA1"
audioDatagramVersion = 1
audioDatagramHeaderSize = 40
audioPacketMaxBytes = 4 * 1024
audioDatagramMaxBytes = audioDatagramHeaderSize + audioPacketMaxBytes
)
const (
videoFlagConfig uint16 = 1 << 0
videoFlagKeyFrame uint16 = 1 << 1
videoFlagFirstFragment uint16 = 1 << 2
videoFlagLastFragment uint16 = 1 << 3
videoFlagDiscontinuity uint16 = 1 << 4
)
const audioFlagDiscontinuity uint16 = 1 << 0
// VideoAccessUnit is a complete encoded frame before transport fragmentation.
// The Hysteria2 relay must treat the resulting datagrams as opaque bytes.
type VideoAccessUnit struct {
StreamID uint32
Generation uint64
Sequence uint64
FrameID uint64
PTS uint64
DTS uint64
Flags uint16
Payload []byte
}
// AudioPacket is one independently decodable Opus packet. Audio is never
// reassembled with video and is forwarded on its own priority path.
type AudioPacket struct {
StreamID uint32
Generation uint64
Sequence uint64
PTS uint64
DurationMS uint16
Flags uint16
Payload []byte
}
func encodeAudioDatagram(packet AudioPacket) ([]byte, error) {
if err := validateAudioPacket(packet); err != nil {
return nil, err
}
output := make([]byte, audioDatagramHeaderSize+len(packet.Payload))
copy(output, audioDatagramMagic)
output[4] = audioDatagramVersion
binary.LittleEndian.PutUint16(output[6:8], audioDatagramHeaderSize)
binary.LittleEndian.PutUint32(output[8:12], packet.StreamID)
binary.LittleEndian.PutUint64(output[12:20], packet.Generation)
binary.LittleEndian.PutUint64(output[20:28], packet.Sequence)
binary.LittleEndian.PutUint64(output[28:36], packet.PTS)
binary.LittleEndian.PutUint16(output[36:38], packet.DurationMS)
binary.LittleEndian.PutUint16(output[38:40], packet.Flags)
copy(output[audioDatagramHeaderSize:], packet.Payload)
return output, nil
}
func decodeAudioDatagram(input []byte) (AudioPacket, error) {
if len(input) < audioDatagramHeaderSize || len(input) > audioDatagramMaxBytes {
return AudioPacket{}, errors.New("audio datagram size is invalid")
}
if string(input[:4]) != audioDatagramMagic || input[4] != audioDatagramVersion || input[5] != 0 {
return AudioPacket{}, errors.New("audio datagram version is invalid")
}
headerBytes := int(binary.LittleEndian.Uint16(input[6:8]))
if headerBytes != audioDatagramHeaderSize || headerBytes > len(input) {
return AudioPacket{}, errors.New("audio datagram header is invalid")
}
packet := AudioPacket{
StreamID: binary.LittleEndian.Uint32(input[8:12]),
Generation: binary.LittleEndian.Uint64(input[12:20]),
Sequence: binary.LittleEndian.Uint64(input[20:28]),
PTS: binary.LittleEndian.Uint64(input[28:36]),
DurationMS: binary.LittleEndian.Uint16(input[36:38]),
Flags: binary.LittleEndian.Uint16(input[38:40]),
Payload: input[headerBytes:],
}
if err := validateAudioPacket(packet); err != nil {
return AudioPacket{}, err
}
return packet, nil
}
func validateAudioPacket(packet AudioPacket) error {
if packet.StreamID == 0 || packet.Generation == 0 || packet.Sequence == 0 {
return errors.New("audio packet identity is invalid")
}
if packet.DurationMS != 10 && packet.DurationMS != 20 && packet.DurationMS != 40 && packet.DurationMS != 60 {
return errors.New("audio packet duration is invalid")
}
if len(packet.Payload) == 0 || len(packet.Payload) > audioPacketMaxBytes {
return errors.New("audio packet payload size is invalid")
}
if packet.Flags & ^audioFlagDiscontinuity != 0 {
return errors.New("audio packet flags are invalid")
}
return nil
}
type videoDatagram struct {
StreamID uint32
Generation uint64
Sequence uint64
FrameID uint64
PTS uint64
DTS uint64
FragmentID uint16
FragmentCount uint16
Flags uint16
Payload []byte
}
func fragmentVideoAccessUnit(unit VideoAccessUnit) ([][]byte, error) {
if unit.StreamID == 0 || unit.Generation == 0 {
return nil, errors.New("video stream identity is invalid")
}
if len(unit.Payload) == 0 || len(unit.Payload) > videoFrameMaxBytes {
return nil, errors.New("video access unit size is invalid")
}
fragmentCount := (len(unit.Payload) + videoDatagramPayloadMax - 1) / videoDatagramPayloadMax
if fragmentCount > videoFrameMaxFragments {
return nil, errors.New("video access unit has too many fragments")
}
datagrams := make([][]byte, 0, fragmentCount)
for fragmentID, offset := 0, 0; offset < len(unit.Payload); fragmentID++ {
end := min(offset+videoDatagramPayloadMax, len(unit.Payload))
flags := unit.Flags
if fragmentID == 0 {
flags |= videoFlagFirstFragment
}
if end == len(unit.Payload) {
flags |= videoFlagLastFragment
}
packet := videoDatagram{
StreamID: unit.StreamID,
Generation: unit.Generation,
Sequence: unit.Sequence,
FrameID: unit.FrameID,
PTS: unit.PTS,
DTS: unit.DTS,
FragmentID: uint16(fragmentID),
FragmentCount: uint16(fragmentCount),
Flags: flags,
Payload: unit.Payload[offset:end],
}
datagrams = append(datagrams, encodeVideoDatagram(packet))
offset = end
}
return datagrams, nil
}
func encodeVideoDatagram(packet videoDatagram) []byte {
output := make([]byte, videoDatagramHeaderBytes+len(packet.Payload))
copy(output, videoDatagramMagic)
output[4] = videoDatagramVersion
binary.LittleEndian.PutUint16(output[6:8], videoDatagramHeaderBytes)
binary.LittleEndian.PutUint32(output[8:12], packet.StreamID)
binary.LittleEndian.PutUint64(output[12:20], packet.Generation)
binary.LittleEndian.PutUint64(output[20:28], packet.Sequence)
binary.LittleEndian.PutUint64(output[28:36], packet.FrameID)
binary.LittleEndian.PutUint64(output[36:44], packet.PTS)
binary.LittleEndian.PutUint64(output[44:52], packet.DTS)
binary.LittleEndian.PutUint16(output[52:54], packet.FragmentID)
binary.LittleEndian.PutUint16(output[54:56], packet.FragmentCount)
binary.LittleEndian.PutUint16(output[56:58], packet.Flags)
copy(output[videoDatagramHeaderBytes:], packet.Payload)
return output
}
func decodeVideoDatagram(input []byte) (videoDatagram, error) {
if len(input) < videoDatagramHeaderBytes || len(input) > videoDatagramHeaderBytes+videoDatagramPayloadMax {
return videoDatagram{}, errors.New("video datagram size is invalid")
}
if string(input[:4]) != videoDatagramMagic || input[4] != videoDatagramVersion {
return videoDatagram{}, errors.New("video datagram version is invalid")
}
headerBytes := int(binary.LittleEndian.Uint16(input[6:8]))
if headerBytes != videoDatagramHeaderBytes || headerBytes > len(input) {
return videoDatagram{}, errors.New("video datagram header is invalid")
}
packet := videoDatagram{
StreamID: binary.LittleEndian.Uint32(input[8:12]),
Generation: binary.LittleEndian.Uint64(input[12:20]),
Sequence: binary.LittleEndian.Uint64(input[20:28]),
FrameID: binary.LittleEndian.Uint64(input[28:36]),
PTS: binary.LittleEndian.Uint64(input[36:44]),
DTS: binary.LittleEndian.Uint64(input[44:52]),
FragmentID: binary.LittleEndian.Uint16(input[52:54]),
FragmentCount: binary.LittleEndian.Uint16(input[54:56]),
Flags: binary.LittleEndian.Uint16(input[56:58]),
Payload: input[headerBytes:],
}
if packet.StreamID == 0 || packet.Generation == 0 || packet.FragmentCount == 0 ||
packet.FragmentCount > videoFrameMaxFragments || packet.FragmentID >= packet.FragmentCount ||
len(packet.Payload) == 0 || len(packet.Payload) > videoDatagramPayloadMax {
return videoDatagram{}, errors.New("video datagram fields are invalid")
}
if packet.Flags&videoFlagFirstFragment != 0 != (packet.FragmentID == 0) ||
packet.Flags&videoFlagLastFragment != 0 != (packet.FragmentID+1 == packet.FragmentCount) {
return videoDatagram{}, errors.New("video datagram fragment flags are invalid")
}
return packet, nil
}
type videoFrameKey struct {
streamID uint32
generation uint64
frameID uint64
}
type videoFrameAssembly struct {
created time.Time
sequence uint64
pts uint64
dts uint64
flags uint16
fragmentCount uint16
fragments map[uint16][]byte
bytes int
}
// videoReassembler is intentionally bounded. It never waits for a missing
// datagram past the deadline and never retransmits an expired video frame.
type videoReassembler struct {
frames map[videoFrameKey]*videoFrameAssembly
}
func newVideoReassembler() *videoReassembler {
return &videoReassembler{frames: make(map[videoFrameKey]*videoFrameAssembly)}
}
func (r *videoReassembler) push(input []byte, now time.Time) (*VideoAccessUnit, error) {
packet, err := decodeVideoDatagram(input)
if err != nil {
return nil, err
}
r.expire(now)
key := videoFrameKey{streamID: packet.StreamID, generation: packet.Generation, frameID: packet.FrameID}
assembly := r.frames[key]
if assembly == nil {
if len(r.frames) >= videoReassemblyMaxFrames {
r.dropOldest()
}
assembly = &videoFrameAssembly{
created: now,
sequence: packet.Sequence,
pts: packet.PTS,
dts: packet.DTS,
flags: packet.Flags &^ (videoFlagFirstFragment | videoFlagLastFragment),
fragmentCount: packet.FragmentCount,
fragments: make(map[uint16][]byte, packet.FragmentCount),
}
r.frames[key] = assembly
} else if assembly.fragmentCount != packet.FragmentCount || assembly.sequence != packet.Sequence {
delete(r.frames, key)
return nil, errors.New("video frame metadata changed during reassembly")
}
if _, exists := assembly.fragments[packet.FragmentID]; !exists {
assembly.fragments[packet.FragmentID] = append([]byte(nil), packet.Payload...)
assembly.bytes += len(packet.Payload)
}
if assembly.bytes > videoFrameMaxBytes || len(assembly.fragments) != int(assembly.fragmentCount) {
return nil, nil
}
payload := make([]byte, 0, assembly.bytes)
for index := uint16(0); index < assembly.fragmentCount; index++ {
fragment, ok := assembly.fragments[index]
if !ok {
return nil, nil
}
payload = append(payload, fragment...)
}
delete(r.frames, key)
return &VideoAccessUnit{
StreamID: packet.StreamID,
Generation: packet.Generation,
Sequence: assembly.sequence,
FrameID: packet.FrameID,
PTS: assembly.pts,
DTS: assembly.dts,
Flags: assembly.flags | (packet.Flags & (videoFlagConfig | videoFlagKeyFrame | videoFlagDiscontinuity)),
Payload: payload,
}, nil
}
func (r *videoReassembler) expire(now time.Time) {
for key, assembly := range r.frames {
if now.Sub(assembly.created) >= videoReassemblyTimeout {
delete(r.frames, key)
}
}
}
func (r *videoReassembler) dropOldest() {
var oldest videoFrameKey
var oldestTime time.Time
for key, assembly := range r.frames {
if oldestTime.IsZero() || assembly.created.Before(oldestTime) {
oldest, oldestTime = key, assembly.created
}
}
if !oldestTime.IsZero() {
delete(r.frames, oldest)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func validateVideoDatagramSize(input []byte) error {
if len(input) > videoDatagramMaxBytes {
return fmt.Errorf("video datagram exceeds %d bytes", videoDatagramMaxBytes)
}
return nil
}
func validateAudioDatagramSize(input []byte) error {
if len(input) > audioDatagramMaxBytes {
return fmt.Errorf("audio datagram exceeds %d bytes", audioDatagramMaxBytes)
}
return nil
}
-156
View File
@@ -1,156 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
)
// EncodedRingRecord is the cross-process contract produced by the Rust
// Windows agent. PTS is the Media Foundation 100-ns media timeline.
type EncodedRingRecord struct {
Sequence uint64
PTS uint64
Kind uint32
KeyFrame bool
Payload []byte
}
// EncodedRing is intentionally a single-consumer interface. The producer may
// drop a video record when this queue is full; the consumer must never block
// input or audio while polling it.
type EncodedRing interface {
Pop() (*EncodedRingRecord, error)
Close() error
}
// AudioRing is a separate consumer contract even though its mmap slot layout
// matches the video ring. Keeping the interfaces distinct prevents audio from
// accidentally sharing video backpressure or drop policy.
type AudioRing interface {
Pop() (*EncodedRingRecord, error)
Close() error
}
func validateRingRecord(record *EncodedRingRecord) error {
if record == nil || record.Sequence == 0 || len(record.Payload) == 0 || len(record.Payload) > videoFrameMaxBytes {
return errors.New("encoded ring record is invalid")
}
return nil
}
// pumpEncodedRing drains the ring into RDV1 datagrams. The callback is the
// already-authenticated Hysteria2 UDP stream; this function never retries a
// failed video packet.
func pumpEncodedRing(ctx context.Context, ring EncodedRing, streamID uint32, generation uint64, send func([]byte) error) error {
if streamID == 0 || generation == 0 {
return errors.New("encoded ring stream identity is invalid")
}
if ring == nil || send == nil {
return errors.New("encoded ring pump arguments are invalid")
}
ticker := newMediaPollTicker()
defer ticker.Stop()
for {
drained := false
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
record, err := ring.Pop()
if err != nil {
return fmt.Errorf("read encoded media ring: %w", err)
}
if record == nil {
break
}
drained = true
if err := validateRingRecord(record); err != nil {
return err
}
flags := uint16(0)
if record.KeyFrame {
flags |= videoFlagKeyFrame
}
datagrams, err := fragmentVideoAccessUnit(VideoAccessUnit{
StreamID: streamID, Generation: generation, Sequence: record.Sequence,
FrameID: record.Sequence, PTS: record.PTS, DTS: record.PTS,
Flags: flags, Payload: record.Payload,
})
if err != nil {
return fmt.Errorf("fragment encoded media: %w", err)
}
for _, datagram := range datagrams {
if err := send(datagram); err != nil {
return fmt.Errorf("send encoded video datagram: %w", err)
}
}
}
if drained {
continue
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
// pumpAudioRing sends independent Opus packets as RDA1 datagrams. Audio does
// not wait for video and does not use video frame reassembly.
func pumpAudioRing(ctx context.Context, ring AudioRing, streamID uint32, generation uint64, send func([]byte) error) error {
if streamID == 0 || generation == 0 {
return errors.New("audio ring stream identity is invalid")
}
if ring == nil || send == nil {
return errors.New("audio ring pump arguments are invalid")
}
ticker := newMediaPollTicker()
defer ticker.Stop()
for {
drained := false
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
record, err := ring.Pop()
if err != nil {
return fmt.Errorf("read audio media ring: %w", err)
}
if record == nil {
break
}
drained = true
if record.Sequence == 0 || len(record.Payload) == 0 || len(record.Payload) > audioPacketMaxBytes {
return errors.New("audio ring record is invalid")
}
flags := uint16(0)
if record.Kind == 2 {
flags |= audioFlagDiscontinuity
}
datagram, err := encodeAudioDatagram(AudioPacket{
StreamID: streamID, Generation: generation, Sequence: record.Sequence,
PTS: record.PTS, DurationMS: 20, Flags: flags, Payload: record.Payload,
})
if err != nil {
return fmt.Errorf("encode audio datagram: %w", err)
}
if err := send(datagram); err != nil {
return fmt.Errorf("send audio datagram: %w", err)
}
}
if drained {
continue
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
@@ -1,11 +0,0 @@
package main
import "time"
type mediaPollTicker struct {
*time.Ticker
}
func newMediaPollTicker() *mediaPollTicker {
return &mediaPollTicker{Ticker: time.NewTicker(2 * time.Millisecond)}
}
@@ -1,105 +0,0 @@
//go:build !windows
package main
import (
"encoding/binary"
"errors"
"fmt"
"os"
"sync/atomic"
"syscall"
"unsafe"
)
const (
ringMagic = "RDMRING1"
ringVersion = uint32(1)
ringHeaderBytes = 40
slotHeaderBytes = 24
maxRingSlots = uint32(256)
maxRingSlotBytes = uint32(16 * 1024 * 1024)
)
type mappedEncodedRing struct {
file *os.File
path string
data []byte
slotCount uint32
slotBytes uint32
}
func openEncodedRing(path string) (EncodedRing, error) {
file, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return nil, fmt.Errorf("open encoded ring: %w", err)
}
stat, err := file.Stat()
if err != nil {
_ = file.Close()
return nil, err
}
if stat.Size() < ringHeaderBytes {
_ = file.Close()
return nil, errors.New("encoded ring is shorter than its header")
}
data, err := syscall.Mmap(int(file.Fd()), 0, int(stat.Size()), syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("map encoded ring: %w", err)
}
if string(data[:8]) != ringMagic || binary.LittleEndian.Uint32(data[8:12]) != ringVersion {
_ = syscall.Munmap(data)
_ = file.Close()
return nil, errors.New("encoded ring header is invalid")
}
slots, slotBytes := binary.LittleEndian.Uint32(data[12:16]), binary.LittleEndian.Uint32(data[16:20])
expected := uint64(ringHeaderBytes) + uint64(slots)*uint64(slotHeaderBytes+slotBytes)
if slots < 2 || slots > maxRingSlots || slots&(slots-1) != 0 || slotBytes == 0 || slotBytes > maxRingSlotBytes || expected != uint64(len(data)) {
_ = syscall.Munmap(data)
_ = file.Close()
return nil, errors.New("encoded ring geometry is invalid")
}
return &mappedEncodedRing{file: file, path: path, data: data, slotCount: slots, slotBytes: slotBytes}, nil
}
func openAudioRing(path string) (AudioRing, error) {
ring, err := openEncodedRing(path)
if err != nil {
return nil, err
}
return ring, nil
}
func (r *mappedEncodedRing) Pop() (*EncodedRingRecord, error) {
read := atomic.LoadUint64((*uint64)(unsafe.Pointer(&r.data[32])))
write := atomic.LoadUint64((*uint64)(unsafe.Pointer(&r.data[24])))
if read == write {
return nil, nil
}
start := ringHeaderBytes + int(read%uint64(r.slotCount))*int(slotHeaderBytes+r.slotBytes)
length := binary.LittleEndian.Uint32(r.data[start+20 : start+24])
if length == 0 || length > r.slotBytes {
return nil, errors.New("encoded ring record length is invalid")
}
record := &EncodedRingRecord{Sequence: binary.LittleEndian.Uint64(r.data[start : start+8]), PTS: binary.LittleEndian.Uint64(r.data[start+8 : start+16]), Kind: binary.LittleEndian.Uint32(r.data[start+16 : start+20]), Payload: append([]byte(nil), r.data[start+slotHeaderBytes:start+slotHeaderBytes+int(length)]...)}
record.KeyFrame = record.Kind == 2
if record.Sequence == 0 {
return nil, errors.New("encoded ring record sequence is invalid")
}
atomic.StoreUint64((*uint64)(unsafe.Pointer(&r.data[32])), read+1)
return record, nil
}
func (r *mappedEncodedRing) Close() error {
err := syscall.Munmap(r.data)
if r.file != nil {
if closeErr := r.file.Close(); err == nil {
err = closeErr
}
}
if removeErr := os.Remove(r.path); err == nil && removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
err = removeErr
}
return err
}
@@ -1,146 +0,0 @@
//go:build windows
package main
import (
"encoding/binary"
"errors"
"fmt"
"os"
"sync/atomic"
"unsafe"
"golang.org/x/sys/windows"
)
const (
ringMagic = "RDMRING1"
ringVersion = uint32(1)
ringHeaderBytes = 40
slotHeaderBytes = 24
maxRingSlots = uint32(256)
maxRingSlotBytes = uint32(16 * 1024 * 1024)
)
type mappedEncodedRing struct {
file *os.File
path string
mapping windows.Handle
address uintptr
data []byte
slotCount uint32
slotBytes uint32
}
func openEncodedRing(path string) (EncodedRing, error) {
file, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return nil, fmt.Errorf("open encoded ring: %w", err)
}
closeOnError := true
defer func() {
if closeOnError {
_ = file.Close()
}
}()
stat, err := file.Stat()
if err != nil {
return nil, fmt.Errorf("stat encoded ring: %w", err)
}
length := stat.Size()
if length < ringHeaderBytes {
return nil, errors.New("encoded ring is shorter than its header")
}
mapping, err := windows.CreateFileMapping(windows.Handle(file.Fd()), nil, windows.PAGE_READWRITE, 0, 0, nil)
if err != nil {
return nil, fmt.Errorf("map encoded ring file: %w", err)
}
address, err := windows.MapViewOfFile(mapping, windows.FILE_MAP_READ|windows.FILE_MAP_WRITE, 0, 0, uintptr(length))
if err != nil {
_ = windows.CloseHandle(mapping)
return nil, fmt.Errorf("map encoded ring view: %w", err)
}
if uintptr(length) > uintptr(^uint(0)>>1) {
_ = windows.UnmapViewOfFile(address)
_ = windows.CloseHandle(mapping)
return nil, errors.New("encoded ring is too large for this process")
}
data := unsafe.Slice((*byte)(unsafe.Pointer(address)), int(length))
if string(data[:8]) != ringMagic || binary.LittleEndian.Uint32(data[8:12]) != ringVersion {
_ = windows.UnmapViewOfFile(address)
_ = windows.CloseHandle(mapping)
return nil, errors.New("encoded ring header magic or version is invalid")
}
slots := binary.LittleEndian.Uint32(data[12:16])
slotBytes := binary.LittleEndian.Uint32(data[16:20])
if slots < 2 || slots > maxRingSlots || slots&(slots-1) != 0 || slotBytes == 0 || slotBytes > maxRingSlotBytes {
_ = windows.UnmapViewOfFile(address)
_ = windows.CloseHandle(mapping)
return nil, errors.New("encoded ring geometry is invalid")
}
expected := uint64(ringHeaderBytes) + uint64(slots)*uint64(slotHeaderBytes+slotBytes)
if expected != uint64(length) {
_ = windows.UnmapViewOfFile(address)
_ = windows.CloseHandle(mapping)
return nil, errors.New("encoded ring file size does not match its header")
}
closeOnError = false
return &mappedEncodedRing{file: file, path: path, mapping: mapping, address: address, data: data, slotCount: slots, slotBytes: slotBytes}, nil
}
func openAudioRing(path string) (AudioRing, error) {
ring, err := openEncodedRing(path)
if err != nil {
return nil, err
}
return ring, nil
}
func (r *mappedEncodedRing) Pop() (*EncodedRingRecord, error) {
read := atomic.LoadUint64((*uint64)(unsafe.Pointer(&r.data[32])))
write := atomic.LoadUint64((*uint64)(unsafe.Pointer(&r.data[24])))
if read == write {
return nil, nil
}
offset := ringHeaderBytes + (read%uint64(r.slotCount))*uint64(slotHeaderBytes+r.slotBytes)
start := int(offset)
if start+slotHeaderBytes > len(r.data) {
return nil, errors.New("encoded ring slot header is out of bounds")
}
length := binary.LittleEndian.Uint32(r.data[start+20 : start+24])
if length == 0 || length > r.slotBytes || uint64(start+slotHeaderBytes)+uint64(length) > uint64(len(r.data)) {
return nil, errors.New("encoded ring record length is invalid")
}
payload := append([]byte(nil), r.data[start+slotHeaderBytes:start+slotHeaderBytes+int(length)]...)
record := &EncodedRingRecord{
Sequence: binary.LittleEndian.Uint64(r.data[start : start+8]),
PTS: binary.LittleEndian.Uint64(r.data[start+8 : start+16]),
Kind: binary.LittleEndian.Uint32(r.data[start+16 : start+20]),
Payload: payload,
}
record.KeyFrame = record.Kind == 2
if record.Sequence == 0 {
return nil, errors.New("encoded ring record sequence is invalid")
}
atomic.StoreUint64((*uint64)(unsafe.Pointer(&r.data[32])), read+1)
return record, nil
}
func (r *mappedEncodedRing) Close() error {
if r.address != 0 {
_ = windows.UnmapViewOfFile(r.address)
r.address = 0
}
if r.mapping != 0 {
_ = windows.CloseHandle(r.mapping)
r.mapping = 0
}
if r.file != nil {
err := r.file.Close()
if removeErr := os.Remove(r.path); err == nil && removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
err = removeErr
}
return err
}
return nil
}
-302
View File
@@ -1,302 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/binary"
"testing"
"time"
)
type testEncodedRing struct {
records []*EncodedRingRecord
}
func (r *testEncodedRing) Pop() (*EncodedRingRecord, error) {
if len(r.records) == 0 {
return nil, nil
}
record := r.records[0]
r.records = r.records[1:]
return record, nil
}
func (*testEncodedRing) Close() error { return nil }
func TestVideoAccessUnitRoundTripThroughFragments(t *testing.T) {
payload := bytes.Repeat([]byte{0x5a}, videoDatagramPayloadMax*2+17)
unit := VideoAccessUnit{
StreamID: 1,
Generation: 2,
Sequence: 3,
FrameID: 4,
PTS: 5,
DTS: 6,
Flags: videoFlagKeyFrame,
Payload: payload,
}
datagrams, err := fragmentVideoAccessUnit(unit)
if err != nil {
t.Fatal(err)
}
if len(datagrams) != 3 {
t.Fatalf("got %d datagrams, want 3", len(datagrams))
}
reassembler := newVideoReassembler()
var recovered *VideoAccessUnit
now := time.Unix(100, 0)
for _, datagram := range datagrams {
recovered, err = reassembler.push(datagram, now)
if err != nil {
t.Fatal(err)
}
}
if recovered == nil {
t.Fatal("frame was not reassembled")
}
if !bytes.Equal(recovered.Payload, payload) || recovered.Flags&videoFlagKeyFrame == 0 {
t.Fatal("reassembled frame does not match source")
}
}
func TestVideoReassemblerExpiresIncompleteFrame(t *testing.T) {
datagrams, err := fragmentVideoAccessUnit(VideoAccessUnit{
StreamID: 1,
Generation: 1,
Sequence: 1,
FrameID: 1,
PTS: 1,
DTS: 1,
Flags: videoFlagKeyFrame,
Payload: bytes.Repeat([]byte{1}, videoDatagramPayloadMax+1),
})
if err != nil {
t.Fatal(err)
}
reassembler := newVideoReassembler()
now := time.Unix(200, 0)
if _, err := reassembler.push(datagrams[0], now); err != nil {
t.Fatal(err)
}
if _, err := reassembler.push(datagrams[0], now.Add(videoReassemblyTimeout+time.Nanosecond)); err != nil {
t.Fatal(err)
}
if len(reassembler.frames) != 1 {
t.Fatalf("expected only the new incomplete assembly, got %d", len(reassembler.frames))
}
}
func TestVideoDatagramRejectsInvalidFragmentFlags(t *testing.T) {
datagrams, err := fragmentVideoAccessUnit(VideoAccessUnit{
StreamID: 1,
Generation: 1,
Sequence: 1,
FrameID: 1,
Payload: []byte{1},
})
if err != nil {
t.Fatal(err)
}
datagrams[0][56] = 0
datagrams[0][57] = 0
if _, err := decodeVideoDatagram(datagrams[0]); err == nil {
t.Fatal("invalid first/last flags were accepted")
}
}
func TestVideoDatagramSizeUsesCompleteDatagramLimit(t *testing.T) {
if err := validateVideoDatagramSize(make([]byte, videoDatagramMaxBytes)); err != nil {
t.Fatal(err)
}
if err := validateVideoDatagramSize(make([]byte, videoDatagramMaxBytes+1)); err == nil {
t.Fatal("oversized datagram was accepted")
}
}
func TestPrepareDatagramKeepsOpaquePacketsUnparsed(t *testing.T) {
input := []byte("audio-or-control")
prepared, kind, err := prepareDatagram(input)
if err != nil {
t.Fatal(err)
}
if kind != datagramOpaque || !bytes.Equal(prepared, input) {
t.Fatalf("unexpected opaque packet result: kind=%d payload=%q", kind, prepared)
}
prepared[0] = 'X'
if input[0] == 'X' {
t.Fatal("prepared datagram aliases the UDP input buffer")
}
}
func TestPrepareDatagramRecognizesVideoPackets(t *testing.T) {
datagrams, err := fragmentVideoAccessUnit(VideoAccessUnit{
StreamID: 1, Generation: 1, Sequence: 1, FrameID: 1, Payload: []byte{1},
})
if err != nil {
t.Fatal(err)
}
_, kind, err := prepareDatagram(datagrams[0])
if err != nil {
t.Fatal(err)
}
if kind != datagramVideo {
t.Fatalf("got datagram kind %d, want video", kind)
}
}
func TestAudioPacketRoundTripIsIndependent(t *testing.T) {
input, err := encodeAudioDatagram(AudioPacket{
StreamID: 1, Generation: 2, Sequence: 3, PTS: 4, DurationMS: 20,
Payload: []byte{0x01, 0x02, 0x03},
})
if err != nil {
t.Fatal(err)
}
decoded, err := decodeAudioDatagram(input)
if err != nil {
t.Fatal(err)
}
if decoded.StreamID != 1 || decoded.Generation != 2 || decoded.Sequence != 3 ||
decoded.PTS != 4 || decoded.DurationMS != 20 || !bytes.Equal(decoded.Payload, []byte{1, 2, 3}) {
t.Fatalf("decoded audio packet does not match source: %+v", decoded)
}
prepared, kind, err := prepareDatagram(input)
if err != nil {
t.Fatal(err)
}
if kind != datagramOpaque || !bytes.Equal(prepared, input) {
t.Fatalf("audio packet was not preserved as opaque transport data")
}
}
func TestAudioDatagramRejectsInvalidDurationAndSize(t *testing.T) {
if _, err := encodeAudioDatagram(AudioPacket{
StreamID: 1, Generation: 1, Sequence: 1, PTS: 1, DurationMS: 15, Payload: []byte{1},
}); err == nil {
t.Fatal("invalid audio duration was accepted")
}
if err := validateAudioDatagramSize(make([]byte, audioDatagramMaxBytes+1)); err == nil {
t.Fatal("oversized audio datagram was accepted")
}
}
func TestAudioDatagramRejectsReservedByteAndUnknownFlags(t *testing.T) {
encoded, err := encodeAudioDatagram(AudioPacket{
StreamID: 1, Generation: 2, Sequence: 3, PTS: 4,
DurationMS: 20, Payload: []byte("opus"),
})
if err != nil {
t.Fatal(err)
}
encoded[5] = 1
if _, err := decodeAudioDatagram(encoded); err == nil {
t.Fatal("audio reserved byte was accepted")
}
encoded[5] = 0
binary.LittleEndian.PutUint16(encoded[38:40], 2)
if _, err := decodeAudioDatagram(encoded); err == nil {
t.Fatal("unknown audio flags were accepted")
}
}
func TestPumpEncodedRingFragmentsAndMarksKeyFrame(t *testing.T) {
payload := bytes.Repeat([]byte{0x42}, videoDatagramPayloadMax+3)
ring := &testEncodedRing{records: []*EncodedRingRecord{{
Sequence: 9, PTS: 123, KeyFrame: true, Payload: payload,
}}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var datagrams [][]byte
send := func(data []byte) error {
datagrams = append(datagrams, append([]byte(nil), data...))
if len(datagrams) == 2 {
cancel()
}
return nil
}
err := pumpEncodedRing(ctx, ring, 7, 8, send)
if err != context.Canceled {
t.Fatalf("pump returned %v, want context cancellation", err)
}
if len(datagrams) != 2 {
t.Fatalf("got %d datagrams, want 2", len(datagrams))
}
first, err := decodeVideoDatagram(datagrams[0])
if err != nil {
t.Fatal(err)
}
if first.Flags&videoFlagKeyFrame == 0 || first.PTS != 123 || first.FrameID != 9 {
t.Fatalf("ring metadata was not preserved: %+v", first)
}
}
func TestPumpEncodedRingRejectsInvalidRecord(t *testing.T) {
ring := &testEncodedRing{records: []*EncodedRingRecord{{Sequence: 0, Payload: []byte{1}}}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
err := pumpEncodedRing(ctx, ring, 1, 1, func([]byte) error { return nil })
if err == nil {
t.Fatal("invalid encoded ring record was accepted")
}
}
func TestPumpAudioRingEncodesRDA1AndDiscontinuity(t *testing.T) {
ring := &testEncodedRing{records: []*EncodedRingRecord{{
Sequence: 7,
PTS: 400_000,
Kind: 2,
Payload: []byte("opus"),
}}}
ctx, cancel := context.WithCancel(context.Background())
var datagram []byte
err := pumpAudioRing(ctx, ring, 11, 12, func(data []byte) error {
datagram = append([]byte(nil), data...)
cancel()
return nil
})
if err != context.Canceled {
t.Fatalf("pump returned %v, want context cancellation", err)
}
packet, err := decodeAudioDatagram(datagram)
if err != nil {
t.Fatal(err)
}
if packet.StreamID != 11 || packet.Generation != 12 || packet.Sequence != 7 ||
packet.PTS != 400_000 || packet.DurationMS != 20 ||
packet.Flags&audioFlagDiscontinuity == 0 || !bytes.Equal(packet.Payload, []byte("opus")) {
t.Fatalf("audio ring metadata was not preserved: %+v", packet)
}
}
func TestPumpAudioRingRejectsInvalidRecords(t *testing.T) {
tests := []struct {
name string
record *EncodedRingRecord
}{
{name: "zero sequence", record: &EncodedRingRecord{Payload: []byte{1}}},
{name: "empty payload", record: &EncodedRingRecord{Sequence: 1}},
{
name: "oversized payload",
record: &EncodedRingRecord{
Sequence: 1,
Payload: make([]byte, audioPacketMaxBytes+1),
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ring := &testEncodedRing{records: []*EncodedRingRecord{test.record}}
err := pumpAudioRing(
context.Background(),
ring,
1,
1,
func([]byte) error { return nil },
)
if err == nil {
t.Fatal("invalid audio ring record was accepted")
}
})
}
}
@@ -1,254 +0,0 @@
package main
import (
"bufio"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"
)
const (
agentControlLineMax = 16 * 1024
agentPipeTokenEnv = "REMOTEDESK_AGENT_PIPE_TOKEN"
agentPipeAuthDomain = "RemoteDesk Windows Agent pipe auth v1\x00"
)
type mediaRingDescriptor struct {
SchemaVersion uint16 `json:"schema_version"`
SessionID string `json:"session_id"`
Generation uint64 `json:"generation"`
OwnerPID uint32 `json:"owner_pid"`
RingPath string `json:"ring_path"`
SlotCount uint32 `json:"slot_count"`
SlotBytes uint32 `json:"slot_bytes"`
VideoTransport string `json:"video_transport"`
AudioTransport string `json:"audio_transport"`
AudioRing *audioRingDescriptor `json:"audio_ring,omitempty"`
}
type audioRingDescriptor struct {
SchemaVersion uint16 `json:"schema_version"`
SessionID string `json:"session_id"`
Generation uint64 `json:"generation"`
OwnerPID uint32 `json:"owner_pid"`
RingPath string `json:"ring_path"`
SlotCount uint32 `json:"slot_count"`
SlotBytes uint32 `json:"slot_bytes"`
AudioTransport string `json:"audio_transport"`
}
type encodedDesktopOpened struct {
Kind string `json:"kind"`
Error string `json:"error"`
VideoCodec string `json:"video_codec"`
MediaTransport string `json:"media_transport"`
Ring mediaRingDescriptor `json:"ring"`
}
type agentHello struct {
Kind string `json:"kind"`
Authenticated bool `json:"authenticated"`
}
type agentAuthChallenge struct {
Kind string `json:"kind"`
ProtocolVersion uint8 `json:"protocol_version"`
Nonce string `json:"nonce"`
}
func openWindowsEncodedSession(address, sessionID string, fps uint8, allowSoftwareFallback bool) (net.Conn, mediaRingDescriptor, error) {
if err := validateAgentControlAddress(address); err != nil {
return nil, mediaRingDescriptor{}, err
}
if !validSessionID(sessionID) || fps == 0 || fps > 120 {
return nil, mediaRingDescriptor{}, errors.New("Windows Agent encoded session parameters are invalid")
}
isPipe := isWindowsAgentPipe(address)
connection, err := dialWindowsAgentControl(address, 2*time.Second)
if err != nil {
return nil, mediaRingDescriptor{}, fmt.Errorf("connect Windows Agent control: %w", err)
}
closeOnError := true
defer func() {
if closeOnError {
_ = connection.Close()
}
}()
_ = connection.SetDeadline(time.Now().Add(10 * time.Second))
reader := bufio.NewReaderSize(connection, agentControlLineMax+1)
if isPipe {
if err := authenticateWindowsAgentPipe(reader, connection); err != nil {
return nil, mediaRingDescriptor{}, err
}
}
var hello agentHello
if err := readBoundedAgentJSON(reader, &hello); err != nil || hello.Kind != "windows_agent_hello" || (isPipe && !hello.Authenticated) {
return nil, mediaRingDescriptor{}, errors.New("Windows Agent control hello is invalid")
}
request := struct {
Kind string `json:"kind"`
SessionID string `json:"session_id"`
CaptureMode string `json:"capture_mode"`
VideoCodec string `json:"video_codec"`
FramesPerSecond uint8 `json:"frames_per_second"`
AllowSoftwareFallback bool `json:"allow_software_fallback"`
}{
Kind: "open_encoded_desktop", SessionID: sessionID,
CaptureMode: "compatibility", VideoCodec: "h264", FramesPerSecond: fps,
AllowSoftwareFallback: allowSoftwareFallback,
}
encoded, err := json.Marshal(request)
if err != nil {
return nil, mediaRingDescriptor{}, err
}
encoded = append(encoded, '\n')
if _, err := connection.Write(encoded); err != nil {
return nil, mediaRingDescriptor{}, fmt.Errorf("open Windows encoded desktop: %w", err)
}
var opened encodedDesktopOpened
if err := readBoundedAgentJSON(reader, &opened); err != nil {
return nil, mediaRingDescriptor{}, err
}
if opened.Kind != "encoded_desktop_opened" || opened.VideoCodec != "h264" ||
opened.MediaTransport != "shared_memory_encoded_access_units" {
return nil, mediaRingDescriptor{}, fmt.Errorf("Windows encoded desktop unavailable: %s", opened.Error)
}
if err := validateMediaRingDescriptor(opened.Ring, sessionID); err != nil {
return nil, mediaRingDescriptor{}, err
}
_ = connection.SetDeadline(time.Time{})
closeOnError = false
return connection, opened.Ring, nil
}
func dialWindowsAgentControl(address string, timeout time.Duration) (net.Conn, error) {
if isWindowsAgentPipe(address) {
return dialWindowsAgentPipe(address, timeout)
}
return net.DialTimeout("tcp", address, timeout)
}
func authenticateWindowsAgentPipe(reader *bufio.Reader, connection net.Conn) error {
var challenge agentAuthChallenge
if err := readBoundedAgentJSON(reader, &challenge); err != nil {
return err
}
if challenge.Kind != "agent_auth_challenge" || challenge.ProtocolVersion != 1 {
return errors.New("Windows Agent Pipe challenge is invalid")
}
nonce, err := base64.RawURLEncoding.DecodeString(challenge.Nonce)
if err != nil || len(nonce) != 32 {
return errors.New("Windows Agent Pipe nonce is invalid")
}
encodedToken, ok := os.LookupEnv(agentPipeTokenEnv)
if !ok {
return fmt.Errorf("%s is required for authenticated Windows Agent Pipe", agentPipeTokenEnv)
}
_ = os.Unsetenv(agentPipeTokenEnv)
key, err := base64.RawURLEncoding.DecodeString(encodedToken)
if err != nil || len(key) != 32 {
return errors.New("Windows Agent Pipe token must be 32-byte base64url")
}
mac := windowsAgentPipeMAC(key, nonce)
request := struct {
Kind string `json:"kind"`
ProtocolVersion uint8 `json:"protocol_version"`
MAC string `json:"mac"`
}{
Kind: "agent_auth",
ProtocolVersion: 1,
MAC: base64.RawURLEncoding.EncodeToString(mac),
}
encoded, err := json.Marshal(request)
if err != nil {
return err
}
encoded = append(encoded, '\n')
if _, err := connection.Write(encoded); err != nil {
return fmt.Errorf("authenticate Windows Agent Pipe: %w", err)
}
return nil
}
func windowsAgentPipeMAC(key, nonce []byte) []byte {
mac := hmac.New(sha256.New, key)
_, _ = mac.Write([]byte(agentPipeAuthDomain))
_, _ = mac.Write(nonce)
return mac.Sum(nil)
}
func readBoundedAgentJSON(reader *bufio.Reader, output any) error {
line, err := reader.ReadSlice('\n')
if err != nil {
return fmt.Errorf("read Windows Agent control: %w", err)
}
if len(line) == 0 || len(line) > agentControlLineMax || line[len(line)-1] != '\n' {
return errors.New("Windows Agent control response exceeds its bound")
}
if err := json.Unmarshal(line, output); err != nil {
return errors.New("Windows Agent control response is invalid JSON")
}
return nil
}
func validateAgentControlAddress(address string) error {
if isWindowsAgentPipe(address) {
if len(address) > 256 || !strings.HasPrefix(strings.ToLower(address), `\\.\pipe\remotedesk\`) || strings.Contains(address, "..") {
return errors.New("Windows Agent Pipe name is outside the RemoteDesk namespace")
}
return nil
}
host, port, err := net.SplitHostPort(address)
if err != nil || port == "" {
return errors.New("Windows Agent control must be an IP socket address")
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return errors.New("Windows Agent control must use a loopback IP")
}
return nil
}
func isWindowsAgentPipe(address string) bool {
return strings.HasPrefix(strings.ToLower(address), `\\.\pipe\`)
}
func validateMediaRingDescriptor(descriptor mediaRingDescriptor, sessionID string) error {
if descriptor.SchemaVersion != 1 || descriptor.SessionID != sessionID || descriptor.Generation == 0 || descriptor.OwnerPID == 0 ||
descriptor.VideoTransport != "shared_memory_encoded_access_units" || descriptor.AudioTransport != "separate_transport" ||
!filepath.IsAbs(descriptor.RingPath) || descriptor.SlotCount < 2 || descriptor.SlotCount > maxRingSlots ||
descriptor.SlotCount&(descriptor.SlotCount-1) != 0 || descriptor.SlotBytes == 0 || descriptor.SlotBytes > maxRingSlotBytes {
return errors.New("Windows Agent media ring descriptor is invalid")
}
if descriptor.AudioRing != nil {
audio := descriptor.AudioRing
if audio.SchemaVersion != 1 || audio.SessionID != sessionID || audio.Generation != descriptor.Generation ||
audio.OwnerPID != descriptor.OwnerPID || audio.AudioTransport != "shared_memory_opus_packets" ||
!filepath.IsAbs(audio.RingPath) || audio.SlotCount < 2 || audio.SlotCount > maxRingSlots ||
audio.SlotCount&(audio.SlotCount-1) != 0 || audio.SlotBytes == 0 || audio.SlotBytes > maxRingSlotBytes {
return errors.New("Windows Agent audio ring descriptor is invalid")
}
}
return nil
}
func validSessionID(value string) bool {
if len(value) == 0 || len(value) > 128 {
return false
}
for _, character := range []byte(value) {
if !((character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '-' || character == '_') {
return false
}
}
return true
}
@@ -1,111 +0,0 @@
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"net"
"path/filepath"
"testing"
)
func TestOpenWindowsEncodedSessionBindsDescriptorToRequest(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
serverError := make(chan error, 1)
go func() {
connection, err := listener.Accept()
if err != nil {
serverError <- err
return
}
defer connection.Close()
if _, err := connection.Write([]byte("{\"kind\":\"windows_agent_hello\"}\n")); err != nil {
serverError <- err
return
}
var request struct {
Kind string `json:"kind"`
SessionID string `json:"session_id"`
FramesPerSecond uint8 `json:"frames_per_second"`
AllowSoftwareFallback bool `json:"allow_software_fallback"`
}
if err := readBoundedAgentJSON(bufio.NewReader(connection), &request); err != nil {
serverError <- err
return
}
if request.Kind != "open_encoded_desktop" || request.SessionID != "session-1" || request.FramesPerSecond != 60 || !request.AllowSoftwareFallback {
serverError <- &testControlError{"encoded desktop request mismatch"}
return
}
response := encodedDesktopOpened{
Kind: "encoded_desktop_opened", VideoCodec: "h264",
MediaTransport: "shared_memory_encoded_access_units",
Ring: mediaRingDescriptor{
SchemaVersion: 1, SessionID: "session-1", Generation: 7, OwnerPID: 42,
RingPath: filepath.Join(t.TempDir(), "media.ring"), SlotCount: 16,
SlotBytes: 8 * 1024 * 1024, VideoTransport: "shared_memory_encoded_access_units",
AudioTransport: "separate_transport",
},
}
encoded, err := json.Marshal(response)
if err == nil {
encoded = append(encoded, '\n')
_, err = connection.Write(encoded)
}
serverError <- err
}()
connection, descriptor, err := openWindowsEncodedSession(listener.Addr().String(), "session-1", 60, true)
if err != nil {
t.Fatal(err)
}
defer connection.Close()
if descriptor.Generation != 7 || descriptor.SessionID != "session-1" {
t.Fatalf("unexpected descriptor: %+v", descriptor)
}
if err := <-serverError; err != nil {
t.Fatal(err)
}
}
func TestWindowsAgentControlRejectsRemoteAndMismatchedDescriptor(t *testing.T) {
if validateAgentControlAddress("192.0.2.10:39501") == nil {
t.Fatal("remote Windows Agent control address was accepted")
}
descriptor := mediaRingDescriptor{
SchemaVersion: 1, SessionID: "other", Generation: 1, OwnerPID: 1,
RingPath: filepath.Join(t.TempDir(), "media.ring"), SlotCount: 16,
SlotBytes: 1024, VideoTransport: "shared_memory_encoded_access_units",
AudioTransport: "separate_transport",
}
if validateMediaRingDescriptor(descriptor, "session-1") == nil {
t.Fatal("descriptor for another session was accepted")
}
}
func TestWindowsAgentPipeAuthenticationMatchesRustWireVector(t *testing.T) {
key := make([]byte, 32)
nonce := make([]byte, 32)
for index := range key {
key[index] = byte(index)
nonce[index] = byte(index + 32)
}
actual := base64.RawURLEncoding.EncodeToString(windowsAgentPipeMAC(key, nonce))
const expected = "KinBDOBvVhz0eRv37iahq8BO0z8QdiI0Qnx7HZmcxIk"
if actual != expected {
t.Fatalf("unexpected Agent Pipe MAC: %s", actual)
}
if validateAgentControlAddress(`\\.\pipe\RemoteDesk\windows-agent`) != nil {
t.Fatal("RemoteDesk Agent Pipe was rejected")
}
if validateAgentControlAddress(`\\.\pipe\Other\windows-agent`) == nil {
t.Fatal("Pipe outside the RemoteDesk namespace was accepted")
}
}
type testControlError struct{ message string }
func (e *testControlError) Error() string { return e.message }
@@ -1,13 +0,0 @@
//go:build !windows
package main
import (
"errors"
"net"
"time"
)
func dialWindowsAgentPipe(_ string, _ time.Duration) (net.Conn, error) {
return nil, errors.New("Windows Agent Named Pipe requires Windows")
}
@@ -1,17 +0,0 @@
//go:build windows
package main
import (
"context"
"net"
"time"
"github.com/Microsoft/go-winio"
)
func dialWindowsAgentPipe(name string, timeout time.Duration) (net.Conn, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return winio.DialPipeContext(ctx, name)
}