use super::{Auth, SpacesdClient, run};
use crate::{CuaError, Result};
use std::sync::Arc;
impl From<cua_host::Error> for CuaError {
fn from(e: cua_host::Error) -> Self {
use cua_host::Error as E;
let m = e.to_string();
match e {
E::InvalidArgument(_) | E::Conflict(_) => CuaError::InvalidArgument(m),
E::Unauthenticated(_) => CuaError::Unauthenticated(m),
E::PermissionDenied(_) => CuaError::PermissionDenied(m),
E::NotFound(_) => CuaError::NotFound(m),
E::Relay(_) | E::Download(_) => CuaError::Http(m),
E::PlanLimit(_) => {
let url = cua_home::plan_limit::PlanLimit::parse(&m)
.map(|l| l.upgrade_url(&cua_home::env::account_app_url()));
CuaError::Http(match url {
Some(url) => format!("{m} Upgrade: {url}"),
None => m,
})
}
E::Service(_) => CuaError::Runtime(m),
E::Io(_) | E::Internal(_) => CuaError::Internal(m),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct RelayClientInfo {
pub id: String,
pub email: Option<String>,
pub name: Option<String>,
pub streams: u32,
pub since: u64,
}
impl From<cua_host::ConnectedClient> for RelayClientInfo {
fn from(c: cua_host::ConnectedClient) -> Self {
Self {
id: c.id,
email: c.email,
name: c.name,
streams: c.streams,
since: c.since,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct RelayMachine {
pub id: String,
pub space_id: String,
pub name: String,
pub owner_id: String,
pub owner_email: Option<String>,
pub role: String,
pub online: bool,
pub sharing: bool,
pub version: String,
pub url: String,
pub allow: Vec<String>,
pub clients: Vec<RelayClientInfo>,
pub confirmed: bool,
pub team: Option<String>,
pub provides_spaces: Option<bool>,
}
impl From<cua_host::Machine> for RelayMachine {
fn from(m: cua_host::Machine) -> Self {
Self {
space_id: format!("relay:{}", m.id),
id: m.id,
name: m.name,
owner_id: m.owner.id,
owner_email: m.owner.email,
role: m.role,
online: m.online,
sharing: m.sharing,
version: m.version,
url: m.url,
allow: m.allow,
clients: m.clients.into_iter().map(Into::into).collect(),
confirmed: m.confirmed,
provides_spaces: m
.meta
.get(cua_host::META_PROVIDES_SPACES)
.map(|v| v == "on"),
team: m.team.filter(|t| !t.trim().is_empty()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct PairingCode {
pub id: String,
pub code: Option<String>,
pub url: Option<String>,
pub install_sh: Option<String>,
pub install_powershell: Option<String>,
pub state: String,
pub team: Option<String>,
pub created_at: u64,
pub expires_at: u64,
pub used_at: Option<u64>,
pub machine: Option<String>,
}
impl PairingCode {
fn from_host(p: cua_host::PairingCode, relay: &str) -> Self {
let made = |f: &dyn Fn(&str) -> String| p.code.as_deref().map(f);
Self {
url: made(&|c| cua_host::add_machine_url(c, relay)),
install_sh: made(&|c| cua_host::install_command(c, relay, "linux")),
install_powershell: made(&|c| cua_host::install_command(c, relay, "windows")),
id: p.id,
code: p.code,
state: p.state,
team: p.team,
created_at: p.created_at,
expires_at: p.expires_at,
used_at: p.used_at,
machine: p.machine,
}
}
}
#[derive(uniffi::Object)]
pub struct Relay {
client: cua_host::RelayClient,
account_token: String,
}
#[uniffi::export]
impl Relay {
#[uniffi::constructor]
pub fn new(relay_url: Option<String>, account_token: String) -> Result<Arc<Self>> {
let url = cua_host::relay_url_for(relay_url.as_deref(), &cua_daemon::cua_home());
Ok(Arc::new(Self {
client: cua_host::RelayClient::new(&url)?,
account_token,
}))
}
pub fn url(&self) -> String {
self.client.base().to_string()
}
pub async fn machines(&self) -> Result<Vec<RelayMachine>> {
let client = self.client.clone();
let token = self.account_token.clone();
run(async move {
Ok(client
.machines(&token)
.await?
.into_iter()
.map(Into::into)
.collect())
})
.await
}
pub async fn machine(&self, machine_id: String) -> Result<RelayMachine> {
let client = self.client.clone();
let token = self.account_token.clone();
run(async move { Ok(client.machine(&token, &machine_id).await?.into()) }).await
}
pub async fn stop_sharing(&self, machine_id: String) -> Result<RelayMachine> {
let client = self.client.clone();
let token = self.account_token.clone();
run(async move { Ok(client.stop_sharing(&token, &machine_id).await?.into()) }).await
}
pub async fn start_sharing(&self, machine_id: String) -> Result<RelayMachine> {
let client = self.client.clone();
let token = self.account_token.clone();
run(async move { Ok(client.start_sharing(&token, &machine_id).await?.into()) }).await
}
pub async fn connect(&self, machine_id: String) -> Result<Arc<SpacesdClient>> {
let url = self.client.machine_url(&machine_id);
let token = self.account_token.clone();
run(async move {
let o = cua_spacesd_client::ConnectOptions::parse(&url)?
.gateway(Arc::new(cua_spacesd_client::StaticBearer(token)));
let client = cua_spacesd_client::SpacesdClient::connect(o).await?;
Ok(Arc::new(SpacesdClient::new(client, vec![]).at("relay")))
})
.await
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)]
pub struct HostSetupOptions {
pub mode: Option<String>,
pub relay_url: Option<String>,
pub direct: Option<String>,
pub name: Option<String>,
pub allow: Vec<String>,
pub driver_bin: Option<String>,
pub runner: Option<String>,
#[uniffi(default = None)]
pub profile: Option<String>,
#[uniffi(default = None)]
pub share_desktop: Option<bool>,
#[uniffi(default = None)]
pub provide_spaces: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, uniffi::Record)]
pub struct HostSettingsChange {
#[uniffi(default = None)]
pub share_desktop: Option<bool>,
#[uniffi(default = None)]
pub provide_spaces: Option<bool>,
#[uniffi(default = None)]
pub max_spaces: Option<u32>,
#[uniffi(default = None)]
pub max_macos_vms: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct HostProvidedSpace {
pub relay_machine: String,
pub local_space: String,
pub name: String,
pub image: String,
pub os: String,
pub kind: String,
pub created_by: String,
pub created_at_ms: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct HostSpacesAuditRecord {
pub at_ms: i64,
pub action: String,
pub who: String,
pub space: String,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct HostPermission {
pub id: String,
pub title: String,
pub settings_url: String,
pub instructions: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct HostStatus {
pub configured: bool,
pub mode: Option<String>,
pub relay_url: Option<String>,
pub direct_url: Option<String>,
pub env_token_path: Option<String>,
pub machine_id: Option<String>,
pub name: Option<String>,
pub sharing: bool,
pub service_installed: bool,
pub service_running: bool,
pub service_kind: String,
pub online: Option<bool>,
pub clients: Vec<RelayClientInfo>,
pub allow: Vec<String>,
pub permissions: Vec<HostPermission>,
pub error: Option<String>,
#[uniffi(default = [])]
pub recent_access: Vec<HostAccessRecord>,
#[uniffi(default = None)]
pub access_log_error: Option<String>,
#[uniffi(default = true)]
pub share_desktop: bool,
#[uniffi(default = false)]
pub provide_spaces: bool,
#[uniffi(default = 0)]
pub max_spaces: u32,
#[uniffi(default = 0)]
pub max_macos_vms: u32,
#[uniffi(default = [])]
pub provided_spaces: Vec<HostProvidedSpace>,
#[uniffi(default = [])]
pub spaces_audit: Vec<HostSpacesAuditRecord>,
#[uniffi(default = None)]
pub spaces_audit_error: Option<String>,
#[uniffi(default = false)]
pub paused_signed_out: bool,
#[uniffi(default = None)]
pub owner: Option<String>,
#[uniffi(default = None)]
pub owner_email: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct HostAccessRecord {
pub at_ms: i64,
pub via: String,
pub who: String,
pub what: String,
}
impl From<cua_host::HostStatus> for HostStatus {
fn from(s: cua_host::HostStatus) -> Self {
Self {
configured: s.configured,
mode: s.mode,
relay_url: s.relay_url,
direct_url: s.direct_url,
env_token_path: s.env_token_path,
machine_id: s.machine_id,
name: s.name,
sharing: s.sharing,
service_installed: s.service.installed,
service_running: s.service.running,
service_kind: s.service.kind,
online: s.online,
clients: s.clients.into_iter().map(Into::into).collect(),
allow: s.allow,
permissions: s
.permissions
.into_iter()
.map(|p| HostPermission {
id: p.id,
title: p.title,
settings_url: p.settings_url,
instructions: p.instructions,
})
.collect(),
error: s.error,
recent_access: s
.recent_access
.into_iter()
.map(|a| HostAccessRecord {
at_ms: i64::try_from(a.at_ms).unwrap_or(i64::MAX),
via: a.via,
who: a.who,
what: a.what,
})
.collect(),
access_log_error: s.access_log_error,
share_desktop: s.share_desktop,
provide_spaces: s.provide_spaces,
max_spaces: s.max_spaces,
max_macos_vms: s.max_macos_vms,
provided_spaces: s
.provided_spaces
.into_iter()
.map(|p| HostProvidedSpace {
relay_machine: p.relay_machine,
local_space: p.local_space,
name: p.name,
image: p.image,
os: p.os,
kind: p.kind,
created_by: p.created_by,
created_at_ms: i64::try_from(p.created_at_ms).unwrap_or(i64::MAX),
})
.collect(),
spaces_audit: s
.spaces_audit
.into_iter()
.map(|a| HostSpacesAuditRecord {
at_ms: i64::try_from(a.at_ms).unwrap_or(i64::MAX),
action: a.action,
who: a.who,
space: a.space,
detail: a.detail,
})
.collect(),
spaces_audit_error: s.spaces_audit_error,
paused_signed_out: s.paused_signed_out,
owner: s.owner,
owner_email: s.owner_email,
}
}
}
pub(crate) fn setup_token_state(mode: &str, token: Option<&str>) -> &'static str {
if mode.trim().eq_ignore_ascii_case("direct") {
return "not_needed";
}
let Some(token) = token.filter(|t| !t.trim().is_empty()) else {
return "missing";
};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
match cua_auth::jwt_claims(token).and_then(|c| c["exp"].as_u64()) {
Some(exp) if exp <= now => "expired",
_ => "provided",
}
}
pub(crate) fn setup_options(o: HostSetupOptions) -> Result<cua_host::SetupOptions> {
let mut opts = match o.mode.as_deref().unwrap_or("relay") {
"relay" => cua_host::SetupOptions::relay(
o.relay_url
.filter(|u| !u.trim().is_empty())
.unwrap_or_else(cua_host::relay_url_from_env),
),
"direct" => {
let listen = o.direct.as_deref().unwrap_or("0.0.0.0:3211");
cua_host::SetupOptions::direct(listen.parse().map_err(|_| {
CuaError::InvalidArgument(format!("direct address {listen:?} is not ip:port"))
})?)
}
other => {
return Err(CuaError::InvalidArgument(format!(
"mode {other:?} (use relay or direct)"
)));
}
};
opts.name = o.name;
opts.allow = o.allow;
opts.driver_bin = o.driver_bin.map(Into::into);
opts.runner = cua_host::RunnerKind::parse(o.runner.as_deref().unwrap_or("auto"))?;
if let Some(p) = o.profile.as_deref().filter(|p| !p.trim().is_empty()) {
opts = opts.profile(cua_host::HostProfile::parse(p)?);
}
if let Some(d) = o.share_desktop {
opts.share_desktop = d;
}
if let Some(p) = o.provide_spaces {
opts.provide_spaces = p;
}
Ok(opts)
}
#[derive(uniffi::Object)]
pub struct Host {
home: std::path::PathBuf,
}
#[uniffi::export]
impl Host {
#[uniffi::constructor]
pub fn new(cua_home: Option<String>) -> Arc<Self> {
Arc::new(Self {
home: cua_home
.map(Into::into)
.unwrap_or_else(cua_daemon::cua_home),
})
}
pub async fn status(&self) -> Result<HostStatus> {
let home = self.home.clone();
run(async move { Ok(cua_host::Host::new(home).status().await?.into()) }).await
}
pub async fn setup(
&self,
options: HostSetupOptions,
account_token: Option<String>,
) -> Result<HostStatus> {
let home = self.home.clone();
let mode = options.mode.clone().unwrap_or_else(|| "relay".into());
let opts = setup_options(options);
let (desktop, provide) = opts
.as_ref()
.map(|o| (o.share_desktop, o.provide_spaces))
.unwrap_or((true, false));
let account_token = account_token.filter(|t| !t.trim().is_empty());
let token_state = setup_token_state(&mode, account_token.as_deref());
let (r, failed_stage, http_status) = match opts {
Ok(opts) => {
let staged = run(async move {
let host = cua_host::Host::new(home);
let staged = match account_token {
Some(t) => host.setup_staged(opts, &cua_host::StaticToken(t)).await,
None => host.setup_staged(opts, &cua_host::NoAccount).await,
};
Ok(staged)
})
.await;
match staged {
Ok(Ok(status)) => (Ok(status.into()), None, None),
Ok(Err((stage, e))) => {
let status = e.http_status();
(Err(e.into()), Some(stage.as_str()), status)
}
Err(e) => (Err(e), Some("preflight"), None),
}
}
Err(e) => (Err(e), Some("preflight"), None),
};
cua_telemetry::capture(cua_telemetry::events::host_setup(
&mode,
desktop,
provide,
if r.is_ok() {
cua_telemetry::Outcome::Ok
} else {
cua_telemetry::Outcome::Error
},
super::telemetry::variant(&r),
&cua_telemetry::events::HostSetupDetail {
signed_in: token_state != "missing" && token_state != "not_needed"
|| cua_auth::may_have_session(),
token_state,
failed_stage,
http_status,
},
));
r
}
pub async fn configure(&self, change: HostSettingsChange) -> Result<HostStatus> {
let home = self.home.clone();
run(async move {
Ok(cua_host::Host::new(home)
.configure(cua_host::HostSettingsChange {
share_desktop: change.share_desktop,
provide_spaces: change.provide_spaces,
max_spaces: change.max_spaces,
max_macos_vms: change.max_macos_vms,
cua_bin: None,
})
.await?
.into())
})
.await
}
pub async fn stop_sharing(&self) -> Result<HostStatus> {
let home = self.home.clone();
run(async move { Ok(cua_host::Host::new(home).stop_sharing().await?.into()) }).await
}
pub async fn start_sharing(&self) -> Result<HostStatus> {
let home = self.home.clone();
run(async move { Ok(cua_host::Host::new(home).start_sharing().await?.into()) }).await
}
pub async fn pause_signed_out(&self) -> Result<HostStatus> {
let home = self.home.clone();
run(async move { Ok(cua_host::Host::new(home).pause_signed_out().await?.into()) }).await
}
pub async fn resume_signed_in(&self, account: String) -> Result<HostStatus> {
let home = self.home.clone();
run(async move {
Ok(cua_host::Host::new(home)
.resume_signed_in(&account)
.await?
.into())
})
.await
}
pub async fn remove(&self) -> Result<()> {
let home = self.home.clone();
run(async move {
cua_host::Host::new(home).remove().await?;
if let Err(e) = cua_sandbox_core::remove_builtin_runtimes().await {
tracing::warn!("could not remove the built-in runtimes: {e}");
}
Ok(())
})
.await
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct RelayDevice {
pub id: String,
pub name: String,
pub state: String,
pub platform: String,
pub created_at: u64,
pub enrolled_until: Option<u64>,
pub last_seen: Option<u64>,
pub current: bool,
}
impl From<cua_host::DeviceView> for RelayDevice {
fn from(d: cua_host::DeviceView) -> Self {
Self {
state: match d.state {
cua_host::DeviceState::Pending => "pending",
cua_host::DeviceState::Enrolled => "enrolled",
cua_host::DeviceState::Expired => "expired",
cua_host::DeviceState::Revoked => "revoked",
}
.into(),
id: d.id,
name: d.name,
platform: d.platform,
created_at: d.created_at,
enrolled_until: d.enrolled_until,
last_seen: d.last_seen,
current: d.current,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct RelayAuditEvent {
pub ts: u64,
pub kind: String,
pub device: Option<String>,
pub machine: Option<String>,
pub subject: Option<String>,
pub detail: Option<String>,
}
impl From<cua_host::AuditEvent> for RelayAuditEvent {
fn from(e: cua_host::AuditEvent) -> Self {
Self {
ts: e.ts,
kind: e.kind,
device: e.device,
machine: e.machine,
subject: e.subject,
detail: e.detail,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct DevicesSnapshot {
pub local_device_id: Option<String>,
pub devices: Vec<RelayDevice>,
pub enforce_after: Option<u64>,
pub audit: Vec<RelayAuditEvent>,
pub machine_names: std::collections::HashMap<String, String>,
pub machines: Vec<RelayMachine>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct DeviceEnrollment {
pub device: RelayDevice,
pub enrolled: bool,
pub code: Option<String>,
}
pub(super) struct SessionTokens(pub(super) Arc<cua_auth::Session>);
#[async_trait::async_trait]
impl cua_host::AccountTokens for SessionTokens {
async fn access_token(&self) -> cua_host::Result<String> {
self.0
.access_token(false)
.await
.map_err(|e| cua_host::Error::Unauthenticated(e.to_string()))
}
async fn signed_in(&self) -> bool {
!matches!(self.0.credentials(), Ok(None))
}
}
#[derive(uniffi::Object)]
pub struct Devices {
auth: Arc<cua_host::DeviceAuth>,
tokens: Arc<SessionTokens>,
}
#[uniffi::export]
impl Auth {
pub fn devices(&self, relay_url: Option<String>, name: Option<String>) -> Result<Arc<Devices>> {
let home = cua_daemon::cua_home();
let url = cua_host::relay_url_for(relay_url.as_deref(), &home);
let session = self.session();
let tokens = Arc::new(SessionTokens(session.clone()));
let auth = cua_host::DeviceAuth::new(
&url,
tokens.clone(),
Arc::new(session.store().clone()),
name.filter(|n| !n.trim().is_empty())
.unwrap_or_else(cua_host::device_name),
)?
.with_pending_file(cua_host::device_pending_file(&home));
Ok(Arc::new(Devices {
auth: Arc::new(auth),
tokens,
}))
}
}
#[uniffi::export]
impl Devices {
pub fn relay_url(&self) -> String {
self.auth.relay_url().to_string()
}
pub fn device_id(&self) -> Result<Option<String>> {
Ok(self.auth.device_id()?)
}
pub async fn snapshot(&self, audit_limit: u32) -> Result<DevicesSnapshot> {
let auth = self.auth.clone();
let tokens = self.tokens.clone();
run(async move {
let listing = auth.listing().await?;
let audit = auth.audit(audit_limit as usize).await.unwrap_or_default();
let machines: Vec<RelayMachine> = match auth.try_session().await {
Some(session) => {
let token = cua_host::AccountTokens::access_token(tokens.as_ref()).await?;
cua_host::RelayClient::new(auth.relay_url())?
.with_device_session(Some(session))
.machines(&token)
.await
.map(|l| l.into_iter().map(Into::into).collect())
.unwrap_or_default()
}
None => Default::default(),
};
let machine_names = machines
.iter()
.map(|m| (m.id.clone(), m.name.clone()))
.collect();
Ok(DevicesSnapshot {
local_device_id: auth.device_id()?,
devices: listing.devices.into_iter().map(Into::into).collect(),
enforce_after: (listing.enforce_after > 0).then_some(listing.enforce_after),
audit: audit.into_iter().map(Into::into).collect(),
machine_names,
machines,
})
})
.await
}
pub async fn enroll(&self) -> Result<DeviceEnrollment> {
let auth = self.auth.clone();
run(async move {
let e = auth.enroll().await?;
Ok(DeviceEnrollment {
enrolled: e.device.state == cua_host::DeviceState::Enrolled,
device: e.device.into(),
code: e.code,
})
})
.await
}
pub async fn check_enrolled(&self) -> bool {
let auth = self.auth.clone();
run(async move {
auth.reset_session().await;
Ok(auth.session().await.is_ok())
})
.await
.unwrap_or(false)
}
pub async fn approve(
&self,
code: Option<String>,
device_id: Option<String>,
) -> Result<RelayDevice> {
let auth = self.auth.clone();
run(async move {
Ok(auth
.approve(code.as_deref(), device_id.as_deref())
.await?
.into())
})
.await
}
pub async fn rename(&self, id: String, name: String) -> Result<RelayDevice> {
let auth = self.auth.clone();
run(async move { Ok(auth.rename(&id, &name).await?.into()) }).await
}
pub async fn revoke(&self, id: String) -> Result<RelayDevice> {
let auth = self.auth.clone();
run(async move { Ok(auth.revoke(&id).await?.into()) }).await
}
pub async fn create_pairing_code(
&self,
team: Option<String>,
ttl_secs: Option<u64>,
) -> Result<PairingCode> {
let auth = self.auth.clone();
let tokens = self.tokens.clone();
run(async move {
let session = auth.session().await?;
let token = cua_host::AccountTokens::access_token(tokens.as_ref()).await?;
let relay = auth.relay_url().to_string();
let code = cua_host::RelayClient::new(&relay)?
.with_device_session(Some(session))
.create_pairing_code(
&token,
&cua_host::PairingCodeRequest {
name: None,
team: None,
ttl_secs,
},
)
.await?;
let code = cua_host::PairingCode {
team: team.filter(|t| !t.trim().is_empty()),
..code
};
Ok(PairingCode::from_host(code, &relay))
})
.await
}
pub async fn pairing_codes(&self) -> Result<Vec<PairingCode>> {
let auth = self.auth.clone();
let tokens = self.tokens.clone();
run(async move {
let session = auth.session().await?;
let token = cua_host::AccountTokens::access_token(tokens.as_ref()).await?;
let relay = auth.relay_url().to_string();
let codes = cua_host::RelayClient::new(&relay)?
.with_device_session(Some(session))
.pairing_codes(&token)
.await?;
Ok(codes
.into_iter()
.map(|c| PairingCode::from_host(c, &relay))
.collect())
})
.await
}
pub async fn revoke_pairing_code(&self, id: String) -> Result<()> {
let auth = self.auth.clone();
let tokens = self.tokens.clone();
run(async move {
let session = auth.session().await?;
let token = cua_host::AccountTokens::access_token(tokens.as_ref()).await?;
cua_host::RelayClient::new(auth.relay_url())?
.with_device_session(Some(session))
.revoke_pairing_code(&token, &id)
.await?;
Ok(())
})
.await
}
pub async fn rename_machine(&self, machine_id: String, name: String) -> Result<RelayMachine> {
let auth = self.auth.clone();
let tokens = self.tokens.clone();
run(async move {
let name = name.trim().to_string();
if name.is_empty() {
return Err(
cua_host::Error::InvalidArgument("a machine needs a name".into()).into(),
);
}
let session = auth.session().await?;
let token = cua_host::AccountTokens::access_token(tokens.as_ref()).await?;
let m: RelayMachine = cua_host::RelayClient::new(auth.relay_url())?
.with_device_session(Some(session))
.patch(
&token,
&machine_id,
&cua_host::MachinePatch {
name: Some(name.clone()),
..Default::default()
},
)
.await?
.into();
cua_host::Host::new(cua_daemon::cua_home()).adopt_name(&machine_id, &name)?;
Ok(m)
})
.await
}
pub async fn confirm_machine(&self, machine_id: String) -> Result<RelayMachine> {
let auth = self.auth.clone();
let tokens = self.tokens.clone();
run(async move {
let session = auth.session().await?;
let token = cua_host::AccountTokens::access_token(tokens.as_ref()).await?;
Ok(cua_host::RelayClient::new(auth.relay_url())?
.with_device_session(Some(session))
.confirm(&token, &machine_id)
.await?
.into())
})
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_relay_machine_carries_its_team_and_whether_it_provides_spaces() {
let mut m = cua_host::Machine {
id: "m1".into(),
role: "shared".into(),
team: Some("team-1".into()),
..Default::default()
};
m.meta
.insert(cua_host::META_PROVIDES_SPACES.into(), "on".into());
let r = RelayMachine::from(m.clone());
assert_eq!(r.team.as_deref(), Some("team-1"));
assert_eq!(r.provides_spaces, Some(true));
m.meta
.insert(cua_host::META_PROVIDES_SPACES.into(), "off".into());
m.team = Some(" ".into());
let r = RelayMachine::from(m.clone());
assert_eq!((r.team, r.provides_spaces), (None, Some(false)));
m.meta.clear();
assert_eq!(RelayMachine::from(m).provides_spaces, None);
}
#[test]
fn setup_reports_whether_it_had_a_token_never_the_token() {
use base64::Engine;
let jwt = |exp: u64| {
let claims = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(format!(r#"{{"sub":"u","exp":{exp}}}"#));
format!("h.{claims}.s")
};
assert_eq!(setup_token_state("relay", None), "missing");
assert_eq!(setup_token_state("relay", Some(" ")), "missing");
assert_eq!(setup_token_state("relay", Some(&jwt(1))), "expired");
assert_eq!(
setup_token_state("relay", Some(&jwt(u64::MAX / 2))),
"provided"
);
assert_eq!(setup_token_state("relay", Some("opaque")), "provided");
assert_eq!(setup_token_state("Direct", None), "not_needed");
}
#[tokio::test]
async fn relay_setup_without_a_token_is_unauthenticated() {
let relay = cua_host::testing::FakeRelay::start().await;
let dir = tempfile::tempdir().unwrap();
let host = Host::new(Some(dir.path().join(".cua").to_string_lossy().into()));
let err = host
.setup(
HostSetupOptions {
mode: Some("relay".into()),
relay_url: Some(relay.url.clone()),
driver_bin: Some(dir.path().join("x").to_string_lossy().into()),
runner: Some("process".into()),
..Default::default()
},
None,
)
.await
.unwrap_err();
assert!(matches!(err, CuaError::Unauthenticated(_)), "{err}");
assert_eq!(relay.register_calls(), 0);
}
#[tokio::test]
async fn devices_enroll_approve_rename_and_revoke_through_the_relay() {
let relay = cua_host::testing::FakeRelay::start().await;
relay.add_account("a.eyJlbWFpbCI6ImFAYi5jIn0.s", "user-1", None);
relay.set_enforce_after(1_900_000_000);
let signed_in = |dir: &std::path::Path| {
let store = cua_auth::Store::File(dir.join("credentials.json"));
store
.save(
&cua_auth::Credentials::from_token_response(&serde_json::json!({
"access_token": "a.eyJlbWFpbCI6ImFAYi5jIn0.s", "expires_in": 3600,
}))
.unwrap(),
)
.unwrap();
super::super::Auth::with_session(cua_auth::Session::new(
cua_auth::Oidc::new("http://127.0.0.1:9", "cua-cli"),
store,
))
};
relay.legacy_enrollment(true);
let (d1, d2) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let mac = signed_in(d1.path())
.devices(Some(relay.url.clone()), Some("MacBook".into()))
.unwrap();
let phone = signed_in(d2.path())
.devices(Some(relay.url.clone()), Some("Phone".into()))
.unwrap();
assert_eq!(mac.device_id().unwrap(), None);
let first = mac.enroll().await.unwrap();
assert!(first.enrolled && first.code.is_none());
assert_eq!(first.device.platform, std::env::consts::OS);
assert!(mac.check_enrolled().await);
let second = phone.enroll().await.unwrap();
assert!(!second.enrolled);
assert!(!phone.check_enrolled().await);
let snap = mac.snapshot(50).await.unwrap();
assert_eq!(snap.enforce_after, Some(1_900_000_000));
assert_eq!(snap.devices.len(), 2);
assert!(
snap.devices
.iter()
.any(|d| d.current && d.name == "MacBook")
);
assert!(snap.audit.iter().any(|e| e.kind == "device_registered"));
assert!(matches!(
phone.approve(second.code.clone(), None).await.unwrap_err(),
CuaError::PermissionDenied(_)
));
let approved = mac.approve(second.code.clone(), None).await.unwrap();
assert_eq!(approved.state, "enrolled");
assert!(phone.check_enrolled().await);
let phone_id = phone.device_id().unwrap().unwrap();
assert_eq!(
mac.rename(phone_id.clone(), "Work phone".into())
.await
.unwrap()
.name,
"Work phone"
);
assert_eq!(mac.revoke(phone_id.clone()).await.unwrap().state, "revoked");
assert!(!phone.check_enrolled().await);
let again = phone.enroll().await.unwrap();
assert_ne!(again.device.id, phone_id);
assert!(!again.enrolled && again.code.is_some());
}
#[test]
fn setup_options_map_modes() {
let o = setup_options(HostSetupOptions {
mode: Some("direct".into()),
direct: Some("10.0.0.2:4000".into()),
..Default::default()
})
.unwrap();
assert_eq!(
o.mode,
cua_host::HostMode::Direct {
listen: "10.0.0.2:4000".parse().unwrap()
}
);
let o = setup_options(HostSetupOptions {
relay_url: Some("https://relay.example".into()),
allow: vec!["a@b".into()],
runner: Some("process".into()),
..Default::default()
})
.unwrap();
assert_eq!(
o.mode,
cua_host::HostMode::Relay {
url: "https://relay.example".into()
}
);
assert_eq!(o.runner, cua_host::RunnerKind::Process);
assert!(o.share_desktop && !o.provide_spaces, "the desktop profile");
let o = setup_options(HostSetupOptions {
profile: Some("spare".into()),
..Default::default()
})
.unwrap();
assert!(!o.share_desktop && o.provide_spaces);
let o = setup_options(HostSetupOptions {
profile: Some("spare".into()),
share_desktop: Some(true),
..Default::default()
})
.unwrap();
assert!(o.share_desktop && o.provide_spaces);
assert!(
setup_options(HostSetupOptions {
mode: Some("carrier-pigeon".into()),
..Default::default()
})
.is_err()
);
assert!(
setup_options(HostSetupOptions {
mode: Some("direct".into()),
direct: Some("nope".into()),
..Default::default()
})
.is_err()
);
}
#[tokio::test]
async fn relay_lists_machines_from_the_directory() {
let relay = cua_host::testing::FakeRelay::start().await;
relay.add_account("tok", "user-1", None);
cua_host::RelayClient::new(&relay.url)
.unwrap()
.register(
"tok",
&cua_host::relay::RegisterRequest {
id: "0123abcd4567ef89".into(),
name: "mini".into(),
allow: vec![],
host: None,
meta: Default::default(),
},
)
.await
.unwrap();
let r = Relay::new(Some(relay.url.clone()), "tok".into()).unwrap();
let machines = r.machines().await.unwrap();
assert_eq!(machines.len(), 1);
assert_eq!(machines[0].space_id, "relay:0123abcd4567ef89");
assert_eq!(machines[0].role, "owner");
let bad = Relay::new(Some(relay.url.clone()), "nope".into()).unwrap();
assert!(matches!(
bad.machines().await.unwrap_err(),
CuaError::Unauthenticated(_)
));
}
}