use crate::{
CuaError, ExitInfo, ImageFormat, Point, ProcessOutput, Result, Screenshot, ScreenshotOptions,
SpacesdCapabilities, SpacesdCommand,
};
use cua_proto::env::v1::{
self as pb, computer_service_client::ComputerServiceClient,
process_service_client::ProcessServiceClient, system_service_client::SystemServiceClient,
};
use std::{collections::HashMap, sync::Arc};
#[cfg(target_arch = "wasm32")]
fn send<F: std::future::Future>(f: F) -> F {
f
}
#[cfg(not(target_arch = "wasm32"))]
fn send<F: std::future::Future>(f: F) -> AssertSend<F> {
AssertSend(f)
}
#[cfg(not(target_arch = "wasm32"))]
struct AssertSend<F>(F);
#[cfg(not(target_arch = "wasm32"))]
unsafe impl<F> Send for AssertSend<F> {}
#[cfg(not(target_arch = "wasm32"))]
impl<F: std::future::Future> std::future::Future for AssertSend<F> {
type Output = F::Output;
fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<F::Output> {
unsafe { self.map_unchecked_mut(|s| &mut s.0) }.poll(cx)
}
}
type Channel = tonic::service::interceptor::InterceptedService<tonic_web_wasm_client::Client, Auth>;
#[derive(Clone)]
struct Auth(Option<tonic::metadata::MetadataValue<tonic::metadata::Ascii>>);
impl tonic::service::Interceptor for Auth {
fn call(
&mut self,
mut req: tonic::Request<()>,
) -> std::result::Result<tonic::Request<()>, tonic::Status> {
if let Some(v) = &self.0 {
req.metadata_mut().insert("authorization", v.clone());
}
Ok(req)
}
}
fn status(e: tonic::Status) -> CuaError {
let m = e.message().to_string();
match e.code() {
tonic::Code::Unauthenticated => CuaError::Unauthenticated(m),
tonic::Code::NotFound => CuaError::NotFound(m),
tonic::Code::InvalidArgument => CuaError::InvalidArgument(m),
tonic::Code::Unimplemented => CuaError::Unsupported(m),
tonic::Code::PermissionDenied => CuaError::PermissionDenied(m),
tonic::Code::DeadlineExceeded => CuaError::Timeout(m),
tonic::Code::Unavailable => CuaError::Transport(m),
_ => CuaError::Env(m),
}
}
#[derive(uniffi::Object)]
pub struct Cua {}
#[uniffi::export]
impl Cua {
#[uniffi::constructor]
pub fn embedded() -> Arc<Self> {
Arc::new(Self {})
}
pub async fn spacesd(&self, url: String, token: Option<String>) -> Result<Arc<SpacesdClient>> {
send(async move {
let base = url.trim_end_matches('/').to_string();
let auth = token
.filter(|t| !t.is_empty())
.map(|t| {
format!("Bearer {t}")
.parse()
.map_err(|_| CuaError::InvalidArgument("token".into()))
})
.transpose()?;
let channel = tonic::service::interceptor::InterceptedService::new(
tonic_web_wasm_client::Client::new(base),
Auth(auth),
);
let client = SpacesdClient { channel };
client.capabilities().await.map_err(|e| match e {
CuaError::Unauthenticated(m) => CuaError::Unauthenticated(m),
other => CuaError::SpacesdNotAvailable(format!("{url}: {other}")),
})?;
Ok(Arc::new(client))
})
.await
}
}
#[derive(uniffi::Object)]
pub struct SpacesdClient {
channel: Channel,
}
impl SpacesdClient {
fn system(&self) -> SystemServiceClient<Channel> {
SystemServiceClient::new(self.channel.clone())
}
fn computer(&self) -> ComputerServiceClient<Channel> {
ComputerServiceClient::new(self.channel.clone()).max_decoding_message_size(64 * 1024 * 1024)
}
fn process(&self) -> ProcessServiceClient<Channel> {
ProcessServiceClient::new(self.channel.clone())
}
async fn pointer(&self, action: pb::pointer_request::Action) -> Result<()> {
self.computer()
.pointer(pb::PointerRequest {
target: None,
action: Some(action),
})
.await
.map_err(status)?;
Ok(())
}
async fn keyboard(&self, action: pb::keyboard_request::Action) -> Result<()> {
self.computer()
.keyboard(pb::KeyboardRequest {
target: None,
action: Some(action),
})
.await
.map_err(status)?;
Ok(())
}
}
fn key(name: &str) -> pb::KeyInput {
let upper = name
.trim_start_matches("KEY_")
.to_ascii_uppercase()
.replace(['-', ' '], "_");
let alias = match upper.as_str() {
"CTRL" => "CONTROL",
"CMD" | "COMMAND" | "SUPER" | "WIN" => "META",
"OPTION" | "OPT" => "ALT",
"ESC" => "ESCAPE",
"RETURN" => "ENTER",
"UP" => "ARROW_UP",
"DOWN" => "ARROW_DOWN",
"LEFT" => "ARROW_LEFT",
"RIGHT" => "ARROW_RIGHT",
other => other,
};
pb::KeyInput {
key: Some(match pb::Key::from_str_name(&format!("KEY_{alias}")) {
Some(k) => pb::key_input::Key::Named(k as i32),
None => pb::key_input::Key::Character(name.to_string()),
}),
}
}
#[uniffi::export]
impl SpacesdClient {
pub async fn capabilities(&self) -> Result<SpacesdCapabilities> {
send(async move {
Ok(self
.system()
.get_capabilities(pb::GetCapabilitiesRequest {})
.await
.map_err(status)?
.into_inner()
.into())
})
.await
}
pub async fn health(&self) -> Result<String> {
send(async move {
let h = self
.system()
.health(pb::HealthRequest {})
.await
.map_err(status)?
.into_inner();
Ok(serde_json::to_string(&h)?)
})
.await
}
pub async fn run(&self, command: SpacesdCommand) -> Result<ProcessOutput> {
send(async move {
let req = pb::StartProcessRequest {
config: Some(pb::ProcessConfig {
command: command.program,
args: command.args,
env: command.env,
cwd: command.cwd.unwrap_or_default(),
user: command.user.unwrap_or_default(),
timeout: command.timeout_ms.map(pbjson_types_duration),
}),
tag: command.tag.unwrap_or_default(),
..Default::default()
};
let mut stream = self
.process()
.start_process(req)
.await
.map_err(status)?
.into_inner();
let mut out = ProcessOutput {
exit: ExitInfo {
code: None,
signal: None,
timed_out: false,
error: None,
success: false,
},
stdout: vec![],
stderr: vec![],
pty: vec![],
};
let mut total = 0usize;
while let Some(msg) = stream.message().await.map_err(status)? {
use pb::process_event::Event;
match msg.event.and_then(|e| e.event) {
Some(Event::Data(d)) => {
use pb::process_data::Output;
let (buf, bytes) = match d.output {
Some(Output::Stdout(b)) => (&mut out.stdout, b),
Some(Output::Stderr(b)) => (&mut out.stderr, b),
Some(Output::Pty(b)) => (&mut out.pty, b),
None => continue,
};
total += bytes.len();
if total > 64 * 1024 * 1024 {
return Err(CuaError::Env("process output exceeds 64 MiB".into()));
}
buf.extend_from_slice(&bytes);
}
Some(Event::End(end)) => {
out.exit = ExitInfo {
success: end.exit_code == Some(0),
code: end.exit_code,
signal: pb::Signal::try_from(end.signal)
.ok()
.filter(|s| *s != pb::Signal::Unspecified)
.map(|s| crate::types::enum_suffix(format!("{s:?}"))),
timed_out: end.timed_out,
error: (!end.error.is_empty()).then_some(end.error),
};
break;
}
_ => {}
}
}
Ok(out)
})
.await
}
pub async fn sh(&self, line: String, timeout_ms: Option<u32>) -> Result<ProcessOutput> {
send(async move {
self.run(SpacesdCommand {
program: "/bin/sh".into(),
args: vec!["-c".into(), line],
env: HashMap::new(),
cwd: None,
user: None,
timeout_ms,
tag: None,
stdin: false,
pty: None,
kill_on_disconnect: false,
})
.await
})
.await
}
pub async fn screenshot(&self, options: Option<ScreenshotOptions>) -> Result<Screenshot> {
send(async move {
let o = options.unwrap_or(ScreenshotOptions {
display: None,
format: None,
quality: None,
max_dimension: None,
include_cursor: false,
});
let r = self
.computer()
.screenshot(pb::ScreenshotRequest {
source: o.display.map(pb::screenshot_request::Source::DisplayId),
region: None,
format: o
.format
.map(ImageFormat::to_pb)
.unwrap_or(pb::ImageFormat::Png) as i32,
quality: o.quality.unwrap_or(0),
max_dimension: o.max_dimension.unwrap_or(0),
include_cursor: o.include_cursor,
})
.await
.map_err(status)?
.into_inner();
let size = r.image_size.unwrap_or_default();
Ok(Screenshot {
format: ImageFormat::from_pb(
pb::ImageFormat::try_from(r.format).unwrap_or_default(),
),
width: size.width,
height: size.height,
scale: r.scale,
screenshot_id: r.screenshot_id,
image: r.image.to_vec(),
})
})
.await
}
pub async fn click(&self, x: f64, y: f64) -> Result<()> {
send(async move {
self.pointer(pb::pointer_request::Action::Click(pb::PointerClick {
position: Some(pb::Point { x, y }),
button: pb::MouseButton::Left as i32,
count: 1,
modifiers: vec![],
}))
.await
})
.await
}
pub async fn move_to(&self, x: f64, y: f64) -> Result<()> {
send(async move {
self.pointer(pb::pointer_request::Action::Move(pb::PointerMove {
position: Some(pb::Point { x, y }),
duration: None,
}))
.await
})
.await
}
pub async fn scroll(&self, dx: f64, dy: f64) -> Result<()> {
send(async move {
self.pointer(pb::pointer_request::Action::Scroll(pb::PointerScroll {
position: None,
delta_x: dx,
delta_y: dy,
unit: pb::ScrollUnit::Line as i32,
}))
.await
})
.await
}
pub async fn type_text(&self, text: String) -> Result<()> {
send(async move {
self.keyboard(pb::keyboard_request::Action::Type(pb::KeyboardType {
text,
mode: pb::TextEntryMode::Keystrokes as i32,
delay: None,
}))
.await
})
.await
}
pub async fn press(&self, key_name: String) -> Result<()> {
send(async move {
self.keyboard(pb::keyboard_request::Action::Press(pb::KeyboardPress {
key: Some(key(&key_name)),
modifiers: vec![],
repeat: 1,
}))
.await
})
.await
}
pub async fn hotkey(&self, keys: Vec<String>) -> Result<()> {
send(async move {
self.keyboard(pb::keyboard_request::Action::Hotkey(pb::KeyboardHotkey {
keys: keys.iter().map(|k| key(k)).collect(),
}))
.await
})
.await
}
pub async fn get_clipboard(&self) -> Result<Option<String>> {
send(async move {
Ok(self
.computer()
.get_clipboard(pb::GetClipboardRequest {})
.await
.map_err(status)?
.into_inner()
.content
.and_then(|c| c.text))
})
.await
}
pub async fn set_clipboard(&self, text: String) -> Result<u64> {
send(async move {
Ok(self
.computer()
.set_clipboard(pb::SetClipboardRequest {
content: Some(pb::ClipboardContent {
text: Some(text),
file_paths: vec![],
image_png: None,
}),
})
.await
.map_err(status)?
.into_inner()
.generation)
})
.await
}
pub async fn cursor_position(&self) -> Result<Point> {
send(async move {
let p = self
.computer()
.get_cursor_position(pb::GetCursorPositionRequest {})
.await
.map_err(status)?
.into_inner()
.position
.unwrap_or_default();
Ok(Point { x: p.x, y: p.y })
})
.await
}
pub async fn call_json(&self, method: String, request_json: String) -> Result<String> {
send(async move {
let path = crate::json::normalize(&method)?;
crate::json::call_unary(self.channel.clone(), &path, &request_json).await
})
.await
}
}
fn pbjson_types_duration(ms: u32) -> cua_proto::wkt::Duration {
cua_proto::wkt::Duration {
seconds: i64::from(ms / 1000),
nanos: ((ms % 1000) * 1_000_000) as i32,
}
}