a second instance on one machine: its own data, its own peer port, and a join that says itself again when it was never seated

This commit is contained in:
KeelrLeek committed 2026-09-03 13:39:06 -05:00
1 parent 67e52697b2
commit 6f7452c803
17 files changed
+125 -56

No files matched your search

+55 -13
View File
@@ -61,6 +61,12 @@ struct SpaceRecord {
/// OWNER ONLY: personas we have invited and will accept a join from.
#[serde(default)]
invited: BTreeMap<String, u64>,
/// When we last told the owner which key we would wear. The answer is one
/// sealed message and it can simply fail to land — the owner's descriptor
/// may not resolve on a young node — and a member who is never seated has
/// no way back in. Opening the Space re-sends after this goes stale.
#[serde(default)]
join_sent_at: u64,
}
#[derive(Default, Serialize, Deserialize)]
@@ -170,9 +176,7 @@ fn record_id(owner: &str, space_id: &str) -> String {
}
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
Ok(crate::paths::app_data(app)
.map_err(|e| e.to_string())?
.join("freenet")
.join(LEDGER_FILE))
@@ -433,6 +437,7 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva
room_secret: None,
roster: BTreeMap::new(),
invited: BTreeMap::new(),
join_sent_at: 0,
};
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
@@ -470,7 +475,10 @@ pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> R
}
}
let rec = l.spaces.get(&rid).cloned().unwrap_or(rec);
Ok(view_of(app, &rec, &state, &worn_key(&rec, &me), reachable))
let worn = worn_key(&rec, &me);
let seated = state.checkpoint.as_ref().is_some_and(|c| c.members.iter().any(|m| m.key == worn));
retry_join_if_stranded(app, &rec, seated);
Ok(view_of(app, &rec, &state, &worn, reachable))
}
pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text: &str) -> Result<(), String> {
@@ -685,6 +693,7 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in
room_secret: None,
roster: BTreeMap::new(),
invited: BTreeMap::new(),
join_sent_at: 0,
});
rec.name = cap(&inv.name, sp::MAX_NAME_BYTES);
rec.private = inv.private;
@@ -708,23 +717,55 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in
secret
};
log::info!("spaces: invited to {:?} by {}", inv.name, b58(&inv.owner));
// The answer: the key we will wear, and a persona signature so the owner
// knows it is us. Sealed to the owner — this pairing is exactly what must
// never be published.
let room_key = sp::member_public(&room);
let proof = match sign(app, sp::join_proof_payload(&inv.space_id, &inv.owner, &room_key)).await {
send_join(app, &inv.owner, &inv.space_id, &room).await;
}
/// Tell the owner which key we will wear: the key itself, and a persona
/// signature over it so they know it is us. Sealed — that pairing is exactly
/// what must never be published. Best-effort, and stamped so `open` can try
/// again; being un-seated is the one state a member cannot fix by hand.
async fn send_join(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16], room: &[u8; 32]) {
let room_key = sp::member_public(room);
let proof = match sign(app, sp::join_proof_payload(space_id, owner, &room_key)).await {
Ok(p) => p,
Err(e) => {
log::warn!("spaces: could not sign the join for {:?}: {e}", inv.name);
log::warn!("spaces: could not sign a join: {e}");
return;
}
};
let join = Cargo::SpaceJoin(SpaceJoin { space_id: inv.space_id, owner: inv.owner, room_key, proof });
if let Err(e) = inbox::send(app, &inv.owner, &join).await {
log::warn!("spaces: could not answer the invitation to {:?}: {e}", inv.name);
let join = Cargo::SpaceJoin(SpaceJoin { space_id: *space_id, owner: *owner, room_key, proof });
match inbox::send(app, owner, &join).await {
Ok(()) => {
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
if let Some(rec) = l.spaces.get_mut(&record_id(&b58(owner), &b58(space_id))) {
rec.join_sent_at = now_secs();
let _ = save(app, &l);
}
log::info!("spaces: told {} which key we will wear", b58(owner));
}
Err(e) => log::warn!("spaces: could not answer an invitation: {e} — will try again on open"),
}
}
/// A member who holds a room key but is not on the roster never got seated.
/// Say so again, spaced out, rather than leaving them stranded.
const JOIN_RETRY_SECS: u64 = 120;
fn retry_join_if_stranded(app: &tauri::AppHandle, rec: &SpaceRecord, seated: bool) {
if seated || rec.my_role == "owner" {
return;
}
let Some(room) = room_secret_of(rec) else { return };
if now_secs().saturating_sub(rec.join_sent_at) < JOIN_RETRY_SECS {
return;
}
let (Ok(owner), Ok(space_id)) = (key_of(&rec.owner), id_of(&rec.space_id)) else { return };
let handle = app.clone();
tauri::async_runtime::spawn(async move {
send_join(&handle, &owner, &space_id, &room).await;
});
}
/// The owner's side of the handshake: someone we invited says which key they
/// will wear. Verify it is really them, then put THAT key — and only that key
/// — on the published roster.
@@ -843,6 +884,7 @@ mod tests {
room_secret: secret.map(|s| b58(&s)),
roster: BTreeMap::new(),
invited: BTreeMap::new(),
join_sent_at: 0,
}
}