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
42 changes: 42 additions & 0 deletions crates/box-cua/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,27 @@ impl CuaConfig {
}
}

/// This config on another of the box's screens: a Bot's own screen on a computer several
/// Bots share is its own X display (`box-screen`), with the same size as the shared one.
/// `None` is the box's own display. Only a display name (`:N`) is taken.
pub fn on(&self, display: Option<&str>) -> Result<Self, CuaError> {
let Some(display) = display.map(str::trim).filter(|d| !d.is_empty()) else {
return Ok(self.clone());
};
let number = display.strip_prefix(':').unwrap_or("");
let named =
!number.is_empty() && number.len() <= 3 && number.bytes().all(|b| b.is_ascii_digit());
if !named {
return Err(CuaError::Invalid(format!(
"display must be :N, not {display:?}"
)));
}
Ok(Self {
display: display.to_string(),
..self.clone()
})
}

/// True when CUA is enabled and the X display socket is present.
pub fn capability_ready(&self) -> bool {
self.enabled && display_up(&self.display)
Expand Down Expand Up @@ -682,6 +703,27 @@ pub fn drag_waypoints(

#[cfg(test)]
mod tests {

#[test]
fn a_request_may_name_another_screen_of_the_box() {
let shared = CuaConfig {
display: ":1".into(),
width: 1280,
height: 800,
enabled: true,
};
assert_eq!(shared.on(None).unwrap().display, ":1");
assert_eq!(shared.on(Some("")).unwrap().display, ":1");
let own = shared.on(Some(":3")).unwrap();
assert_eq!(
(own.display.as_str(), own.width, own.height),
(":3", 1280, 800)
);
for bad in ["3", ":", ":1.0", ":abc", ":1234", "; rm -rf /"] {
assert!(shared.on(Some(bad)).is_err(), "{bad:?}");
}
}

use super::*;

#[test]
Expand Down
85 changes: 60 additions & 25 deletions crates/box-exec/src/cua.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ use crate::AppState;
#[derive(Debug, Deserialize)]
pub struct ScreenshotQuery {
pub format: Option<String>,
/// Another of the box's screens (`:N`); see [`OnScreen`].
pub display: Option<String>,
}

/// `?display=:N`: the action lands on that screen of the box, a Bot's own on a computer several
/// Bots share (`box-screen`). Without it, the box's own display, as always.
#[derive(Debug, Default, Deserialize)]
pub struct OnScreen {
pub display: Option<String>,
}

pub fn map_err(err: CuaError) -> ApiError {
Expand Down Expand Up @@ -67,93 +76,105 @@ pub async fn screenshot_handler(
headers: HeaderMap,
Query(query): Query<ScreenshotQuery>,
) -> Result<Response, ApiError> {
let cua = state.cua.on(query.display.as_deref()).map_err(map_err)?;
if want_png(&headers, &query) {
let png = screenshot_png(&state.cua).await.map_err(map_err)?;
let png = screenshot_png(&cua).await.map_err(map_err)?;
return Ok(([(header::CONTENT_TYPE, "image/png")], png).into_response());
}
screenshot(&state.cua)
screenshot(&cua)
.await
.map(|body| Json(body).into_response())
.map_err(map_err)
}

pub async fn click_handler(
State(state): State<AppState>,
Query(on): Query<OnScreen>,
Json(req): Json<ClickRequest>,
) -> Result<Json<OkResponse>, ApiError> {
click(&state.cua, &req).await.map(Json).map_err(map_err)
let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?;
click(&cua, &req).await.map(Json).map_err(map_err)
}

pub async fn double_click_handler(
State(state): State<AppState>,
Query(on): Query<OnScreen>,
Json(req): Json<ClickRequest>,
) -> Result<Json<OkResponse>, ApiError> {
double_click(&state.cua, &req)
.await
.map(Json)
.map_err(map_err)
let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?;
double_click(&cua, &req).await.map(Json).map_err(map_err)
}

pub async fn move_handler(
State(state): State<AppState>,
Query(on): Query<OnScreen>,
Json(req): Json<MoveRequest>,
) -> Result<Json<OkResponse>, ApiError> {
move_pointer(&state.cua, &req)
.await
.map(Json)
.map_err(map_err)
let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?;
move_pointer(&cua, &req).await.map(Json).map_err(map_err)
}

pub async fn drag_handler(
State(state): State<AppState>,
Query(on): Query<OnScreen>,
Json(req): Json<DragRequest>,
) -> Result<Json<OkResponse>, ApiError> {
drag(&state.cua, &req).await.map(Json).map_err(map_err)
let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?;
drag(&cua, &req).await.map(Json).map_err(map_err)
}

pub async fn press_handler(
State(state): State<AppState>,
Query(on): Query<OnScreen>,
Json(req): Json<ClickRequest>,
) -> Result<Json<OkResponse>, ApiError> {
press(&state.cua, &req).await.map(Json).map_err(map_err)
let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?;
press(&cua, &req).await.map(Json).map_err(map_err)
}

pub async fn release_handler(
State(state): State<AppState>,
Query(on): Query<OnScreen>,
Json(req): Json<ReleaseRequest>,
) -> Result<Json<OkResponse>, ApiError> {
release(&state.cua, &req).await.map(Json).map_err(map_err)
let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?;
release(&cua, &req).await.map(Json).map_err(map_err)
}

pub async fn type_handler(
State(state): State<AppState>,
Query(on): Query<OnScreen>,
Json(req): Json<TypeRequest>,
) -> Result<Json<OkResponse>, ApiError> {
type_text(&state.cua, &req).await.map(Json).map_err(map_err)
let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?;
type_text(&cua, &req).await.map(Json).map_err(map_err)
}

pub async fn key_handler(
State(state): State<AppState>,
Query(on): Query<OnScreen>,
Json(req): Json<KeyRequest>,
) -> Result<Json<OkResponse>, ApiError> {
key(&state.cua, &req).await.map(Json).map_err(map_err)
let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?;
key(&cua, &req).await.map(Json).map_err(map_err)
}

pub async fn scroll_handler(
State(state): State<AppState>,
Query(on): Query<OnScreen>,
Json(req): Json<ScrollRequest>,
) -> Result<Json<OkResponse>, ApiError> {
scroll(&state.cua, &req).await.map(Json).map_err(map_err)
let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?;
scroll(&cua, &req).await.map(Json).map_err(map_err)
}

pub async fn recipe_handler(
State(state): State<AppState>,
Query(on): Query<OnScreen>,
Json(req): Json<RecipeRequest>,
) -> Result<Json<RecipeResponse>, ApiError> {
run_recipe(&state.cua, &req)
.await
.map(Json)
.map_err(map_err)
let cua = state.cua.on(on.display.as_deref()).map_err(map_err)?;
run_recipe(&cua, &req).await.map(Json).map_err(map_err)
}

#[cfg(test)]
Expand All @@ -166,13 +187,15 @@ mod tests {
assert!(want_png(
&headers,
&ScreenshotQuery {
format: Some("png".into())
format: Some("png".into()),
display: None,
}
));
assert!(!want_png(
&headers,
&ScreenshotQuery {
format: Some("json".into())
format: Some("json".into()),
display: None,
}
));
}
Expand All @@ -181,8 +204,20 @@ mod tests {
fn png_from_accept() {
let mut headers = HeaderMap::new();
headers.insert(header::ACCEPT, "image/png".parse().unwrap());
assert!(want_png(&headers, &ScreenshotQuery { format: None }));
assert!(want_png(
&headers,
&ScreenshotQuery {
format: None,
display: None,
}
));
headers.insert(header::ACCEPT, "*/*".parse().unwrap());
assert!(!want_png(&headers, &ScreenshotQuery { format: None }));
assert!(!want_png(
&headers,
&ScreenshotQuery {
format: None,
display: None,
}
));
}
}
39 changes: 38 additions & 1 deletion crates/box-host/src/active_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,32 @@ use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

/// Where `box-active-tab` keeps the latest report. Removed when Chromium closes the port.
/// Where `box-active-tab` keeps the latest report for the box's own screen. Removed when
/// Chromium closes the port.
pub const ACTIVE_TAB_FILE: &str = "/tmp/box-active-tab.json";

/// Where the report for a screen is kept. Each of the box's screens has its own Chromium
/// (`box-screen`: a Bot's own screen on a computer several Bots share), and each Chromium starts
/// its own `box-active-tab`, which inherits that Chromium's `DISPLAY`. The shared screen
/// (`BOX_SHARED_DISPLAY`, `:1` by default) keeps [`ACTIVE_TAB_FILE`]; any other `:N` is
/// `/tmp/box-active-tab.N.json`. Anything that is not a display name is the shared screen.
///
/// Not `BOX_DISPLAY`: a Bot's own Chromium is started with `BOX_DISPLAY` set to its own screen,
/// and its host would take that screen for the shared one.
pub fn file_for(display: Option<&str>) -> std::path::PathBuf {
let own = std::env::var("BOX_SHARED_DISPLAY").unwrap_or_else(|_| ":1".to_string());
let number = |d: &str| {
let n = d.trim().strip_prefix(':')?.split('.').next()?.to_string();
(!n.is_empty() && n.len() <= 3 && n.bytes().all(|b| b.is_ascii_digit())).then_some(n)
};
match display.and_then(number) {
Some(n) if Some(n.clone()) != number(&own) => {
std::path::PathBuf::from(format!("/tmp/box-active-tab.{n}.json"))
}
_ => std::path::PathBuf::from(ACTIVE_TAB_FILE),
}
}

/// Native messaging caps a message to the host at 64 MiB; a URL is far smaller, and anything
/// past this is not the extension speaking.
const MAX_MESSAGE: u32 = 64 * 1024;
Expand Down Expand Up @@ -185,6 +208,20 @@ mod tests {
);
}

/// Each screen's Chromium reports to its own file; the box's own screen keeps the old one.
#[test]
fn each_screen_has_its_own_report() {
assert_eq!(file_for(None), Path::new(ACTIVE_TAB_FILE));
assert_eq!(file_for(Some(":1")), Path::new(ACTIVE_TAB_FILE));
assert_eq!(file_for(Some(":1.0")), Path::new(ACTIVE_TAB_FILE));
assert_eq!(
file_for(Some(":3")),
Path::new("/tmp/box-active-tab.3.json")
);
assert_eq!(file_for(Some("../../etc")), Path::new(ACTIVE_TAB_FILE));
assert_eq!(file_for(Some(":3/../x")), Path::new(ACTIVE_TAB_FILE));
}

#[test]
fn a_message_longer_than_any_url_is_refused() {
let input = (MAX_MESSAGE + 1).to_ne_bytes();
Expand Down
6 changes: 4 additions & 2 deletions crates/box-host/src/bin/box-active-tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
//! it records the page in front until the browser closes the port. See `box_host::active_tab`.

fn main() {
let path = std::path::Path::new(box_host::active_tab::ACTIVE_TAB_FILE);
if let Err(error) = box_host::active_tab::serve(&mut std::io::stdin().lock(), path) {
// Chromium starts this host, so its DISPLAY is the screen that Chromium is on.
let display = std::env::var("DISPLAY").ok();
let path = box_host::active_tab::file_for(display.as_deref());
if let Err(error) = box_host::active_tab::serve(&mut std::io::stdin().lock(), &path) {
eprintln!("box-active-tab: {error}");
std::process::exit(1);
}
Expand Down
17 changes: 12 additions & 5 deletions crates/box-host/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use std::time::{Duration, Instant};

use axum::extract::{Request, State};
use axum::extract::{Query, Request, State};
use axum::middleware::{self, Next};
use axum::response::Response;
use axum::routing::{get, post};
Expand Down Expand Up @@ -261,11 +261,18 @@ async fn egress(State(state): State<AppState>) -> Json<EgressStatus> {
Json(probe_egress(&state.egress))
}

/// `?display=:N`: one of the box's other screens (a Bot's own, `box-screen`).
#[derive(Debug, Default, serde::Deserialize)]
struct OnScreen {
display: Option<String>,
}

/// The page in front of Chromium, as the box's extension last reported it (`url: null` when
/// nothing has). Read before a saved login is typed; see [`active_tab`].
async fn active_tab_now() -> Json<active_tab::ActiveTab> {
Json(active_tab::read(std::path::Path::new(
active_tab::ACTIVE_TAB_FILE,
/// nothing has), on the box's own screen or the one named. Read before a saved login is typed;
/// see [`active_tab`].
async fn active_tab_now(Query(on): Query<OnScreen>) -> Json<active_tab::ActiveTab> {
Json(active_tab::read(&active_tab::file_for(
on.display.as_deref(),
)))
}

Expand Down
Loading
Loading