use std::path::{Path, PathBuf};
use std::sync::Arc;
use personae::{IdentityProvider, ProfileId};
use crate::resident_blobs::{LEGACY_PERSONAL_LEASE, LegacyBlobMigration, ResidentBlobCustody};
use crate::settings::{
self as owner_settings, DataRootMigration, OwnerSettings, OwnerSettingsError, SyncOverrides,
};
use graphshell::identity_endpoint::TransferDecision;
use graphshell::native::device_broker::{DeviceSurface, DeviceSurfaceHandle};
use graphshell::native::personal_sync_host::{
PersonalSyncHost, PersonalSyncHostConfig, PersonalSyncHostError,
};
use graphshell::native::transfer_staging::{receive_transfer, released_blobs_for};
use graphshell::personal_sync::{PersonalGraphEvent, SyncRoster, SyncSelection};
const PERSONAL_GRAPH_DOMAIN: &[u8] = b"mere.graphshell/personal-graph/v1";
const PAIRING_POLL: std::time::Duration = std::time::Duration::from_secs(5);
pub fn personal_graph_id(name: &str) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(PERSONAL_GRAPH_DOMAIN);
hasher.update(name.as_bytes());
*hasher.finalize().as_bytes()
}
#[derive(Debug, thiserror::Error)]
pub enum DeviceSyncError {
#[error(transparent)]
Settings(#[from] OwnerSettingsError),
#[error(transparent)]
Host(#[from] PersonalSyncHostError),
#[error(
"no personal graph is configured for profile {profile:?}; set sync.graph in {path} first"
)]
NoGraphConfigured { profile: String, path: String },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SeedNote {
pub address: String,
pub title: String,
}
const BLOB_FETCH_WAIT_TICK: std::time::Duration = std::time::Duration::from_secs(2);
const BLOB_FETCH_WAIT_TICKS: u64 = 30;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BlobAction {
Stage { path: PathBuf },
Fetch { blob: [u8; 32] },
}
pub fn resolve_data_root(
app_dir: &Path,
vault_dir: &Path,
override_path: Option<PathBuf>,
) -> Result<PathBuf, DeviceSyncError> {
if let Some(explicit) = override_path {
return Ok(explicit);
}
let current = owner_settings::default_data_root(app_dir);
let legacy = owner_settings::legacy_data_root(vault_dir);
if let DataRootMigration::Moved { from, to } =
owner_settings::migrate_data_root(&legacy, ¤t)?
{
tracing::info!(
from = %from.display(),
to = %to.display(),
"moved the Graphshell data root out of the Personae vault"
);
}
Ok(current)
}
pub async fn start<P: IdentityProvider + ?Sized>(
identity: &P,
app_dir: &Path,
vault_dir: &Path,
profile: &ProfileId,
data_root_override: Option<PathBuf>,
overrides: SyncOverrides,
peer_tickets: Vec<String>,
seed_notes: Vec<SeedNote>,
blob_actions: Vec<BlobAction>,
blob_custody: ResidentBlobCustody,
) -> Result<Option<DeviceSurfaceHandle>, DeviceSyncError> {
let settings_file = owner_settings::settings_path(app_dir, profile);
let stored = OwnerSettings::load(&settings_file)?;
tracing::info!(
path = %settings_file.display(),
configured = stored.sync.is_some(),
"owner settings"
);
let Some(sync) = owner_settings::resolve_sync(stored.sync, overrides) else {
return Ok(None);
};
let graph = personal_graph_id(&sync.graph);
let data_root = resolve_data_root(app_dir, vault_dir, data_root_override)?;
let store_path = sync.store_path.clone().unwrap_or_else(|| {
data_root
.join("personal-sync")
.join(format!("{}.redb", owner_settings::hex32(&graph)))
});
let blob_scope = transport::BlobScope::new(graph);
let legacy_blob_root = store_path.with_extension("blobs");
match blob_custody
.migrate_legacy_store(&legacy_blob_root, blob_scope, LEGACY_PERSONAL_LEASE)
.await
.map_err(|error| DeviceSyncError::Host(PersonalSyncHostError::Transport(error)))?
{
LegacyBlobMigration::Copied { blobs } => tracing::info!(
source = %legacy_blob_root.display(),
blobs,
"imported the personal graph's old blob store into resident custody"
),
LegacyBlobMigration::AlreadyComplete { blobs } => tracing::debug!(
source = %legacy_blob_root.display(),
blobs,
"personal graph blob migration remains verified"
),
LegacyBlobMigration::SourceAbsent | LegacyBlobMigration::AlreadyShared => {}
}
blob_custody
.bind_scope(blob_scope)
.await
.map_err(|error| PersonalSyncHostError::Transport(error))?;
let mut roots = sync.roster_root_keys()?;
roots.push(identity.master_public_key().to_bytes());
roots.sort_unstable();
roots.dedup();
let paired_devices = paired_nodes_with_roots(&sync)?;
let paired_nodes = paired_devices.keys().copied().collect::<Vec<_>>();
let relays =
transport::P2pandaHostPolicy::parse_relay_urls(sync.relay_urls.iter().map(String::as_str))
.map_err(|error| {
DeviceSyncError::Host(PersonalSyncHostError::Transport(error.to_string()))
})?;
if !relays.is_empty() {
tracing::info!(relays = relays.len(), "personal sync will register relays");
}
let mut facets = sync.lanes.facets.clone();
for facet in graphshell::receipts::sync_facets() {
if !facets.iter().any(|selected| selected == facet) {
facets.push(facet.to_string());
}
}
let selection = SyncSelection::default()
.with_facets(facets)
.with_access_records(sync.lanes.access_records)
.with_saved_scenes(sync.lanes.saved_scenes)
.with_handler_preferences(sync.lanes.handler_preferences)
.with_blob_availability(sync.lanes.blob_availability)
.with_synthetic_addresses([graphshell::receipts::sync_address_rule()]);
let peer_hints: Vec<String> = sync
.paired_devices
.iter()
.filter_map(|device| device.last_endpoint.clone())
.collect();
if !peer_hints.is_empty() {
tracing::info!(hints = peer_hints.len(), "seeding stored dial hints");
}
let host = Arc::new(
PersonalSyncHost::open_with_blob_custody(
identity,
PersonalSyncHostConfig {
graph,
store_path,
roster: SyncRoster::new(roots),
selection,
peer_tickets,
peer_hints,
paired_nodes: paired_nodes.clone(),
relay_urls: relays,
},
blob_custody.blobs(),
blob_custody.authorizer(),
)
.await?,
);
tracing::info!(
graph = %owner_settings::hex32(&graph),
node_id = %owner_settings::hex32(&host.node_id()),
paired = sync.paired_devices.len(),
ticket = %host.ticket().await?,
"personal graph sync listening"
);
for device in sync.receive_only_devices() {
tracing::warn!(
node = %device.node_id,
label = %device.label,
"paired device has no roster root: it will receive this graph, and \
its own writes will be refused"
);
}
for note in seed_notes {
host.author(vec![PersonalGraphEvent::AddNode {
id: uuid::Uuid::new_v4(),
address: note.address.clone(),
title: note.title.clone(),
}])
.await?;
tracing::info!(address = %note.address, title = %note.title, "authored a node");
}
for action in blob_actions {
match action {
BlobAction::Stage { path } => match std::fs::read(&path) {
Ok(bytes) => {
let byte_len = bytes.len();
let container = uuid::Uuid::new_v4();
match host.stage_blob(container, bytes).await {
Ok(blob) => tracing::info!(
path = %path.display(),
byte_len,
container = %container,
blob = %owner_settings::hex32(&blob),
"staged a blob; a paired device can now fetch it by this hash"
),
Err(error) => {
tracing::error!(path = %path.display(), %error, "could not stage the blob")
}
}
}
Err(error) => {
tracing::error!(path = %path.display(), %error, "could not read the file to stage")
}
},
BlobAction::Fetch { blob } => {
let mut holders = Vec::new();
for _ in 0..BLOB_FETCH_WAIT_TICKS {
holders = host.blob_holders(blob).await.unwrap_or_default();
if !holders.is_empty() {
break;
}
tokio::time::sleep(BLOB_FETCH_WAIT_TICK).await;
}
if holders.is_empty() {
let peers = host.known_peers().await.unwrap_or_default();
let connected = peers.iter().filter(|peer| peer.connected).count();
let waited_s = BLOB_FETCH_WAIT_TICKS * BLOB_FETCH_WAIT_TICK.as_secs();
if peers.is_empty() {
tracing::error!(
blob = %owner_settings::hex32(&blob),
waited_s,
"no device is paired onto this graph's overlay, so \
nothing could advertise this blob"
);
} else if connected == 0 {
tracing::error!(
blob = %owner_settings::hex32(&blob),
waited_s,
peers = peers.len(),
"no paired device has a live path, so no \
advertisement could arrive. This is a connectivity \
failure, not a missing blob: check a firewall on \
either end, then whether a relay is configured and \
reachable"
);
} else {
tracing::error!(
blob = %owner_settings::hex32(&blob),
waited_s,
peers = peers.len(),
connected,
"a device is connected but none advertised this \
blob. The holder most likely never staged it, or \
staged it with the blob-availability lane disabled"
);
}
continue;
}
match host.fetch_blob_by_availability(blob).await {
Ok(supplier) => tracing::info!(
blob = %owner_settings::hex32(&blob),
supplier = %owner_settings::hex32(&supplier),
"fetched a blob from a paired device"
),
Err(error) => tracing::error!(
blob = %owner_settings::hex32(&blob),
%error,
"could not fetch the blob"
),
}
}
}
}
spawn_pairing_watch(
Arc::clone(&host),
settings_file,
paired_devices,
identity.master_public_key().to_bytes(),
);
let reader_host = Arc::clone(&host);
let blob_reader: Arc<graphshell::native::device_broker::BlobReader> =
Arc::new(move |resource: &chirograph::ContentHash| {
let host = Arc::clone(&reader_host);
let hash = transport::BlobHash::from_bytes(resource.0);
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current()
.block_on(async { host.blobs().get_bytes(hash).await })
.ok()
.map(|bytes| bytes.to_vec())
})
});
let surface: DeviceSurfaceHandle = Arc::new(tokio::sync::RwLock::new(DeviceSurface {
cards: host.supplemental_cards().await?,
released_blobs: Vec::new(),
decisions: Default::default(),
blob_reader: Some(blob_reader),
}));
spawn_card_refresh(Arc::clone(&host), Arc::clone(&surface));
spawn_receipt_intake(
Arc::clone(&host),
graphshell::receipts::inbox_dir(&data_root),
);
spawn_accept_watch(host, Arc::clone(&surface));
Ok(Some(surface))
}
fn paired_nodes_with_roots(
sync: &owner_settings::SyncSettings,
) -> Result<std::collections::BTreeMap<[u8; 32], Option<[u8; 32]>>, OwnerSettingsError> {
let mut paired = std::collections::BTreeMap::new();
for device in &sync.paired_devices {
let node = owner_settings::parse_hex32(&device.node_id)?;
let root = match device.root.as_deref() {
Some(root) => Some(owner_settings::parse_hex32(root)?),
None => None,
};
paired.insert(node, root);
}
Ok(paired)
}
fn spawn_pairing_watch(
host: Arc<PersonalSyncHost>,
settings_file: PathBuf,
already_applied: std::collections::BTreeMap<[u8; 32], Option<[u8; 32]>>,
local_root: [u8; 32],
) {
let mut applied = already_applied;
let mut reported: Option<Vec<(String, bool, bool)>> = None;
tokio::spawn(async move {
loop {
tokio::time::sleep(PAIRING_POLL).await;
let reloaded = match OwnerSettings::load(&settings_file) {
Ok(settings) => settings,
Err(error) => {
tracing::warn!(%error, "could not reload owner settings");
continue;
}
};
let Some(sync) = reloaded.sync else { continue };
let desired: std::collections::BTreeMap<[u8; 32], Option<[u8; 32]>> =
match paired_nodes_with_roots(&sync) {
Ok(paired) => paired,
Err(error) => {
tracing::warn!(%error, "owner settings hold an unusable node id");
continue;
}
};
match sync.roster_root_keys() {
Ok(mut roots) => {
roots.push(local_root);
roots.sort_unstable();
roots.dedup();
let roots_len = roots.len();
let next = SyncRoster::new(roots);
if host.roster().await != next {
host.set_roster(next).await;
tracing::info!(admitted = roots_len, "personal sync roster changed");
}
}
Err(error) => tracing::warn!(%error, "owner settings hold an unusable roster root"),
}
let authority_changes: Vec<([u8; 32], Option<[u8; 32]>, Option<[u8; 32]>)> = desired
.iter()
.filter_map(|(node, next)| {
let previous = applied.get(node)?;
(previous != next).then_some((*node, *previous, *next))
})
.collect();
for (node, previous, next) in authority_changes {
if let Some(root) = previous {
match host.retire_reader(root).await {
Ok(()) => tracing::info!(
node = %owner_settings::hex32(&node),
"retired the device's previous readership"
),
Err(error) => {
tracing::warn!(
%error,
node = %owner_settings::hex32(&node),
"could not revoke changed key authority; will retry"
);
continue;
}
}
}
applied.insert(node, next);
}
let relayed: std::collections::BTreeMap<[u8; 32], Option<String>> = sync
.paired_devices
.iter()
.filter_map(|device| {
let node = owner_settings::parse_hex32(&device.node_id).ok()?;
Some((node, device.prekey.clone()))
})
.collect();
let arrivals: Vec<([u8; 32], Option<[u8; 32]>)> = desired
.iter()
.filter(|(node, _)| !applied.contains_key(*node))
.map(|(node, root)| (*node, *root))
.collect();
for (node, root) in arrivals {
match host.pair_node(node).await {
Ok(()) => {
applied.insert(node, root);
if let Some(root) = root {
if let Err(error) = host.admit_reader(root, "paired device").await {
tracing::warn!(
%error,
node = %owner_settings::hex32(&node),
"could not admit the paired device as a reader"
);
}
}
if let Some(prekey) = relayed.get(&node).cloned().flatten() {
match owner_settings::parse_hex(&prekey) {
Ok(bundle) => {
if let Err(error) = host
.author(vec![PersonalGraphEvent::PublishPrekey { bundle }])
.await
{
tracing::warn!(
%error,
node = %owner_settings::hex32(&node),
"could not relay the paired device's pre-key; it stays reachable but unreadable until this succeeds"
);
}
}
Err(error) => tracing::warn!(
%error,
node = %owner_settings::hex32(&node),
"the paired device's recorded pre-key is unreadable"
),
}
}
tracing::info!(
node = %owner_settings::hex32(&node),
"applied a newly paired device without a restart"
);
}
Err(error) => tracing::warn!(
%error,
node = %owner_settings::hex32(&node),
"could not apply a paired device"
),
}
}
let departures: Vec<([u8; 32], Option<[u8; 32]>)> = applied
.iter()
.filter(|(node, _)| !desired.contains_key(*node))
.map(|(node, root)| (*node, *root))
.collect();
for (node, root) in departures {
if let Some(root) = root {
match host.retire_reader(root).await {
Ok(()) => tracing::info!(
node = %owner_settings::hex32(&node),
"retired the unpaired device's readership"
),
Err(error) => {
tracing::warn!(
%error,
node = %owner_settings::hex32(&node),
"could not retire the unpaired device's readership; will retry"
);
continue;
}
}
}
match host.unpair_node(node).await {
Ok(()) => {
applied.remove(&node);
tracing::info!(
node = %owner_settings::hex32(&node),
"dropped an unpaired device without a restart"
);
}
Err(error) => tracing::warn!(
%error,
node = %owner_settings::hex32(&node),
"could not drop an unpaired device"
),
}
}
if sync.encrypted && !host.is_keyed().await {
match host.key_group_exists().await {
Ok(false) => match host.enable_encryption().await {
Ok(()) => tracing::info!("encryption is on for this graph"),
Err(error) => tracing::warn!(
%error,
"could not turn encryption on; this graph stays readable to every admitted device"
),
},
Ok(true) => {}
Err(error) => {
tracing::warn!(%error, "could not tell whether this graph has a key group")
}
}
}
match host.key_paired_devices().await {
Ok(0) => {}
Ok(keyed) => tracing::info!(keyed, "keyed newly paired devices"),
Err(error) => tracing::warn!(%error, "could not key paired devices this pass"),
}
match host.known_peers().await {
Ok(peers) => {
for peer in peers.iter().filter(|peer| peer.connected) {
let node = peer.peer.to_bytes();
let ticket = match host.peer_ticket(node).await {
Ok(Some(ticket)) => ticket,
Ok(None) => continue,
Err(error) => {
tracing::warn!(%error, "could not read a peer's current address");
continue;
}
};
let stored = sync
.paired_devices
.iter()
.find(|device| {
device
.node_id
.eq_ignore_ascii_case(&owner_settings::hex32(&node))
})
.and_then(|device| device.last_endpoint.as_deref());
if stored == Some(ticket.as_str()) {
continue;
}
match OwnerSettings::load(&settings_file) {
Ok(mut latest) => {
let Some(live) = latest.sync.as_mut() else {
continue;
};
if live.record_endpoint(&node, &ticket) {
match latest.save(&settings_file) {
Ok(()) => tracing::info!(
node = %owner_settings::hex32(&node),
"recorded a fresh dial hint for a connected device"
),
Err(error) => tracing::warn!(
%error,
"could not persist a refreshed dial hint"
),
}
}
}
Err(error) => {
tracing::warn!(%error, "could not reload settings to refresh a hint");
}
}
}
let mut current: Vec<(String, bool, bool)> = peers
.iter()
.map(|peer| {
(
owner_settings::hex32(&peer.peer.to_bytes()),
peer.reachable,
peer.connected,
)
})
.collect();
current.sort();
if reported.as_ref() != Some(¤t) {
let addressed = current.iter().filter(|(_, ok, _)| *ok).count();
let connected = current.iter().filter(|(_, _, live)| *live).count();
tracing::info!(
peers = current.len(),
addressed,
connected,
detail = ?current,
"personal sync peer directory changed"
);
if connected == 0 && !current.is_empty() {
tracing::warn!(
peers = current.len(),
addressed,
"no paired device has a live path: this host is \
replicating nothing. Check a firewall on either \
end, then whether a relay is configured and \
reachable"
);
}
reported = Some(current);
}
}
Err(error) => tracing::warn!(%error, "could not read the peer directory"),
}
}
});
}
fn spawn_accept_watch(host: Arc<PersonalSyncHost>, surface: DeviceSurfaceHandle) {
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let decisions = surface.read().await.decisions.clone();
let accepted = match decisions.lock() {
Ok(mut queue) => std::mem::take(&mut *queue),
Err(_) => {
tracing::warn!("transfer decisions are unreadable; accepts will not be served");
continue;
}
};
for decision in accepted {
match serve_accepted_transfer(&host, &decision).await {
Ok(released) => {
let blobs = released.len();
surface.write().await.released_blobs = released;
tracing::info!(
transfer = %decision.transfer_id,
blobs,
"accepted transfer is staged and released to the browser"
);
}
Err(error) => tracing::warn!(
transfer = %decision.transfer_id,
%error,
"accepted transfer could not be served; the offer remains"
),
}
}
}
});
}
async fn serve_accepted_transfer(
host: &PersonalSyncHost,
decision: &TransferDecision,
) -> Result<Vec<(chirograph::ContentHash, Vec<u8>)>, DeviceSyncError> {
let offers = host.offers().await.map_err(DeviceSyncError::Host)?;
let offer = offers
.into_iter()
.find(|offer| offer.transfer_id.to_string() == decision.transfer_id)
.ok_or_else(|| {
DeviceSyncError::Host(PersonalSyncHostError::Transport(format!(
"no offer named {} is addressed to this device",
decision.transfer_id
)))
})?;
let staging = muniment::BlobStore::new(muniment::MemoryBackend::new());
let manifest = receive_transfer(host, &staging, &offer)
.await
.map_err(|error| {
DeviceSyncError::Host(PersonalSyncHostError::Transport(error.to_string()))
})?;
released_blobs_for(host, &manifest)
.await
.map_err(|error| DeviceSyncError::Host(PersonalSyncHostError::Transport(error.to_string())))
}
fn spawn_card_refresh(host: Arc<PersonalSyncHost>, surface: DeviceSurfaceHandle) {
let mut reported: Option<usize> = None;
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
match host.supplemental_cards().await {
Ok(snapshot) => {
if reported != Some(snapshot.len()) {
tracing::info!(cards = snapshot.len(), "personal graph projection changed");
reported = Some(snapshot.len());
}
surface.write().await.cards = snapshot;
}
Err(error) => tracing::warn!(%error, "personal sync projection refresh failed"),
}
}
});
}
async fn stage_captures(
host: &PersonalSyncHost,
receipt: &graphshell::receipts::PendingReceipt,
) -> Result<usize, String> {
let captures = graphshell::receipts::captures_in(&receipt.events);
let mut staged = 0;
for (name, expected) in captures {
if host
.blobs()
.has(transport::BlobHash::from_bytes(expected))
.await
.map_err(|error| format!("{name}: {error}"))?
{
continue;
}
let path = receipt.source.join(&name);
let bytes = std::fs::read(&path).map_err(|error| format!("{}: {error}", path.display()))?;
let hash = host
.blobs()
.put_bytes(bytes)
.await
.map_err(|error| format!("{name}: {error}"))?;
if hash.as_bytes() != &expected {
return Err(format!(
"{name}: bytes hash to {} but the receipt claims {}",
owner_settings::hex32(hash.as_bytes()),
owner_settings::hex32(&expected),
));
}
staged += 1;
}
Ok(staged)
}
const RECEIPT_POLL: std::time::Duration = std::time::Duration::from_secs(10);
fn spawn_receipt_intake(host: Arc<PersonalSyncHost>, inbox: PathBuf) {
tokio::spawn(async move {
loop {
tokio::time::sleep(RECEIPT_POLL).await;
let waiting = match graphshell::receipts::pending(&inbox) {
Ok(waiting) if waiting.is_empty() => continue,
Ok(waiting) => waiting,
Err(error) => {
tracing::warn!(%error, inbox = %inbox.display(), "receipt intake scan failed");
continue;
}
};
for receipt in waiting {
let events = receipt.events.len();
match stage_captures(&host, &receipt).await {
Ok(0) => {}
Ok(staged) => tracing::info!(
staged,
path = %receipt.path.display(),
"staged receipt captures into the replicating store"
),
Err(error) => {
tracing::warn!(
%error,
path = %receipt.path.display(),
"could not stage a receipt's captures; leaving it pending"
);
continue;
}
}
match host.author(receipt.events).await {
Ok(()) => {
if let Err(error) = graphshell::receipts::mark_applied(&receipt.path) {
tracing::warn!(
%error,
path = %receipt.path.display(),
"authored a receipt but could not clear its file; \
it will be authored again next poll"
);
}
tracing::info!(
events,
path = %receipt.path.display(),
"authored a receipt into the personal graph"
);
}
Err(error) => tracing::warn!(
%error,
path = %receipt.path.display(),
"could not author a receipt; leaving it pending"
),
}
}
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_data_root_override_is_taken_as_given_and_skips_the_migration() {
let directory = tempfile::tempdir().unwrap();
let vault = directory.path().join("vault");
std::fs::create_dir_all(owner_settings::legacy_data_root(&vault)).unwrap();
let chosen = directory.path().join("elsewhere");
let resolved = resolve_data_root(directory.path(), &vault, Some(chosen.clone())).unwrap();
assert_eq!(resolved, chosen);
assert!(
owner_settings::legacy_data_root(&vault).exists(),
"an explicit --data-root must not move anything behind the \
owner's back"
);
}
#[test]
fn a_graph_name_maps_to_one_id_and_different_names_do_not_collide() {
assert_eq!(personal_graph_id("personal"), personal_graph_id("personal"));
assert_ne!(personal_graph_id("personal"), personal_graph_id("scratch"));
}
#[test]
fn initial_pairing_watch_state_retains_each_device_root() {
let node = [0xd4; 32];
let root = [0xd5; 32];
let mut sync = owner_settings::SyncSettings::default();
assert!(sync.pair(node, Some(root), "sibling", 1));
let applied = paired_nodes_with_roots(&sync).unwrap();
assert_eq!(applied.get(&node), Some(&Some(root)));
}
}