Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
8 changes: 5 additions & 3 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand All @@ -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.

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的屏幕唤醒与锁屏恢复见下;剪贴板只在控制端的远控窗口处于前台时同步。
Expand All @@ -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` 才能唤醒已关闭的屏幕;缺少该协议不影响采集和编码。

Expand Down
101 changes: 101 additions & 0 deletions src/locked_video.rs
Original file line number Diff line number Diff line change
@@ -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<Bytes, String> {
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<F, Fut>(pc: &RTCPeerConnection, track: &VideoTrack, desktop: &PortalDesktop, mut encode: F) -> Result<(), String>
where F: FnMut(VideoCodec) -> Fut, Fut: std::future::Future<Output = Result<Bytes, String>> {
// #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)) => {
Comment on lines +79 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the synthetic access-unit write cancellation-safe

When a lock/readiness notification arrives after a backpressured send has begun, either changed() arm wins and drops write_sample_with_extensions in the middle of an access unit (the two-second timeout can do the same). VideoTrack::write_sample_with_extensions advances the shared timestamp up front and the sequence before each awaited packet write (src/video_track.rs:64-70), so the handoff to live capture can start after a partial, unmarked IDR and with a sequence gap, causing loss/NACKs or delayed first video on exactly the slow path this timeout covers. Make this access-unit write cancellation-safe, or allow the in-flight frame to finish before reacting to state changes.

Useful? React with 👍 / 👎.

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;
Loading
Loading