diff --git a/AGENTS.md b/AGENTS.md index 68cc726..a12ccb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,7 @@ every 60 s. | `src/auth.rs`, `src/config.rs`, `src/login.rs`, `src/login_ui.rs` | signed REST client, config, device init + QR login, GTK window | | `src/webrtc.rs` | peer connection, data channels, CONTROL dispatch, initialization messages | | `src/video.rs`, `src/capture_setting.rs`, `src/video_track.rs`, `src/hevc.rs` | FFmpeg H.264/H.265 pipeline, one video track for both codecs (RFC 7798 HEVC payloader); viewer quality/fps/resolution/chroma settings | +| `src/locked_video.rs` | low-rate synthetic black video on the negotiated track while Portal is pending and locked; enables iPhone cold auto unlock (#29), requires FFmpeg libx264/libx265 for the selected codec | | `src/audio.rs` | Pulse/PipeWire monitor → Opus audio track | | `src/input.rs`, `src/input_mapping.rs`, `src/uinput.rs` | input injection (X11 XTest / Wayland / portal / uinput), Windows VK → keysym → evdev | | `src/cursor.rs` | pointer shape mirroring (X11) | diff --git a/README.en.md b/README.en.md index 8a8e9a8..3209316 100644 --- a/README.en.md +++ b/README.en.md @@ -67,7 +67,7 @@ authorized session. Independent file/terminal tool peers do not reset input. | Hyprland | Wayland (native wlroots protocols) | 🧪 Experimental, tested in a VM (some features limited, see below) | `--features wayland` | | GNOME / KDE Plasma | Wayland (xdg-desktop-portal) | 🧪 Experimental; validation scope and limits below | `--features wayland,portal` | -The GNOME Wayland Portal/EIS path has been checked for video and mouse/keyboard input on Ubuntu 26.04, GNOME 50, with one 4K screen at 200% scale; auto unlock after a cold locked connection is verified with the official Mac viewer, while other lock recovery and reconnection still need hardware acceptance testing. Apart from that path and GNOME on X11, desktop testing has primarily used virtual machines. If something goes wrong on +The GNOME Wayland Portal/EIS path has been checked for video and mouse/keyboard input on Ubuntu 26.04, GNOME 50, with one 4K screen at 200% scale; auto unlock after a cold locked connection is verified with the official Mac and iPhone 4.42.0 viewers, while other lock recovery and reconnection still need hardware acceptance testing. Apart from that path and GNOME on X11, desktop testing has primarily used virtual machines. If something goes wrong on real hardware, please [open an issue](../../issues) with your distribution, desktop, GPU and the relevant lines from `journalctl --user -u uuremote-native.service`. @@ -78,9 +78,11 @@ Scaled GNOME desktops using native Portal input use EIS on the same authorized s GNOME Portal CONTROL no longer waits for a consent dialog or the first video frame. It announces the selected desktop before reporting secure-desktop state and receiving the official viewer's “auto unlock host” request. The official Android viewer requires this desktop selection before entering its auto-unlock flow, so the notification must precede capture readiness; actual screen dimensions, codec information and capture permissions still follow their real readiness state. Enable auto unlock in the viewer and use the current Linux user's login password. The host verifies it with PAM, unlocks the existing GNOME session, requests display wake, and restores capture, input and clipboard through the shared Portal session. Initial consent or revoked permissions still require local approval. Reconnect if physical or logical dimensions change. -The host also accepts a desktop connection when the service started on an unlocked desktop (no Portal yet) and the screen was locked afterwards (a cold connection): it reports the lock state and accepts auto unlock, then restores picture, input and clipboard after unlock. Verified on Ubuntu GNOME Wayland with the official Mac viewer; the official iPhone 4.42.0 does not send the auto-unlock request in this scenario and is unresolved ([#29](../../issues/29)). **Limitation**: without 「自动解锁被控端」 (auto unlock) enabled, the locked screen stays black and the unlock page cannot be activated; in the UU client use 控制中心 → 安全 → 自动解锁被控端 and enter the password to unlock. With no saved Portal permission GNOME refuses to create a session while locked, so the picture only appears after unlock. +The host also accepts a desktop connection while GNOME is locked with no Portal session yet (a cold connection): it reports lock state and accepts auto unlock, then restores picture, input and clipboard after unlock. While Portal is pending, the host sends generated black video once per second on the negotiated track by default, allowing the official iPhone viewer to enter its auto-unlock flow without extra configuration. Verified on Ubuntu GNOME Wayland with the official Mac viewer; iPhone 4.42.0 with H.265 completed a same-executable control experiment: black video ON succeeded, OFF failed, and ON succeeded again ([#29](../../issues/29)). **Limitation**: this is synthetic black video, not the GNOME lock screen, and it does not enable native mouse/keyboard input while locked. Enable 「自动解锁被控端」 in the UU client under 控制中心 → 安全 and enter the password. Without saved Portal permission, local consent is still required after unlock before the real desktop can appear. -The five-minute screen-off and lock settings remain unchanged. Only capture recovery in an unlocked session temporarily holds a GNOME idle inhibitor; the first frame, lock, disconnect or initialization failure releases it. An idle viewer still allows the normal screen-off and lock timeout, and manual lock remains effective. Desktop input is blocked and video is hidden while locked or when lock state is unknown. Independent terminal, file-transfer and port-mapping peers do not wait for video initialization and survive desktop reconnection. Automated protocol and lifecycle regression tests cover this path; auto unlock and capture recovery after a cold locked connection are verified on GNOME hardware with the official Mac viewer, while screen-off and the other scenarios still need acceptance testing. Waking a fully suspended or hibernating machine is outside this CONTROL path. +Existing screen-off and lock settings remain unchanged. Only capture recovery in an unlocked session temporarily holds a GNOME idle inhibitor; the first frame, lock, disconnect or initialization failure releases it. An idle viewer still allows the normal screen-off and lock timeout, and manual lock remains effective. Desktop input is blocked and real video is hidden while locked or when lock state is unknown. Independent terminal, file-transfer and port-mapping peers do not wait for video initialization and survive desktop reconnection. Automated protocol and lifecycle regression tests cover this path; auto unlock and capture recovery after a cold locked connection are verified on GNOME hardware with the official Mac and iPhone 4.42.0 viewers, while screen-off and the other scenarios still need acceptance testing. Waking a fully suspended or hibernating machine is outside this CONTROL path. + +Locked black video requires FFmpeg's libx264/libx265 encoder for the negotiated H.264/H.265 codec; hardware encoding support alone does not satisfy this dependency. Encoder failures are logged explicitly. Synthetic video does not change capture permissions or advertised monitor dimensions. It pauses on unlock or unknown lock state, ends when capture is ready/rejected or the connection closes, and then hands the same video track to real capture. See [#29](../../issues/29) and [#34](../../pull/34) for the controlled experiment and validation scope. The native Wayland backend needs `wlr-output-power-management` from the compositor to wake a turned-off screen; without it only wake is unavailable — capture and encoding are unaffected. diff --git a/README.md b/README.md index 56f0fcf..06ef0a2 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Portal 仅作用于本服务获授权的会话。文件传输、终端等独立 | Hyprland | Wayland(wlroots 原生协议) | 🧪 实验性,虚拟机测试(部分功能受限,见下) | `--features wayland` | | GNOME / KDE Plasma | Wayland(xdg-desktop-portal) | 🧪 实验性,验证范围与限制见下 | `--features wayland,portal` | -GNOME Wayland 的 Portal/EIS 路径已在 Ubuntu 26.04、GNOME 50、单屏 4K/200% 缩放下验证画面和键鼠;锁屏冷连接自动解锁已用 Mac 客户端实机验证,其余锁屏恢复及重连仍待验收。除 GNOME 的上述路径和 X11 外,其余桌面目前主要在虚拟机里测试。如果你在真机上遇到问题,欢迎[提交 issue](../../issues), +GNOME Wayland 的 Portal/EIS 路径已在 Ubuntu 26.04、GNOME 50、单屏 4K/200% 缩放下验证画面和键鼠;锁屏冷连接自动解锁已用 Mac 和 iPhone 4.42.0 客户端实机验证,其余锁屏恢复及重连仍待验收。除 GNOME 的上述路径和 X11 外,其余桌面目前主要在虚拟机里测试。如果你在真机上遇到问题,欢迎[提交 issue](../../issues), 请附上发行版、桌面环境、显卡型号和 `journalctl --user -u uuremote-native.service` 里的相关日志。 Wayland 下的限制:只支持单屏、固定分辨率;Hyprland 原生后端默认使用双缓冲流水线 shm screencopy,控制端帧率设置会同步到采集节拍和编码器,但实际帧率仍受合成器、渲染和读回速度限制(旧串行路径在 2560x1600 曾测得约 16~22 帧;流水线只在虚拟机里测量过,不代表实机保证);portal 后端暂不支持动态分辨率;GNOME 的屏幕唤醒与锁屏恢复见下;剪贴板只在控制端的远控窗口处于前台时同步。 @@ -70,9 +70,11 @@ GNOME 缩放桌面在使用 Portal 原生键鼠时,若支持 `ConnectToEIS` GNOME Portal 的控制通道不再等待授权窗口或第一帧画面:先通知客户端当前选择桌面,再上报锁屏状态、接收官方客户端的「自动解锁被控端」请求。官方 Android 客户端需要桌面目标通知才会进入自动解锁流程,因此该通知不能等待采集就绪;真实屏幕尺寸、编码信息及采集权限仍按实际就绪状态上报。请在客户端启用自动解锁,使用当前 Linux 用户的登录密码;主机通过 PAM 验证后解锁当前 GNOME 会话、请求点亮屏幕,并恢复同一 Portal 会话中的画面、键鼠和剪贴板。首次授权或权限被撤销时仍需本机确认。物理或逻辑分辨率变化后需要重连。 -服务启动时桌面已解锁、尚未建立 Portal,随后锁屏再连接(冷连接)时,主机也会建立控制通道:上报锁屏状态并接受自动解锁,解锁后再恢复画面、键鼠和剪贴板。官方 Mac 客户端已在 Ubuntu GNOME Wayland 上验证;官方 iPhone 4.42.0 在该场景下不发送自动解锁请求,尚未解决([#29](../../issues/29))。**局限**:未开启「自动解锁被控端」时,锁屏下仍是黑屏,无法唤起解锁页;请在 UU 客户端 控制中心 → 安全 → 自动解锁被控端 中输入密码解锁。若本机没有已保存的 Portal 授权,GNOME 在锁屏时不允许创建会话,画面要等解锁后才出现。 +桌面锁屏且尚未建立 Portal 时(冷连接),主机也会建立控制通道:上报锁屏状态并接受自动解锁,解锁后再恢复画面、键鼠和剪贴板。Portal 等待就绪期间,主机默认通过已协商的视频轨每秒发送一帧生成的纯黑画面,让官方 iPhone 客户端进入自动解锁流程,无需额外配置。Ubuntu GNOME Wayland 上已用官方 Mac 客户端验证;iPhone 4.42.0 的 H.265 冷连接完成了同一实验二进制「开启黑帧成功 → 关闭失败 → 再开成功」对照([#29](../../issues/29))。**局限**:黑帧不是真实 GNOME 锁屏画面,也不支持在锁屏界面操作原生键鼠;需要在 UU 客户端 控制中心 → 安全 → 自动解锁被控端 中启用自动解锁并输入密码。若本机没有已保存的 Portal 授权,解锁后仍需本机确认授权才能显示真实桌面。 -无需修改五分钟熄屏和锁屏设置。主机仅在会话已解锁的采集恢复期间临时申请 GNOME 的 idle 抑制,拿到首帧后立即释放;锁屏、断开或初始化失败也会释放。远控窗口空闲时仍按原策略熄屏和锁屏,手动锁屏也继续生效。锁屏及锁状态未知时停止桌面输入并隐藏画面;独立终端、文件传输和端口映射连接不依赖画面初始化,也不会因桌面重连被关闭。这一恢复路径已覆盖自动化协议与生命周期回归测试,锁屏冷连接后的自动解锁与采集恢复已用官方 Mac 客户端在 GNOME 实机验证,熄屏等其余场景仍待验收;不支持从整机挂起或休眠状态通过现有控制通道唤醒。 +无需修改现有熄屏和锁屏设置。主机仅在会话已解锁的采集恢复期间临时申请 GNOME 的 idle 抑制,拿到首帧后立即释放;锁屏、断开或初始化失败也会释放。远控窗口空闲时仍按原策略熄屏和锁屏,手动锁屏也继续生效。锁屏及锁状态未知时停止桌面输入并隐藏真实画面;独立终端、文件传输和端口映射连接不依赖画面初始化,也不会因桌面重连被关闭。这一恢复路径已覆盖自动化协议与生命周期回归测试,锁屏冷连接后的自动解锁与采集恢复已用官方 Mac 和 iPhone 4.42.0 客户端在 GNOME 实机验证,熄屏等其余场景仍待验收;不支持从整机挂起或休眠状态通过现有控制通道唤醒。 + +锁屏黑帧使用 FFmpeg 的 libx264/libx265 编码器(按已协商的 H.264/H.265 选择),硬件编码器可用并不代表这些软件编码器已安装。编码失败会明确记录日志。黑帧不改变采集权限或上报的真实屏幕尺寸;解锁或锁状态未知时暂停,采集就绪、拒绝或连接关闭时结束,再由真实视频接续同一条视频轨。对照实验与验证范围见 [#29](../../issues/29)、[#34](../../pull/34)。 原生 Wayland 后端需要合成器提供 `wlr-output-power-management` 才能唤醒已关闭的屏幕;缺少该协议不影响采集和编码。 diff --git a/src/locked_video.rs b/src/locked_video.rs new file mode 100644 index 0000000..951d441 --- /dev/null +++ b/src/locked_video.rs @@ -0,0 +1,101 @@ +//! Black video keeps locked Portal initialization reachable by the iPhone viewer. +use std::time::Duration; +use bytes::Bytes; +use webrtc::{media::Sample, peer_connection::{RTCPeerConnection, peer_connection_state::RTCPeerConnectionState}}; +use crate::{capture_setting::VideoCodec, desktop_session::PortalDesktop, video_track::VideoTrack}; + +async fn black_frame(codec: VideoCodec) -> Result { + let mut command = crate::video::ffmpeg_command(); + command.args(["-hide_banner", "-loglevel", "error", "-nostdin", + "-f", "lavfi", "-i", "color=c=black:s=1280x720:r=1", "-frames:v", "1", + "-an", "-pix_fmt", "yuv420p", "-threads", "1", "-preset", "ultrafast", "-tune", "zerolatency"]); + match codec { + VideoCodec::H264 => { command.args(["-c:v", "libx264", "-profile:v", "baseline", + "-x264-params", "keyint=1:repeat-headers=1:aud=1", "-f", "h264"]); }, + VideoCodec::H265 => { command.args(["-c:v", "libx265", + "-x265-params", "keyint=1:repeat-headers=1:aud=1:pools=1:frame-threads=1:log-level=error", "-f", "hevc"]); }, + } + command.arg("pipe:1").kill_on_drop(true); + let output = tokio::time::timeout(Duration::from_secs(5), command.output()).await + .map_err(|_| "Locked desktop video encoder timed out".to_string())? + .map_err(|error| format!("Locked desktop video encoder: {error}"))?; + if !output.status.success() || output.stdout.is_empty() { + return Err(format!("Locked desktop video encoder failed: status={}", output.status)); + } + Ok(output.stdout.into()) +} + +pub async fn run(pc: &RTCPeerConnection, track: &VideoTrack, desktop: &PortalDesktop) -> Result<(), String> { + run_with_encoder(pc, track, desktop, black_frame).await +} + +async fn run_with_encoder(pc: &RTCPeerConnection, track: &VideoTrack, desktop: &PortalDesktop, mut encode: F) -> Result<(), String> +where F: FnMut(VideoCodec) -> Fut, Fut: std::future::Future> { + // #29: iPhone 4.42.0 cold ON/OFF/ON tests only sent action15 after black video. + // Keep CONTROL permissions truthful and use the existing video track until real capture is ready. + let mut updates = desktop.subscribe(); + let mut locked = desktop.lock_state(); + let mut tick = tokio::time::interval(Duration::from_secs(1)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut frame = None; + let mut sent = 0u64; + let mut attempts = 0u64; + loop { + let state = updates.borrow_and_update().clone(); + let _ = locked.borrow_and_update(); + if state.ready() || !state.waiting || crate::video::transport_closed(pc) { + if attempts > 0 { log::info!("Locked desktop video ended: sent={sent} attempts={attempts}"); } + return Ok(()); + } + tokio::select! { + biased; + changed = updates.changed() => if changed.is_err() { return Ok(()); }, + changed = locked.changed() => if changed.is_err() { return Ok(()); }, + _ = tick.tick() => { + if !updates.borrow().waiting || *locked.borrow() != Some(true) + || pc.connection_state() != RTCPeerConnectionState::Connected { continue; } + if frame.is_none() { + let codec = crate::capture_setting::preferred_codec( + track.supports(VideoCodec::H264), track.supports(VideoCodec::H265)); + let encoded = tokio::select! { + biased; + _ = updates.changed() => continue, + _ = locked.changed() => continue, + result = encode(codec) => result?, + }; + frame = Some((codec, encoded)); + } + if crate::video::transport_closed(pc) { return Ok(()); } + // Encoding can finish in the same poll that publishes unlock/readiness. + if !updates.borrow().waiting || *locked.borrow() != Some(true) + || pc.connection_state() != RTCPeerConnectionState::Connected { continue; } + let (codec, data) = frame.as_ref().unwrap(); + let sample = Sample { data: data.clone(), duration: Duration::from_secs(1), ..Default::default() }; + let extensions = [crate::rtp_metadata::capture_index(0), crate::rtp_metadata::new_frame()]; + let revision = track.revision(); + // No monitor inventory or permission claims: 1280x720 is only this synthetic bitstream. + tokio::select! { + biased; + _ = updates.changed() => continue, + _ = locked.changed() => continue, + result = tokio::time::timeout(Duration::from_secs(2), + track.write_sample_with_extensions(*codec, &sample, &extensions)) => { + if crate::video::transport_closed(pc) { return Ok(()); } + result.map_err(|_| "Locked desktop video send timed out".to_string())? + .map_err(|error| format!("Locked desktop video send: {error}"))?; + }, + } + attempts += 1; + if track.revision() > revision { sent += 1; } + tick.reset(); + if attempts == 1 || attempts % 10 == 0 { + log::info!("Locked desktop video: sent={sent} codec={codec:?} synthetic_size=1280x720 bytes={} attempts={attempts}", data.len()); + } + }, + } + } +} + +#[cfg(test)] +#[path = "locked_video_tests.rs"] +mod tests; diff --git a/src/locked_video_tests.rs b/src/locked_video_tests.rs new file mode 100644 index 0000000..bbbca3e --- /dev/null +++ b/src/locked_video_tests.rs @@ -0,0 +1,350 @@ +use super::{black_frame, run, run_with_encoder}; +use crate::{ + capture::ScreenCapture, + capture_setting::VideoCodec, + desktop_backend::DesktopBackend, + desktop_session::PortalDesktop, + video_track::VideoTrack, +}; +use std::{process::Stdio, sync::Arc, time::Duration}; +use tokio::{io::AsyncWriteExt, sync::{mpsc, watch}}; +use webrtc::{ + api::{media_engine::MediaEngine, APIBuilder}, + media::Sample, + peer_connection::{ + configuration::RTCConfiguration, + peer_connection_state::RTCPeerConnectionState, + RTCPeerConnection, + }, + rtp::packet::Packet, + rtp_transceiver::{ + rtp_codec::{RTCRtpCodecCapability, RTCRtpCodecParameters, RTPCodecType}, + }, + util::vnet::router::Router, +}; + +async fn decode_gray(data: &[u8], demuxer: &str) -> (Vec, String) { + let mut command = crate::video::ffmpeg_command(); + command.args([ + "-hide_banner", "-loglevel", "info", "-nostdin", "-f", demuxer, "-i", "pipe:0", + "-frames:v", "1", "-vf", "scale=in_range=tv:out_range=pc,format=gray,showinfo", + "-pix_fmt", "gray", "-f", "rawvideo", "pipe:1", + ]); + command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).kill_on_drop(true); + let mut child = command.spawn().expect("start FFmpeg decoder"); + let mut stdin = child.stdin.take().expect("FFmpeg decoder stdin"); + let output = tokio::time::timeout(Duration::from_secs(10), async move { + stdin.write_all(data).await.expect("write encoded frame to FFmpeg"); + drop(stdin); + child.wait_with_output().await.expect("wait for FFmpeg decoder") + }).await.expect("FFmpeg decoder timed out"); + assert!(output.status.success(), "FFmpeg decoder failed: status={}", output.status); + (output.stdout, String::from_utf8_lossy(&output.stderr).into_owned()) +} + +async fn black_frame_decodes_as_1280x720_black(codec: VideoCodec, demuxer: &str) { + let encoded = black_frame(codec).await.expect("encode black frame"); + assert!(!encoded.is_empty()); + let (gray, diagnostics) = decode_gray(&encoded, demuxer).await; + assert!(diagnostics.contains("s:1280x720"), "decoded dimensions were not 1280x720"); + assert_eq!(gray.len(), 1280 * 720, "expected one 1280x720 gray frame"); + assert!(gray.iter().all(|&pixel| pixel <= 1), "decoded frame was not black"); +} + +#[tokio::test] +#[ignore = "requires FFmpeg libx264/libx265"] +async fn h264_black_frame_decodes_to_1280x720_black() { + black_frame_decodes_as_1280x720_black(VideoCodec::H264, "h264").await; +} + +#[tokio::test] +#[ignore = "requires FFmpeg libx264/libx265"] +async fn h265_black_frame_decodes_to_1280x720_black() { + black_frame_decodes_as_1280x720_black(VideoCodec::H265, "hevc").await; +} + +fn ready_screen() -> Arc { + let mut screen = ScreenCapture::unavailable("synthetic ready desktop".into()); + screen.backend = DesktopBackend::Portal; + screen.init_error = None; + screen.display = "wayland:test".into(); + screen.width = 1280; + screen.height = 720; + Arc::new(screen) +} + +async fn receive_frame(packets: &mut mpsc::UnboundedReceiver, within: Duration) -> Vec { + tokio::time::timeout(within, async { + let mut frame = Vec::new(); + loop { + let packet = packets.recv().await.expect("RTP packet receiver closed"); + let marker = packet.header.marker; + frame.push(packet); + if marker { + return frame; + } + } + }).await.expect("timed out waiting for an RTP frame") +} + +async fn assert_no_rtp(packets: &mut mpsc::UnboundedReceiver, within: Duration) { + assert!(tokio::time::timeout(within, packets.recv()).await.is_err(), + "locked video task sent RTP while transmission was disabled"); +} + +fn assert_single_rtp_frame(frame: &[Packet]) { + let first = frame.first().expect("RTP frame has packets"); + let last = frame.last().unwrap(); + assert!(last.header.marker, "RTP frame ends with a marker packet"); + for pair in frame.windows(2) { + assert_eq!(pair[0].header.ssrc, pair[1].header.ssrc); + assert_eq!(pair[0].header.timestamp, pair[1].header.timestamp); + assert_eq!(pair[1].header.sequence_number, pair[0].header.sequence_number.wrapping_add(1)); + } + assert!(frame.iter().all(|packet| packet.header.ssrc == first.header.ssrc)); +} + +struct VirtualPeers { + host: Arc, + remote: Arc, + _control: Arc, + router: Arc>, + track: Arc, + packets: mpsc::UnboundedReceiver, +} + +impl VirtualPeers { + async fn new(codec: VideoCodec) -> Self { + let (remote_setting, host_setting, router) = crate::test_network::virtual_network().await; + + let mut remote_media = MediaEngine::default(); + match codec { + VideoCodec::H264 => remote_media.register_codec(RTCRtpCodecParameters { + capability: RTCRtpCodecCapability { + mime_type: VideoCodec::H264.mime().into(), + clock_rate: 90_000, + sdp_fmtp_line: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f".into(), + ..Default::default() + }, + payload_type: 102, + ..Default::default() + }, RTPCodecType::Video).unwrap(), + VideoCodec::H265 => crate::video_track::register_hevc(&mut remote_media).unwrap(), + } + let remote = Arc::new(APIBuilder::new().with_setting_engine(remote_setting) + .with_media_engine(remote_media).build() + .new_peer_connection(RTCConfiguration::default()).await.unwrap()); + remote.add_transceiver_from_kind(RTPCodecType::Video, None).await.unwrap(); + let control = remote.create_data_channel("CONTROL_DATA_CHANNEL", None).await.unwrap(); + + let mut host_media = MediaEngine::default(); + host_media.register_default_codecs().unwrap(); + crate::video_track::register_hevc(&mut host_media).unwrap(); + let host = Arc::new(APIBuilder::new().with_setting_engine(host_setting) + .with_media_engine(host_media).build() + .new_peer_connection(RTCConfiguration::default()).await.unwrap()); + let track = Arc::new(VideoTrack::new()); + host.add_track(track.clone()).await.unwrap(); + + let (packet_tx, packets) = mpsc::unbounded_channel(); + remote.on_track(Box::new(move |remote_track, _, _| { + let packet_tx = packet_tx.clone(); + Box::pin(async move { + tokio::spawn(async move { + while let Ok((packet, _)) = remote_track.read_rtp().await { + if packet_tx.send(packet).is_err() { break; } + } + }); + }) + })); + + Self { host, remote, _control: control, router, track, packets } + } + + async fn connect(&self, codec: VideoCodec) { + let offer = self.remote.create_offer(None).await.unwrap(); + let mut gathered = self.remote.gathering_complete_promise().await; + self.remote.set_local_description(offer).await.unwrap(); + gathered.recv().await; + self.host.set_remote_description(self.remote.local_description().await.unwrap()).await.unwrap(); + let answer = self.host.create_answer(None).await.unwrap(); + let mut gathered = self.host.gathering_complete_promise().await; + self.host.set_local_description(answer).await.unwrap(); + gathered.recv().await; + self.remote.set_remote_description(self.host.local_description().await.unwrap()).await.unwrap(); + tokio::time::timeout(Duration::from_secs(15), async { + while self.host.connection_state() != RTCPeerConnectionState::Connected + || !self.track.supports(codec) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("virtual WebRTC peers did not connect with the requested codec"); + assert!(!self.track.supports(match codec { + VideoCodec::H264 => VideoCodec::H265, + VideoCodec::H265 => VideoCodec::H264, + })); + } + + async fn close(self) { + self.host.close().await.unwrap(); + self.remote.close().await.unwrap(); + self.router.lock().await.stop().await.unwrap(); + } +} + +async fn exercise_locked_video(codec: VideoCodec) { + tokio::time::timeout(Duration::from_secs(35), async { + let mut peers = VirtualPeers::new(codec).await; + let (lock_tx, lock_rx) = watch::channel(Some(true)); + let desktop = Arc::new(PortalDesktop::with_lock_state( + Arc::new(ScreenCapture::unavailable("synthetic pending desktop".into())), + lock_rx, + )); + assert!(desktop.snapshot().waiting); + assert!(!desktop.snapshot().ready()); + + let run_host = peers.host.clone(); + let run_track = peers.track.clone(); + let run_desktop = desktop.clone(); + let task = tokio::spawn(async move { run(&run_host, &run_track, &run_desktop).await }); + + assert_ne!(peers.host.connection_state(), RTCPeerConnectionState::Connected); + assert_no_rtp(&mut peers.packets, Duration::from_millis(150)).await; + + peers.connect(codec).await; + + let locked_frame = receive_frame(&mut peers.packets, Duration::from_secs(5)).await; + assert_single_rtp_frame(&locked_frame); + let last_locked_packet = locked_frame.last().unwrap().clone(); + + lock_tx.send_replace(None); + assert_no_rtp(&mut peers.packets, Duration::from_millis(1200)).await; + lock_tx.send_replace(Some(false)); + assert_no_rtp(&mut peers.packets, Duration::from_millis(1200)).await; + + desktop.publish_test_screen(ready_screen()); + tokio::time::timeout(Duration::from_secs(2), task).await + .expect("locked video task did not stop when the desktop became ready") + .expect("locked video task panicked") + .expect("locked video task returned an error"); + + let live_data = black_frame(codec).await.expect("encode live test sample"); + peers.track.write_sample_with_extensions(codec, &Sample { + data: live_data, + duration: Duration::from_millis(20), + ..Default::default() + }, &[]).await.unwrap(); + let live_frame = receive_frame(&mut peers.packets, Duration::from_secs(5)).await; + assert_single_rtp_frame(&live_frame); + let first_live_packet = &live_frame[0]; + assert_eq!(first_live_packet.header.ssrc, last_locked_packet.header.ssrc); + assert_eq!(first_live_packet.header.sequence_number, + last_locked_packet.header.sequence_number.wrapping_add(1)); + assert_eq!(first_live_packet.header.timestamp, + last_locked_packet.header.timestamp.wrapping_add(90_000)); + + peers.close().await; + }).await.expect("locked video WebRTC test exceeded its deadline"); +} + +#[tokio::test] +#[ignore = "requires FFmpeg libx264/libx265"] +async fn locked_video_gates_on_pending_locked_connected_and_preserves_video_timeline() { + exercise_locked_video(VideoCodec::H264).await; + exercise_locked_video(VideoCodec::H265).await; +} + +#[tokio::test] +async fn locked_video_rechecks_state_when_encoding_finishes() { + #[derive(Clone, Copy)] + enum Transition { Unlocked, Unknown, Ready } + tokio::time::timeout(Duration::from_secs(20), async { + let mut peers = VirtualPeers::new(VideoCodec::H264).await; + peers.connect(VideoCodec::H264).await; + for transition in [Transition::Unlocked, Transition::Unknown, Transition::Ready] { + let (lock_tx, lock_rx) = watch::channel(Some(true)); + let desktop = Arc::new(PortalDesktop::with_lock_state( + Arc::new(ScreenCapture::unavailable("synthetic pending desktop".into())), + lock_rx, + )); + let run_host = peers.host.clone(); + let run_track = peers.track.clone(); + let run_desktop = desktop.clone(); + let (encoded_tx, mut encoded_rx) = mpsc::unbounded_channel(); + let revision = peers.track.revision(); + let task = tokio::spawn(async move { + run_with_encoder(&run_host, &run_track, &run_desktop, |_| { + let desktop = run_desktop.clone(); + let lock_tx = lock_tx.clone(); + let encoded_tx = encoded_tx.clone(); + async move { + // Change state during the encoder's final poll, before it returns Ready. + match transition { + Transition::Unlocked => { lock_tx.send_replace(Some(false)); }, + Transition::Unknown => { lock_tx.send_replace(None); }, + Transition::Ready => desktop.publish_test_screen(ready_screen()), + } + encoded_tx.send(()).unwrap(); + Ok(bytes::Bytes::from_static(&[0, 0, 1, 65, 1, 2])) + } + }).await + }); + tokio::time::timeout(Duration::from_secs(2), encoded_rx.recv()).await + .expect("test encoder was not polled").expect("test encoder channel closed"); + assert_no_rtp(&mut peers.packets, Duration::from_millis(250)).await; + assert_eq!(peers.track.revision(), revision, "stale state allowed a synthetic frame"); + desktop.publish_test_screen(ready_screen()); + tokio::time::timeout(Duration::from_secs(2), task).await + .expect("locked video task did not stop after readiness") + .expect("locked video task panicked") + .expect("locked video task returned an error"); + } + peers.close().await; + }).await.expect("locked video concurrent-transition test exceeded its deadline"); +} + +#[tokio::test] +async fn locked_video_stops_on_capture_rejection_and_terminal_peer_close() { + tokio::time::timeout(Duration::from_secs(25), async { + let mut peers = VirtualPeers::new(VideoCodec::H264).await; + peers.connect(VideoCodec::H264).await; + let (_lock_tx, lock_rx) = watch::channel(Some(false)); + + let rejected_desktop = Arc::new(PortalDesktop::with_lock_state( + Arc::new(ScreenCapture::unavailable("synthetic pending desktop".into())), + lock_rx.clone(), + )); + let run_host = peers.host.clone(); + let run_track = peers.track.clone(); + let run_desktop = rejected_desktop.clone(); + let rejection_task = tokio::spawn(async move { run(&run_host, &run_track, &run_desktop).await }); + rejected_desktop.publish_test_screen( + Arc::new(ScreenCapture::unavailable("synthetic capture rejection".into())), + ); + assert_eq!(rejected_desktop.snapshot().permission(true), + crate::system_metrics::Permission::Reject); + tokio::time::timeout(Duration::from_secs(2), rejection_task).await + .expect("locked video task did not stop after capture rejection") + .expect("locked video task panicked") + .expect("locked video task returned an error"); + + let pending_desktop = Arc::new(PortalDesktop::with_lock_state( + Arc::new(ScreenCapture::unavailable("synthetic pending desktop".into())), + lock_rx, + )); + let run_host = peers.host.clone(); + let run_track = peers.track.clone(); + let run_desktop = pending_desktop; + let close_task = tokio::spawn(async move { run(&run_host, &run_track, &run_desktop).await }); + peers.host.close().await.unwrap(); + tokio::time::timeout(Duration::from_secs(2), close_task).await + .expect("locked video task did not stop after terminal peer close") + .expect("locked video task panicked") + .expect("locked video task returned an error"); + + peers.packets.close(); + assert!(peers.packets.try_recv().is_err(), "locked video task sent RTP while unlocked"); + peers.remote.close().await.unwrap(); + peers.router.lock().await.stop().await.unwrap(); + }).await.expect("locked video lifecycle test exceeded its deadline"); +} diff --git a/src/main.rs b/src/main.rs index 6f80a0b..ecd0b00 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ mod terminal; mod webrtc; mod video; mod video_track; +mod locked_video; #[cfg(test)] mod test_network; mod hevc; diff --git a/src/video.rs b/src/video.rs index 7277a42..e69024d 100644 --- a/src/video.rs +++ b/src/video.rs @@ -274,7 +274,7 @@ fn ffmpeg_spec() -> FfmpegSpec { } } -fn ffmpeg_command() -> Command { ffmpeg_spec().command() } +pub(crate) fn ffmpeg_command() -> Command { ffmpeg_spec().command() } #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum FfmpegTimingMode { Modern, Legacy } @@ -673,7 +673,7 @@ fn wayland_capture_command(executable:&std::path::Path,fps:u32)->Command { command } -fn transport_closed(pc: &RTCPeerConnection) -> bool { +pub(crate) fn transport_closed(pc: &RTCPeerConnection) -> bool { matches!(pc.connection_state(), RTCPeerConnectionState::Closed | RTCPeerConnectionState::Failed) || pc.sctp().state() == webrtc::sctp_transport::sctp_transport_state::RTCSctpTransportState::Closed } diff --git a/src/webrtc.rs b/src/webrtc.rs index 9a7a6e5..90c6ec6 100644 --- a/src/webrtc.rs +++ b/src/webrtc.rs @@ -287,12 +287,15 @@ impl WebRtcSession { let state_pc = streaming_pc.clone(); tokio::spawn(async move { let screen = if let Some(desktop) = stream_desktop { + if let Err(error) = crate::locked_video::run(&streaming_pc, &video_track, &desktop).await { + log::warn!("Locked desktop video unavailable: {error}"); + } let mut updates = desktop.subscribe(); loop { let state = updates.borrow_and_update().clone(); if state.ready() { break Some(state.screen); } if !state.waiting { return; } - if matches!(streaming_pc.connection_state(), RTCPeerConnectionState::Closed | RTCPeerConnectionState::Failed) { return; } + if crate::video::transport_closed(&streaming_pc) { return; } tokio::select! { changed = updates.changed() => if changed.is_err() { return; }, _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {},