use super::teleport_types::TeleportManifest;
use super::{AudioSink, Backend, FrameSink, MediaEvent, run};
use crate::{CuaError, Result};
use cua_daemon::client::DaemonClient;
use cua_proto::daemon::v1 as dpb;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
pub const SPACES_TOOL_METHODS: &[(&str, &str)] = &[
("add_space", "Spaces.add"),
("remove_space", "Spaces.remove"),
("list_spaces", "Spaces.list"),
("create_space", "Spaces.create"),
("delete_space", "Spaces.delete"),
("stop_space", "Spaces.stop"),
("start_space", "Spaces.start"),
("space_bash", "Space.bash"),
("space_write", "Space.write"),
("upload", "Space.upload"),
("send_file", "Space.send_file"),
("download", "Space.download"),
("stream_endpoint", "Space.open_stream"),
("list_space_windows", "Space.windows"),
("stream_space_window", "Spaces.call_tool_json"),
("show_space_pip", "Spaces.call_tool_json"),
("hide_space_pip", "Spaces.call_tool_json"),
("open_space_viewer", "Spaces.call_tool_json"),
("list_tools", "Space.list_tools"),
("call_tool", "Space.call_tool"),
("agent_start", "Space.agent_start"),
("agent_message", "Space.agent_message"),
("agent_status", "Space.agent_status"),
("agent_events", "Space.agent_events"),
("agent_interrupt", "Space.agent_interrupt"),
("agent_stop", "Space.agent_stop"),
("agent_list", "Space.agent_list"),
("agent_capabilities", "Spaces.agent_capabilities"),
("persistent_agent_create", "Spaces.persistent_agent_create"),
("persistent_agent_list", "Spaces.persistent_agents"),
("persistent_agent_remove", "Spaces.persistent_agent_remove"),
("persistent_agent_send", "Spaces.persistent_agent_send"),
("persistent_agent_save", "Spaces.persistent_agent_save"),
("agent_pause", "Spaces.agent_pause"),
("agent_resume", "Spaces.agent_resume"),
("routine_add", "Spaces.routine_add"),
("routine_list", "Spaces.routines"),
("routine_remove", "Spaces.routine_remove"),
("routine_set_enabled", "Spaces.routine_set_enabled"),
("notify_user", "Spaces.notify_user"),
("notifications_list", "Spaces.notifications"),
("notifications_ack", "Spaces.notifications_ack"),
("computer_access_grant", "Spaces.computer_access_grant"),
("computer_access_revoke", "Spaces.computer_access_revoke"),
("computer_access_list", "Spaces.computer_access"),
("teleport_manifest", "Space.teleport_manifest"),
("teleport_app", "Space.teleport"),
("request_site_login", "Space.request_site_login"),
("hotspot_start", "Space.start_hotspot"),
("hotspot_stop", "Space.stop_hotspot"),
("hotspot_status", "Space.hotspot_status"),
("volume_ls", "Spaces.volume_ls"),
("volume_read", "Spaces.volume_read"),
("volume_write", "Spaces.volume_write"),
("volume_delete", "Spaces.volume_delete"),
("volume_history", "Spaces.volume_history"),
("volume_restore", "Spaces.volume_restore"),
("volume_grant", "Spaces.volume_grant"),
("volume_revoke", "Spaces.volume_revoke"),
("volume_grants", "Spaces.volume_grants"),
("volume_request_access", "Spaces.volume_request_access"),
("volume_requests", "Spaces.volume_requests"),
("volume_approve", "Spaces.volume_approve"),
("volume_deny", "Spaces.volume_deny"),
("volume_audit", "Spaces.volume_audit"),
("volume_storage", "Spaces.volume_storage"),
("volume_storage_set", "Spaces.volume_storage_set"),
("volume_mount_status", "Spaces.volume_mount_status"),
("volume_mount", "Spaces.volume_mount"),
("volume_unmount", "Spaces.volume_unmount"),
("volume_sync_status", "Spaces.volume_sync_status"),
("volume_sync_events", "Spaces.volume_sync_events"),
("volume_sync_resolve", "Spaces.volume_sync_resolve"),
("volume_cache_stats", "Spaces.volume_cache_stats"),
("volume_cache_set", "Spaces.volume_cache_set"),
("volume_cache_clear", "Spaces.volume_cache_clear"),
("share_space", "Space.share"),
("unshare_space", "Space.unshare"),
("space_shares", "Space.shares"),
("relay_register_space", "Spaces.relay_register"),
("relay_unregister_space", "Spaces.relay_unregister"),
("cloud_status", "Spaces.cloud_status"),
("cloud_connect", "Spaces.cloud_connect"),
("cloud_test", "Spaces.cloud_test"),
("cloud_disconnect", "Spaces.cloud_disconnect"),
("cloud_sweep", "Spaces.cloud_sweep"),
];
pub const SPACES_SDK_ONLY_METHODS: &[&str] = &[
"Spaces.resolve",
"Spaces.space",
"Spaces.list_tools_json",
"Spaces.call_tool_json",
"Spaces.resources",
"Spaces.watch_resources",
"Space.home",
"Space.screenshot",
"Space.close_stream",
"Space.attach_stream",
"Space.stream_session",
"Space.join_presence",
];
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SpacesToolMethod {
pub tool: String,
pub method: String,
}
#[uniffi::export]
pub fn spaces_tool_methods() -> Vec<SpacesToolMethod> {
SPACES_TOOL_METHODS
.iter()
.map(|(tool, method)| SpacesToolMethod {
tool: (*tool).into(),
method: (*method).into(),
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct SpaceInfo {
pub id: String,
pub name: String,
pub provider: String,
pub spacesd_version: String,
pub features: Vec<String>,
#[serde(default)]
pub os: String,
#[serde(default)]
pub os_name: String,
#[serde(default)]
pub os_pretty_name: String,
#[serde(default)]
pub image: String,
#[serde(default)]
pub image_digest: String,
#[serde(default)]
pub kind: String,
#[serde(default)]
pub arch: String,
#[serde(default)]
pub services: Vec<String>,
#[serde(default)]
pub added_at: Option<String>,
#[serde(default)]
pub host: String,
#[serde(default)]
pub host_name: String,
#[serde(default)]
pub power: String,
#[serde(default)]
pub power_state: String,
#[serde(default)]
pub cloud: String,
#[serde(default)]
pub cloud_place: String,
#[serde(default)]
pub cloud_delete: String,
#[serde(default)]
#[uniffi(default)]
pub stop_reason: String,
}
impl From<dpb::Space> for SpaceInfo {
fn from(s: dpb::Space) -> Self {
let provider = cua_spaces::SpaceId::parse(&s.id)
.map(|id| id.provider().as_str().to_string())
.unwrap_or_default();
SpaceInfo {
added_at: s.added_at.as_ref().map(|t| {
let at = std::time::UNIX_EPOCH
+ Duration::new(t.seconds.max(0) as u64, t.nanos.max(0) as u32);
humantime_rfc3339(at)
}),
id: s.id,
name: s.name,
provider,
spacesd_version: s.spacesd_version,
features: s.features,
os: s.os,
os_name: s.os_name,
os_pretty_name: s.os_pretty_name,
image: s.image,
image_digest: s.image_digest,
kind: s.kind,
arch: s.arch,
services: s.services,
host: s.host,
host_name: s.host_name,
power: s.power,
power_state: s.power_state,
cloud: s.cloud,
cloud_place: s.cloud_place,
cloud_delete: s.cloud_delete,
stop_reason: s.stop_reason,
}
}
}
impl From<cua_spaces::SpaceInfo> for SpaceInfo {
fn from(s: cua_spaces::SpaceInfo) -> Self {
SpaceInfo {
id: s.id,
name: s.name,
provider: s.provider.as_str().into(),
spacesd_version: s.spacesd_version,
features: s.features,
os: s.os,
os_name: s.os_name,
os_pretty_name: s.os_pretty_name,
image: s.image,
image_digest: s.image_digest,
kind: s.kind,
arch: s.arch,
services: s.services,
added_at: s.added_at,
host: s.host,
host_name: s.host_name,
power: s.power,
power_state: s.power_state,
cloud: s.cloud,
cloud_place: s.cloud_place,
cloud_delete: s.cloud_delete,
stop_reason: s.stop_reason,
}
}
}
fn humantime_rfc3339(at: std::time::SystemTime) -> String {
let secs = at
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let days = (secs / 86_400) as i64;
let rem = secs % 86_400;
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
rem / 3600,
(rem % 3600) / 60,
rem % 60
)
}
#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)]
pub struct SpaceCreateOptions {
#[uniffi(default = None)]
pub image: Option<String>,
#[uniffi(default = None)]
pub on: Option<String>,
#[uniffi(default = None)]
pub kind: Option<String>,
#[uniffi(default = None)]
pub runtime: Option<String>,
#[uniffi(default = None)]
pub name: Option<String>,
#[uniffi(default = None)]
pub cpus: Option<u32>,
#[uniffi(default = None)]
pub memory_mb: Option<u64>,
#[uniffi(default = None)]
pub disk_gb: Option<u32>,
#[uniffi(default = None)]
pub timeout_ms: Option<u64>,
#[uniffi(default = None)]
pub wait: Option<bool>,
#[uniffi(default = false)]
pub reuse: bool,
#[uniffi(default = None)]
pub command: Option<Vec<String>>,
#[uniffi(default)]
pub env: HashMap<String, String>,
#[uniffi(default)]
pub services: HashMap<String, u16>,
#[uniffi(default = None)]
pub spacesd: Option<bool>,
#[uniffi(default = None)]
pub gpu: Option<String>,
#[uniffi(default = None)]
pub create_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct GpuOption {
pub id: String,
pub label: String,
pub experimental: bool,
pub supported: bool,
pub reason: String,
pub learn_more: Option<String>,
pub usd_per_hour: Option<f64>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SpacesHostLimit {
pub resource: String,
pub used: u32,
pub limit: u32,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SpacesHost {
pub id: String,
pub name: String,
pub via: String,
pub online: bool,
pub os: String,
pub limits: Vec<SpacesHostLimit>,
}
impl From<cua_spaces::host_spaces::HostOffer> for SpacesHost {
fn from(h: cua_spaces::host_spaces::HostOffer) -> Self {
Self {
id: h.id,
name: h.name,
via: h.via,
online: h.online,
os: h.os,
limits: h
.limits
.into_iter()
.map(|l| SpacesHostLimit {
resource: l.resource,
used: l.used,
limit: l.limit,
reason: l.reason,
})
.collect(),
}
}
}
impl From<dpb::SpacesHost> for SpacesHost {
fn from(h: dpb::SpacesHost) -> Self {
Self {
id: h.id,
name: h.name,
via: h.via,
online: h.online,
os: h.os,
limits: h
.limits
.into_iter()
.map(|l| SpacesHostLimit {
resource: l.resource,
used: l.used,
limit: l.limit,
reason: l.reason,
})
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct GpuSupport {
pub runtime: String,
pub options: Vec<GpuOption>,
pub reason: String,
}
impl From<cua_sandbox_core::gpu::GpuSupport> for GpuSupport {
fn from(g: cua_sandbox_core::gpu::GpuSupport) -> Self {
Self {
runtime: g.runtime,
options: g
.options
.into_iter()
.map(|o| GpuOption {
id: o.id,
label: o.label,
experimental: o.experimental,
supported: o.supported,
reason: o.reason,
learn_more: o.learn_more,
usd_per_hour: o.usd_per_hour,
})
.collect(),
reason: g.reason,
}
}
}
impl From<dpb::GpuSupport> for GpuSupport {
fn from(g: dpb::GpuSupport) -> Self {
Self {
runtime: g.runtime,
options: g
.options
.into_iter()
.map(|o| GpuOption {
id: o.id,
label: o.label,
experimental: o.experimental,
supported: o.supported,
reason: o.reason,
learn_more: (!o.learn_more.is_empty()).then_some(o.learn_more),
usd_per_hour: o.usd_per_hour,
})
.collect(),
reason: g.reason,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SpaceCancelOutcome {
pub id: String,
pub state: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SpaceCreateResult {
pub space: Option<SpaceInfo>,
pub pending_id: Option<String>,
pub reused: bool,
}
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct SpaceCreateProgress {
pub phase: String,
pub fraction: Option<f64>,
pub detail: String,
#[uniffi(default = None)]
pub bytes_done: Option<u64>,
#[uniffi(default = None)]
pub bytes_total: Option<u64>,
#[uniffi(default = None)]
pub bytes_per_second: Option<f64>,
#[uniffi(default = "")]
pub space: String,
}
impl From<&cua_spaces::CreateProgress> for SpaceCreateProgress {
fn from(p: &cua_spaces::CreateProgress) -> Self {
Self {
phase: p.phase.as_str().into(),
fraction: p.fraction,
detail: p.detail.clone(),
bytes_done: p.bytes.map(|b| b.done),
bytes_total: p.bytes.map(|b| b.total).filter(|t| *t > 0),
bytes_per_second: p.bytes.and_then(|b| b.per_second),
space: p.target.clone(),
}
}
}
#[uniffi::export(with_foreign)]
pub trait SpaceCreateListener: Send + Sync {
fn on_progress(&self, progress: SpaceCreateProgress);
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SpaceBashResult {
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
pub signal: Option<String>,
pub timed_out: bool,
pub error: Option<String>,
pub rendered: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct SpaceWriteReport {
pub path: String,
pub bytes: u64,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct SpaceTransferReport {
pub source: String,
pub dest: String,
pub kind: String,
pub files: u64,
#[serde(default)]
pub directories: u64,
pub bytes: u64,
#[serde(default)]
pub sha256: Option<String>,
pub verified: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SpaceSendFileOptions {
#[uniffi(default = None)]
pub target_directory: Option<String>,
#[uniffi(default = true)]
pub respect_ignore_files: bool,
#[uniffi(default = None)]
pub conflict: Option<String>,
}
impl Default for SpaceSendFileOptions {
fn default() -> Self {
Self {
target_directory: None,
respect_ignore_files: true,
conflict: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct SpaceSentFile {
pub path: String,
pub size: u64,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct SpaceSendFileReport {
pub source: String,
pub destination: String,
pub dest: String,
pub kind: String,
pub files: Vec<SpaceSentFile>,
pub bytes: u64,
pub skipped_by_ignorefiles: Vec<String>,
pub skipped_by_guest: Vec<String>,
pub verified: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SpaceToolInfo {
pub name: String,
pub description: String,
pub input_schema_json: String,
pub read_only: bool,
pub destructive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SpaceToolResult {
pub content_json: String,
pub structured_json: Option<String>,
pub is_error: bool,
pub text: String,
pub meta_json: Option<String>,
}
impl SpaceToolResult {
fn with_meta(
content: Vec<Value>,
structured: Option<Value>,
is_error: bool,
meta: Option<Value>,
) -> Self {
let text = content
.iter()
.filter_map(|c| c.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n");
SpaceToolResult {
content_json: Value::Array(content).to_string(),
structured_json: structured.map(|s| s.to_string()),
is_error,
text,
meta_json: meta.map(|m| m.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, uniffi::Record, Deserialize)]
pub struct SpaceWindow {
pub window_id: String,
pub epoch: u64,
pub title: String,
pub app_name: String,
pub app_id: String,
pub pid: u32,
pub bounds: Vec<f64>,
pub on_screen: bool,
pub focused: bool,
pub available: bool,
#[serde(default)]
pub limitation: String,
}
#[derive(Debug, Clone, Copy, PartialEq, uniffi::Record, Deserialize)]
pub struct SpaceUsage {
#[uniffi(default = 0)]
#[serde(default)]
pub cpu_count: u32,
#[uniffi(default = 0.0)]
#[serde(default)]
pub cpu_percent: f64,
#[uniffi(default = 0.0)]
#[serde(default)]
pub load_average_1m: f64,
pub memory_used: u64,
pub memory_total: u64,
pub memory_limited: bool,
pub disk_used: u64,
pub disk_total: u64,
pub disk_limited: bool,
}
#[derive(Debug, Clone, PartialEq, uniffi::Record, Deserialize)]
pub struct SpaceDisplay {
pub id: String,
pub name: String,
pub primary: bool,
pub width_px: u32,
pub height_px: u32,
pub scale_factor: f64,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SpaceAppIcon {
pub bytes: Vec<u8>,
pub content_type: String,
pub bytes_1x: Vec<u8>,
}
impl From<cua_spaces::AppIcon> for SpaceAppIcon {
fn from(i: cua_spaces::AppIcon) -> Self {
SpaceAppIcon {
bytes: i.bytes,
content_type: i.content_type.into(),
bytes_1x: i.bytes_1x,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)]
pub struct SpaceAppIconRequest {
pub app_name: String,
#[uniffi(default = "")]
pub app_id: String,
#[uniffi(default = 0)]
pub pid: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)]
pub struct SpaceStreamOptions {
#[uniffi(default = None)]
pub window_id: Option<String>,
#[uniffi(default = None)]
pub app_name: Option<String>,
#[uniffi(default = None)]
pub display: Option<String>,
#[uniffi(default = [])]
pub codecs: Vec<String>,
#[uniffi(default = 0)]
pub max_fps: u32,
#[uniffi(default = 0)]
pub max_dimension: u32,
#[uniffi(default = false)]
pub audio: bool,
#[uniffi(default = None)]
pub ticket_ttl_ms: Option<u64>,
#[uniffi(default = None)]
pub policy: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct SpaceStreamTicket {
pub space: String,
pub media_session_id: String,
pub ws_url: String,
pub ticket: String,
pub ticket_expires_at: Option<String>,
pub codec: String,
pub wire_version: u32,
pub width: u32,
pub height: u32,
pub needs_headers: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Record)]
pub struct SpaceStreamStats {
pub frames: u64,
pub keyframes: u64,
pub frames_dropped: u64,
pub frames_gated: u64,
pub keyframe_requests: u64,
pub audio_packets: u64,
pub audio_lost: u64,
pub events: u64,
pub malformed: u64,
}
impl From<cua_spaces::stream::StreamStats> for SpaceStreamStats {
fn from(s: cua_spaces::stream::StreamStats) -> Self {
SpaceStreamStats {
frames: s.frames,
keyframes: s.keyframes,
frames_dropped: s.frames_dropped,
frames_gated: s.frames_gated,
keyframe_requests: s.keyframe_requests,
audio_packets: s.audio_packets,
audio_lost: s.audio_lost,
events: s.events,
malformed: s.malformed,
}
}
}
#[uniffi::export]
pub fn presence_color(id: String) -> String {
cua_spaces::presence::color_for(&id).to_string()
}
#[uniffi::export]
pub fn presence_text_color(background: String) -> String {
cua_spaces::presence::text_color_on(&background).to_string()
}
#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)]
pub struct PresenceIdentity {
pub id: String,
pub display_name: String,
#[uniffi(default = "")]
pub color: String,
#[uniffi(default = false)]
pub agent: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct PresenceParticipant {
pub participant_id: String,
pub principal_id: String,
pub display_name: String,
pub color: String,
pub kind: String,
}
impl From<cua_spaces::presence::Participant> for PresenceParticipant {
fn from(p: cua_spaces::presence::Participant) -> Self {
PresenceParticipant {
participant_id: p.participant_id,
principal_id: p.principal_id,
display_name: p.display_name,
color: p.color,
kind: p.kind,
}
}
}
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct PresenceCursor {
#[uniffi(default = "")]
pub display_id: String,
#[uniffi(default = None)]
pub window_id: Option<String>,
pub x: f64,
pub y: f64,
#[uniffi(default = true)]
pub visible: bool,
#[uniffi(default = false)]
pub pressed: bool,
#[uniffi(default = "arrow")]
pub shape: String,
#[uniffi(default = "unspecified")]
pub shape_source: String,
#[uniffi(default = 0.0)]
pub at_ms: f64,
#[uniffi(default = 0.0)]
pub received_ms: f64,
}
impl From<cua_spaces::presence::Cursor> for PresenceCursor {
fn from(c: cua_spaces::presence::Cursor) -> Self {
PresenceCursor {
display_id: c.display_id,
window_id: c.window_id,
x: c.x,
y: c.y,
visible: c.visible,
pressed: c.pressed,
shape: c.shape.as_str().into(),
shape_source: c.shape_source.as_str().into(),
at_ms: c.at_ms,
received_ms: c.received_ms,
}
}
}
impl From<PresenceCursor> for cua_spaces::presence::Cursor {
fn from(c: PresenceCursor) -> Self {
cua_spaces::presence::Cursor {
display_id: c.display_id,
window_id: c.window_id,
x: c.x,
y: c.y,
visible: c.visible,
pressed: c.pressed,
shape: cua_spaces::presence::CursorShape::parse(&c.shape),
shape_source: cua_spaces::presence::ShapeSource::parse(&c.shape_source),
at_ms: c.at_ms,
received_ms: c.received_ms,
}
}
}
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct PresenceMember {
pub participant: PresenceParticipant,
pub cursor: Option<PresenceCursor>,
}
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct PresenceEvent {
pub kind: String,
pub participant: Option<PresenceParticipant>,
pub participant_id: Option<String>,
pub cursor: Option<PresenceCursor>,
#[uniffi(default = None)]
pub shape: Option<String>,
#[uniffi(default = None)]
pub shape_source: Option<String>,
#[uniffi(default = None)]
pub reason: Option<String>,
#[uniffi(default = None)]
pub participant_ids: Option<Vec<String>>,
}
impl From<cua_spaces::presence::PresenceEvent> for PresenceEvent {
fn from(e: cua_spaces::presence::PresenceEvent) -> Self {
use cua_spaces::presence::PresenceEvent as E;
let mut out = PresenceEvent {
kind: String::new(),
participant: None,
participant_id: None,
cursor: None,
shape: None,
shape_source: None,
reason: None,
participant_ids: None,
};
match e {
E::Joined { participant } => {
out.kind = "joined".into();
out.participant = Some(participant.into());
}
E::Left {
participant_id,
reason,
} => {
out.kind = "left".into();
out.participant_id = Some(participant_id);
out.reason = Some(reason);
}
E::CursorMoved {
participant_id,
cursor,
} => {
out.kind = "cursor_moved".into();
out.participant_id = Some(participant_id);
out.cursor = Some(cursor.into());
}
E::ShapeChanged {
participant_id,
shape,
source,
} => {
out.kind = "shape_changed".into();
out.participant_id = Some(participant_id);
out.shape = Some(shape.as_str().into());
out.shape_source = Some(source.as_str().into());
}
E::Heartbeat { participant_ids } => {
out.kind = "heartbeat".into();
out.participant_ids = Some(participant_ids);
}
E::KeepAlive => out.kind = "keep_alive".into(),
}
out
}
}
impl PresenceEvent {
fn to_core(&self) -> Option<cua_spaces::presence::PresenceEvent> {
use cua_spaces::presence::{CursorShape, PresenceEvent as E, ShapeSource};
let pid = || self.participant_id.clone();
Some(match self.kind.as_str() {
"joined" => E::Joined {
participant: self.participant.clone()?.into(),
},
"left" => E::Left {
participant_id: pid()?,
reason: self.reason.clone().unwrap_or_default(),
},
"cursor_moved" => E::CursorMoved {
participant_id: pid()?,
cursor: self.cursor.clone()?.into(),
},
"shape_changed" => E::ShapeChanged {
participant_id: pid()?,
shape: CursorShape::parse(self.shape.as_deref()?),
source: ShapeSource::parse(self.shape_source.as_deref().unwrap_or_default()),
},
"heartbeat" => E::Heartbeat {
participant_ids: self.participant_ids.clone()?,
},
"keep_alive" => E::KeepAlive,
_ => return None,
})
}
}
impl From<PresenceParticipant> for cua_spaces::presence::Participant {
fn from(p: PresenceParticipant) -> Self {
cua_spaces::presence::Participant {
participant_id: p.participant_id,
principal_id: p.principal_id,
display_name: p.display_name,
color: p.color,
kind: p.kind,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, uniffi::Record)]
pub struct PresencePoint {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct PresenceDrawable {
pub participant_id: String,
pub display_name: String,
pub color: String,
pub is_me: bool,
pub is_agent: bool,
pub x: f64,
pub y: f64,
pub shape: String,
pub shape_source: String,
pub alpha: f64,
pub display_id: String,
pub window_id: Option<String>,
}
impl From<cua_spaces::presence::Drawable> for PresenceDrawable {
fn from(d: cua_spaces::presence::Drawable) -> Self {
PresenceDrawable {
participant_id: d.participant_id,
display_name: d.display_name,
color: d.color,
is_me: d.is_me,
is_agent: d.is_agent,
x: d.x,
y: d.y,
shape: d.shape.as_str().into(),
shape_source: d.shape_source.as_str().into(),
alpha: d.alpha,
display_id: d.display_id,
window_id: d.window_id,
}
}
}
#[derive(uniffi::Object)]
pub struct PresenceView {
inner: std::sync::Mutex<cua_spaces::presence::PresenceView>,
}
impl PresenceView {
fn wrap(v: cua_spaces::presence::PresenceView) -> Arc<Self> {
Arc::new(PresenceView {
inner: std::sync::Mutex::new(v),
})
}
}
#[uniffi::export]
impl PresenceView {
#[uniffi::constructor(default(delay_ms = None))]
pub fn new(me: String, delay_ms: Option<f64>) -> Arc<Self> {
Self::wrap(cua_spaces::presence::PresenceView::new(
&me,
delay_ms.unwrap_or(cua_spaces::presence::view::STREAM_DELAY_MS),
))
}
pub fn upsert(&self, participant: PresenceParticipant) {
self.inner.lock().unwrap().upsert(participant.into());
}
pub fn apply(&self, event: PresenceEvent, local_ms: f64) -> bool {
match event.to_core() {
Some(e) => self.inner.lock().unwrap().apply(&e, local_ms),
None => false,
}
}
#[uniffi::method(default(pointer = None))]
pub fn drawables(
&self,
local_ms: f64,
pointer: Option<PresencePoint>,
) -> Vec<PresenceDrawable> {
self.inner
.lock()
.unwrap()
.drawables(local_ms, pointer.map(|p| (p.x, p.y)))
.into_iter()
.map(Into::into)
.collect()
}
pub fn expire(&self, local_ms: f64) -> Vec<String> {
self.inner.lock().unwrap().expire(local_ms)
}
pub fn participant_ids(&self) -> Vec<String> {
self.inner.lock().unwrap().participant_ids()
}
pub fn participant(&self, participant_id: String) -> Option<PresenceParticipant> {
self.inner
.lock()
.unwrap()
.participant(&participant_id)
.cloned()
.map(Into::into)
}
pub fn shape_of(&self, participant_id: String) -> Option<String> {
self.inner
.lock()
.unwrap()
.shape_of(&participant_id)
.map(|(s, _)| s.as_str().to_string())
}
pub fn set_heartbeat_interval_ms(&self, ms: f64) {
self.inner.lock().unwrap().set_heartbeat_interval_ms(ms);
}
}
#[uniffi::export]
pub fn presence_now_ms() -> f64 {
cua_spaces::presence::now_ms()
}
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct CursorArt {
pub shape: String,
pub path_d: String,
pub hotspot_x: f64,
pub hotspot_y: f64,
pub canvas: f64,
pub outline_color: String,
pub outline_width: f64,
}
impl From<&cua_spaces::presence::art::CursorArt> for CursorArt {
fn from(a: &cua_spaces::presence::art::CursorArt) -> Self {
CursorArt {
shape: a.shape.as_str().into(),
path_d: a.path_d.clone(),
hotspot_x: a.hotspot_x,
hotspot_y: a.hotspot_y,
canvas: a.canvas,
outline_color: a.outline_color.clone(),
outline_width: a.outline_width,
}
}
}
#[uniffi::export]
pub fn presence_cursor_art(shape: String) -> CursorArt {
cua_spaces::presence::art::cursor_art(cua_spaces::presence::CursorShape::parse(&shape)).into()
}
#[uniffi::export]
pub fn presence_cursor_art_all() -> Vec<CursorArt> {
cua_spaces::presence::art::all_cursor_art()
.iter()
.map(Into::into)
.collect()
}
#[uniffi::export]
pub fn presence_cursor_art_svg(shape: String, color: String, size: f64) -> String {
cua_spaces::presence::art::cursor_art_svg(
cua_spaces::presence::CursorShape::parse(&shape),
&color,
size,
)
}
#[uniffi::export]
pub fn presence_conformance_json() -> String {
cua_spaces::presence::view::CONFORMANCE_JSON.to_string()
}
#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)]
pub struct TeleportDecision {
#[uniffi(default = None)]
pub include: Option<Vec<String>>,
#[uniffi(default = false)]
pub acknowledge_sensitive: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct TeleportReceipt {
#[serde(default = "status_moved")]
pub status: String,
#[serde(default)]
pub request_id: Option<String>,
#[serde(default)]
pub app: String,
#[serde(default)]
pub space: String,
#[serde(default)]
pub method: String,
#[serde(default)]
pub transferred_paths: Vec<String>,
#[serde(default)]
pub bundle_bytes: u64,
#[serde(default)]
pub bundle_sha256: String,
#[serde(default)]
pub imported: Vec<String>,
#[serde(default)]
pub skipped: Vec<String>,
#[serde(default)]
pub launched: bool,
}
fn status_moved() -> String {
"moved".to_string()
}
#[uniffi::export(with_foreign)]
pub trait TeleportApprover: Send + Sync {
fn approve(&self, manifest: TeleportManifest) -> Option<TeleportDecision>;
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct SpaceHotspotStatus {
pub space: String,
pub state: String,
pub hotspot_id: String,
pub socks_address: String,
pub active_connections: u32,
pub bytes_out: u64,
pub bytes_in: u64,
pub served_here: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct SpaceShareEntry {
pub who: String,
pub role: String,
#[serde(default)]
pub connected: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct SpaceShares {
pub space: String,
#[serde(default)]
pub machine: String,
#[serde(default)]
pub invitee_space: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub online: bool,
#[serde(default)]
pub shares: Vec<SpaceShareEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct SpaceRelayRegistration {
pub space: String,
pub relay_space: String,
pub machine: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub online: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct SpacePowerReport {
pub space: String,
pub state: String,
pub power: String,
#[serde(default)]
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct AgentStartReport {
pub run_id: String,
pub agent: String,
pub space: String,
pub process_tag: String,
pub notes: Vec<String>,
pub json: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct AgentRunStatus {
pub run_id: String,
pub agent: Option<String>,
pub status: String,
pub reason: String,
pub phase: String,
pub result_text: Option<String>,
pub accepts_message: bool,
pub output_tail: Option<String>,
pub json: String,
}
impl AgentRunStatus {
fn from_json(v: Value) -> Result<Self> {
let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
Ok(AgentRunStatus {
run_id: s("run_id").unwrap_or_default(),
agent: s("agent"),
status: status_string(v.get("status")),
reason: s("reason").unwrap_or_default(),
phase: s("phase").unwrap_or_default(),
result_text: v
.get("result")
.and_then(|r| r.get("text"))
.and_then(Value::as_str)
.map(str::to_string),
accepts_message: v
.get("accepts_message")
.and_then(Value::as_bool)
.unwrap_or(false),
output_tail: s("output_tail"),
json: v.to_string(),
})
}
}
fn status_string(v: Option<&Value>) -> String {
match v {
Some(Value::String(s)) => s.clone(),
Some(other) => other.to_string(),
None => String::new(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, uniffi::Record)]
pub struct SiteLoginOptions {
#[uniffi(default = None)]
pub username: Option<String>,
#[uniffi(default = None)]
pub agent: Option<String>,
#[uniffi(default = None)]
pub session: Option<String>,
#[uniffi(default = None)]
pub target_id: Option<String>,
#[uniffi(default = None)]
pub tab_id: Option<String>,
#[uniffi(default = None)]
pub request_id: Option<String>,
#[uniffi(default = None)]
pub wait_secs: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, uniffi::Record, Deserialize)]
pub struct SiteLoginReport {
pub status: String,
#[serde(default)]
pub request_id: Option<String>,
#[serde(default)]
pub site: Option<String>,
#[serde(default)]
pub origin: Option<String>,
#[serde(default)]
pub username_hint: Option<String>,
#[serde(default)]
pub submitted: bool,
#[serde(default)]
pub page_url: Option<String>,
#[serde(default)]
pub session: Option<String>,
#[serde(default)]
pub target_id: Option<String>,
#[serde(default)]
pub tab_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, uniffi::Record)]
pub struct SpaceAgentOptions {
#[uniffi(default)]
pub env_from_host: Vec<String>,
#[uniffi(default)]
pub env: std::collections::HashMap<String, String>,
#[uniffi(default = None)]
pub repo: Option<String>,
#[uniffi(default = None)]
pub branch: Option<String>,
#[uniffi(default = None)]
pub cwd: Option<String>,
#[uniffi(default = None)]
pub model: Option<String>,
#[uniffi(default = None)]
pub base_url: Option<String>,
#[uniffi(default = None)]
pub exit_when_idle: Option<bool>,
#[uniffi(default = None)]
pub home: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct AgentActionReport {
pub run_id: String,
pub ok: bool,
pub reason: String,
pub json: String,
}
fn tag_error(kind: &str, message: String) -> CuaError {
match kind {
"invalid_argument" => CuaError::InvalidArgument(message),
"not_found" => CuaError::NotFound(super::not_found_message(message)),
"ambiguous_sandbox" => CuaError::AmbiguousSandbox(message),
"insufficient_disk" => CuaError::InsufficientDisk(message),
"spacesd_not_available" => CuaError::SpacesdNotAvailable(message),
"capability_missing" => CuaError::CapabilityMissing(message),
"host_capability_missing" => CuaError::HostCapabilityMissing(message),
"wrong_provider" => CuaError::Unsupported(message),
"teleport_refused" => CuaError::TeleportRefused(message),
"timeout" => CuaError::Timeout(message),
"cancelled" => CuaError::Cancelled(message),
"unauthenticated" => CuaError::Unauthenticated(message),
"cloud" => CuaError::Cloud(message),
"sandbox" => CuaError::Runtime(message),
"forbidden" | "not_confirmed" => CuaError::PermissionDenied(message),
"precondition_failed" | "lease_held" | "secret_detected" => {
CuaError::InvalidArgument(message)
}
"io" | "json" => CuaError::Internal(message),
"mcp" => CuaError::Http(message),
_ => CuaError::Env(message),
}
}
impl From<cua_spaces::Error> for CuaError {
fn from(e: cua_spaces::Error) -> Self {
let kind = e.tag();
let message = e.to_string();
match e {
cua_spaces::Error::Env(env) => env.into(),
cua_spaces::Error::TeleportRefused(why) => CuaError::TeleportRefused(why),
_ => tag_error(kind, message),
}
}
}
fn daemon_err(s: tonic::Status) -> CuaError {
cua_daemon::client::status_error(s).into()
}
async fn missing_tool(d: &DaemonClient, tool: &str, e: CuaError) -> CuaError {
let unknown =
matches!(&e, CuaError::NotFound(m) if m.ends_with(&format!("Spaces tool {tool}")));
if !unknown {
return e;
}
match d.info().await {
Ok(info) => CuaError::NotFound(cua_daemon::identity::missing_tool_error(tool, &info)),
Err(_) => e,
}
}
fn outcome_value(out: ToolOut) -> Result<Value> {
if out.is_error {
let (kind, message) = out
.structured
.as_ref()
.and_then(|s| s.get("error"))
.map(|e| {
(
e.get("kind").and_then(Value::as_str).unwrap_or("env"),
e.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
)
})
.unwrap_or(("env", out.text()));
return Err(tag_error(kind, message));
}
let text = out.text();
Ok(serde_json::from_str(&text).unwrap_or(Value::String(text)))
}
pub(super) fn parse<T: serde::de::DeserializeOwned>(v: Value) -> Result<T> {
serde_json::from_value(v)
.map_err(|e| CuaError::Internal(format!("unexpected tool output: {e}")))
}
async fn teleport_embedded(
spaces: &cua_spaces::Spaces,
space: &str,
manifest: &TeleportManifest,
decision: TeleportDecision,
) -> Result<TeleportReceipt> {
let receipt = spaces
.call_extension(
"teleport",
"teleport.send",
json!({
"space": space,
"app": manifest.app,
"scope": manifest.scope,
"include": decision.include,
"acknowledge_sensitive": decision.acknowledge_sensitive,
}),
)
.await?;
parse(receipt)
}
fn teleport_app_response(v: &Value) -> Result<TeleportReceipt> {
if v.get("moved").and_then(Value::as_bool) == Some(true) {
let strings = |key: &str| -> Vec<String> {
v.get(key)
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
};
return Ok(TeleportReceipt {
status: "moved".into(),
request_id: None,
app: v
.get("app")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
space: v
.get("space")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
method: "import_session".into(),
transferred_paths: strings("transferred_paths"),
bundle_bytes: 0,
bundle_sha256: String::new(),
imported: strings("transferred_paths"),
skipped: Vec::new(),
launched: false,
});
}
if v.get("consent_required").and_then(Value::as_bool) == Some(true)
&& let Some(id) = v.get("request_id").and_then(Value::as_str)
{
return Ok(TeleportReceipt {
status: "pending".into(),
request_id: Some(id.to_string()),
app: v
.get("app")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
space: v
.get("space")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
method: "import_session".into(),
transferred_paths: Vec::new(),
bundle_bytes: 0,
bundle_sha256: String::new(),
imported: Vec::new(),
skipped: Vec::new(),
launched: false,
});
}
Err(keyvault_refusal(v))
}
fn keyvault_refusal(v: &Value) -> CuaError {
let message = v
.get("message")
.or_else(|| v.pointer("/error/message"))
.and_then(Value::as_str)
.unwrap_or("moving a signed-in session needs the user's approval in Cua (Keyvault)");
match v.get("request_id").and_then(Value::as_str) {
Some(id) => CuaError::TeleportRefused(format!(
"{message} Keyvault request {id}: approve it in Cua (Keyvault page)."
)),
None => CuaError::TeleportRefused(message.to_string()),
}
}
fn via_json<T: serde::Serialize, U: serde::de::DeserializeOwned>(v: &T) -> Result<U> {
parse(serde_json::to_value(v).map_err(|e| CuaError::Internal(e.to_string()))?)
}
struct ToolOut {
content: Vec<Value>,
structured: Option<Value>,
is_error: bool,
meta: Option<Value>,
}
impl ToolOut {
fn text(&self) -> String {
self.content
.iter()
.filter_map(|c| c.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n")
}
}
include!("spaces_resources.rs");
#[derive(Clone)]
enum Host {
Embedded(cua_spaces::Spaces),
Daemon(DaemonClient),
}
impl Host {
fn of(backend: &Backend) -> Self {
match backend {
Backend::Embedded(rt) => Host::Embedded(rt.spaces().clone()),
Backend::Daemon(d) => Host::Daemon(d.clone()),
}
}
async fn tool(&self, name: &str, arguments: Value) -> Result<ToolOut> {
match self {
Host::Embedded(spaces) => {
let o =
match cua_spaces::agents::keys::is_app_method(name) {
true => cua_spaces::agents::keys::app_tool(name, arguments).unwrap_or_else(
|| cua_spaces::mcp::ToolOutcome::error_message("not_found", name),
),
false => {
cua_spaces::mcp::McpServer::new(spaces.clone())
.call(name, arguments)
.await
}
};
Ok(ToolOut {
content: o.content,
structured: o.structured,
is_error: o.is_error,
meta: o.meta,
})
}
Host::Daemon(d) => {
let r = match d
.spaces()
.call_space_tool(dpb::CallSpaceToolRequest {
name: name.into(),
arguments_json: arguments.to_string(),
})
.await
{
Ok(r) => r.into_inner(),
Err(s) => return Err(missing_tool(d, name, daemon_err(s)).await),
};
let content: Vec<Value> = serde_json::from_str(&r.content_json)
.map_err(|e| CuaError::Internal(format!("content_json: {e}")))?;
let structured = (!r.structured_json.is_empty())
.then(|| serde_json::from_str(&r.structured_json))
.transpose()
.map_err(|e| CuaError::Internal(format!("structured_json: {e}")))?;
let meta = (!r.meta_json.is_empty())
.then(|| serde_json::from_str(&r.meta_json))
.transpose()
.map_err(|e| CuaError::Internal(format!("meta_json: {e}")))?;
Ok(ToolOut {
content,
structured,
is_error: r.is_error,
meta,
})
}
}
}
async fn tool_value(&self, name: &str, arguments: Value) -> Result<Value> {
outcome_value(self.tool(name, arguments).await?)
}
async fn record(&self, space: &str) -> Result<SpaceInfo> {
match self {
Host::Embedded(s) => Ok(s.find(space).await?.into()),
Host::Daemon(d) => d
.spaces()
.resolve_space(dpb::ResolveSpaceRequest {
space: space.into(),
})
.await
.map_err(daemon_err)?
.into_inner()
.space
.map(Into::into)
.ok_or_else(|| CuaError::Internal("no space returned".into())),
}
}
async fn connect(&self, space: &str) -> Result<(SpaceInfo, cua_spaces::Space)> {
match self {
Host::Embedded(s) => {
let handle = s.space(space).await?;
let info = self.record(&handle.id().to_string()).await?;
Ok((info, handle))
}
Host::Daemon(d) => {
let r = d
.spaces()
.connect_space(dpb::ConnectSpaceRequest {
space: space.into(),
})
.await
.map_err(daemon_err)?
.into_inner();
let record = r
.space
.ok_or_else(|| CuaError::Internal("no space returned".into()))?;
let caps =
<cua_spacesd_client::pb::GetCapabilitiesResponse as prost::Message>::decode(
r.capabilities.as_ref(),
)
.map_err(|e| CuaError::Internal(format!("capabilities: {e}")))?;
let id = cua_spaces::SpaceId::parse(&record.id)?;
let bearer = vec![("authorization".to_string(), format!("Bearer {}", r.token))];
let services = r
.services
.iter()
.map(|svc| {
(
svc.name.clone(),
cua_spaces::SpaceService {
source: cua_spaces::ServiceSource::Url(
cua_sandbox_core::ServiceEndpoint {
url: svc.url.clone(),
headers: bearer.clone(),
},
),
mcp_path: svc.mcp_path.clone(),
},
)
})
.collect();
if r.env_url.is_empty() {
let handle = cua_spaces::Space::generic(id, record.name.clone(), services);
return Ok((record.into(), handle));
}
let mut o = cua_spacesd_client::ConnectOptions::parse(&r.env_url)?
.transport(cua_spacesd_client::TransportPreference::Native)
.probe(false);
o.token = Some(r.token.clone());
let env = cua_spacesd_client::SpacesdClient::connect(o).await?;
let handle = cua_spaces::Space::attach(id, record.name.clone(), env, caps, bearer)
.with_services(services);
Ok((record.into(), handle))
}
}
}
}
#[derive(Clone)]
pub(super) struct SpacesToolCaller(Host);
impl SpacesToolCaller {
pub(super) async fn tool_value(&self, name: &str, arguments: Value) -> Result<Value> {
self.0.tool_value(name, arguments).await
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct MachineHealthCheck {
pub id: String,
pub status: String,
pub message: String,
pub remediation: String,
pub facts: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct MachineHealth {
pub cua_version: String,
pub os: String,
pub checks: Vec<MachineHealthCheck>,
pub report_json: String,
}
impl From<cua_proto::env::v1::DiagnoseHostResponse> for MachineHealth {
fn from(r: cua_proto::env::v1::DiagnoseHostResponse) -> Self {
let report = cua_spacesd_client::diagnose::Report::from_pb(r.report.unwrap_or_default());
Self {
cua_version: r.cua_version,
os: r.os,
checks: report
.checks
.iter()
.map(|c| MachineHealthCheck {
id: c.id.clone(),
status: c.status.as_str().to_string(),
message: c.message.clone(),
remediation: c.remediation.clone(),
facts: c.facts.clone().into_iter().collect(),
})
.collect(),
report_json: report.to_json(),
}
}
}
#[derive(uniffi::Object)]
pub struct Spaces {
host: Host,
}
impl Spaces {
pub(crate) fn new(backend: &Backend) -> Self {
Self {
host: Host::of(backend),
}
}
pub(super) fn tool_caller(&self) -> SpacesToolCaller {
SpacesToolCaller(self.host.clone())
}
}
#[uniffi::export]
impl Spaces {
pub async fn add(
&self,
url: String,
token: Option<String>,
name: Option<String>,
) -> Result<SpaceInfo> {
self.add_with_service(url, token, name, None).await
}
pub async fn add_with_service(
&self,
url: String,
token: Option<String>,
name: Option<String>,
service: Option<String>,
) -> Result<SpaceInfo> {
let host = self.host.clone();
run(async move {
match &host {
Host::Embedded(s) => {
Ok(s.add_with_service(&url, token, name, service).await?.into())
}
Host::Daemon(d) => d
.spaces()
.add_space(dpb::AddSpaceRequest {
url,
token: token.unwrap_or_default(),
name: name.unwrap_or_default(),
service: service.unwrap_or_default(),
})
.await
.map_err(daemon_err)?
.into_inner()
.space
.map(Into::into)
.ok_or_else(|| CuaError::Internal("no space returned".into())),
}
})
.await
}
pub async fn list(&self) -> Result<Vec<SpaceInfo>> {
let host = self.host.clone();
run(async move {
match &host {
Host::Embedded(s) => Ok(s.list_all().await?.into_iter().map(Into::into).collect()),
Host::Daemon(d) => Ok(d
.spaces()
.list_spaces(dpb::ListSpacesRequest {})
.await
.map_err(daemon_err)?
.into_inner()
.spaces
.into_iter()
.map(Into::into)
.collect()),
}
})
.await
}
pub async fn resolve(&self, space: String) -> Result<SpaceInfo> {
let host = self.host.clone();
run(async move { host.record(&space).await }).await
}
pub async fn remove(&self, space: String) -> Result<()> {
let host = self.host.clone();
run(async move {
match &host {
Host::Embedded(s) => {
s.remove(&space).await?;
}
Host::Daemon(d) => {
d.spaces()
.remove_space(dpb::RemoveSpaceRequest { id: space })
.await
.map_err(daemon_err)?;
}
}
Ok(())
})
.await
}
pub async fn create(&self, options: SpaceCreateOptions) -> Result<SpaceCreateResult> {
self.create_impl(options, None).await
}
pub async fn create_with_progress(
&self,
options: SpaceCreateOptions,
listener: Arc<dyn SpaceCreateListener>,
) -> Result<SpaceCreateResult> {
self.create_impl(
SpaceCreateOptions {
wait: Some(true),
..options
},
Some(listener),
)
.await
}
}
impl Spaces {
async fn create_impl(
&self,
options: SpaceCreateOptions,
listener: Option<Arc<dyn SpaceCreateListener>>,
) -> Result<SpaceCreateResult> {
use cua_sandbox_core::placement::{Kind, On, Runtime};
let place =
|e: cua_sandbox_core::placement::PlacementError| CuaError::InvalidPlacement(e.message);
let text = |v: &Option<String>| {
v.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
};
let (on, _) = match text(&options.on) {
Some(o) => (
On::parse(&o).map_err(place)?,
cua_sandbox_core::settings::Source::Explicit,
),
None => cua_sandbox_core::settings::Settings::load()
.and_then(|s| s.default_on())
.map_err(|e| CuaError::InvalidArgument(e.to_string()))?,
};
let kind = Kind::parse(&text(&options.kind).unwrap_or_default()).map_err(place)?;
let runtime = Runtime::parse(&text(&options.runtime).unwrap_or_default()).map_err(place)?;
cua_sandbox_core::placement::validate(&on, kind, &runtime).map_err(place)?;
let host = self.host.clone();
run(async move {
match &host {
Host::Embedded(s) => {
let created = s
.create(cua_spaces::SpaceCreate {
image: options.image,
on: Some(on),
kind,
runtime,
name: options.name,
cpus: options.cpus,
memory_mb: options.memory_mb,
disk_gb: options.disk_gb,
timeout: options.timeout_ms.map(Duration::from_millis),
wait: options.wait,
reuse: options.reuse,
command: options.command,
env: options.env.into_iter().collect(),
services: options.services.into_iter().collect(),
spacesd: options.spacesd,
env_token: None,
progress: listener.clone().map(|l| {
cua_spaces::ProgressSink::new(move |p| l.on_progress(p.into()))
}),
create_id: options.create_id.clone(),
gpu: options.gpu.clone().filter(|g| !g.trim().is_empty()),
})
.await?;
Ok(match created {
cua_spaces::SpaceCreated::Ready { info, reused } => SpaceCreateResult {
space: Some(info.into()),
pending_id: None,
reused,
},
cua_spaces::SpaceCreated::Starting(p) => SpaceCreateResult {
space: None,
pending_id: Some(p.id),
reused: false,
},
})
}
Host::Daemon(d) => {
let request = dpb::CreateSpaceRequest {
image: options.image.unwrap_or_default(),
location: on.to_string(),
kind: kind.to_string(),
runtime: runtime.to_string(),
name: options.name.unwrap_or_default(),
cpus: options.cpus.unwrap_or(0),
memory_mb: options.memory_mb.unwrap_or(0),
disk_gb: options.disk_gb.unwrap_or(0),
timeout: options.timeout_ms.map(|ms| pbjson_types::Duration {
seconds: (ms / 1000) as i64,
nanos: ((ms % 1000) * 1_000_000) as i32,
}),
wait: options.wait,
reuse: options.reuse,
command: options.command.unwrap_or_default(),
env: options.env,
services: options
.services
.into_iter()
.map(|(k, v)| (k, u32::from(v)))
.collect(),
spacesd: options.spacesd,
gpu: options.gpu.unwrap_or_default(),
create_id: options.create_id.unwrap_or_default(),
};
let r = match listener {
Some(l) => daemon_create_streaming(d, request, l).await?,
None => d
.spaces()
.create_space(request)
.await
.map_err(daemon_err)?
.into_inner(),
};
Ok(if r.phase == "starting" {
SpaceCreateResult {
space: None,
pending_id: r.space.map(|s| s.id),
reused: false,
}
} else {
SpaceCreateResult {
space: r.space.map(Into::into),
pending_id: None,
reused: r.reused,
}
})
}
}
})
.await
}
}
const CREATE_STREAM_IDLE: Duration = Duration::from_secs(15 * 60);
const CREATE_STREAM_IDLE_MARGIN: Duration = Duration::from_secs(5 * 60);
async fn daemon_create_streaming(
d: &DaemonClient,
request: dpb::CreateSpaceRequest,
listener: Arc<dyn SpaceCreateListener>,
) -> Result<dpb::CreateSpaceResponse> {
let timeout = request
.timeout
.as_ref()
.map(|t| Duration::from_secs(t.seconds.max(0) as u64));
use dpb::create_space_stream_response::Event;
let ready = || SpaceCreateProgress {
phase: "ready".into(),
fraction: None,
detail: String::new(),
bytes_done: None,
bytes_total: None,
bytes_per_second: None,
space: String::new(),
};
let mut stream = match d
.spaces()
.create_space_stream(dpb::CreateSpaceStreamRequest {
create: Some(request.clone()),
})
.await
{
Ok(s) => s.into_inner(),
Err(e) if e.code() == tonic::Code::Unimplemented => {
let r = d
.spaces()
.create_space(request)
.await
.map_err(daemon_err)?
.into_inner();
listener.on_progress(ready());
return Ok(r);
}
Err(e) => return Err(daemon_err(e)),
};
let idle = timeout
.map(|t| t + CREATE_STREAM_IDLE_MARGIN)
.unwrap_or(CREATE_STREAM_IDLE)
.max(CREATE_STREAM_IDLE);
while let Some(msg) = tokio::time::timeout(idle, stream.message())
.await
.map_err(|_| {
CuaError::Timeout(format!(
"the cua daemon reported nothing about the create for {} min; see `cua daemon \
status` and `cua sb ls`",
idle.as_secs() / 60
))
})?
.map_err(daemon_err)?
{
match msg.event {
Some(Event::Progress(p)) => listener.on_progress(SpaceCreateProgress {
phase: p.phase,
fraction: p.fraction,
detail: p.detail,
bytes_done: p.bytes_done,
bytes_total: p.bytes_total,
bytes_per_second: p.bytes_per_second,
space: p.space,
}),
Some(Event::Result(r)) => return Ok(r),
None => {}
}
}
Err(CuaError::Internal(
"the daemon ended the create stream without a result".into(),
))
}
#[uniffi::export]
impl Spaces {
pub async fn cancel_create(&self, space: String) -> Result<SpaceCancelOutcome> {
let host = self.host.clone();
run(async move {
Ok(match &host {
Host::Embedded(s) => {
let o = s.cancel_create(&space).await?;
SpaceCancelOutcome {
id: o.id,
state: o.state.as_str().into(),
message: o.message,
}
}
Host::Daemon(d) => {
let r = d
.spaces()
.cancel_create_space(dpb::CancelCreateSpaceRequest { space })
.await
.map_err(daemon_err)?
.into_inner();
SpaceCancelOutcome {
id: r.id,
state: r.state,
message: r.message,
}
}
})
})
.await
}
pub async fn gpu_support(&self, on: Option<String>) -> Result<Vec<GpuSupport>> {
let host = self.host.clone();
run(async move {
let location = on.unwrap_or_default();
Ok(match &host {
Host::Embedded(s) => {
let on = if location.trim().is_empty() {
cua_sandbox_core::placement::On::Local
} else {
cua_sandbox_core::placement::On::parse(&location)
.map_err(|e| CuaError::InvalidPlacement(e.message))?
};
s.gpu_support(&on)
.await
.into_iter()
.map(Into::into)
.collect()
}
Host::Daemon(d) => d
.spaces()
.get_gpu_support(dpb::GetGpuSupportRequest { location })
.await
.map_err(daemon_err)?
.into_inner()
.runtimes
.into_iter()
.map(Into::into)
.collect(),
})
})
.await
}
pub async fn hosts(&self) -> Result<Vec<SpacesHost>> {
let host = self.host.clone();
run(async move {
Ok(match &host {
Host::Embedded(s) => s.hosts().await?.into_iter().map(Into::into).collect(),
Host::Daemon(d) => d
.spaces()
.list_hosts(dpb::ListHostsRequest {})
.await
.map_err(daemon_err)?
.into_inner()
.hosts
.into_iter()
.map(Into::into)
.collect(),
})
})
.await
}
pub async fn machine_health(&self, machine: Option<String>) -> Result<MachineHealth> {
let host = self.host.clone();
run(async move {
let machine = machine.filter(|m| !m.trim().is_empty());
let r = match (&host, machine) {
(Host::Embedded(_), None) => cua_daemon::machine_doctor::response().await,
(Host::Embedded(s), Some(m)) => s.machine_health(&m).await?,
(Host::Daemon(d), m) => d.diagnose_host(m.unwrap_or_default()).await?,
};
Ok(r.into())
})
.await
}
pub async fn delete(&self, space: String) -> Result<String> {
let host = self.host.clone();
run(async move {
match &host {
Host::Embedded(s) => Ok(s.delete(&space).await?),
Host::Daemon(d) => Ok(d
.spaces()
.delete_space(dpb::DeleteSpaceRequest { space })
.await
.map_err(daemon_err)?
.into_inner()
.message),
}
})
.await
}
pub async fn stop(&self, space: String) -> Result<SpacePowerReport> {
let host = self.host.clone();
run(async move {
parse(
host.tool_value("stop_space", json!({"space": space}))
.await?,
)
})
.await
}
pub async fn start(&self, space: String) -> Result<SpacePowerReport> {
let host = self.host.clone();
run(async move {
parse(
host.tool_value("start_space", json!({"space": space}))
.await?,
)
})
.await
}
pub async fn relay_register(&self, space: String) -> Result<SpaceRelayRegistration> {
let host = self.host.clone();
run(async move {
parse(
host.tool_value("relay_register_space", json!({"space": space}))
.await?,
)
})
.await
}
pub async fn relay_unregister(&self, space: String) -> Result<bool> {
let host = self.host.clone();
run(async move {
let v = host
.tool_value("relay_unregister_space", json!({"space": space}))
.await?;
Ok(v.get("unregistered")
.and_then(Value::as_bool)
.unwrap_or(false))
})
.await
}
pub async fn space(&self, space: String) -> Result<Arc<Space>> {
let host = self.host.clone();
run(async move {
let (mut info, inner) = host.connect(&space).await?;
if let Some((family, name, pretty_name)) = inner.reported_os() {
info.os = family.into();
info.os_name = name.into();
info.os_pretty_name = pretty_name.into();
}
Ok(Arc::new(Space { host, info, inner }))
})
.await
}
pub async fn agent_capabilities(&self) -> Result<String> {
let host = self.host.clone();
run(async move {
Ok(host
.tool_value("agent_capabilities", json!({}))
.await?
.to_string())
})
.await
}
pub async fn list_tools_json(&self) -> Result<String> {
let host = self.host.clone();
run(async move {
match &host {
Host::Embedded(s) => Ok(cua_spaces::mcp::McpServer::new(s.clone())
.tools_list()
.to_string()),
Host::Daemon(d) => Ok(d
.spaces()
.list_space_tools(dpb::ListSpaceToolsRequest {})
.await
.map_err(daemon_err)?
.into_inner()
.tools_json),
}
})
.await
}
pub async fn call_tool_json(
&self,
tool: String,
arguments_json: Option<String>,
) -> Result<SpaceToolResult> {
let host = self.host.clone();
run(async move {
if cua_spaces::contract::tool(&tool).is_none()
&& !cua_spaces::agents::keys::is_app_method(&tool)
{
return Err(CuaError::NotFound(format!("Spaces tool {tool}")));
}
let args: Value = match arguments_json.as_deref().map(str::trim) {
None | Some("") => json!({}),
Some(s) => serde_json::from_str(s)?,
};
let o = host.tool(&tool, args).await?;
Ok(SpaceToolResult::with_meta(
o.content,
o.structured,
o.is_error,
o.meta,
))
})
.await
}
}
#[derive(uniffi::Object)]
pub struct Space {
host: Host,
info: SpaceInfo,
inner: cua_spaces::Space,
}
impl Space {
pub fn handle(&self) -> &cua_spaces::Space {
&self.inner
}
pub async fn call_tool_here(
&self,
tool: &str,
arguments: Value,
) -> cua_spaces::mcp::ToolOutcome {
let s = self.inner.clone();
let tool = tool.to_string();
run(async move { Ok(cua_spaces::mcp::call_on(&s, &tool, arguments).await) })
.await
.unwrap_or_else(|e| cua_spaces::mcp::ToolOutcome::error_message("env", e.to_string()))
}
}
fn stream_target(
s: &cua_spaces::Space,
o: &SpaceStreamOptions,
) -> impl std::future::Future<Output = Result<cua_spaces::stream::StreamTarget>> + Send {
let s = s.clone();
let o = o.clone();
async move {
use cua_spaces::stream::StreamTarget;
Ok(match (o.window_id, o.app_name) {
(Some(w), _) if !w.is_empty() => StreamTarget::Window(w),
(_, Some(app)) if !app.is_empty() => {
StreamTarget::Window(s.find_window(&app).await?.window_id)
}
_ => StreamTarget::Display(o.display),
})
}
}
fn stream_options(o: &SpaceStreamOptions) -> Result<cua_spaces::stream::StreamOptions> {
use cua_spacesd_client::pb::MediaCodec;
let codecs = o
.codecs
.iter()
.map(|c| match c.to_ascii_lowercase().as_str() {
"h264" => Ok(MediaCodec::H264),
"bgra" => Ok(MediaCodec::Bgra),
"png" => Ok(MediaCodec::Png),
other => Err(CuaError::InvalidArgument(format!(
"unknown codec {other:?}"
))),
})
.collect::<Result<Vec<_>>>()?;
Ok(cua_spaces::stream::StreamOptions {
codecs,
max_fps: o.max_fps,
max_dimension: o.max_dimension,
audio: o.audio,
ticket_ttl: o.ticket_ttl_ms.map(Duration::from_millis),
policy: match o.policy.as_deref() {
None | Some("") => None,
Some("view_only") => Some(cua_spacesd_client::pb::SessionPolicy::ViewOnly),
Some("background_only") => Some(cua_spacesd_client::pb::SessionPolicy::BackgroundOnly),
Some("allow_activation") => {
Some(cua_spacesd_client::pb::SessionPolicy::AllowActivation)
}
Some(other) => {
return Err(CuaError::InvalidArgument(format!(
"policy must be view_only, background_only or allow_activation (got {other:?})"
)));
}
},
})
}
#[uniffi::export]
impl Space {
pub fn id(&self) -> String {
self.info.id.clone()
}
pub fn info(&self) -> SpaceInfo {
self.info.clone()
}
pub fn supports(&self, feature: String) -> bool {
self.inner.supports(&feature)
}
pub fn capabilities_json(&self) -> Result<String> {
Ok(serde_json::to_string(self.inner.capabilities())?)
}
pub fn spacesd(&self) -> Result<Arc<super::spacesd::SpacesdClient>> {
let client = self.inner.spacesd()?.clone();
let s = self.inner.clone();
let refresh: super::spacesd::WsHeaderRefresh = Arc::new(move || {
let s = s.clone();
Box::pin(async move { Ok(s.websocket_headers().await?) })
});
Ok(Arc::new(
super::spacesd::SpacesdClient::new(client, vec![])
.at(self.inner.provider().as_str())
.with_ws_refresh(refresh),
))
}
pub async fn websocket_headers(&self) -> Result<Vec<super::HttpHeader>> {
let s = self.inner.clone();
run(async move {
Ok(s.websocket_headers()
.await?
.into_iter()
.map(|(name, value)| super::HttpHeader { name, value })
.collect())
})
.await
}
pub async fn bash(&self, command: String, timeout_ms: Option<u64>) -> Result<SpaceBashResult> {
let s = self.inner.clone();
run(async move {
let t = Duration::from_millis(timeout_ms.unwrap_or(60_000).clamp(1, 86_400_000));
let o = s.bash(&command, t).await?;
Ok(SpaceBashResult {
rendered: o.render(),
stdout: o.stdout,
stderr: o.stderr,
exit_code: o.exit_code,
signal: o.signal,
timed_out: o.timed_out,
error: o.error,
})
})
.await
}
pub async fn write(&self, path: String, content: Vec<u8>) -> Result<SpaceWriteReport> {
let s = self.inner.clone();
run(async move { via_json(&s.write(&path, content).await?) }).await
}
pub async fn home(&self) -> Result<String> {
let s = self.inner.clone();
run(async move { Ok(s.home().await?) }).await
}
pub async fn upload(
&self,
local_path: String,
dest: Option<String>,
) -> Result<SpaceTransferReport> {
let s = self.inner.clone();
run(async move {
via_json(
&s.upload(std::path::Path::new(&local_path), dest.as_deref())
.await?,
)
})
.await
}
pub async fn download(
&self,
remote_path: String,
dest_dir: String,
) -> Result<SpaceTransferReport> {
let s = self.inner.clone();
run(async move {
via_json(
&s.download(&remote_path, std::path::Path::new(&dest_dir))
.await?,
)
})
.await
}
pub async fn send_file(
&self,
local_path: String,
options: SpaceSendFileOptions,
) -> Result<SpaceSendFileReport> {
let s = self.inner.clone();
run(async move {
use cua_spaces::files::{SendFileOptions, downloads_subdir};
let needs_home = options
.target_directory
.as_deref()
.is_some_and(|t| t.starts_with('/'));
let home = if needs_home {
s.home().await.ok()
} else {
None
};
let subdir = downloads_subdir(options.target_directory.as_deref(), home.as_deref())?;
let conflict = parse_conflict(options.conflict.as_deref())?;
via_json(
&s.send_file(
std::path::Path::new(&local_path),
SendFileOptions {
subdir,
respect_ignore_files: options.respect_ignore_files,
conflict,
},
)
.await?,
)
})
.await
}
pub fn services(&self) -> Vec<String> {
self.inner.services()
}
pub async fn list_tools(&self, service: Option<String>) -> Result<Vec<SpaceToolInfo>> {
let s = self.inner.clone();
run(async move {
let (tools, _) = s.list_tools(service.as_deref()).await?;
Ok(tools
.into_iter()
.map(|t| SpaceToolInfo {
name: t.name,
description: t.description,
input_schema_json: t.input_schema.to_string(),
read_only: t.read_only,
destructive: t.destructive,
})
.collect())
})
.await
}
pub async fn screenshot(
&self,
options: Option<crate::types::ScreenshotOptions>,
) -> Result<crate::types::Screenshot> {
let s = self.inner.clone();
let request = super::spacesd::screenshot_request(options);
run(async move {
let shot = s.spacesd()?.screenshot(request).await?;
Ok(super::spacesd::screenshot_reply(shot))
})
.await
}
pub async fn thumbnail(&self, max_age_ms: Option<u64>) -> Result<crate::types::SpaceThumbnail> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
let max_age = max_age_ms.map(Duration::from_millis);
Ok(match &host {
Host::Embedded(s) => {
let t = s.thumbnail(&id, max_age).await?;
crate::types::SpaceThumbnail {
format: thumbnail_format(&t.format),
width: t.width,
height: t.height,
captured_at_ms: t
.captured_at
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0),
image: t.image,
}
}
Host::Daemon(d) => {
let r = d
.spaces()
.get_space_thumbnail(dpb::GetSpaceThumbnailRequest {
space: id,
max_age: max_age.map(|d| pbjson_types::Duration {
seconds: d.as_secs() as i64,
nanos: d.subsec_nanos() as i32,
}),
})
.await
.map_err(daemon_err)?
.into_inner();
let captured_at_ms = r
.captured_at
.map(|t| {
(t.seconds.max(0) as u64) * 1000 + (t.nanos.max(0) as u64) / 1_000_000
})
.unwrap_or(0);
crate::types::SpaceThumbnail {
image: r.image,
format: thumbnail_format(&r.format),
width: r.width,
height: r.height,
captured_at_ms,
}
}
})
})
.await
}
pub async fn call_tool(
&self,
tool: String,
arguments_json: Option<String>,
service: Option<String>,
timeout_ms: Option<u64>,
) -> Result<SpaceToolResult> {
let s = self.inner.clone();
run(async move {
let args = match arguments_json.as_deref().map(str::trim) {
None | Some("") => serde_json::Map::new(),
Some(j) => match serde_json::from_str::<Value>(j)? {
Value::Object(m) => m,
_ => {
return Err(CuaError::InvalidArgument(
"arguments_json must be a JSON object".into(),
));
}
},
};
let r = s
.call_tool(
service.as_deref(),
&tool,
args,
timeout_ms.map(Duration::from_millis),
)
.await?;
Ok(SpaceToolResult::with_meta(
r.content,
r.structured,
r.is_error,
r.meta,
))
})
.await
}
pub async fn windows(&self, app: Option<String>) -> Result<Vec<SpaceWindow>> {
let s = self.inner.clone();
run(async move { via_json(&s.windows(app.as_deref()).await?) }).await
}
pub async fn window_thumbnail(
&self,
window_id: String,
epoch: u64,
max_dimension: u32,
) -> Result<Option<Vec<u8>>> {
let s = self.inner.clone();
run(async move { Ok(s.window_thumbnail(&window_id, epoch, max_dimension).await?) }).await
}
pub async fn usage(&self) -> Result<SpaceUsage> {
let s = self.inner.clone();
run(async move { via_json(&s.usage().await?) }).await
}
pub async fn displays(&self) -> Result<Vec<SpaceDisplay>> {
let s = self.inner.clone();
run(async move { via_json(&s.displays().await?) }).await
}
pub async fn app_icon(
&self,
app_name: String,
app_id: String,
pid: u32,
) -> Result<Option<SpaceAppIcon>> {
Ok(self
.app_icons(vec![SpaceAppIconRequest {
app_name,
app_id,
pid,
}])
.await?
.pop()
.flatten())
}
pub async fn app_icons(
&self,
requests: Vec<SpaceAppIconRequest>,
) -> Result<Vec<Option<SpaceAppIcon>>> {
let s = self.inner.clone();
run(async move {
let requests: Vec<cua_spaces::IconRequest> = requests
.into_iter()
.map(|r| cua_spaces::IconRequest {
app_name: r.app_name,
app_id: r.app_id,
pid: r.pid,
})
.collect();
Ok(s.app_icons(&requests)
.await?
.into_iter()
.map(|i| i.map(SpaceAppIcon::from))
.collect())
})
.await
}
pub async fn open_stream(&self, options: SpaceStreamOptions) -> Result<SpaceStreamTicket> {
let s = self.inner.clone();
let daemon = matches!(self.host, Host::Daemon(_));
run(async move {
let target = stream_target(&s, &options).await?;
let t = s.open_stream(target, stream_options(&options)?).await?;
Ok(SpaceStreamTicket {
space: t.space,
media_session_id: t.media_session_id,
ws_url: t.ws_url,
ticket: t.ticket,
ticket_expires_at: t.ticket_expires_at,
codec: t.codec,
wire_version: t.wire_version,
width: t.frame_size[0],
height: t.frame_size[1],
needs_headers: t.needs_gateway_headers || daemon,
})
})
.await
}
pub async fn close_stream(&self, media_session_id: String) -> Result<()> {
let s = self.inner.clone();
run(async move { Ok(s.close_stream(&media_session_id).await?) }).await
}
pub async fn attach_stream(
&self,
ticket: SpaceStreamTicket,
frames: Arc<dyn FrameSink>,
audio: Option<Arc<dyn AudioSink>>,
) -> Result<Arc<super::MediaSession>> {
let s = self.inner.clone();
let headers = run(async move { Ok(s.websocket_headers().await?) }).await?;
super::MediaSession::attach_url(ticket.ws_url, headers, frames, audio).await
}
pub async fn stream_session(
&self,
options: SpaceStreamOptions,
frames: Arc<dyn FrameSink>,
audio: Option<Arc<dyn AudioSink>>,
) -> Result<Arc<SpaceStreamSession>> {
let s = self.inner.clone();
run(async move {
let target = stream_target(&s, &options).await?;
let frames: Arc<dyn cua_spaces::stream::FrameSink> = Arc::new(FrameAdapter(frames));
let audio =
audio.map(|a| Arc::new(AudioAdapter(a)) as Arc<dyn cua_spaces::stream::AudioSink>);
let session = s
.stream_session(target, stream_options(&options)?, frames, audio)
.await?;
Ok(Arc::new(SpaceStreamSession {
media_session_id: session.media_session_id().to_string(),
codec: session.codec().to_string(),
inner: tokio::sync::Mutex::new(Some(session)),
}))
})
.await
}
pub async fn join_presence(
&self,
identity: PresenceIdentity,
timeout_ms: Option<u64>,
) -> Result<Arc<SpacePresence>> {
let s = self.inner.clone();
let started = std::time::Instant::now();
let r = run(async move {
let session = s
.join_presence(
cua_spaces::presence::Identity {
id: identity.id,
display_name: identity.display_name,
color: identity.color,
agent: identity.agent,
},
Duration::from_millis(timeout_ms.unwrap_or(10_000)),
)
.await?;
let peak = session.roster().len() as u64;
Ok(Arc::new(SpacePresence {
sender: session.sender(),
datagrams: session.uses_datagrams(),
view_seed: session.view(),
inner: Arc::new(tokio::sync::Mutex::new(Some(session))),
stats: PresenceStats::new(peak),
}))
})
.await;
if r.is_err() {
cua_telemetry::capture(cua_telemetry::events::presence_session(
cua_telemetry::Outcome::Error,
0,
started.elapsed(),
));
}
r
}
#[uniffi::method(default(cursor_probe = None))]
pub async fn set_presence_settings(&self, cursor_probe: Option<bool>) -> Result<()> {
let s = self.inner.clone();
run(async move { Ok(s.set_presence_settings(cursor_probe).await?) }).await
}
pub async fn teleport_manifest(
&self,
app: String,
scope: Option<String>,
) -> Result<TeleportManifest> {
let host = self.host.clone();
run(async move {
parse(
host.tool_value("teleport_manifest", json!({"app": app, "scope": scope}))
.await?,
)
})
.await
}
#[uniffi::method(default(request_id = None))]
pub async fn teleport(
&self,
app: String,
scope: Option<String>,
approver: Arc<dyn TeleportApprover>,
request_id: Option<String>,
) -> Result<TeleportReceipt> {
let started = std::time::Instant::now();
let app_id = app.clone();
let r = self.teleport_inner(app, scope, approver, request_id).await;
super::telemetry::teleport(&app_id, "full", "app_with_state", started, &r, 0);
r
}
}
impl Space {
async fn teleport_inner(
&self,
app: String,
scope: Option<String>,
approver: Arc<dyn TeleportApprover>,
request_id: Option<String>,
) -> Result<TeleportReceipt> {
let host = self.host.clone();
let id = self.info.id.clone();
if let (Some(rid), Host::Daemon(_)) = (&request_id, &host) {
let rid = rid.clone();
return run(async move {
let out = host
.tool(
"teleport_app",
json!({"space": id, "app": app, "request_id": rid}),
)
.await?;
let v = out
.structured
.clone()
.or_else(|| serde_json::from_str(&out.text()).ok())
.unwrap_or(Value::Null);
teleport_app_response(&v)
})
.await;
}
run(async move {
let manifest: TeleportManifest = parse(
host.tool_value("teleport_manifest", json!({"app": app, "scope": scope}))
.await?,
)?;
let m = manifest.clone();
let decision = tokio::task::spawn_blocking(move || approver.approve(m))
.await
.map_err(|e| CuaError::Internal(format!("approver: {e}")))?
.ok_or_else(|| CuaError::TeleportRefused("the approver declined".into()))?;
match &host {
Host::Embedded(spaces) => teleport_embedded(spaces, &id, &manifest, decision).await,
Host::Daemon(_) => {
let out = host
.tool(
"teleport_app",
json!({
"space": id,
"app": manifest.app,
"scope": manifest.scope,
"include": decision.include,
"acknowledge_sensitive": decision.acknowledge_sensitive,
}),
)
.await?;
let v = out
.structured
.clone()
.or_else(|| serde_json::from_str(&out.text()).ok())
.unwrap_or(Value::Null);
teleport_app_response(&v)
}
}
})
.await
}
}
#[uniffi::export]
impl Space {
pub async fn start_hotspot(
&self,
set_system_proxy: Option<bool>,
bypass: Option<Vec<String>>,
) -> Result<SpaceHotspotStatus> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
parse(
host.tool_value(
"hotspot_start",
json!({
"space": id,
"set_system_proxy": set_system_proxy.unwrap_or(false),
"bypass": bypass.unwrap_or_default(),
}),
)
.await?,
)
})
.await
}
pub async fn stop_hotspot(&self) -> Result<Vec<String>> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
let v = host
.tool_value("hotspot_stop", json!({"space": id}))
.await?;
parse(v.get("stopped").cloned().unwrap_or(json!([])))
})
.await
}
pub async fn request_site_login(
&self,
url: String,
options: Option<SiteLoginOptions>,
) -> Result<SiteLoginReport> {
let host = self.host.clone();
let id = self.info.id.clone();
let o = options.unwrap_or_default();
run(async move {
parse(
host.tool_value(
"request_site_login",
json!({
"space": id,
"url": url,
"username": o.username,
"agent": o.agent,
"session": o.session,
"target_id": o.target_id,
"tab_id": o.tab_id,
"request_id": o.request_id,
"wait_secs": o.wait_secs,
}),
)
.await?,
)
})
.await
}
pub async fn hotspot_status(&self) -> Result<Vec<SpaceHotspotStatus>> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
let v = host
.tool_value("hotspot_status", json!({"space": id}))
.await?;
parse(v.get("hotspots").cloned().unwrap_or(json!([])))
})
.await
}
pub async fn share(&self, who: String, role: Option<String>) -> Result<SpaceShares> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
parse(
host.tool_value(
"share_space",
json!({"space": id, "who": who, "role": role.unwrap_or_else(|| "viewer".into())}),
)
.await?,
)
})
.await
}
pub async fn unshare(&self, who: Option<String>) -> Result<SpaceShares> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
parse(
host.tool_value("unshare_space", json!({"space": id, "who": who}))
.await?,
)
})
.await
}
pub async fn shares(&self) -> Result<SpaceShares> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
parse(
host.tool_value("space_shares", json!({"space": id}))
.await?,
)
})
.await
}
pub async fn agent_start(
&self,
agent: String,
prompt: String,
show: Option<bool>,
options: Option<SpaceAgentOptions>,
) -> Result<AgentStartReport> {
let host = self.host.clone();
let id = self.info.id.clone();
let o = options.unwrap_or_default();
run(async move {
let v = host
.tool_value(
"agent_start",
json!({"space": id, "agent": agent, "prompt": prompt, "show": show,
"env_from_host": o.env_from_host, "repo": o.repo, "branch": o.branch,
"cwd": o.cwd, "model": o.model, "base_url": o.base_url,
"exit_when_idle": o.exit_when_idle, "home": o.home, "env": o.env}),
)
.await?;
let s = |k: &str| {
v.get(k)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
};
Ok(AgentStartReport {
run_id: s("run_id"),
agent: s("agent"),
space: s("space"),
process_tag: s("process_tag"),
notes: parse(v.get("notes").cloned().unwrap_or(json!([])))?,
json: v.to_string(),
})
})
.await
}
pub async fn agent_status(&self, run_id: String, tail: Option<u32>) -> Result<AgentRunStatus> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
AgentRunStatus::from_json(
host.tool_value(
"agent_status",
json!({"space": id, "run_id": run_id, "tail": tail}),
)
.await?,
)
})
.await
}
pub async fn agent_message(
&self,
run_id: String,
text: String,
force: Option<bool>,
) -> Result<AgentActionReport> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
let v = host
.tool_value(
"agent_message",
json!({"space": id, "run_id": run_id, "text": text, "force": force}),
)
.await?;
Ok(AgentActionReport {
run_id: v["run_id"].as_str().unwrap_or_default().into(),
ok: v["delivered"].as_bool().unwrap_or(false),
reason: v["reason"].as_str().unwrap_or_default().into(),
json: v.to_string(),
})
})
.await
}
pub async fn agent_stop(&self, run_id: String) -> Result<AgentActionReport> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
let v = host
.tool_value("agent_stop", json!({"space": id, "run_id": run_id}))
.await?;
Ok(AgentActionReport {
run_id: v["run_id"].as_str().unwrap_or_default().into(),
ok: v["stopped"].as_bool().unwrap_or(false),
reason: v["reason"].as_str().unwrap_or_default().into(),
json: v.to_string(),
})
})
.await
}
pub async fn agent_events(
&self,
run_id: String,
cursor: Option<u64>,
max: Option<u32>,
) -> Result<String> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
Ok(host
.tool_value(
"agent_events",
json!({"space": id, "run_id": run_id, "cursor": cursor, "max": max}),
)
.await?
.to_string())
})
.await
}
pub async fn agent_interrupt(&self, run_id: String) -> Result<AgentActionReport> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
let v = host
.tool_value("agent_interrupt", json!({"space": id, "run_id": run_id}))
.await?;
Ok(AgentActionReport {
run_id: v["run_id"].as_str().unwrap_or_default().into(),
ok: v["interrupted"].as_bool().unwrap_or(false),
reason: v["status"].as_str().unwrap_or_default().into(),
json: v.to_string(),
})
})
.await
}
pub async fn agent_list(&self) -> Result<Vec<AgentRunStatus>> {
let host = self.host.clone();
let id = self.info.id.clone();
run(async move {
let v = host.tool_value("agent_list", json!({"space": id})).await?;
v.get("runs")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default()
.into_iter()
.map(AgentRunStatus::from_json)
.collect()
})
.await
}
}
struct FrameAdapter(Arc<dyn FrameSink>);
impl cua_spaces::stream::FrameSink for FrameAdapter {
fn on_frame(&self, f: cua_spaces::stream::VideoFrame) {
self.0.on_frame(super::VideoFrame {
sequence: f.sequence,
codec: f.codec,
keyframe: f.keyframe,
width: f.width,
height: f.height,
capture_timestamp_us: f.capture_timestamp_us,
codec_epoch: f.codec_epoch,
geometry_epoch: f.geometry_epoch,
data: f.data,
header_json: String::new(),
});
}
fn on_event(&self, e: cua_spaces::stream::StreamEvent) {
use cua_spaces::stream::StreamEvent as E;
let (kind, json) = match e {
E::Opened(v) => ("session_opened".to_string(), v.to_string()),
E::AudioConfigured(v) => ("audio_config".to_string(), v.to_string()),
E::Lifecycle(v) => ("lifecycle".to_string(), v.to_string()),
E::Stats(v) => ("stats".to_string(), v.to_string()),
E::Message(v) => (
v.get("type")
.and_then(Value::as_str)
.unwrap_or("message")
.to_string(),
v.to_string(),
),
E::Closed { code, reason } => (
"closed".to_string(),
json!({"code": code, "reason": reason}).to_string(),
),
};
self.0.on_event(MediaEvent { kind, json });
}
}
struct AudioAdapter(Arc<dyn AudioSink>);
impl cua_spaces::stream::AudioSink for AudioAdapter {
fn on_audio(&self, p: cua_spaces::stream::AudioPacket) {
self.0.on_audio(super::AudioPacket {
track_id: p.track_id,
sequence: p.sequence,
pts_us: p.pts_us,
frame_samples: p.frame_samples,
config_epoch: 0,
discontinuity: p.lost > 0,
dtx: p.dtx,
data: p.data,
});
}
}
#[derive(uniffi::Object)]
pub struct SpaceStreamSession {
media_session_id: String,
codec: String,
inner: tokio::sync::Mutex<Option<cua_spaces::stream::StreamSession>>,
}
#[uniffi::export]
impl SpaceStreamSession {
pub fn media_session_id(&self) -> String {
self.media_session_id.clone()
}
pub fn codec(&self) -> String {
self.codec.clone()
}
pub fn stats(&self) -> SpaceStreamStats {
self.inner
.try_lock()
.ok()
.and_then(|g| g.as_ref().map(|s| s.stats().into()))
.unwrap_or(SpaceStreamStats {
frames: 0,
keyframes: 0,
frames_dropped: 0,
frames_gated: 0,
keyframe_requests: 0,
audio_packets: 0,
audio_lost: 0,
events: 0,
malformed: 0,
})
}
pub fn is_open(&self) -> bool {
self.inner
.try_lock()
.ok()
.and_then(|g| g.as_ref().map(|s| s.is_open()))
.unwrap_or(false)
}
pub fn request_keyframe(&self) -> Result<()> {
let g = self
.inner
.try_lock()
.map_err(|_| CuaError::Internal("session busy".into()))?;
let s = g
.as_ref()
.ok_or_else(|| CuaError::Closed("stream session".into()))?;
Ok(s.request_keyframe()?)
}
pub fn send_text(&self, json: String) -> Result<()> {
let g = self
.inner
.try_lock()
.map_err(|_| CuaError::Internal("session busy".into()))?;
let s = g
.as_ref()
.ok_or_else(|| CuaError::Closed("stream session".into()))?;
Ok(s.send_text(json)?)
}
pub async fn close(&self) -> Result<SpaceStreamStats> {
let s = self.inner.lock().await.take();
run(async move {
match s {
Some(s) => Ok(s.close().await?.into()),
None => Err(CuaError::Closed("stream session".into())),
}
})
.await
}
}
#[derive(uniffi::Object)]
pub struct SpacePresence {
inner: Arc<tokio::sync::Mutex<Option<cua_spaces::presence::PresenceSession>>>,
sender: cua_spaces::presence::CursorSender,
datagrams: bool,
view_seed: cua_spaces::presence::PresenceView,
stats: PresenceStats,
}
struct PresenceStats {
joined: std::time::Instant,
peak: std::sync::atomic::AtomicU64,
recorded: std::sync::atomic::AtomicBool,
}
impl PresenceStats {
fn new(peak: u64) -> Self {
Self {
joined: std::time::Instant::now(),
peak: std::sync::atomic::AtomicU64::new(peak),
recorded: std::sync::atomic::AtomicBool::new(false),
}
}
fn saw(&self, present: usize) {
self.peak
.fetch_max(present as u64, std::sync::atomic::Ordering::Relaxed);
}
fn finish(&self) {
if self
.recorded
.swap(true, std::sync::atomic::Ordering::SeqCst)
{
return;
}
cua_telemetry::capture(cua_telemetry::events::presence_session(
cua_telemetry::Outcome::Ok,
self.peak.load(std::sync::atomic::Ordering::Relaxed),
self.joined.elapsed(),
));
}
}
impl Drop for SpacePresence {
fn drop(&mut self) {
self.stats.finish();
}
}
impl SpacePresence {
async fn with<T>(
&self,
f: impl FnOnce(&mut cua_spaces::presence::PresenceSession) -> T,
) -> Result<T> {
let mut g = self.inner.lock().await;
let s = g
.as_mut()
.ok_or_else(|| CuaError::Closed("presence session".into()))?;
Ok(f(s))
}
}
#[uniffi::export]
impl SpacePresence {
pub async fn me(&self) -> Result<PresenceParticipant> {
self.with(|s| s.me().clone().into()).await
}
pub async fn roster(&self) -> Result<Vec<PresenceMember>> {
self.with(|s| {
self.stats.saw(s.roster().len());
s.roster()
.iter()
.map(|(p, c)| PresenceMember {
participant: p.clone().into(),
cursor: c.clone().map(Into::into),
})
.collect()
})
.await
}
pub async fn update_cursor(&self, cursor: PresenceCursor) -> Result<()> {
let sender = self.sender.clone();
run(async move { Ok(sender.update(&cursor.into()).await?) }).await
}
pub fn uses_datagrams(&self) -> bool {
self.datagrams
}
pub fn view(&self) -> Arc<PresenceView> {
PresenceView::wrap(self.view_seed.clone())
}
pub async fn next_event(&self, timeout_ms: Option<u64>) -> Result<Option<PresenceEvent>> {
let inner = self.inner.clone();
run(async move {
let mut g = inner.lock().await;
let s = g
.as_mut()
.ok_or_else(|| CuaError::Closed("presence session".into()))?;
let e = s
.next_event(Duration::from_millis(timeout_ms.unwrap_or(5_000)))
.await?;
Ok((e, s.roster().len()))
})
.await
.map(|(e, present)| {
self.stats.saw(present);
e.map(Into::into)
})
}
pub async fn leave(&self) -> Result<()> {
self.stats.finish();
let s = self.inner.lock().await.take();
run(async move {
match s {
Some(s) => Ok(s.leave().await?),
None => Ok(()),
}
})
.await
}
}
fn parse_conflict(conflict: Option<&str>) -> Result<cua_spaces::files::Conflict> {
use cua_spaces::files::Conflict;
match conflict.unwrap_or("") {
"rename" | "" => Ok(Conflict::Rename),
"overwrite" => Ok(Conflict::Overwrite),
"skip" => Ok(Conflict::Skip),
other => Err(CuaError::InvalidArgument(format!(
"conflict must be rename, skip or overwrite (got {other:?})"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
#[test]
fn a_teleport_refusal_says_teleport_refused_once() {
let err = CuaError::from(cua_spaces::Error::TeleportRefused(
"the approver declined".into(),
));
assert!(matches!(err, CuaError::TeleportRefused(_)), "{err:?}");
assert_eq!(err.to_string(), "teleport refused: the approver declined");
}
#[test]
fn an_embedded_insufficient_disk_becomes_insufficient_disk() {
let err = cua_spaces::Error::from(cua_sandbox_core::Error::Runtime(
cua_sandbox_core::RuntimeError::InsufficientDisk("need 52 GB".into()),
));
assert_eq!(err.tag(), "insufficient_disk");
match CuaError::from(err) {
CuaError::InsufficientDisk(message) => assert!(message.contains("need 52 GB")),
other => panic!("expected InsufficientDisk, got {other:?}"),
}
}
#[test]
fn send_file_keeps_both_copies_unless_overwrite_is_explicit() {
use cua_spaces::files::Conflict;
assert_eq!(parse_conflict(None).unwrap(), Conflict::Rename);
assert_eq!(parse_conflict(Some("")).unwrap(), Conflict::Rename);
assert_eq!(parse_conflict(Some("rename")).unwrap(), Conflict::Rename);
assert_eq!(parse_conflict(Some("skip")).unwrap(), Conflict::Skip);
assert_eq!(
parse_conflict(Some("overwrite")).unwrap(),
Conflict::Overwrite
);
assert!(parse_conflict(Some("replace")).is_err());
}
#[test]
fn a_filed_request_is_pending_with_a_retryable_request_id_not_an_error() {
let v = json!({
"consent_required": true,
"moved": false,
"request_id": "req-1",
"app": "chrome",
"space": "dev-1",
});
let r = teleport_app_response(&v).unwrap();
assert_eq!(r.status, "pending");
assert_eq!(r.request_id.as_deref(), Some("req-1"));
}
#[test]
fn a_still_pending_retry_stays_pending_with_the_same_request_id() {
let v = json!({
"consent_required": true,
"moved": false,
"request_id": "req-1",
"status": "pending",
});
let r = teleport_app_response(&v).unwrap();
assert_eq!(r.status, "pending");
assert_eq!(r.request_id.as_deref(), Some("req-1"));
}
#[test]
fn a_granted_and_delivered_request_is_moved() {
let v = json!({
"consent_required": false,
"moved": true,
"app": "chrome",
"space": "dev-1",
"items": ["item-1"],
"transferred_paths": ["tabs.json", "cookies.json"],
"import_ids": ["cua-kv-1"],
"expires_ms": 123,
});
let r = teleport_app_response(&v).unwrap();
assert_eq!(r.status, "moved");
assert_eq!(r.request_id, None);
assert_eq!(r.app, "chrome");
assert_eq!(r.space, "dev-1");
assert_eq!(r.method, "import_session");
assert_eq!(r.transferred_paths, ["tabs.json", "cookies.json"]);
assert_eq!(r.imported, ["tabs.json", "cookies.json"]);
}
#[test]
fn a_denied_request_is_a_refusal_not_pending() {
let v = json!({
"moved": false,
"denied": true,
"message": "the user declined the teleport (user); do not ask again unless they bring it up",
});
let err = teleport_app_response(&v).unwrap_err();
assert!(matches!(err, CuaError::TeleportRefused(_)), "{err:?}");
match err {
CuaError::TeleportRefused(m) => assert!(m.contains("declined")),
_ => unreachable!(),
}
}
#[test]
fn an_error_response_is_a_refusal_with_its_message() {
let v = json!({
"moved": false,
"error": {"code": "requires_cua_app", "message": "install Cua"},
});
let err = teleport_app_response(&v).unwrap_err();
match err {
CuaError::TeleportRefused(m) => assert!(m.contains("install Cua")),
other => panic!("{other:?}"),
}
}
#[test]
fn presence_view_runs_the_conformance_vectors_through_the_bindings() {
let doc: Value = serde_json::from_str(&presence_conformance_json()).unwrap();
for case in doc["cases"].as_array().unwrap() {
let name = case["name"].as_str().unwrap();
let view = PresenceView::new(
case["me"].as_str().unwrap().into(),
case["delay_ms"].as_f64(),
);
for step in case["steps"].as_array().unwrap() {
let at = step["at"].as_f64().unwrap();
if let Some(e) = step.get("event") {
let core: cua_spaces::presence::PresenceEvent =
serde_json::from_value(e.clone()).unwrap();
let record: PresenceEvent = core.clone().into();
assert_eq!(record.to_core(), Some(core), "{name}");
view.apply(record, at);
continue;
}
let pointer = step["pointer"].as_array().map(|p| PresencePoint {
x: p[0].as_f64().unwrap(),
y: p[1].as_f64().unwrap(),
});
let got = view.drawables(at, pointer);
let want = step["expect"].as_array().unwrap();
assert_eq!(got.len(), want.len(), "{name}: {got:?}");
for (g, w) in got.iter().zip(want) {
assert_eq!(g.participant_id, w["participant_id"].as_str().unwrap());
assert!(
(g.x - w["x"].as_f64().unwrap()).abs() < 1e-6,
"{name} {g:?}"
);
assert!((g.alpha - w["alpha"].as_f64().unwrap()).abs() < 1e-6);
assert_eq!(g.shape, w["shape"].as_str().unwrap());
}
}
}
}
#[test]
fn presence_art_is_exported_for_every_shape() {
let all = presence_cursor_art_all();
assert_eq!(all.len(), 14);
assert_eq!(presence_cursor_art("text".into()).shape, "text");
assert_eq!(presence_cursor_art("nonsense".into()).shape, "arrow");
assert!(
presence_cursor_art_svg("pointer".into(), "#3cb44b".into(), 24.0).contains("#3cb44b")
);
}
#[test]
fn an_agent_list_row_keeps_its_published_accepts_message() {
let row = json!({"run_id": "run-1", "agent": "goose", "status": "idle",
"phase": "waiting", "reason": "waiting for a follow-up", "turn": 1,
"alive": true, "accepts_message": true});
let s = AgentRunStatus::from_json(row).unwrap();
assert_eq!(s.status, "idle");
assert!(s.accepts_message);
assert_eq!(s.reason, "waiting for a follow-up");
}
#[test]
fn every_contract_tool_maps_to_an_sdk_method() {
let contract: BTreeSet<&str> = cua_spaces::contract::tools()
.iter()
.map(|t| t.name)
.collect();
let mapped: BTreeSet<&str> = SPACES_TOOL_METHODS.iter().map(|(t, _)| *t).collect();
assert_eq!(
mapped, contract,
"SPACES_TOOL_METHODS must list exactly the Spaces contract tools"
);
}
#[test]
fn rfc3339_matches_known_instants() {
let at = std::time::UNIX_EPOCH + Duration::from_secs(1_758_300_000);
assert_eq!(humantime_rfc3339(at), "2025-09-19T16:40:00Z");
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)]
pub struct DriveView {
#[uniffi(default = None)]
pub as_agent: Option<String>,
#[uniffi(default = None)]
pub in_space: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveEntry {
pub path: String,
pub name: String,
pub folder: bool,
#[serde(default)]
pub size: u64,
#[serde(default)]
pub modified_ms: u64,
#[serde(default)]
pub etag: String,
pub mode: String,
#[serde(default)]
pub sync: Option<DriveFileSync>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveFileSync {
pub state: String,
#[serde(default)]
pub written_by: Option<String>,
#[serde(default)]
pub conflict_path: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DrivePendingUpload {
pub path: String,
pub bytes: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveSpaceVolume {
pub space: String,
pub mount_path: String,
pub backend: String,
pub principal: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveListing {
pub path: String,
pub principal: String,
pub entries: Vec<DriveEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct DriveFile {
pub path: String,
pub size: u64,
pub etag: String,
pub version: String,
pub modified_ms: u64,
pub content: Vec<u8>,
pub sync: Option<DriveFileSync>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveObject {
pub key: String,
pub size: u64,
pub etag: String,
pub version: String,
pub modified_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveVersion {
pub version: String,
pub size: u64,
pub modified_ms: u64,
pub deleted: bool,
pub latest: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveGrant {
pub id: String,
pub principal: String,
pub prefix: String,
pub mode: String,
pub created_ms: u64,
#[serde(default)]
pub expires_ms: Option<u64>,
#[serde(default)]
pub revoked: bool,
#[serde(default)]
pub note: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveAccessRequest {
pub id: String,
pub principal: String,
pub prefix: String,
pub mode: String,
#[serde(default)]
pub reason: String,
pub created_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveAuditEvent {
pub seq: u64,
pub ts_ms: u64,
pub principal: String,
pub action: String,
pub path: String,
#[serde(default)]
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveAudit {
pub events: Vec<DriveAuditEvent>,
pub verified: bool,
#[serde(default)]
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize, Serialize)]
pub struct DriveS3Settings {
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub region: String,
pub bucket: String,
#[serde(default)]
pub root: String,
#[serde(default)]
pub path_style: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveStorage {
pub backend: String,
pub fs_path: String,
#[serde(default)]
pub s3: Option<DriveS3Settings>,
pub has_keys: bool,
pub cloud_available: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Serialize)]
pub struct DriveStorageUpdate {
pub backend: String,
pub s3: Option<DriveS3Settings>,
pub access_key_id: Option<String>,
pub secret_access_key: Option<String>,
pub dry_run: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveStorageCheck {
pub ok: bool,
pub reachable: bool,
pub authorized: bool,
pub versioning: bool,
#[serde(default)]
pub detail: Option<String>,
#[serde(default)]
pub problem: Option<String>,
pub applied: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveMountStatus {
pub enabled: bool,
pub state: String,
pub method: String,
#[serde(default)]
pub path: Option<String>,
pub volume_name: String,
#[serde(default)]
pub detail: Option<String>,
#[serde(default)]
pub settings_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveConflict {
pub path: String,
pub conflict_path: String,
pub winner_device: String,
pub loser_device: String,
pub winner_version: String,
pub loser_version: String,
pub ts_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveDevice {
pub id: String,
pub name: String,
pub this_device: bool,
pub last_seen_ms: u64,
pub last_change_ms: u64,
pub changes: u64,
}
#[derive(Debug, Clone, PartialEq, uniffi::Record, Deserialize)]
pub struct DriveSyncStatus {
pub device_id: String,
pub device_name: String,
pub feed: String,
pub poll_interval_ms: u64,
pub last_poll_ms: u64,
pub last_remote_change_ms: u64,
pub pending_uploads: u32,
pub pending_bytes: u64,
pub conflicts: Vec<DriveConflict>,
pub devices: Vec<DriveDevice>,
#[serde(default)]
pub last_error: Option<String>,
#[serde(default)]
pub pending: Vec<DrivePendingUpload>,
#[serde(default)]
pub backend: String,
#[serde(default)]
pub mount: String,
#[serde(default)]
pub cache: Option<DriveCacheStats>,
#[serde(default)]
pub volumes: Vec<DriveSpaceVolume>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveSyncEvent {
pub seq: u64,
pub ts_ms: u64,
pub kind: String,
pub path: String,
pub device: String,
pub size: u64,
pub version: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record, Deserialize)]
pub struct DriveSyncEvents {
pub events: Vec<DriveSyncEvent>,
pub next_seq: u64,
}
#[derive(Debug, Clone, PartialEq, uniffi::Record, Deserialize)]
pub struct DriveCacheStats {
pub dir: String,
pub size_bytes: u64,
pub capacity_bytes: u64,
pub block_bytes: u64,
pub blocks: u64,
pub hits: u64,
pub misses: u64,
pub hit_rate: f64,
pub prefetched_bytes: u64,
pub evictions: u64,
}
fn with_view(mut args: Value, view: Option<DriveView>) -> Value {
if let Some(v) = view {
if let Some(a) = v.as_agent {
args["as_agent"] = json!(a);
}
if let Some(s) = v.in_space {
args["in_space"] = json!(s);
}
}
args
}
fn b64() -> base64::engine::GeneralPurpose {
base64::engine::general_purpose::STANDARD
}
#[uniffi::export]
impl Spaces {
pub async fn volume_ls(
&self,
path: Option<String>,
view: Option<DriveView>,
) -> Result<DriveListing> {
let host = self.host.clone();
run(async move {
let args = with_view(json!({"path": path.unwrap_or_default()}), view);
parse(host.tool_value("volume_ls", args).await?)
})
.await
}
pub async fn volume_read(
&self,
path: String,
version: Option<String>,
view: Option<DriveView>,
) -> Result<DriveFile> {
use base64::Engine as _;
let host = self.host.clone();
run(async move {
let mut args = json!({"path": path});
if let Some(v) = version {
args["version"] = json!(v);
}
let v = host
.tool_value("volume_read", with_view(args, view))
.await?;
let s = |k: &str| v[k].as_str().unwrap_or_default().to_string();
let content = match v["encoding"].as_str() {
Some("base64") => b64()
.decode(s("content"))
.map_err(|e| CuaError::Internal(format!("volume_read content: {e}")))?,
_ => s("content").into_bytes(),
};
Ok(DriveFile {
path: s("path"),
size: v["size"].as_u64().unwrap_or(0),
etag: s("etag"),
version: s("version"),
modified_ms: v["modified_ms"].as_u64().unwrap_or(0),
content,
sync: serde_json::from_value(v["sync"].clone()).ok(),
})
})
.await
}
pub async fn volume_write(
&self,
path: String,
content: Vec<u8>,
if_etag: Option<String>,
create_only: bool,
view: Option<DriveView>,
) -> Result<DriveObject> {
use base64::Engine as _;
let host = self.host.clone();
run(async move {
let mut args = json!({"path": path, "content": b64().encode(&content),
"encoding": "base64", "create_only": create_only});
if let Some(e) = if_etag {
args["if_etag"] = json!(e);
}
parse(
host.tool_value("volume_write", with_view(args, view))
.await?,
)
})
.await
}
pub async fn volume_delete(
&self,
path: String,
if_etag: Option<String>,
view: Option<DriveView>,
) -> Result<()> {
let host = self.host.clone();
run(async move {
let mut args = json!({"path": path});
if let Some(e) = if_etag {
args["if_etag"] = json!(e);
}
host.tool_value("volume_delete", with_view(args, view))
.await?;
Ok(())
})
.await
}
pub async fn volume_history(
&self,
path: String,
view: Option<DriveView>,
) -> Result<Vec<DriveVersion>> {
let host = self.host.clone();
run(async move {
let v = host
.tool_value("volume_history", with_view(json!({"path": path}), view))
.await?;
parse(v["versions"].clone())
})
.await
}
pub async fn volume_restore(
&self,
path: String,
version: String,
view: Option<DriveView>,
) -> Result<DriveObject> {
let host = self.host.clone();
run(async move {
let args = with_view(json!({"path": path, "version": version}), view);
parse(host.tool_value("volume_restore", args).await?)
})
.await
}
pub async fn volume_grant(
&self,
principal: String,
prefix: String,
mode: String,
expires_in_secs: Option<u64>,
note: Option<String>,
) -> Result<DriveGrant> {
let host = self.host.clone();
run(async move {
let args = json!({"principal": principal, "prefix": prefix, "mode": mode,
"expires_in_secs": expires_in_secs, "note": note});
parse(host.tool_value("volume_grant", args).await?)
})
.await
}
pub async fn volume_revoke(&self, grant_id: String) -> Result<DriveGrant> {
let host = self.host.clone();
run(async move {
parse(
host.tool_value("volume_revoke", json!({"grant_id": grant_id}))
.await?,
)
})
.await
}
pub async fn volume_grants(&self, all: bool) -> Result<Vec<DriveGrant>> {
let host = self.host.clone();
run(async move {
let v = host
.tool_value("volume_grants", json!({"all": all}))
.await?;
parse(v["grants"].clone())
})
.await
}
pub async fn volume_request_access(
&self,
agent: String,
prefix: String,
mode: String,
reason: Option<String>,
in_space: Option<String>,
) -> Result<DriveAccessRequest> {
let host = self.host.clone();
run(async move {
let args = json!({"as_agent": agent, "in_space": in_space, "prefix": prefix,
"mode": mode, "reason": reason});
parse(host.tool_value("volume_request_access", args).await?)
})
.await
}
pub async fn volume_requests(&self) -> Result<Vec<DriveAccessRequest>> {
let host = self.host.clone();
run(async move {
let v = host.tool_value("volume_requests", json!({})).await?;
parse(v["requests"].clone())
})
.await
}
pub async fn volume_approve(
&self,
request_id: String,
expires_in_secs: Option<u64>,
) -> Result<DriveGrant> {
let host = self.host.clone();
run(async move {
let args = json!({"request_id": request_id, "expires_in_secs": expires_in_secs});
parse(host.tool_value("volume_approve", args).await?)
})
.await
}
pub async fn volume_deny(&self, request_id: String) -> Result<()> {
let host = self.host.clone();
run(async move {
host.tool_value("volume_deny", json!({"request_id": request_id}))
.await?;
Ok(())
})
.await
}
pub async fn volume_storage(&self) -> Result<DriveStorage> {
let host = self.host.clone();
run(async move { parse(host.tool_value("volume_storage", json!({})).await?) }).await
}
pub async fn volume_storage_set(
&self,
update: DriveStorageUpdate,
) -> Result<DriveStorageCheck> {
let host = self.host.clone();
run(async move {
let args = serde_json::to_value(&update)
.map_err(|e| CuaError::Internal(format!("volume_storage_set: {e}")))?;
parse(host.tool_value("volume_storage_set", args).await?)
})
.await
}
pub async fn volume_mount_status(&self) -> Result<DriveMountStatus> {
let host = self.host.clone();
run(async move { parse(host.tool_value("volume_mount_status", json!({})).await?) }).await
}
pub async fn volume_mount(&self) -> Result<DriveMountStatus> {
let host = self.host.clone();
run(async move { parse(host.tool_value("volume_mount", json!({})).await?) }).await
}
pub async fn volume_unmount(&self) -> Result<DriveMountStatus> {
let host = self.host.clone();
run(async move { parse(host.tool_value("volume_unmount", json!({})).await?) }).await
}
pub async fn volume_sync_status(&self) -> Result<DriveSyncStatus> {
let host = self.host.clone();
run(async move { parse(host.tool_value("volume_sync_status", json!({})).await?) }).await
}
pub async fn volume_sync_events(
&self,
since_seq: Option<u64>,
wait_ms: Option<u32>,
) -> Result<DriveSyncEvents> {
let host = self.host.clone();
run(async move {
parse(
host.tool_value(
"volume_sync_events",
json!({"since_seq": since_seq, "wait_ms": wait_ms}),
)
.await?,
)
})
.await
}
pub async fn volume_sync_resolve(&self, path: String) -> Result<()> {
let host = self.host.clone();
run(async move {
host.tool_value("volume_sync_resolve", json!({"path": path}))
.await?;
Ok(())
})
.await
}
pub async fn volume_cache_stats(&self) -> Result<DriveCacheStats> {
let host = self.host.clone();
run(async move { parse(host.tool_value("volume_cache_stats", json!({})).await?) }).await
}
pub async fn volume_cache_set(&self, capacity_bytes: u64) -> Result<DriveCacheStats> {
let host = self.host.clone();
run(async move {
parse(
host.tool_value(
"volume_cache_set",
json!({"capacity_bytes": capacity_bytes}),
)
.await?,
)
})
.await
}
pub async fn volume_cache_clear(&self) -> Result<DriveCacheStats> {
let host = self.host.clone();
run(async move { parse(host.tool_value("volume_cache_clear", json!({})).await?) }).await
}
pub async fn volume_audit(&self, limit: Option<u32>) -> Result<DriveAudit> {
let host = self.host.clone();
run(async move {
parse(
host.tool_value("volume_audit", json!({"limit": limit}))
.await?,
)
})
.await
}
}
impl Spaces {
#[deprecated(note = "renamed to `volume_ls` (Cua Volume); removed after this release")]
pub async fn drive_ls(
&self,
path: Option<String>,
view: Option<DriveView>,
) -> Result<DriveListing> {
self.volume_ls(path, view).await
}
#[deprecated(note = "renamed to `volume_read` (Cua Volume); removed after this release")]
pub async fn drive_read(
&self,
path: String,
version: Option<String>,
view: Option<DriveView>,
) -> Result<DriveFile> {
self.volume_read(path, version, view).await
}
#[deprecated(note = "renamed to `volume_write` (Cua Volume); removed after this release")]
pub async fn drive_write(
&self,
path: String,
content: Vec<u8>,
if_etag: Option<String>,
create_only: bool,
view: Option<DriveView>,
) -> Result<DriveObject> {
self.volume_write(path, content, if_etag, create_only, view)
.await
}
#[deprecated(note = "renamed to `volume_delete` (Cua Volume); removed after this release")]
pub async fn drive_delete(
&self,
path: String,
if_etag: Option<String>,
view: Option<DriveView>,
) -> Result<()> {
self.volume_delete(path, if_etag, view).await
}
#[deprecated(note = "renamed to `volume_history` (Cua Volume); removed after this release")]
pub async fn drive_history(
&self,
path: String,
view: Option<DriveView>,
) -> Result<Vec<DriveVersion>> {
self.volume_history(path, view).await
}
#[deprecated(note = "renamed to `volume_restore` (Cua Volume); removed after this release")]
pub async fn drive_restore(
&self,
path: String,
version: String,
view: Option<DriveView>,
) -> Result<DriveObject> {
self.volume_restore(path, version, view).await
}
#[deprecated(note = "renamed to `volume_grant` (Cua Volume); removed after this release")]
pub async fn drive_grant(
&self,
principal: String,
prefix: String,
mode: String,
expires_in_secs: Option<u64>,
note: Option<String>,
) -> Result<DriveGrant> {
self.volume_grant(principal, prefix, mode, expires_in_secs, note)
.await
}
#[deprecated(note = "renamed to `volume_revoke` (Cua Volume); removed after this release")]
pub async fn drive_revoke(&self, grant_id: String) -> Result<DriveGrant> {
self.volume_revoke(grant_id).await
}
#[deprecated(note = "renamed to `volume_grants` (Cua Volume); removed after this release")]
pub async fn drive_grants(&self, all: bool) -> Result<Vec<DriveGrant>> {
self.volume_grants(all).await
}
#[deprecated(
note = "renamed to `volume_request_access` (Cua Volume); removed after this release"
)]
pub async fn drive_request_access(
&self,
agent: String,
prefix: String,
mode: String,
reason: Option<String>,
in_space: Option<String>,
) -> Result<DriveAccessRequest> {
self.volume_request_access(agent, prefix, mode, reason, in_space)
.await
}
#[deprecated(note = "renamed to `volume_requests` (Cua Volume); removed after this release")]
pub async fn drive_requests(&self) -> Result<Vec<DriveAccessRequest>> {
self.volume_requests().await
}
#[deprecated(note = "renamed to `volume_approve` (Cua Volume); removed after this release")]
pub async fn drive_approve(
&self,
request_id: String,
expires_in_secs: Option<u64>,
) -> Result<DriveGrant> {
self.volume_approve(request_id, expires_in_secs).await
}
#[deprecated(note = "renamed to `volume_deny` (Cua Volume); removed after this release")]
pub async fn drive_deny(&self, request_id: String) -> Result<()> {
self.volume_deny(request_id).await
}
#[deprecated(note = "renamed to `volume_storage` (Cua Volume); removed after this release")]
pub async fn drive_storage(&self) -> Result<DriveStorage> {
self.volume_storage().await
}
#[deprecated(note = "renamed to `volume_storage_set` (Cua Volume); removed after this release")]
pub async fn drive_storage_set(&self, update: DriveStorageUpdate) -> Result<DriveStorageCheck> {
self.volume_storage_set(update).await
}
#[deprecated(
note = "renamed to `volume_mount_status` (Cua Volume); removed after this release"
)]
pub async fn drive_mount_status(&self) -> Result<DriveMountStatus> {
self.volume_mount_status().await
}
#[deprecated(note = "renamed to `volume_mount` (Cua Volume); removed after this release")]
pub async fn drive_mount(&self) -> Result<DriveMountStatus> {
self.volume_mount().await
}
#[deprecated(note = "renamed to `volume_unmount` (Cua Volume); removed after this release")]
pub async fn drive_unmount(&self) -> Result<DriveMountStatus> {
self.volume_unmount().await
}
#[deprecated(note = "renamed to `volume_sync_status` (Cua Volume); removed after this release")]
pub async fn drive_sync_status(&self) -> Result<DriveSyncStatus> {
self.volume_sync_status().await
}
#[deprecated(note = "renamed to `volume_sync_events` (Cua Volume); removed after this release")]
pub async fn drive_sync_events(
&self,
since_seq: Option<u64>,
wait_ms: Option<u32>,
) -> Result<DriveSyncEvents> {
self.volume_sync_events(since_seq, wait_ms).await
}
#[deprecated(
note = "renamed to `volume_sync_resolve` (Cua Volume); removed after this release"
)]
pub async fn drive_sync_resolve(&self, path: String) -> Result<()> {
self.volume_sync_resolve(path).await
}
#[deprecated(note = "renamed to `volume_cache_stats` (Cua Volume); removed after this release")]
pub async fn drive_cache_stats(&self) -> Result<DriveCacheStats> {
self.volume_cache_stats().await
}
#[deprecated(note = "renamed to `volume_cache_set` (Cua Volume); removed after this release")]
pub async fn drive_cache_set(&self, capacity_bytes: u64) -> Result<DriveCacheStats> {
self.volume_cache_set(capacity_bytes).await
}
#[deprecated(note = "renamed to `volume_cache_clear` (Cua Volume); removed after this release")]
pub async fn drive_cache_clear(&self) -> Result<DriveCacheStats> {
self.volume_cache_clear().await
}
#[deprecated(note = "renamed to `volume_audit` (Cua Volume); removed after this release")]
pub async fn drive_audit(&self, limit: Option<u32>) -> Result<DriveAudit> {
self.volume_audit(limit).await
}
}
fn thumbnail_format(word: &str) -> crate::types::ImageFormat {
match word {
"png" => crate::types::ImageFormat::Png,
"webp" => crate::types::ImageFormat::Webp,
_ => crate::types::ImageFormat::Jpeg,
}
}