ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / package-preview (push) Canceled after 0s
ci / package-installer (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
1215 lines
39 KiB
Rust
1215 lines
39 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fmt;
|
|
|
|
use remotedesk_protocol::{
|
|
DisplayDescriptor, DisplayId, DisplayLayout, DisplaySelection, DisplaySelectionError,
|
|
MemoryPathStatus,
|
|
};
|
|
|
|
use super::{
|
|
AdapterId, CaptureMemoryPath, ConfigurationError, DesktopBackend, ImportProbeResult,
|
|
PipelineState, Resolution, TransferMode, ZeroCopyPolicy, validate_capture_for_backend,
|
|
};
|
|
|
|
/// Capture information that is local to the Linux Agent and not sent as
|
|
/// display-topology metadata.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct DisplayCaptureSource {
|
|
pub id: DisplayId,
|
|
pub name: String,
|
|
pub capture_path: CaptureMemoryPath,
|
|
}
|
|
|
|
impl DisplayCaptureSource {
|
|
/// Associates a protocol display identity with an observed capture path.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`MultiDisplayPlanError::EmptyDisplayName`] for an empty name.
|
|
pub fn new(
|
|
id: DisplayId,
|
|
name: impl Into<String>,
|
|
capture_path: CaptureMemoryPath,
|
|
) -> Result<Self, MultiDisplayPlanError> {
|
|
let name = name.into();
|
|
if name.trim().is_empty() {
|
|
return Err(MultiDisplayPlanError::EmptyDisplayName { id });
|
|
}
|
|
Ok(Self {
|
|
id,
|
|
name,
|
|
capture_path,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The union of all selected display rectangles.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct DesktopBounds {
|
|
pub x: i32,
|
|
pub y: i32,
|
|
pub width: u32,
|
|
pub height: u32,
|
|
}
|
|
|
|
/// A point normalized to the top-left of [`DesktopBounds`].
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct NormalizedPoint {
|
|
pub x: u32,
|
|
pub y: u32,
|
|
}
|
|
|
|
impl DesktopBounds {
|
|
/// Converts a native desktop point to non-negative stream coordinates.
|
|
#[must_use]
|
|
pub fn normalize(self, x: i32, y: i32) -> Option<NormalizedPoint> {
|
|
let normalized_x = i64::from(x) - i64::from(self.x);
|
|
let normalized_y = i64::from(y) - i64::from(self.y);
|
|
if normalized_x < 0
|
|
|| normalized_y < 0
|
|
|| normalized_x >= i64::from(self.width)
|
|
|| normalized_y >= i64::from(self.height)
|
|
{
|
|
return None;
|
|
}
|
|
Some(NormalizedPoint {
|
|
x: u32::try_from(normalized_x).ok()?,
|
|
y: u32::try_from(normalized_y).ok()?,
|
|
})
|
|
}
|
|
|
|
/// Converts a stream point back to native Linux desktop coordinates.
|
|
#[must_use]
|
|
pub fn denormalize(self, point: NormalizedPoint) -> Option<(i32, i32)> {
|
|
if point.x >= self.width || point.y >= self.height {
|
|
return None;
|
|
}
|
|
let x = i64::from(self.x) + i64::from(point.x);
|
|
let y = i64::from(self.y) + i64::from(point.y);
|
|
Some((i32::try_from(x).ok()?, i32::try_from(y).ok()?))
|
|
}
|
|
}
|
|
|
|
/// One selected protocol descriptor paired with its Agent capture source.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct SelectedDisplaySource {
|
|
pub descriptor: DisplayDescriptor,
|
|
pub capture: DisplayCaptureSource,
|
|
}
|
|
|
|
/// A protocol-generation-bound selection with computed stream coordinates.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct SelectedDisplayLayout {
|
|
generation: u64,
|
|
sources: Vec<SelectedDisplaySource>,
|
|
bounds: DesktopBounds,
|
|
}
|
|
|
|
impl SelectedDisplayLayout {
|
|
/// Resolves Agent capture sources against a validated protocol selection.
|
|
///
|
|
/// The protocol layout owns display identity, geometry, scale, primary
|
|
/// status, selection ordering, and stale-generation rejection. This layer
|
|
/// only joins those descriptors to locally observed capture memory paths.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Rejects invalid protocol selections, duplicate local source IDs,
|
|
/// missing selected sources, and unrepresentable combined desktop bounds.
|
|
pub fn resolve(
|
|
topology: &DisplayLayout,
|
|
capture_sources: &[DisplayCaptureSource],
|
|
selection: &DisplaySelection,
|
|
) -> Result<Self, MultiDisplayPlanError> {
|
|
let descriptors = topology
|
|
.selected_displays(selection)
|
|
.map_err(MultiDisplayPlanError::Selection)?;
|
|
let mut capture_by_id = BTreeMap::new();
|
|
for source in capture_sources {
|
|
if capture_by_id.insert(source.id, source).is_some() {
|
|
return Err(MultiDisplayPlanError::DuplicateCaptureSource { id: source.id });
|
|
}
|
|
}
|
|
let sources = descriptors
|
|
.into_iter()
|
|
.map(|descriptor| {
|
|
let capture = capture_by_id
|
|
.get(&descriptor.id)
|
|
.copied()
|
|
.ok_or(MultiDisplayPlanError::MissingCaptureSource { id: descriptor.id })?;
|
|
Ok(SelectedDisplaySource {
|
|
descriptor: descriptor.clone(),
|
|
capture: capture.clone(),
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>, MultiDisplayPlanError>>()?;
|
|
let bounds = desktop_bounds(&sources)?;
|
|
Ok(Self {
|
|
generation: topology.generation(),
|
|
sources,
|
|
bounds,
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn generation(&self) -> u64 {
|
|
self.generation
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn sources(&self) -> &[SelectedDisplaySource] {
|
|
&self.sources
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn bounds(&self) -> DesktopBounds {
|
|
self.bounds
|
|
}
|
|
|
|
/// Returns the selected display's origin relative to the stream desktop.
|
|
#[must_use]
|
|
pub fn normalized_origin(&self, id: DisplayId) -> Option<NormalizedPoint> {
|
|
let source = self
|
|
.sources
|
|
.iter()
|
|
.find(|source| source.descriptor.id == id)?;
|
|
self.bounds
|
|
.normalize(source.descriptor.rect.x, source.descriptor.rect.y)
|
|
}
|
|
}
|
|
|
|
fn desktop_bounds(
|
|
sources: &[SelectedDisplaySource],
|
|
) -> Result<DesktopBounds, MultiDisplayPlanError> {
|
|
let left = sources
|
|
.iter()
|
|
.map(|source| source.descriptor.rect.x)
|
|
.min()
|
|
.ok_or(MultiDisplayPlanError::NoSelectedDisplays)?;
|
|
let top = sources
|
|
.iter()
|
|
.map(|source| source.descriptor.rect.y)
|
|
.min()
|
|
.ok_or(MultiDisplayPlanError::NoSelectedDisplays)?;
|
|
let right = sources
|
|
.iter()
|
|
.filter_map(|source| source.descriptor.rect.right())
|
|
.max()
|
|
.ok_or(MultiDisplayPlanError::DesktopBoundsOverflow)?;
|
|
let bottom = sources
|
|
.iter()
|
|
.filter_map(|source| source.descriptor.rect.bottom())
|
|
.max()
|
|
.ok_or(MultiDisplayPlanError::DesktopBoundsOverflow)?;
|
|
let width = u32::try_from(i64::from(right) - i64::from(left))
|
|
.map_err(|_| MultiDisplayPlanError::DesktopBoundsOverflow)?;
|
|
let height = u32::try_from(i64::from(bottom) - i64::from(top))
|
|
.map_err(|_| MultiDisplayPlanError::DesktopBoundsOverflow)?;
|
|
Ok(DesktopBounds {
|
|
x: left,
|
|
y: top,
|
|
width,
|
|
height,
|
|
})
|
|
}
|
|
|
|
/// Import result for one display on one target encoder.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct DisplayImportProbe {
|
|
pub display_id: DisplayId,
|
|
pub result: ImportProbeResult,
|
|
}
|
|
|
|
/// One target encoder with independent import evidence for every display.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct MultiDisplayGpuCandidate {
|
|
adapter: AdapterId,
|
|
encoder_name: String,
|
|
source_probes: Vec<DisplayImportProbe>,
|
|
}
|
|
|
|
impl MultiDisplayGpuCandidate {
|
|
/// Creates a target encoder from per-display import probes.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Uses the same encoder-name validation as [`super::GpuCandidate`] and
|
|
/// rejects duplicate probe IDs because their evidence would be ambiguous.
|
|
pub fn new(
|
|
adapter: AdapterId,
|
|
encoder_name: impl Into<String>,
|
|
source_probes: Vec<DisplayImportProbe>,
|
|
) -> Result<Self, MultiDisplayPlanError> {
|
|
let encoder_name = encoder_name.into();
|
|
if encoder_name.trim().is_empty() {
|
|
return Err(MultiDisplayPlanError::Configuration(
|
|
ConfigurationError::EmptyEncoderName,
|
|
));
|
|
}
|
|
let mut ids = BTreeSet::new();
|
|
for probe in &source_probes {
|
|
if !ids.insert(probe.display_id) {
|
|
return Err(MultiDisplayPlanError::DuplicateImportProbe {
|
|
id: probe.display_id,
|
|
});
|
|
}
|
|
}
|
|
Ok(Self {
|
|
adapter,
|
|
encoder_name,
|
|
source_probes,
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn adapter(&self) -> &AdapterId {
|
|
&self.adapter
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn encoder_name(&self) -> &str {
|
|
&self.encoder_name
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn source_probes(&self) -> &[DisplayImportProbe] {
|
|
&self.source_probes
|
|
}
|
|
}
|
|
|
|
/// Capture-to-encode behavior selected for one display.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct DisplayPathPlan {
|
|
pub display_id: DisplayId,
|
|
pub capture_status: MemoryPathStatus,
|
|
pub transfer_mode: TransferMode,
|
|
}
|
|
|
|
impl DisplayPathPlan {
|
|
#[must_use]
|
|
pub const fn is_strict_zero_copy(&self) -> bool {
|
|
self.capture_status.satisfies_strict_zero_copy()
|
|
&& self.transfer_mode.is_verified_zero_copy()
|
|
}
|
|
}
|
|
|
|
/// Whether every selected display is strict or compatibility fallback is used.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum MultiDisplayCompliance {
|
|
StrictZeroCopy,
|
|
CompatibilityDegraded,
|
|
}
|
|
|
|
/// A plan that uses one encoder target for every selected display.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct MultiDisplayCapturePlan {
|
|
pub encoder_adapter: AdapterId,
|
|
pub encoder_name: String,
|
|
pub layout: SelectedDisplayLayout,
|
|
pub paths: Vec<DisplayPathPlan>,
|
|
pub compliance: MultiDisplayCompliance,
|
|
}
|
|
|
|
impl MultiDisplayCapturePlan {
|
|
/// Selects a single encoder that can import all selected display sources.
|
|
///
|
|
/// Strict policy rejects CPU, opaque, and cross-adapter paths. Compatibility
|
|
/// mode chooses the candidate with the least severe worst path and reports
|
|
/// every display's actual fallback in [`Self::paths`].
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`MultiDisplayPlanError`] when protocol selection, source/path
|
|
/// compatibility, strict policy, or common encoder requirements fail.
|
|
pub fn create(
|
|
backend: DesktopBackend,
|
|
policy: ZeroCopyPolicy,
|
|
topology: &DisplayLayout,
|
|
capture_sources: &[DisplayCaptureSource],
|
|
selection: &DisplaySelection,
|
|
candidates: &[MultiDisplayGpuCandidate],
|
|
) -> Result<Self, MultiDisplayPlanError> {
|
|
if !backend.carries_video() {
|
|
return Err(MultiDisplayPlanError::TerminalHasNoVideoPipeline);
|
|
}
|
|
let layout = SelectedDisplayLayout::resolve(topology, capture_sources, selection)?;
|
|
for selected in layout.sources() {
|
|
let source = &selected.capture;
|
|
validate_capture_for_backend(backend, &source.capture_path).map_err(|_| {
|
|
MultiDisplayPlanError::BackendCaptureMismatch {
|
|
display_id: source.id,
|
|
backend,
|
|
}
|
|
})?;
|
|
if policy == ZeroCopyPolicy::RequiredEndToEnd
|
|
&& !source
|
|
.capture_path
|
|
.protocol_status()
|
|
.satisfies_strict_zero_copy()
|
|
{
|
|
return Err(MultiDisplayPlanError::StrictSourceRejected {
|
|
display_id: source.id,
|
|
status: source.capture_path.protocol_status(),
|
|
});
|
|
}
|
|
}
|
|
|
|
let mut plans = candidates
|
|
.iter()
|
|
.filter_map(|candidate| plan_candidate(&layout, candidate))
|
|
.filter(|(_, paths)| {
|
|
policy != ZeroCopyPolicy::RequiredEndToEnd
|
|
|| paths.iter().all(DisplayPathPlan::is_strict_zero_copy)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
plans.sort_by_key(|(_, paths)| paths.iter().map(path_severity).max().unwrap_or(u8::MAX));
|
|
|
|
let Some((candidate, paths)) = plans.into_iter().next() else {
|
|
return Err(if policy == ZeroCopyPolicy::RequiredEndToEnd {
|
|
MultiDisplayPlanError::NoVerifiedSingleAdapterEncoder
|
|
} else {
|
|
MultiDisplayPlanError::NoCommonEncoder
|
|
});
|
|
};
|
|
let compliance = if paths.iter().all(DisplayPathPlan::is_strict_zero_copy) {
|
|
MultiDisplayCompliance::StrictZeroCopy
|
|
} else {
|
|
MultiDisplayCompliance::CompatibilityDegraded
|
|
};
|
|
Ok(Self {
|
|
encoder_adapter: candidate.adapter.clone(),
|
|
encoder_name: candidate.encoder_name.clone(),
|
|
layout,
|
|
paths,
|
|
compliance,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn plan_candidate<'a>(
|
|
layout: &SelectedDisplayLayout,
|
|
candidate: &'a MultiDisplayGpuCandidate,
|
|
) -> Option<(&'a MultiDisplayGpuCandidate, Vec<DisplayPathPlan>)> {
|
|
let probes = candidate
|
|
.source_probes
|
|
.iter()
|
|
.map(|probe| (probe.display_id, &probe.result))
|
|
.collect::<BTreeMap<_, _>>();
|
|
let paths = layout
|
|
.sources()
|
|
.iter()
|
|
.map(|selected| {
|
|
let source = &selected.capture;
|
|
let result = probes.get(&source.id)?;
|
|
let transfer_mode = transfer_mode_for_probe(source, candidate, result)?;
|
|
Some(DisplayPathPlan {
|
|
display_id: source.id,
|
|
capture_status: source.capture_path.protocol_status(),
|
|
transfer_mode,
|
|
})
|
|
})
|
|
.collect::<Option<Vec<_>>>()?;
|
|
Some((candidate, paths))
|
|
}
|
|
|
|
fn transfer_mode_for_probe(
|
|
source: &DisplayCaptureSource,
|
|
candidate: &MultiDisplayGpuCandidate,
|
|
result: &ImportProbeResult,
|
|
) -> Option<TransferMode> {
|
|
match result {
|
|
ImportProbeResult::SameAdapterZeroCopy
|
|
if matches!(
|
|
&source.capture_path,
|
|
CaptureMemoryPath::DmaBuf { adapter } if *adapter == candidate.adapter
|
|
) =>
|
|
{
|
|
Some(TransferMode::SameAdapterZeroCopy)
|
|
}
|
|
ImportProbeResult::CrossAdapterCopy { source_adapter }
|
|
if source.capture_path.adapter() == Some(source_adapter)
|
|
&& candidate.adapter != *source_adapter =>
|
|
{
|
|
Some(TransferMode::CrossAdapterCopy)
|
|
}
|
|
ImportProbeResult::CpuUpload => Some(TransferMode::CpuUpload),
|
|
ImportProbeResult::Software => Some(TransferMode::Software),
|
|
ImportProbeResult::OpaqueGpuPath => Some(TransferMode::OpaqueGpuPath),
|
|
ImportProbeResult::SameAdapterZeroCopy
|
|
| ImportProbeResult::CrossAdapterCopy { .. }
|
|
| ImportProbeResult::Unsupported { .. } => None,
|
|
}
|
|
}
|
|
|
|
const fn path_severity(path: &DisplayPathPlan) -> u8 {
|
|
let transfer = match path.transfer_mode {
|
|
TransferMode::SameAdapterZeroCopy => 0,
|
|
TransferMode::CrossAdapterCopy => 1,
|
|
TransferMode::CpuUpload => 2,
|
|
TransferMode::OpaqueGpuPath => 3,
|
|
TransferMode::Software => 4,
|
|
};
|
|
if path.capture_status.satisfies_strict_zero_copy() {
|
|
transfer
|
|
} else {
|
|
4
|
|
}
|
|
}
|
|
|
|
/// Revalidation state for a multi-display stream.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct MultiDisplayPipeline {
|
|
policy: ZeroCopyPolicy,
|
|
resolution: Resolution,
|
|
layout: SelectedDisplayLayout,
|
|
epoch: u64,
|
|
state: PipelineState,
|
|
plan: Option<MultiDisplayCapturePlan>,
|
|
}
|
|
|
|
impl MultiDisplayPipeline {
|
|
/// Creates an active pipeline from a policy-compliant plan.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Rejects a compatibility-degraded plan under strict policy.
|
|
pub fn active(
|
|
policy: ZeroCopyPolicy,
|
|
resolution: Resolution,
|
|
plan: MultiDisplayCapturePlan,
|
|
) -> Result<Self, MultiDisplayPipelineError> {
|
|
validate_plan_for_policy(policy, &plan)?;
|
|
Ok(Self {
|
|
policy,
|
|
resolution,
|
|
layout: plan.layout.clone(),
|
|
epoch: 0,
|
|
state: PipelineState::Active,
|
|
plan: Some(plan),
|
|
})
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn epoch(&self) -> u64 {
|
|
self.epoch
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn state(&self) -> PipelineState {
|
|
self.state
|
|
}
|
|
|
|
#[must_use]
|
|
pub const fn resolution(&self) -> Resolution {
|
|
self.resolution
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn layout(&self) -> &SelectedDisplayLayout {
|
|
&self.layout
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn plan(&self) -> Option<&MultiDisplayCapturePlan> {
|
|
self.plan.as_ref()
|
|
}
|
|
|
|
/// Applies output resolution or selected-layout changes. Any real change
|
|
/// invalidates all per-display import evidence and increments the epoch.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns [`MultiDisplayPipelineError::GenerationOverflow`] instead of
|
|
/// applying a change that cannot receive a fresh epoch.
|
|
pub fn reconfigure(
|
|
&mut self,
|
|
resolution: Resolution,
|
|
layout: SelectedDisplayLayout,
|
|
) -> Result<bool, MultiDisplayPipelineError> {
|
|
if self.resolution == resolution && self.layout == layout {
|
|
return Ok(false);
|
|
}
|
|
let next_epoch = self
|
|
.epoch
|
|
.checked_add(1)
|
|
.ok_or(MultiDisplayPipelineError::GenerationOverflow)?;
|
|
self.resolution = resolution;
|
|
self.layout = layout;
|
|
self.epoch = next_epoch;
|
|
self.state = PipelineState::Revalidating;
|
|
self.plan = None;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Activates a freshly probed plan for the current epoch.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Rejects invalid state, stale layout evidence, and a plan that violates
|
|
/// the pipeline's zero-copy policy.
|
|
pub fn finish_revalidation(
|
|
&mut self,
|
|
plan: MultiDisplayCapturePlan,
|
|
) -> Result<(), MultiDisplayPipelineError> {
|
|
if self.state != PipelineState::Revalidating {
|
|
return Err(MultiDisplayPipelineError::InvalidState {
|
|
expected: PipelineState::Revalidating,
|
|
actual: self.state,
|
|
});
|
|
}
|
|
if self.layout != plan.layout {
|
|
return Err(MultiDisplayPipelineError::StaleLayout);
|
|
}
|
|
validate_plan_for_policy(self.policy, &plan)?;
|
|
self.plan = Some(plan);
|
|
self.state = PipelineState::Active;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn validate_plan_for_policy(
|
|
policy: ZeroCopyPolicy,
|
|
plan: &MultiDisplayCapturePlan,
|
|
) -> Result<(), MultiDisplayPipelineError> {
|
|
if policy == ZeroCopyPolicy::RequiredEndToEnd
|
|
&& plan.compliance != MultiDisplayCompliance::StrictZeroCopy
|
|
{
|
|
return Err(MultiDisplayPipelineError::PlanRejectedByPolicy);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum MultiDisplayPlanError {
|
|
EmptyDisplayName {
|
|
id: DisplayId,
|
|
},
|
|
DesktopBoundsOverflow,
|
|
NoSelectedDisplays,
|
|
DuplicateCaptureSource {
|
|
id: DisplayId,
|
|
},
|
|
MissingCaptureSource {
|
|
id: DisplayId,
|
|
},
|
|
DuplicateImportProbe {
|
|
id: DisplayId,
|
|
},
|
|
Selection(DisplaySelectionError),
|
|
Configuration(ConfigurationError),
|
|
TerminalHasNoVideoPipeline,
|
|
BackendCaptureMismatch {
|
|
display_id: DisplayId,
|
|
backend: DesktopBackend,
|
|
},
|
|
StrictSourceRejected {
|
|
display_id: DisplayId,
|
|
status: MemoryPathStatus,
|
|
},
|
|
NoVerifiedSingleAdapterEncoder,
|
|
NoCommonEncoder,
|
|
}
|
|
|
|
impl fmt::Display for MultiDisplayPlanError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::EmptyDisplayName { id } => {
|
|
write!(formatter, "display {id:?} name must not be empty")
|
|
}
|
|
Self::DesktopBoundsOverflow => {
|
|
formatter.write_str("combined desktop bounds exceed supported dimensions")
|
|
}
|
|
Self::NoSelectedDisplays => formatter.write_str("no displays were selected"),
|
|
Self::DuplicateCaptureSource { id } => {
|
|
write!(formatter, "capture source id {id:?} is duplicated")
|
|
}
|
|
Self::MissingCaptureSource { id } => {
|
|
write!(formatter, "selected display {id:?} has no capture source")
|
|
}
|
|
Self::DuplicateImportProbe { id } => {
|
|
write!(
|
|
formatter,
|
|
"encoder has duplicate import probe for display {id:?}"
|
|
)
|
|
}
|
|
Self::Selection(error) => error.fmt(formatter),
|
|
Self::Configuration(error) => error.fmt(formatter),
|
|
Self::TerminalHasNoVideoPipeline => {
|
|
formatter.write_str("terminal sessions do not use a video pipeline")
|
|
}
|
|
Self::BackendCaptureMismatch {
|
|
display_id,
|
|
backend,
|
|
} => write!(
|
|
formatter,
|
|
"display {display_id:?} capture path does not match backend {backend:?}"
|
|
),
|
|
Self::StrictSourceRejected { display_id, status } => write!(
|
|
formatter,
|
|
"strict policy rejected display {display_id:?} capture status {status:?}"
|
|
),
|
|
Self::NoVerifiedSingleAdapterEncoder => formatter.write_str(
|
|
"no single encoder adapter has verified zero-copy imports for every display",
|
|
),
|
|
Self::NoCommonEncoder => {
|
|
formatter.write_str("no single encoder can import every selected display")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for MultiDisplayPlanError {}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum MultiDisplayPipelineError {
|
|
InvalidState {
|
|
expected: PipelineState,
|
|
actual: PipelineState,
|
|
},
|
|
StaleLayout,
|
|
PlanRejectedByPolicy,
|
|
GenerationOverflow,
|
|
}
|
|
|
|
impl fmt::Display for MultiDisplayPipelineError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
Self::InvalidState { expected, actual } => write!(
|
|
formatter,
|
|
"pipeline transition requires {expected:?}, but current state is {actual:?}"
|
|
),
|
|
Self::StaleLayout => {
|
|
formatter.write_str("capture plan belongs to a stale display layout")
|
|
}
|
|
Self::PlanRejectedByPolicy => {
|
|
formatter.write_str("strict zero-copy policy rejected the multi-display plan")
|
|
}
|
|
Self::GenerationOverflow => {
|
|
formatter.write_str("multi-display pipeline generation overflowed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for MultiDisplayPipelineError {}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use remotedesk_protocol::{DisplayRect, DisplayScale};
|
|
|
|
use super::*;
|
|
|
|
fn adapter(value: &str) -> AdapterId {
|
|
AdapterId::new(value).unwrap()
|
|
}
|
|
|
|
fn id(value: u8) -> DisplayId {
|
|
DisplayId::from_bytes([value; 16])
|
|
}
|
|
|
|
fn descriptor(
|
|
value: u8,
|
|
x: i32,
|
|
y: i32,
|
|
width: u32,
|
|
height: u32,
|
|
primary: bool,
|
|
) -> DisplayDescriptor {
|
|
DisplayDescriptor {
|
|
id: id(value),
|
|
rect: DisplayRect {
|
|
x,
|
|
y,
|
|
width,
|
|
height,
|
|
},
|
|
scale: DisplayScale::ONE,
|
|
is_primary: primary,
|
|
}
|
|
}
|
|
|
|
fn topology(generation: u64, descriptors: Vec<DisplayDescriptor>) -> DisplayLayout {
|
|
DisplayLayout::new(generation, descriptors).unwrap()
|
|
}
|
|
|
|
fn source(value: u8, adapter_name: &str) -> DisplayCaptureSource {
|
|
DisplayCaptureSource::new(
|
|
id(value),
|
|
format!("display-{value}"),
|
|
CaptureMemoryPath::DmaBuf {
|
|
adapter: adapter(adapter_name),
|
|
},
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn probe(value: u8, result: ImportProbeResult) -> DisplayImportProbe {
|
|
DisplayImportProbe {
|
|
display_id: id(value),
|
|
result,
|
|
}
|
|
}
|
|
|
|
fn candidate(adapter_name: &str, probes: Vec<DisplayImportProbe>) -> MultiDisplayGpuCandidate {
|
|
MultiDisplayGpuCandidate::new(adapter(adapter_name), "vaapi-h264", probes).unwrap()
|
|
}
|
|
|
|
fn two_display_topology(generation: u64) -> DisplayLayout {
|
|
topology(
|
|
generation,
|
|
vec![
|
|
descriptor(1, -1920, -120, 1920, 1440, false),
|
|
descriptor(2, 0, 0, 2560, 1440, true),
|
|
],
|
|
)
|
|
}
|
|
|
|
fn strict_plan(
|
|
topology: &DisplayLayout,
|
|
sources: &[DisplayCaptureSource],
|
|
) -> MultiDisplayCapturePlan {
|
|
let probes = sources
|
|
.iter()
|
|
.map(|source| DisplayImportProbe {
|
|
display_id: source.id,
|
|
result: ImportProbeResult::SameAdapterZeroCopy,
|
|
})
|
|
.collect();
|
|
MultiDisplayCapturePlan::create(
|
|
DesktopBackend::WaylandPipeWire,
|
|
ZeroCopyPolicy::RequiredEndToEnd,
|
|
topology,
|
|
sources,
|
|
&DisplaySelection::all(topology.generation()),
|
|
&[candidate("gpu-0", probes)],
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn protocol_selection_and_negative_coordinates_map_consistently() {
|
|
let topology = two_display_topology(7);
|
|
let sources = [source(1, "gpu-0"), source(2, "gpu-0")];
|
|
let layout = SelectedDisplayLayout::resolve(
|
|
&topology,
|
|
&sources,
|
|
&DisplaySelection::all(topology.generation()),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(layout.generation(), 7);
|
|
assert_eq!(
|
|
layout.bounds(),
|
|
DesktopBounds {
|
|
x: -1920,
|
|
y: -120,
|
|
width: 4480,
|
|
height: 1560,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
layout.normalized_origin(id(1)),
|
|
Some(NormalizedPoint { x: 0, y: 0 })
|
|
);
|
|
assert_eq!(
|
|
layout.normalized_origin(id(2)),
|
|
Some(NormalizedPoint { x: 1920, y: 120 })
|
|
);
|
|
let point = layout.bounds().normalize(-1, 0).unwrap();
|
|
assert_eq!(layout.bounds().denormalize(point), Some((-1, 0)));
|
|
}
|
|
|
|
#[test]
|
|
fn all_single_and_custom_selection_reuse_protocol_ordering() {
|
|
let topology = topology(
|
|
9,
|
|
vec![
|
|
descriptor(1, -1920, 0, 1920, 1080, false),
|
|
descriptor(2, 0, 0, 1920, 1080, true),
|
|
descriptor(3, 1920, 0, 1920, 1080, false),
|
|
],
|
|
);
|
|
let sources = [source(1, "gpu-0"), source(2, "gpu-0"), source(3, "gpu-0")];
|
|
|
|
let all =
|
|
SelectedDisplayLayout::resolve(&topology, &sources, &DisplaySelection::all(9)).unwrap();
|
|
assert_eq!(
|
|
all.sources()
|
|
.iter()
|
|
.map(|source| source.descriptor.id)
|
|
.collect::<Vec<_>>(),
|
|
vec![id(1), id(2), id(3)]
|
|
);
|
|
|
|
let single = SelectedDisplayLayout::resolve(
|
|
&topology,
|
|
&sources,
|
|
&DisplaySelection::single(9, id(2)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(single.sources().len(), 1);
|
|
assert_eq!(single.bounds().width, 1920);
|
|
|
|
let custom = SelectedDisplayLayout::resolve(
|
|
&topology,
|
|
&sources,
|
|
&DisplaySelection::custom(9, vec![id(3), id(2)]),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(custom.sources()[0].descriptor.id, id(3));
|
|
assert_eq!(custom.sources()[1].descriptor.id, id(2));
|
|
assert_eq!(custom.bounds().x, 0);
|
|
assert_eq!(custom.bounds().width, 3840);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_empty_duplicate_and_unknown_protocol_selections_fail_closed() {
|
|
let topology = two_display_topology(7);
|
|
let sources = [source(1, "gpu-0"), source(2, "gpu-0")];
|
|
let cases = [
|
|
(
|
|
DisplaySelection::all(6),
|
|
DisplaySelectionError::LayoutGenerationMismatch {
|
|
expected: 7,
|
|
actual: 6,
|
|
},
|
|
),
|
|
(
|
|
DisplaySelection::custom(7, Vec::new()),
|
|
DisplaySelectionError::EmptyCustomSelection,
|
|
),
|
|
(
|
|
DisplaySelection::custom(7, vec![id(1), id(1)]),
|
|
DisplaySelectionError::DuplicateDisplay(id(1)),
|
|
),
|
|
(
|
|
DisplaySelection::single(7, id(99)),
|
|
DisplaySelectionError::UnknownDisplay(id(99)),
|
|
),
|
|
];
|
|
|
|
for (selection, expected) in cases {
|
|
assert_eq!(
|
|
SelectedDisplayLayout::resolve(&topology, &sources, &selection),
|
|
Err(MultiDisplayPlanError::Selection(expected))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_or_missing_agent_capture_source_is_rejected() {
|
|
let topology = two_display_topology(7);
|
|
let duplicate = [source(1, "gpu-0"), source(1, "gpu-0")];
|
|
assert_eq!(
|
|
SelectedDisplayLayout::resolve(&topology, &duplicate, &DisplaySelection::all(7)),
|
|
Err(MultiDisplayPlanError::DuplicateCaptureSource { id: id(1) })
|
|
);
|
|
assert_eq!(
|
|
SelectedDisplayLayout::resolve(
|
|
&topology,
|
|
&[source(1, "gpu-0")],
|
|
&DisplaySelection::all(7),
|
|
),
|
|
Err(MultiDisplayPlanError::MissingCaptureSource { id: id(2) })
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn strict_plan_uses_one_gpu_for_every_display() {
|
|
let topology = two_display_topology(7);
|
|
let sources = [source(1, "gpu-0"), source(2, "gpu-0")];
|
|
let plan = strict_plan(&topology, &sources);
|
|
|
|
assert_eq!(plan.encoder_adapter, adapter("gpu-0"));
|
|
assert_eq!(plan.paths.len(), 2);
|
|
assert_eq!(plan.compliance, MultiDisplayCompliance::StrictZeroCopy);
|
|
assert!(plan.paths.iter().all(DisplayPathPlan::is_strict_zero_copy));
|
|
}
|
|
|
|
#[test]
|
|
fn strict_plan_rejects_displays_captured_on_different_adapters() {
|
|
let topology = two_display_topology(7);
|
|
let sources = [source(1, "gpu-0"), source(2, "gpu-1")];
|
|
let candidates = [
|
|
candidate(
|
|
"gpu-0",
|
|
vec![
|
|
probe(1, ImportProbeResult::SameAdapterZeroCopy),
|
|
probe(
|
|
2,
|
|
ImportProbeResult::CrossAdapterCopy {
|
|
source_adapter: adapter("gpu-1"),
|
|
},
|
|
),
|
|
],
|
|
),
|
|
candidate(
|
|
"gpu-1",
|
|
vec![
|
|
probe(
|
|
1,
|
|
ImportProbeResult::CrossAdapterCopy {
|
|
source_adapter: adapter("gpu-0"),
|
|
},
|
|
),
|
|
probe(2, ImportProbeResult::SameAdapterZeroCopy),
|
|
],
|
|
),
|
|
];
|
|
|
|
assert_eq!(
|
|
MultiDisplayCapturePlan::create(
|
|
DesktopBackend::WaylandPipeWire,
|
|
ZeroCopyPolicy::RequiredEndToEnd,
|
|
&topology,
|
|
&sources,
|
|
&DisplaySelection::all(7),
|
|
&candidates,
|
|
),
|
|
Err(MultiDisplayPlanError::NoVerifiedSingleAdapterEncoder)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compatibility_reports_each_cross_adapter_downgrade() {
|
|
let topology = two_display_topology(7);
|
|
let sources = [source(1, "gpu-0"), source(2, "gpu-1")];
|
|
let candidate = candidate(
|
|
"gpu-0",
|
|
vec![
|
|
probe(1, ImportProbeResult::SameAdapterZeroCopy),
|
|
probe(
|
|
2,
|
|
ImportProbeResult::CrossAdapterCopy {
|
|
source_adapter: adapter("gpu-1"),
|
|
},
|
|
),
|
|
],
|
|
);
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn strict_rejects_cpu_and_opaque_capture_sources() {
|
|
let topology = topology(7, vec![descriptor(1, 0, 0, 1920, 1080, true)]);
|
|
for capture_path in [
|
|
CaptureMemoryPath::CpuMemory,
|
|
CaptureMemoryPath::OpaqueGpu {
|
|
adapter: Some(adapter("gpu-0")),
|
|
},
|
|
] {
|
|
let source = DisplayCaptureSource::new(id(1), "primary", capture_path.clone()).unwrap();
|
|
let result = MultiDisplayCapturePlan::create(
|
|
DesktopBackend::WaylandPipeWire,
|
|
ZeroCopyPolicy::RequiredEndToEnd,
|
|
&topology,
|
|
&[source],
|
|
&DisplaySelection::all(7),
|
|
&[candidate(
|
|
"gpu-0",
|
|
vec![probe(1, ImportProbeResult::OpaqueGpuPath)],
|
|
)],
|
|
);
|
|
assert_eq!(
|
|
result,
|
|
Err(MultiDisplayPlanError::StrictSourceRejected {
|
|
display_id: id(1),
|
|
status: capture_path.protocol_status(),
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn strict_rejects_degraded_imports_while_compatibility_reports_them() {
|
|
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,
|
|
),
|
|
] {
|
|
let candidate = candidate("gpu-0", vec![probe(1, probe_result)]);
|
|
assert_eq!(
|
|
MultiDisplayCapturePlan::create(
|
|
DesktopBackend::WaylandPipeWire,
|
|
ZeroCopyPolicy::RequiredEndToEnd,
|
|
&topology,
|
|
&sources,
|
|
&DisplaySelection::all(7),
|
|
std::slice::from_ref(&candidate),
|
|
),
|
|
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);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn missing_probe_means_candidate_cannot_cover_selection() {
|
|
let topology = two_display_topology(7);
|
|
let sources = [source(1, "gpu-0"), source(2, "gpu-0")];
|
|
let incomplete = candidate(
|
|
"gpu-0",
|
|
vec![probe(1, ImportProbeResult::SameAdapterZeroCopy)],
|
|
);
|
|
|
|
assert_eq!(
|
|
MultiDisplayCapturePlan::create(
|
|
DesktopBackend::WaylandPipeWire,
|
|
ZeroCopyPolicy::RequiredEndToEnd,
|
|
&topology,
|
|
&sources,
|
|
&DisplaySelection::all(7),
|
|
&[incomplete],
|
|
),
|
|
Err(MultiDisplayPlanError::NoCommonEncoder)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resolution_and_protocol_layout_generation_force_revalidation() {
|
|
let initial_topology = two_display_topology(7);
|
|
let initial_sources = [source(1, "gpu-0"), source(2, "gpu-0")];
|
|
let initial_plan = strict_plan(&initial_topology, &initial_sources);
|
|
let mut pipeline = MultiDisplayPipeline::active(
|
|
ZeroCopyPolicy::RequiredEndToEnd,
|
|
Resolution::new(4480, 1440).unwrap(),
|
|
initial_plan.clone(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
pipeline.reconfigure(
|
|
Resolution::new(4480, 1440).unwrap(),
|
|
initial_plan.layout.clone()
|
|
),
|
|
Ok(false)
|
|
);
|
|
assert_eq!(
|
|
pipeline.reconfigure(
|
|
Resolution::new(2240, 720).unwrap(),
|
|
initial_plan.layout.clone()
|
|
),
|
|
Ok(true)
|
|
);
|
|
assert_eq!(pipeline.epoch(), 1);
|
|
assert_eq!(pipeline.state(), PipelineState::Revalidating);
|
|
pipeline.finish_revalidation(initial_plan).unwrap();
|
|
|
|
let changed_topology = topology(
|
|
8,
|
|
vec![
|
|
descriptor(1, -2560, 0, 2560, 1440, false),
|
|
descriptor(2, 0, 0, 2560, 1440, true),
|
|
],
|
|
);
|
|
let changed_plan = strict_plan(&changed_topology, &initial_sources);
|
|
assert_eq!(
|
|
pipeline.reconfigure(
|
|
Resolution::new(2240, 720).unwrap(),
|
|
changed_plan.layout.clone()
|
|
),
|
|
Ok(true)
|
|
);
|
|
assert_eq!(pipeline.epoch(), 2);
|
|
assert_eq!(pipeline.layout().generation(), 8);
|
|
assert!(pipeline.plan().is_none());
|
|
pipeline.finish_revalidation(changed_plan).unwrap();
|
|
assert_eq!(pipeline.state(), PipelineState::Active);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_layout_proof_cannot_finish_revalidation() {
|
|
let old_topology = two_display_topology(7);
|
|
let sources = [source(1, "gpu-0"), source(2, "gpu-0")];
|
|
let old_plan = strict_plan(&old_topology, &sources);
|
|
let mut pipeline = MultiDisplayPipeline::active(
|
|
ZeroCopyPolicy::RequiredEndToEnd,
|
|
Resolution::new(4480, 1440).unwrap(),
|
|
old_plan.clone(),
|
|
)
|
|
.unwrap();
|
|
let new_topology = topology(
|
|
8,
|
|
vec![
|
|
descriptor(1, -2560, 0, 2560, 1440, false),
|
|
descriptor(2, 0, 0, 2560, 1440, true),
|
|
],
|
|
);
|
|
let new_plan = strict_plan(&new_topology, &sources);
|
|
assert_eq!(
|
|
pipeline.reconfigure(Resolution::new(4480, 1440).unwrap(), new_plan.layout),
|
|
Ok(true)
|
|
);
|
|
|
|
assert_eq!(
|
|
pipeline.finish_revalidation(old_plan),
|
|
Err(MultiDisplayPipelineError::StaleLayout)
|
|
);
|
|
assert_eq!(pipeline.state(), PipelineState::Revalidating);
|
|
}
|
|
|
|
#[test]
|
|
fn generation_overflow_fails_closed_without_mutating_pipeline() {
|
|
let topology = two_display_topology(7);
|
|
let sources = [source(1, "gpu-0"), source(2, "gpu-0")];
|
|
let plan = strict_plan(&topology, &sources);
|
|
let mut pipeline = MultiDisplayPipeline::active(
|
|
ZeroCopyPolicy::RequiredEndToEnd,
|
|
Resolution::new(4480, 1440).unwrap(),
|
|
plan,
|
|
)
|
|
.unwrap();
|
|
pipeline.epoch = u64::MAX;
|
|
let original = pipeline.clone();
|
|
|
|
assert_eq!(
|
|
pipeline.reconfigure(Resolution::new(2240, 720).unwrap(), original.layout.clone()),
|
|
Err(MultiDisplayPipelineError::GenerationOverflow)
|
|
);
|
|
assert_eq!(pipeline, original);
|
|
}
|
|
}
|