Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
@@ -0,0 +1,155 @@
//! Keyboard input handling for Wayland via the RemoteDesktop portal.
use std::collections::HashSet;
use ashpd::desktop::Session;
use ashpd::desktop::remote_desktop::{KeyState, RemoteDesktop};
use super::super::keysym::{XK_SHIFT_L, char_to_keysym, keysym_needs_shift};
use crate::Key;
/// Keyboard state for tracking auto-shifted keys.
pub struct Keyboard {
/// Keysyms currently held that required auto-shift.
/// When non-empty, shift is being held by us.
auto_shift_keys: HashSet<i32>,
}
impl Keyboard {
pub fn new() -> Self {
Self {
auto_shift_keys: HashSet::new(),
}
}
/// Types a string of text by sending keysym events.
pub async fn type_text<'a>(
&mut self,
remote_desktop: &RemoteDesktop<'a>,
session: &Session<'a, RemoteDesktop<'a>>,
text: &str,
) -> Result<(), String> {
for ch in text.chars() {
self.type_char(remote_desktop, session, ch).await?;
}
Ok(())
}
/// Sends a key down event for the given key.
///
/// For `Key::Char`, this will automatically press shift if needed.
pub async fn key_down<'a>(
&mut self,
remote_desktop: &RemoteDesktop<'a>,
session: &Session<'a, RemoteDesktop<'a>>,
key: &Key,
) -> Result<(), String> {
let (keysym, needs_shift) = self.resolve_key(key);
if needs_shift {
// Press shift only if this is the first auto-shifted key.
if self.auto_shift_keys.is_empty() {
self.press_shift(remote_desktop, session).await?;
}
self.auto_shift_keys.insert(keysym);
}
remote_desktop
.notify_keyboard_keysym(session, keysym, KeyState::Pressed)
.await
.map_err(|e| format!("Failed to send key down: {e}"))
}
/// Sends a key up event for the given key.
///
/// For `Key::Char`, this will automatically release shift if it was auto-pressed
/// and this is the last auto-shifted key being released.
pub async fn key_up<'a>(
&mut self,
remote_desktop: &RemoteDesktop<'a>,
session: &Session<'a, RemoteDesktop<'a>>,
key: &Key,
) -> Result<(), String> {
let (keysym, _) = self.resolve_key(key);
remote_desktop
.notify_keyboard_keysym(session, keysym, KeyState::Released)
.await
.map_err(|e| format!("Failed to send key up: {e}"))?;
// Release shift only if this key was auto-shifted and it's the last one.
if self.auto_shift_keys.remove(&keysym) && self.auto_shift_keys.is_empty() {
self.release_shift(remote_desktop, session).await?;
}
Ok(())
}
async fn type_char<'a>(
&mut self,
remote_desktop: &RemoteDesktop<'a>,
session: &Session<'a, RemoteDesktop<'a>>,
ch: char,
) -> Result<(), String> {
let keysym = char_to_keysym(ch) as i32;
let needs_shift = keysym_needs_shift(keysym as u32);
// Press shift if needed, then press and release the key, then release shift.
if needs_shift {
self.press_shift(remote_desktop, session).await?;
}
remote_desktop
.notify_keyboard_keysym(session, keysym, KeyState::Pressed)
.await
.map_err(|e| format!("Failed to send key press: {e}"))?;
remote_desktop
.notify_keyboard_keysym(session, keysym, KeyState::Released)
.await
.map_err(|e| format!("Failed to send key release: {e}"))?;
if needs_shift {
self.release_shift(remote_desktop, session).await?;
}
Ok(())
}
/// Resolves a `Key` to a keysym and shift requirement.
fn resolve_key(&self, key: &Key) -> (i32, bool) {
match key {
Key::Keycode(keysym) => {
// Key::Keycode uses X11 keysyms, which we can use directly.
(*keysym, false)
}
Key::Char(ch) => {
let keysym = char_to_keysym(*ch) as i32;
let needs_shift = keysym_needs_shift(keysym as u32);
(keysym, needs_shift)
}
}
}
async fn press_shift<'a>(
&self,
remote_desktop: &RemoteDesktop<'a>,
session: &Session<'a, RemoteDesktop<'a>>,
) -> Result<(), String> {
remote_desktop
.notify_keyboard_keysym(session, XK_SHIFT_L as i32, KeyState::Pressed)
.await
.map_err(|e| format!("Failed to press shift: {e}"))
}
async fn release_shift<'a>(
&self,
remote_desktop: &RemoteDesktop<'a>,
session: &Session<'a, RemoteDesktop<'a>>,
) -> Result<(), String> {
remote_desktop
.notify_keyboard_keysym(session, XK_SHIFT_L as i32, KeyState::Released)
.await
.map_err(|e| format!("Failed to release shift: {e}"))
}
}
@@ -0,0 +1,161 @@
//! Wayland implementation of computer use actions.
//!
//! This module handles the Wayland environment using native XDG portals:
//! - RemoteDesktop portal for input injection (keyboard, mouse)
//! - ScreenCast portal for absolute pointer positioning (stream IDs)
//! - Screenshot portal for taking screenshots
mod keyboard;
mod mouse;
mod screenshot;
mod session;
use async_trait::async_trait;
use pathfinder_geometry::vector::Vector2I;
use warpui::r#async::Timer;
use crate::{Action, ActionResult, Options};
use keyboard::Keyboard;
use mouse::Mouse;
use session::PortalSession;
/// An actor that performs computer use actions on Wayland via XDG portals.
pub struct Actor {
/// The portal session, created lazily on first use.
session: Option<PortalSession<'static>>,
/// Keyboard state for tracking shift and other modifiers.
keyboard: Keyboard,
/// Mouse state for tracking position.
mouse: Mouse,
}
impl Actor {
pub fn new() -> Result<Self, String> {
Ok(Self {
session: None,
keyboard: Keyboard::new(),
mouse: Mouse::new(),
})
}
/// Ensures a portal session is available, creating one if needed.
async fn ensure_session(&mut self) -> Result<(), String> {
if self.session.is_none() {
self.session = Some(PortalSession::new().await?);
// Wait for the permission dialog to fully dismiss before returning.
Timer::after(std::time::Duration::from_millis(500)).await;
}
Ok(())
}
}
#[async_trait]
impl crate::Actor for Actor {
fn platform(&self) -> Option<crate::Platform> {
Some(crate::Platform::LinuxWayland)
}
async fn perform_actions(
&mut self,
actions: &[Action],
options: Options,
) -> Result<ActionResult, String> {
// Ensure we have an active session before processing actions.
// This may show a permission dialog on first use.
self.ensure_session().await?;
let mut last_mouse_position: Option<Vector2I> = None;
for action in actions {
// Re-acquire session reference each iteration (borrow checker workaround).
let session = self
.session
.as_ref()
.expect("session must exist after ensure_session");
let remote_desktop = session.remote_desktop();
let portal_session = session.session();
let stream_id = session.stream_id();
match action {
Action::Wait(duration) => {
Timer::after(*duration).await;
}
Action::MouseDown { button, at } => {
session.require_pointer()?;
self.mouse
.move_to(remote_desktop, portal_session, stream_id, *at)
.await?;
self.mouse
.button_down(remote_desktop, portal_session, button)
.await?;
last_mouse_position = Some(*at);
}
Action::MouseUp { button } => {
session.require_pointer()?;
self.mouse
.button_up(remote_desktop, portal_session, button)
.await?;
}
Action::MouseMove { to } => {
session.require_pointer()?;
self.mouse
.move_to(remote_desktop, portal_session, stream_id, *to)
.await?;
last_mouse_position = Some(*to);
}
Action::MouseWheel {
at,
direction,
distance,
} => {
session.require_pointer()?;
self.mouse
.move_to(remote_desktop, portal_session, stream_id, *at)
.await?;
self.mouse
.scroll(remote_desktop, portal_session, direction, distance)
.await?;
last_mouse_position = Some(*at);
}
Action::TypeText { text } => {
session.require_keyboard()?;
self.keyboard
.type_text(remote_desktop, portal_session, text)
.await?;
}
Action::KeyDown { key } => {
session.require_keyboard()?;
self.keyboard
.key_down(remote_desktop, portal_session, key)
.await?;
}
Action::KeyUp { key } => {
session.require_keyboard()?;
self.keyboard
.key_up(remote_desktop, portal_session, key)
.await?;
}
}
}
// Take screenshot if requested.
let screenshot = if let Some(params) = options.screenshot_params {
Some(screenshot::take(params).await?)
} else {
None
};
// Get the final cursor position.
let cursor_position = if let Some(pos) = last_mouse_position {
Some(pos)
} else {
self.mouse.last_position()
};
Ok(ActionResult {
screenshot,
cursor_position,
})
}
}
@@ -0,0 +1,136 @@
//! Mouse input handling for Wayland via the RemoteDesktop portal.
use ashpd::desktop::Session;
use ashpd::desktop::remote_desktop::{Axis, KeyState, RemoteDesktop};
use pathfinder_geometry::vector::Vector2I;
use crate::{MouseButton, ScrollDirection, ScrollDistance};
/// Linux evdev button codes.
const BTN_LEFT: i32 = 0x110;
const BTN_RIGHT: i32 = 0x111;
const BTN_MIDDLE: i32 = 0x112;
const BTN_SIDE: i32 = 0x113; // Back.
const BTN_EXTRA: i32 = 0x114; // Forward.
/// Mouse state for the Wayland portal.
pub struct Mouse {
/// The last known mouse position (for returning in ActionResult).
last_position: Option<Vector2I>,
}
impl Mouse {
pub fn new() -> Self {
Self {
last_position: None,
}
}
/// Moves the mouse to an absolute position.
pub async fn move_to<'a>(
&mut self,
remote_desktop: &RemoteDesktop<'a>,
session: &Session<'a, RemoteDesktop<'a>>,
stream_id: u32,
target: Vector2I,
) -> Result<(), String> {
remote_desktop
.notify_pointer_motion_absolute(
session,
stream_id,
target.x() as f64,
target.y() as f64,
)
.await
.map_err(|e| format!("Failed to move mouse: {e}"))?;
self.last_position = Some(target);
Ok(())
}
/// Presses a mouse button down.
pub async fn button_down<'a>(
&self,
remote_desktop: &RemoteDesktop<'a>,
session: &Session<'a, RemoteDesktop<'a>>,
button: &MouseButton,
) -> Result<(), String> {
let evdev_button = mouse_button_to_evdev(button);
remote_desktop
.notify_pointer_button(session, evdev_button, KeyState::Pressed)
.await
.map_err(|e| format!("Failed to press mouse button: {e}"))
}
/// Releases a mouse button.
pub async fn button_up<'a>(
&self,
remote_desktop: &RemoteDesktop<'a>,
session: &Session<'a, RemoteDesktop<'a>>,
button: &MouseButton,
) -> Result<(), String> {
let evdev_button = mouse_button_to_evdev(button);
remote_desktop
.notify_pointer_button(session, evdev_button, KeyState::Released)
.await
.map_err(|e| format!("Failed to release mouse button: {e}"))
}
/// Performs a scroll action.
pub async fn scroll<'a>(
&self,
remote_desktop: &RemoteDesktop<'a>,
session: &Session<'a, RemoteDesktop<'a>>,
direction: &ScrollDirection,
distance: &ScrollDistance,
) -> Result<(), String> {
match distance {
ScrollDistance::Clicks(clicks) => {
// Use discrete scrolling for click-based scrolling.
let (axis, steps) = match direction {
ScrollDirection::Up => (Axis::Vertical, -*clicks),
ScrollDirection::Down => (Axis::Vertical, *clicks),
ScrollDirection::Left => (Axis::Horizontal, -*clicks),
ScrollDirection::Right => (Axis::Horizontal, *clicks),
};
remote_desktop
.notify_pointer_axis_discrete(session, axis, steps)
.await
.map_err(|e| format!("Failed to scroll: {e}"))
}
ScrollDistance::Pixels(pixels) => {
// Use smooth scrolling for pixel-based scrolling.
let (dx, dy) = match direction {
ScrollDirection::Up => (0.0, -(*pixels as f64)),
ScrollDirection::Down => (0.0, *pixels as f64),
ScrollDirection::Left => (-(*pixels as f64), 0.0),
ScrollDirection::Right => (*pixels as f64, 0.0),
};
remote_desktop
.notify_pointer_axis(session, dx, dy, true)
.await
.map_err(|e| format!("Failed to scroll: {e}"))
}
}
}
/// Returns the last known mouse position.
pub fn last_position(&self) -> Option<Vector2I> {
self.last_position
}
}
/// Converts a MouseButton to a Linux evdev button code.
fn mouse_button_to_evdev(button: &MouseButton) -> i32 {
match button {
MouseButton::Left => BTN_LEFT,
MouseButton::Right => BTN_RIGHT,
MouseButton::Middle => BTN_MIDDLE,
MouseButton::Back => BTN_SIDE,
MouseButton::Forward => BTN_EXTRA,
}
}
@@ -0,0 +1,127 @@
//! Screenshot capture for Wayland using the XDG Desktop Portal.
use std::collections::HashMap;
use futures::StreamExt as _;
use zbus::zvariant;
use crate::{Screenshot, ScreenshotParams};
/// A D-Bus proxy for the Screenshot portal.
#[zbus::proxy(
interface = "org.freedesktop.portal.Screenshot",
default_service = "org.freedesktop.portal.Desktop",
default_path = "/org/freedesktop/portal/desktop"
)]
trait ScreenshotPortal {
/// Takes a screenshot.
///
/// Returns an object path for a Request object that will receive the response.
fn screenshot(
&self,
parent_window: &str,
options: HashMap<&str, zvariant::Value<'_>>,
) -> zbus::fdo::Result<zvariant::OwnedObjectPath>;
}
/// A D-Bus proxy for portal Request objects.
#[zbus::proxy(
interface = "org.freedesktop.portal.Request",
default_service = "org.freedesktop.portal.Desktop"
)]
trait PortalRequest {
/// Signal emitted when the request completes.
#[zbus(signal)]
fn response(
&self,
response: u32,
results: HashMap<String, zvariant::OwnedValue>,
) -> zbus::fdo::Result<()>;
}
/// Takes a screenshot using the XDG Desktop Portal.
pub async fn take(params: ScreenshotParams) -> Result<Screenshot, String> {
let connection = zbus::Connection::session()
.await
.map_err(|e| format!("Failed to connect to D-Bus session bus: {e}"))?;
let screenshot_proxy = ScreenshotPortalProxy::new(&connection)
.await
.map_err(|e| format!("Failed to create screenshot portal proxy: {e}"))?;
// Request a non-interactive screenshot.
let mut options: HashMap<&str, zvariant::Value> = HashMap::new();
options.insert("interactive", zvariant::Value::Bool(false));
let request_path = screenshot_proxy
.screenshot("", options)
.await
.map_err(|e| format!("Failed to request screenshot: {e}"))?;
// Wait for the response signal.
let request_proxy = PortalRequestProxy::builder(&connection)
.path(request_path)
.map_err(|e| format!("Failed to build request proxy: {e}"))?
.build()
.await
.map_err(|e| format!("Failed to create request proxy: {e}"))?;
let mut response_stream = request_proxy
.receive_response()
.await
.map_err(|e| format!("Failed to subscribe to response signal: {e}"))?;
// Wait for the response.
let response = response_stream
.next()
.await
.ok_or("Screenshot request was cancelled or timed out")?;
let args = response
.args()
.map_err(|e| format!("Failed to get response arguments: {e}"))?;
// Response code 0 means success, 1 means cancelled, 2 means other error.
if args.response != 0 {
return Err(format!(
"Screenshot request failed with response code: {}",
args.response
));
}
// Extract the URI from the results.
let uri_value = args
.results
.get("uri")
.ok_or("Screenshot response missing 'uri' field")?;
let uri: &str = uri_value
.downcast_ref()
.map_err(|e| format!("Failed to get URI from response: {e}"))?;
// Parse the file:// URI and read the file.
let path = url::Url::parse(uri)
.map_err(|e| format!("Failed to parse screenshot URI: {e}"))?
.to_file_path()
.map_err(|_| format!("Screenshot URI is not a valid file path: {uri}"))?;
// Load the image from the temporary file.
let img = image::ImageReader::open(&path)
.map_err(|e| format!("Failed to open screenshot file: {e}"))?
.decode()
.map_err(|e| format!("Failed to decode screenshot: {e}"))?;
// Clean up the temporary file created by the portal.
let _ = std::fs::remove_file(&path);
// If capturing a region, crop the full-display image.
// The XDG Portal doesn't support region capture natively.
let img = if let Some(region) = params.region {
region.validate()?;
crate::screenshot_utils::crop_to_region(img, region.top_left, region.bottom_right)
} else {
img
};
crate::screenshot_utils::process_screenshot(img, params)
}
@@ -0,0 +1,149 @@
//! Session management for the XDG RemoteDesktop and ScreenCast portals.
//!
//! This module handles creating and maintaining a portal session that enables
//! input emulation via the RemoteDesktop portal and provides stream IDs for
//! absolute pointer positioning via the ScreenCast portal.
use ashpd::desktop::PersistMode;
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop};
use ashpd::desktop::screencast::{CursorMode, Screencast, SourceType};
use ashpd::enumflags2::BitFlags;
/// A portal session that provides input emulation capabilities.
///
/// This session combines RemoteDesktop (for input) and ScreenCast (for absolute
/// positioning coordinates) into a single session with one permission dialog.
pub struct PortalSession<'a> {
remote_desktop: RemoteDesktop<'a>,
session: ashpd::desktop::Session<'a, RemoteDesktop<'a>>,
/// The PipeWire stream node ID for the primary monitor.
/// Used for absolute pointer positioning.
stream_id: u32,
/// The device types that were granted by the user.
granted_devices: BitFlags<DeviceType>,
}
impl<'a> PortalSession<'a> {
/// Creates and starts a new portal session.
///
/// This will show a permission dialog to the user (unless permissions are
/// already persisted from a previous session).
pub async fn new() -> Result<Self, String> {
let remote_desktop = RemoteDesktop::new()
.await
.map_err(|e| format!("Failed to create RemoteDesktop proxy: {e}"))?;
let screencast = Screencast::new()
.await
.map_err(|e| format!("Failed to create Screencast proxy: {e}"))?;
// Create a RemoteDesktop session. This session is shared with ScreenCast.
let session = remote_desktop
.create_session()
.await
.map_err(|e| format!("Failed to create RemoteDesktop session: {e}"))?;
// Select input devices (keyboard and pointer).
// Note: Some portals don't support persistence for remote desktop sessions,
// so we use DoNot to avoid errors.
remote_desktop
.select_devices(
&session,
DeviceType::Keyboard | DeviceType::Pointer,
None, // No restore token.
PersistMode::DoNot,
)
.await
.map_err(|e| format!("Failed to select devices: {e}"))?;
// Select screencast sources (monitors) to get stream IDs for absolute positioning.
// We use CursorMode::Metadata since we only need the stream ID, not the video.
screencast
.select_sources(
&session,
CursorMode::Metadata,
SourceType::Monitor.into(),
true, // Allow multiple monitors.
None, // No restore token.
PersistMode::DoNot,
)
.await
.map_err(|e| format!("Failed to select screencast sources: {e}"))?;
// Start the session. This shows the permission dialog to the user.
let response = remote_desktop
.start(&session, None)
.await
.map_err(|e| format!("Failed to start session request: {e}"))?
.response()
.map_err(|e| format!("Session start failed: {e}"))?;
// Extract the stream ID from the response.
let streams = response
.streams()
.ok_or("No streams returned from ScreenCast")?;
if streams.is_empty() {
return Err("No monitors available for screen casting".to_string());
}
// Use the first stream (primary monitor) for now.
let stream_id = streams[0].pipe_wire_node_id();
// Get the devices that were actually granted by the user.
let granted_devices = response.devices();
Ok(Self {
remote_desktop,
session,
stream_id,
granted_devices,
})
}
/// Returns a reference to the RemoteDesktop proxy.
pub fn remote_desktop(&self) -> &RemoteDesktop<'a> {
&self.remote_desktop
}
/// Returns a reference to the session.
pub fn session(&self) -> &ashpd::desktop::Session<'a, RemoteDesktop<'a>> {
&self.session
}
/// Returns the stream ID for the primary monitor.
///
/// This ID is used for absolute pointer positioning via
/// `notify_pointer_motion_absolute`.
pub fn stream_id(&self) -> u32 {
self.stream_id
}
/// Validates that keyboard input permission was granted.
///
/// Returns an error with a clear message for the agent if permission was denied.
pub fn require_keyboard(&self) -> Result<(), String> {
if self.granted_devices.contains(DeviceType::Keyboard) {
Ok(())
} else {
Err(
"Keyboard input permission was not granted. \
The user must allow keyboard input in the portal dialog to perform keyboard actions."
.to_string(),
)
}
}
/// Validates that pointer/mouse input permission was granted.
///
/// Returns an error with a clear message for the agent if permission was denied.
pub fn require_pointer(&self) -> Result<(), String> {
if self.granted_devices.contains(DeviceType::Pointer) {
Ok(())
} else {
Err("Mouse input permission was not granted. \
The user must allow mouse input in the portal dialog to perform mouse actions."
.to_string())
}
}
}