use serde::{Deserialize, Serialize};
use std::time::Duration;
use tracing::debug;
use vta_sdk::client::VtaClient;
use crate::errors::OpenVTCError;
pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(300);
pub const LIVENESS_WINDOW: Duration = Duration::from_secs(900);
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceRecord {
pub device_id: String,
#[serde(default)]
pub consumer_did: Option<String>,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub platform: Option<String>,
#[serde(default)]
pub registered_at: Option<String>,
#[serde(default)]
pub last_seen_at: Option<String>,
#[serde(default)]
pub disabled_at: Option<String>,
#[serde(default)]
pub wiped_at: Option<String>,
}
impl DeviceRecord {
#[must_use]
pub fn is_active(&self) -> bool {
self.disabled_at.is_none() && self.wiped_at.is_none()
}
#[must_use]
pub fn last_seen(&self) -> Option<chrono::DateTime<chrono::Utc>> {
self.last_seen_at
.as_deref()
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map(|dt| dt.with_timezone(&chrono::Utc))
}
#[must_use]
pub fn is_self(&self, self_device_id: Option<&str>, self_did: Option<&str>) -> bool {
if self_device_id.is_some_and(|id| id == self.device_id) {
return true;
}
match (self.consumer_did.as_deref(), self_did) {
(Some(owner), Some(ours)) => owner == ours,
_ => false,
}
}
#[must_use]
pub fn is_live_at(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
if !self.is_active() {
return false;
}
let Some(seen) = self.last_seen() else {
return false;
};
match (now - seen).to_std() {
Ok(elapsed) => elapsed <= LIVENESS_WINDOW,
Err(_) => true,
}
}
#[must_use]
pub fn label(&self) -> &str {
if self.display_name.is_empty() {
&self.device_id
} else {
&self.display_name
}
}
}
fn consumer_kind() -> serde_json::Value {
serde_json::json!({ "kind": "companion", "formFactor": "desktop" })
}
#[must_use]
pub fn display_name(profile: &str) -> String {
let host = sysinfo::System::host_name().unwrap_or_else(|| "unknown host".to_string());
format!("OpenVTC on {host} ({profile})")
}
#[derive(Clone, Debug)]
pub enum Registration {
Claimed(DeviceRecord),
AlreadyRegistered,
}
pub async fn register(client: &VtaClient, profile: &str) -> Result<Registration, OpenVTCError> {
let response = match client
.device_register(
consumer_kind(),
&display_name(profile),
Some(std::env::consts::OS),
None,
)
.await
{
Ok(response) => response,
Err(e) if is_already_registered(&e) => return Ok(Registration::AlreadyRegistered),
Err(e) => {
return Err(OpenVTCError::Vta(format!(
"device registration failed: {e}"
)));
}
};
parse_binding(&response)
.map(Registration::Claimed)
.ok_or_else(|| OpenVTCError::Vta("device registration returned no binding".to_string()))
}
fn is_already_registered(e: &vta_sdk::error::VtaError) -> bool {
if e.is_conflict() {
return true;
}
let msg = e.to_string();
msg.contains("alreadyRegistered") || msg.contains("already_registered")
}
pub async fn heartbeat(client: &VtaClient, profile: &str) -> Result<(), OpenVTCError> {
client
.device_heartbeat_named(Some(std::env::consts::OS), Some(&display_name(profile)))
.await
.map(|_| ())
.map_err(|e| OpenVTCError::Vta(format!("device heartbeat failed: {e}")))
}
#[must_use]
pub fn name_correction_due(row: &DeviceRecord, profile: &str) -> bool {
row.display_name != display_name(profile)
}
pub async fn list(client: &VtaClient) -> Result<Vec<DeviceRecord>, OpenVTCError> {
let response = client
.device_list(serde_json::json!({}))
.await
.map_err(|e| OpenVTCError::Vta(format!("device list failed: {e}")))?;
let devices = response
.get("devices")
.and_then(|d| d.as_array())
.ok_or_else(|| OpenVTCError::Vta("device list returned no `devices` array".to_string()))?;
let mut out = Vec::with_capacity(devices.len());
for raw in devices {
match serde_json::from_value::<DeviceRecord>(raw.clone()) {
Ok(record) => out.push(record),
Err(e) => debug!("skipping unparseable device row: {e}"),
}
}
Ok(out)
}
fn parse_binding(response: &serde_json::Value) -> Option<DeviceRecord> {
let candidate = response.get("device").unwrap_or(response);
serde_json::from_value(candidate.clone()).ok()
}
#[must_use]
pub fn live_siblings(
devices: &[DeviceRecord],
self_device_id: Option<&str>,
self_did: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Vec<DeviceRecord> {
devices
.iter()
.filter(|d| !d.is_self(self_device_id, self_did))
.filter(|d| d.is_live_at(now))
.cloned()
.collect()
}
#[must_use]
pub fn sibling_warning(siblings: &[DeviceRecord]) -> Option<String> {
match siblings {
[] => None,
[one] => Some(format!(
"This account is also open on {}. Both instances share one mediator \
connection per persona, so messaging may be unstable until one is closed.",
one.label()
)),
many => Some(format!(
"This account is also open on {} other instances ({}). Messaging may be \
unstable until they are closed.",
many.len(),
many.iter()
.map(DeviceRecord::label)
.collect::<Vec<_>>()
.join(", ")
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration as ChronoDuration, Utc};
fn record(id: &str, last_seen: Option<chrono::DateTime<Utc>>) -> DeviceRecord {
DeviceRecord {
device_id: id.to_string(),
consumer_did: None,
display_name: format!("OpenVTC on {id}"),
platform: Some("linux".to_string()),
registered_at: None,
last_seen_at: last_seen.map(|t| t.to_rfc3339()),
disabled_at: None,
wiped_at: None,
}
}
#[test]
fn a_recently_seen_device_is_live() {
let now = Utc::now();
let d = record("a", Some(now - ChronoDuration::minutes(2)));
assert!(d.is_live_at(now));
}
#[test]
fn a_stale_device_is_not_live() {
let now = Utc::now();
let d = record("a", Some(now - ChronoDuration::minutes(30)));
assert!(!d.is_live_at(now));
}
#[test]
fn one_missed_heartbeat_is_still_live() {
let now = Utc::now();
let missed_one = now - ChronoDuration::from_std(HEARTBEAT_INTERVAL * 2).unwrap();
assert!(record("a", Some(missed_one)).is_live_at(now));
}
#[test]
fn a_device_whose_clock_is_ahead_is_live() {
let now = Utc::now();
let d = record("a", Some(now + ChronoDuration::minutes(3)));
assert!(d.is_live_at(now), "clock skew must not read as stale");
}
#[test]
fn a_device_with_no_timestamp_is_not_live() {
assert!(!record("a", None).is_live_at(Utc::now()));
}
#[test]
fn a_disabled_or_wiped_device_is_never_live() {
let now = Utc::now();
let mut disabled = record("a", Some(now));
disabled.disabled_at = Some(now.to_rfc3339());
assert!(!disabled.is_live_at(now));
let mut wiped = record("b", Some(now));
wiped.wiped_at = Some(now.to_rfc3339());
assert!(!wiped.is_live_at(now));
}
#[test]
fn our_own_binding_is_never_a_sibling() {
let now = Utc::now();
let devices = vec![record("self", Some(now)), record("other", Some(now))];
let siblings = live_siblings(&devices, Some("self"), None, now);
assert_eq!(siblings.len(), 1);
assert_eq!(siblings[0].device_id, "other");
}
#[test]
fn stale_siblings_are_not_reported() {
let now = Utc::now();
let devices = vec![
record("self", Some(now)),
record("old-laptop", Some(now - ChronoDuration::days(3))),
];
assert!(live_siblings(&devices, Some("self"), None, now).is_empty());
}
#[test]
fn the_warning_names_the_other_instance() {
let now = Utc::now();
let siblings = vec![record("glenn-laptop", Some(now))];
let warning = sibling_warning(&siblings).expect("a live sibling warns");
assert!(warning.contains("OpenVTC on glenn-laptop"), "{warning}");
assert!(warning.contains("mediator"), "{warning}");
}
#[test]
fn no_siblings_means_no_warning() {
assert!(sibling_warning(&[]).is_none());
}
#[test]
fn several_siblings_are_counted_and_named() {
let now = Utc::now();
let siblings = vec![record("a", Some(now)), record("b", Some(now))];
let warning = sibling_warning(&siblings).expect("warns");
assert!(warning.contains('2'), "{warning}");
assert!(
warning.contains("OpenVTC on a") && warning.contains("OpenVTC on b"),
"{warning}"
);
}
#[test]
fn a_sparse_binding_still_parses() {
let raw = serde_json::json!({
"deviceId": "dev-1",
"displayName": "OpenVTC on host",
"registeredAt": "2026-08-20T00:00:00Z",
"somethingNewerVtasSend": true
});
let d: DeviceRecord = serde_json::from_value(raw).expect("tolerant parse");
assert_eq!(d.device_id, "dev-1");
assert!(d.last_seen_at.is_none());
assert!(d.is_active());
}
#[test]
fn a_binding_with_no_name_falls_back_to_its_id() {
let d = DeviceRecord {
device_id: "dev-1".to_string(),
consumer_did: None,
display_name: String::new(),
platform: None,
registered_at: None,
last_seen_at: None,
disabled_at: None,
wiped_at: None,
};
assert_eq!(d.label(), "dev-1");
}
#[test]
fn the_consumer_kind_matches_the_vta_wire_form() {
assert_eq!(
consumer_kind(),
serde_json::json!({ "kind": "companion", "formFactor": "desktop" })
);
}
#[test]
fn a_drifted_name_is_a_correction_due() {
let mut row = record("dev-1", Some(Utc::now()));
row.display_name = "OpenVTC on old-host (default)".to_string();
assert!(name_correction_due(&row, "default"));
}
#[test]
fn a_current_name_is_not_a_correction() {
let mut row = record("dev-1", Some(Utc::now()));
row.display_name = display_name("default");
assert!(!name_correction_due(&row, "default"));
}
#[test]
fn the_profile_half_of_the_name_drifts_too() {
let mut row = record("dev-1", Some(Utc::now()));
row.display_name = display_name("work");
assert!(name_correction_due(&row, "personal"));
assert!(!name_correction_due(&row, "work"));
}
#[test]
fn a_nameless_binding_is_a_correction_due() {
let mut row = record("dev-1", Some(Utc::now()));
row.display_name = String::new();
assert!(name_correction_due(&row, "default"));
}
#[test]
fn the_display_name_carries_host_and_profile() {
let name = display_name("work");
assert!(name.starts_with("OpenVTC on "), "{name}");
assert!(name.ends_with("(work)"), "{name}");
}
#[test]
fn our_own_binding_is_ours_by_did_when_we_never_learned_its_id() {
let now = Utc::now();
let mut mine = record("dev-1", Some(now));
mine.consumer_did = Some("did:key:zSelf".to_string());
assert!(
live_siblings(&[mine], None, Some("did:key:zSelf"), now).is_empty(),
"the binding owned by the DID we authenticate as is this install"
);
}
#[test]
fn another_dids_binding_is_still_a_sibling() {
let now = Utc::now();
let mut mine = record("dev-1", Some(now));
mine.consumer_did = Some("did:key:zSelf".to_string());
let mut theirs = record("dev-2", Some(now));
theirs.consumer_did = Some("did:key:zOther".to_string());
let siblings = live_siblings(&[mine, theirs], None, Some("did:key:zSelf"), now);
assert_eq!(siblings.len(), 1);
assert_eq!(siblings[0].device_id, "dev-2");
}
#[test]
fn an_unidentifiable_install_still_reports_what_is_live() {
let now = Utc::now();
let mut row = record("dev-1", Some(now));
row.consumer_did = Some("did:key:zOther".to_string());
assert_eq!(live_siblings(&[row.clone()], None, None, now).len(), 1);
assert_eq!(
live_siblings(&[row], None, Some("did:key:zSelf"), now).len(),
1
);
}
#[test]
fn a_row_without_an_owner_falls_back_to_the_device_id() {
let now = Utc::now();
let row = record("dev-1", Some(now));
assert!(row.is_self(Some("dev-1"), None));
assert!(!row.is_self(None, Some("did:key:zSelf")));
}
#[test]
fn the_owning_did_parses_off_the_wire() {
let raw = serde_json::json!({
"deviceId": "dev-1",
"consumerDid": "did:key:zSelf",
"displayName": "OpenVTC on host (default)",
});
let d: DeviceRecord = serde_json::from_value(raw).expect("tolerant parse");
assert_eq!(d.consumer_did.as_deref(), Some("did:key:zSelf"));
}
#[test]
fn an_already_registered_refusal_is_recognised_across_transports() {
use vta_sdk::error::VtaError;
assert!(is_already_registered(&VtaError::Conflict("dup".into())));
for code in [
"device/register:alreadyRegistered",
"device/register:already_registered",
] {
assert!(
is_already_registered(&VtaError::Protocol(format!(
"trust task rejected: {code} — a DeviceBinding already exists"
))),
"{code} must read as already-registered"
);
}
assert!(!is_already_registered(&VtaError::Protocol(
"trust task rejected: something else".into()
)));
}
}