From c9a7719115944bed90a5e1b74f7af28a1402337d Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 16:14:32 -0600 Subject: [PATCH 1/6] fix(reticulum): adapt Games sidecar to lrgp-rs 0.4 Floating sibling lrgp-rs broke macOS Build Binaries; match IncomingDispatch/PreparedOutgoing, native msgpack LXMF fields, and insert-only session persist. --- reticulum-sidecar/Cargo.lock | 2 +- reticulum-sidecar/src/stack/games_session.rs | 218 ++++++++++++------- reticulum-sidecar/src/stack/live.rs | 5 +- 3 files changed, 141 insertions(+), 84 deletions(-) diff --git a/reticulum-sidecar/Cargo.lock b/reticulum-sidecar/Cargo.lock index 4313e2900..c7f87d9e5 100644 --- a/reticulum-sidecar/Cargo.lock +++ b/reticulum-sidecar/Cargo.lock @@ -1407,7 +1407,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lrgp" -version = "0.3.1" +version = "0.4.0" dependencies = [ "cozy-chess", "hex", diff --git a/reticulum-sidecar/src/stack/games_session.rs b/reticulum-sidecar/src/stack/games_session.rs index 9ecb70429..2b4dab7a0 100644 --- a/reticulum-sidecar/src/stack/games_session.rs +++ b/reticulum-sidecar/src/stack/games_session.rs @@ -13,8 +13,7 @@ use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use lrgp::apps::chess::ChessApp; -use lrgp::apps::tictactoe::TicTacToeApp; +use lrgp::app_base::IncomingDispatch; use lrgp::constants::{ CMD_CHALLENGE, CMD_MOVE, ERR_INVALID_MOVE, ERR_NOT_YOUR_TURN, KEY_APP, KEY_COMMAND, KEY_PAYLOAD, KEY_SESSION, @@ -86,9 +85,7 @@ impl GamesSessionManager { identity_id: String, event_tx: broadcast::Sender, ) -> Self { - let router = Arc::new(LrgpRouter::new()); - router.register(Box::new(TicTacToeApp::new())); - router.register(Box::new(ChessApp::new())); + let router = Arc::new(LrgpRouter::with_builtin_apps()); let games_dir = storage_dir.join("lrgp"); let store = match std::fs::create_dir_all(&games_dir) { @@ -178,12 +175,7 @@ impl GamesSessionManager { for session in sessions { let app_id = session.app_id.clone(); let session_id = session.session_id.clone(); - if let Err(e) = self.router.rollback_outgoing( - &app_id, - &session_id, - &self.identity_id, - Some(session), - ) { + if let Err(e) = self.router.restore_session(session) { tracing::warn!( target: "games", "failed to hydrate session {session_id} ({app_id}): {e}" @@ -233,10 +225,7 @@ impl GamesSessionManager { } else { session.app_id.clone() }; - match self - .router - .rollback_outgoing(&app, session_id, &self.identity_id, Some(session)) - { + match self.router.restore_session(session) { Ok(()) => Ok(true), Err(e) => { tracing::warn!( @@ -452,7 +441,34 @@ impl GamesSessionManager { .router .dispatch_incoming(&envelope, sender_hash, &self.identity_id) { - Ok(r) => r, + Ok(IncomingDispatch::Applied(r)) => r, + Ok(IncomingDispatch::Replay) => { + tracing::debug!( + target: "games", + "dropping in-process LRGP replay for app_id={app_id} session_id={session_id} command={command}" + ); + return true; + } + Ok(IncomingDispatch::RemoteError(error)) => { + // Remote protocol errors are accepted LRGP actions; persist so + // transport replay after restart cannot resurface the same rejection. + self.schedule_persist_session(&error.app_id, &error.session_id); + let payload = serde_json::json!({ + "app_id": error.app_id, + "session_id": error.session_id, + "command": command, + "sender_hash": sender_hash, + "direction": "inbound", + "session": JsonValue::Null, + "error": { + "code": error.code, + "message": error.message, + "reference": error.reference, + }, + }); + self.emit("games.update", &payload); + return true; + } Err(e) => { tracing::warn!( target: "games", @@ -573,30 +589,33 @@ impl GamesSessionManager { .router .snapshot_before_outgoing(app_id, &session_id, &self.identity_id); - let (envelope, fallback_text) = self + // Challenges must bind the remote peer before accept can succeed; use + // dispatch_outgoing_to for every outbound action (matches Ratspeak). + let prepared = self .router - .dispatch_outgoing( + .dispatch_outgoing_to( app_id, version, command, &session_id, &payload, &self.identity_id, + &dest_hash, ) .map_err(|e| format!("dispatch_error: {e}"))?; - let fields = - transport::pack_into_fields(&envelope).map_err(|e| format!("encode_error: {e}"))?; - let envelope_bytes = - envelope::pack_to_bytes(&envelope).map_err(|e| format!("encode_error: {e}"))?; + let fields = transport::pack_into_preencoded_fields(&prepared.envelope) + .map_err(|e| format!("encode_error: {e}"))?; + let envelope_bytes = envelope::pack_to_bytes(&prepared.envelope) + .map_err(|e| format!("encode_error: {e}"))?; Ok(PreparedGameAction { app_id: app_id.to_string(), - session_id, + session_id: prepared.session_id, dest_hash, fields, envelope_bytes, - fallback_text, + fallback_text: prepared.fallback_text, snapshot, }) } @@ -657,7 +676,7 @@ impl GamesSessionManager { .router .with_app(&app_id, |app| app.render_fallback(&command, &payload)) .unwrap_or_default(); - let fields = transport::pack_into_fields(&envelope) + let fields = transport::pack_into_preencoded_fields(&envelope) .map_err(|e| format!("resend_encode_error: {e}"))?; Ok(PreparedResend { @@ -984,6 +1003,9 @@ fn now_secs() -> f64 { /// Mirrors Ratspeak's `save_session_from_state` — persist the app's own /// in-memory `get_session_state()` JSON snapshot into the SQLite mirror after /// every dispatch (inbound or outbound) so list/detail endpoints stay current. +/// +/// `LrgpStore::save_session` is insert-only (lrgp 0.4+); existing rows go through +/// the mutable-column allowlist on `update_session`. fn save_session_from_state( store: &LrgpStore, session_id: &str, @@ -1026,22 +1048,40 @@ fn save_session_from_state( .and_then(JsonValue::as_f64) .unwrap_or_else(now_secs); - store - .save_session( - session_id, - identity_id, - app_id, - app_version, - contact_hash, - initiator, - status, - &metadata, - unread, - created_at, - updated_at, - last_action_at, - ) - .map_err(|e| e.to_string()) + match store + .get_session(session_id, identity_id) + .map_err(|e| e.to_string())? + { + Some(_) => { + let meta_json = + serde_json::to_string(&metadata).map_err(|e| format!("metadata serialize: {e}"))?; + let mut updates = HashMap::new(); + updates.insert("status".to_string(), status.to_string()); + updates.insert("metadata".to_string(), meta_json); + updates.insert("unread".to_string(), unread.to_string()); + updates.insert("updated_at".to_string(), updated_at.to_string()); + updates.insert("last_action_at".to_string(), last_action_at.to_string()); + store + .update_session(session_id, identity_id, &updates) + .map_err(|e| e.to_string()) + } + None => store + .save_session( + session_id, + identity_id, + app_id, + app_version, + contact_hash, + initiator, + status, + &metadata, + unread, + created_at, + updated_at, + last_action_at, + ) + .map_err(|e| e.to_string()), + } } fn json_to_rmpv(value: &JsonValue) -> rmpv::Value { @@ -1104,8 +1144,9 @@ mod tests { } fn inbound_challenge_fields(session_id: &str) -> BTreeMap> { - let env = envelope::pack_envelope("ttt", 1, CMD_CHALLENGE, session_id, None, None); - transport::pack_into_fields(&env) + let env = envelope::pack_envelope("ttt", 1, CMD_CHALLENGE, session_id, None, None) + .expect("pack challenge envelope"); + transport::pack_into_preencoded_fields(&env) .expect("pack challenge") .into_iter() .collect() @@ -1118,12 +1159,14 @@ mod tests { let ids: Vec<&str> = apps.iter().map(|m| m.app_id.as_str()).collect(); assert!(ids.contains(&"ttt")); assert!(ids.contains(&"chess")); + assert!(ids.contains(&"four_in_a_row")); } #[test] fn pack_extract_roundtrip_via_transport() { - let env = envelope::pack_envelope("ttt", 1, "challenge", "abc123", None, None); - let fields = transport::pack_into_fields(&env).expect("pack"); + let env = envelope::pack_envelope("ttt", 1, "challenge", "abcdef0123456789", None, None) + .expect("pack envelope"); + let fields = transport::pack_into_preencoded_fields(&env).expect("pack"); let recovered = transport::extract_envelope(&fields) .expect("extract") .expect("some"); @@ -1169,10 +1212,11 @@ mod tests { fn local_reject_maps_invalid_move_on_empty_payload() { let (_dir, manager, _) = test_manager(); let dest = "a".repeat(32); - assert!(manager.handle_inbound_lxmf(&inbound_challenge_fields("sess1"), &dest, "")); + let session_id = "cccccccccccccccc"; + assert!(manager.handle_inbound_lxmf(&inbound_challenge_fields(session_id), &dest, "")); let _guard = hydrate_err_test_guard(); let err = manager - .prepare_action(&dest, "ttt", CMD_MOVE, Some("sess1"), None) + .prepare_action(&dest, "ttt", CMD_MOVE, Some(session_id), None) .expect_err("expected local reject"); assert_eq!(err, ERR_INVALID_MOVE); } @@ -1182,7 +1226,7 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let identity = "selfidentityhash"; let peer = "c".repeat(32); - let session_id = "sess-rehydrate"; + let session_id = "aaaaaaaaaaaaaaaa"; let (event_tx, _rx) = broadcast::channel(16); { @@ -1206,45 +1250,54 @@ mod tests { #[test] fn local_reject_maps_not_your_turn() { - let (_dir, manager, _) = test_manager(); - let dest = "b".repeat(32); + let dir = tempfile::tempdir().expect("tempdir"); + let (event_tx, _rx) = broadcast::channel(16); + // prepare_action requires 32-hex dest hashes; use the same shape for identity ids. + let self_id = "a".repeat(32); + let peer = "b".repeat(32); + let manager = GamesSessionManager::spawn(dir.path(), self_id.clone(), event_tx.clone()); + let peer_manager = + GamesSessionManager::spawn(&dir.path().join("peer"), peer.clone(), event_tx); - // Our own outgoing challenge creates the session (turn unset yet). let challenge = manager - .prepare_action(&dest, "ttt", CMD_CHALLENGE, None, None) + .prepare_action(&peer, "ttt", CMD_CHALLENGE, None, None) .expect("challenge prepared"); + let sid = challenge.session_id.clone(); + let challenge_fields: BTreeMap> = + challenge.fields.clone().into_iter().collect(); manager.commit_action(&challenge, Some("testhash2")); - // Opponent's accept arrives inbound and hands the turn to them. - let mut payload = serde_json::Map::new(); - payload.insert("b".into(), JsonValue::String("_________".into())); - payload.insert("t".into(), JsonValue::String(dest.clone())); - let accept_payload = json_payload_to_rmpv_map(Some(&JsonValue::Object(payload))); - let accept_env = envelope::pack_envelope( - "ttt", - 1, - "accept", - &challenge.session_id, - Some(accept_payload), - None, - ); - let accept_fields: BTreeMap> = transport::pack_into_fields(&accept_env) - .expect("pack accept") - .into_iter() - .collect(); - assert!(manager.handle_inbound_lxmf(&accept_fields, &dest, "")); + assert!(peer_manager.handle_inbound_lxmf(&challenge_fields, &self_id, "")); + let accept = peer_manager + .prepare_action(&self_id, "ttt", CMD_ACCEPT, Some(&sid), None) + .expect("peer accept"); + let accept_fields: BTreeMap> = accept.fields.clone().into_iter().collect(); + assert!(manager.handle_inbound_lxmf(&accept_fields, &peer, "")); let _guard = hydrate_err_test_guard(); - let err = manager - .prepare_action( - &dest, - "ttt", - CMD_MOVE, - Some(&challenge.session_id), - Some(&serde_json::json!({ "i": 0 })), - ) - .expect_err("expected not_your_turn"); - assert_eq!(err, ERR_NOT_YOUR_TURN); + // Coin-flip first turn: if we own the turn, play once so the peer owns it. + match manager.prepare_action( + &peer, + "ttt", + CMD_MOVE, + Some(&sid), + Some(&serde_json::json!({ "i": 0 })), + ) { + Err(e) => assert_eq!(e, ERR_NOT_YOUR_TURN), + Ok(action) => { + manager.commit_action(&action, Some("move1")); + let err = manager + .prepare_action( + &peer, + "ttt", + CMD_MOVE, + Some(&sid), + Some(&serde_json::json!({ "i": 1 })), + ) + .expect_err("expected not_your_turn after handing off"); + assert_eq!(err, ERR_NOT_YOUR_TURN); + } + } } #[test] @@ -1263,7 +1316,7 @@ mod tests { } #[test] - fn list_apps_includes_both_builtin_games() { + fn list_apps_includes_builtin_games() { let (_dir, manager, _) = test_manager(); let apps = manager.list_apps(); let ids: Vec = apps["apps"] @@ -1274,6 +1327,7 @@ mod tests { .collect(); assert!(ids.contains(&"ttt".to_string())); assert!(ids.contains(&"chess".to_string())); + assert!(ids.contains(&"four_in_a_row".to_string())); } #[test] @@ -1281,7 +1335,7 @@ mod tests { let dir = tempfile::tempdir().expect("tempdir"); let identity = "selfidentityhash"; let peer = "d".repeat(32); - let session_id = "sess-env-persist"; + let session_id = "bbbbbbbbbbbbbbbb"; let (event_tx, _rx) = broadcast::channel(16); { let manager = GamesSessionManager::spawn(dir.path(), identity.into(), event_tx.clone()); diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 8c44f34f8..c2e1f8f81 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -3016,7 +3016,10 @@ impl LiveBridge { method, ); for (&field_id, bytes) in fields { - msg.set_field(field_id, bytes.clone()); + // LRGP packs native MessagePack field values (0xFB string / 0xFD map). + // `set_field` would wrap those in BIN and break Python/Ratspeak peers. + msg.set_msgpack_field(field_id, bytes.clone()) + .map_err(|e| format!("lxmf set_msgpack_field {field_id:#x}: {e}"))?; } let signing_key = self .identity From ec207e09e73f97bbd90855803172566a300fe229 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 16:38:01 -0600 Subject: [PATCH 2/6] fix(reticulum): finish local PN host ingress and Sync catch-up Wire lxmd-parity Resource admission/stamp store for Host PN, drive idle peer inventory sync, correlatable deposit/retrieve logs, and catch up inbound LXMF when remote Sync Completes so one-way DMs surface in Chat. --- docs/reticulum.md | 4 +- docs/troubleshooting.md | 19 +- reticulum-sidecar/src/stack/live.rs | 112 ++- reticulum-sidecar/src/stack/lxmf_outbound.rs | 52 +- reticulum-sidecar/src/stack/mod.rs | 2 + reticulum-sidecar/src/stack/pn_inbound.rs | 690 ++++++++++++++++++ .../src/stack/propagation_bridge.rs | 155 +++- .../src/stack/propagation_serve.rs | 470 +++++++++++- ...time.inbound-lxmf-catchup.contract.test.ts | 7 + src/renderer/runtime/useReticulumRuntime.ts | 36 +- 10 files changed, 1491 insertions(+), 56 deletions(-) create mode 100644 reticulum-sidecar/src/stack/pn_inbound.rs diff --git a/docs/reticulum.md b/docs/reticulum.md index b8c8755b8..b226b1d5d 100644 --- a/docs/reticulum.md +++ b/docs/reticulum.md @@ -272,7 +272,7 @@ When multiple enabled local RNode interfaces are connected, the interface list s - **Config validate:** Electron IPC `reticulum:validateConfig` → one-shot sidecar `validate-config --json` against `userData/reticulum/config` - **Announces:** interval (`announce_interval_sec`, 0–86400; default **3600** s / 1 h when unset; `0` = startup-only) persisted in rnsd config. The live sidecar sends an **LXMF delivery** announce shortly after stack start and on that interval (Ratspeak/lxmd parity). **Announce now** (`POST /api/v1/announces`) forces an immediate delivery announce. **Clear announces** (`DELETE /api/v1/announces`) clears the stub peer cache; the live path table may refill on the next peer refresh. Per-interface `announce_interval_min` (RMAP/discoverable interfaces) is separate. - **Inbound LXMF:** the sidecar registers `lxmf.delivery` with the transport (`RegisterDestination` + `LinkManager`) and feeds decrypted link/resource payloads into the delivery callback (WS `lxmf_message`). Without this registration, peer DMs never appear in Chat even when paths exist. -- **Propagation:** preferred node for offline DMs, per-node **Sync messages**, add remote propagation nodes by 32-character `lxmf.propagation` hash or from the **Discovered on network** list (heard PN announces; Add / Add & prefer — never silent auto-add), **rename** / **delete** remote nodes, optional **local PN hosting** (announce + `/offer`/`/get`), Network **Advanced PN hosting** policy (`peering_cost`, `max_peering_cost`, autopeer, stamps, storage), Add-time `/offer` probe, **auto-sync interval** (`auto_sync_interval_sec`; `0` disables periodic sync; interval measured from last _successful_ sync with a short failure cooldown). Remote sync **always sends an LXMF delivery announce** then settles briefly (~2s) before Establishing so the PN has a reverse path for LRPROOF, **re-requests the forward path** (does not reuse a possibly stale hop count), pins/persists PN identity during Establishing (avoids announce-flood eviction), resolves identity+path before Establishing, rejects non-PN destinations (`PROPAGATION_TARGET_NOT_PN`), requires a peering stamp when cost > 0, treats HaveAll/Complete as success (not failure), surfaces `NoLinkProof` when establish stalls without a proof, and the renderer cancels Establishing-only stalls (~45s) plus a hard ceiling (~180s) via `reticulumPropagationSync.ts` without overwriting sidecar failure keys. +- **Propagation:** preferred node for offline DMs, per-node **Sync messages**, add remote propagation nodes by 32-character `lxmf.propagation` hash or from the **Discovered on network** list (heard PN announces; Add / Add & prefer — never silent auto-add), **rename** / **delete** remote nodes, optional **local PN hosting** (announce + `/offer`/`/get` + Link Resource deposit ingress with stamp validation into the local store, plus outbound peer inventory sync when hosting + autopeer/static peers are on), Network **Advanced PN hosting** policy (`peering_cost`, `max_peering_cost`, autopeer, stamps, storage), Add-time `/offer` probe, **auto-sync interval** (`auto_sync_interval_sec`; `0` disables periodic sync; interval measured from last _successful_ sync with a short failure cooldown). Remote sync **always sends an LXMF delivery announce** then settles briefly (~2s) before Establishing so the PN has a reverse path for LRPROOF, **re-requests the forward path** (does not reuse a possibly stale hop count), pins/persists PN identity during Establishing (avoids announce-flood eviction), resolves identity+path before Establishing, rejects non-PN destinations (`PROPAGATION_TARGET_NOT_PN`), requires a peering stamp when cost > 0, treats HaveAll/Complete as success (not failure), surfaces `NoLinkProof` when establish stalls without a proof, and the renderer cancels Establishing-only stalls (~45s) plus a hard ceiling (~180s) via `reticulumPropagationSync.ts` without overwriting sidecar failure keys. After Sync Completes, the renderer runs inbound LXMF catch-up so Chat does not wait for the periodic ring poll. Correlatable deposit/retrieve logs use targets `propagation-deposit` / `propagation-retrieve` (`message_hash`, `transient_id`, `pn_hash`). --- @@ -311,7 +311,7 @@ IRC-style multi-pane client (`RrcPanel` + `rrcHubStore` / `rrcSessionStore`): | Destination absent | None | Error `no_propagation_node`; set preferred **remote** node on Network tab | | n/a (offline) | n/a | **Paper** — encrypted QR/`lxm://` handoff (`DeliveryMethod::Paper`); no path table or PN; Completes immediately; badge **Paper**; does not use `lxmf_outbound_status` | -**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node (or **paper** QR handoff). **Local PN hosting** is this device’s optional local serving / inbox — it does **not** deposit outbound DMs for unreachable peers. Propagated Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. +**Path ≠ delivered:** a path-table entry means RNS knows a route, not that LXMF completed. Reticulum is async — offline peers need a **remote** propagation node (or **paper** QR handoff). **Local PN hosting** accepts network deposits and peers inventory with other PNs when enabled — it still does **not** replace a preferred **remote** PN for _your_ unreachable outbound DMs. Propagated Completes mean the PN accepted the encrypted blob (Ratspeak envelope parity), not that the recipient opened Chat. LXMF retrieval is **any-node**: deposit on PN A and Sync from PN B is valid when the fabric peers; parties need not share the same preferred PN. --- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9b5f5fd18..a1fce1cb6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1275,9 +1275,24 @@ Bond-stale **TX queue full** hints (`txQueueDropsHintBleBondStale`) point at the **Symptoms**: Local Host propagation node is enabled but peers never hear your PN announce / cannot `/offer` or `/get`. -**Cause**: Hosting requires a live stack with identity signing key; enable starts `lxmf.propagation` LinkManager + announce loop. +**Cause**: Hosting requires a live stack with identity signing key; enable starts `lxmf.propagation` LinkManager + announce loop (Resource deposit ingress + stamp validation into the local store, `/offer` admission, and outbound peer inventory sync when idle). -**Fix**: Confirm sidecar is running, identity is configured, **Network → Propagation → Host propagation node** is Enabled, and check logs for `[propagation-serve]` / `[propagation-announce]`. Tune announce interval under **Advanced PN hosting**. +**Fix**: Confirm sidecar is running, identity is configured, **Network → Propagation → Host propagation node** is Enabled, and check logs for `[propagation-serve]` / `[propagation-announce]` / `[propagation-deposit]`. Tune announce interval under **Advanced PN hosting**. Peers depositing to your host should see your `lxmf.propagation` hash; bad stamps are rejected and logged under `propagation-deposit`. + +### Reticulum: Stored at PN but Sync leaves Chat empty + +**Symptoms**: Sender shows **Stored at propagation node** (Propagated Completes). Recipient runs **Sync messages** (progress reaches Complete / HaveAll) but the DM never appears in Chat. Preferred PN hashes may differ between the two clients. + +**Cause (any-node model)**: LXMF does **not** require both parties to prefer the same PN. Deposit on PN A and retrieve via Sync from PN B is valid when autopeer/static peering moves inventory. Empty Chat after Sync is usually a fabric/retrieve/ingest gap (mail never reached the synced node, stamp/admission drop on a host PN, or inbound ring not catch-up’d into Chat) — not “wrong preferred PN.” + +**Do not** tell users they must share the same preferred PN. Prefer log correlation instead: + +1. Sender Device log: `propagation-deposit` with `message_hash`, `transient_id`, `pn_hash` (deposit Completes). +2. Recipient (or Host PN) log: `propagation-retrieve` with matching `message_hash` / `transient_id` after Sync, plus `retrieve_mode=have_all|transfer`. +3. Renderer: `[catchUpRecentInboundLxmf] … reason=propagation_sync` or `propagation-retrieve catch-up after sync Completes count=N` (`count=0 (empty ring)` means Sync Completes with no new inbound for Chat). +4. Confirm remote Sync Completes and that Host PN (if used) shows `[propagation-deposit] local PN accepted stamped propagated blob`. + +**Fix**: Retry Sync after path/announce settle; if using local Host, confirm ingress logs and peer sync ticks (`local host queued outbound peer inventory sync`). Export developer bundles from both sides and `rg 'propagation-deposit|propagation-retrieve'`. ### Reticulum PN hosting policy apply fails diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index c2e1f8f81..4955944be 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -19,6 +19,7 @@ const FIELD_REPLY_TO: u8 = 0x30; const FIELD_REPLY_QUOTE: u8 = 0x31; /// Cap wire quote length (matches renderer `REPLY_PREVIEW_MAX_LEN` without ellipsis). const REPLY_QUOTE_MAX_CHARS: usize = 50; +use lxmf_core::peer::OutboundOfferPolicy; use lxmf_core::router::LxmRouter; use rns_identity::destination::Destination; use rns_identity::identity::Identity; @@ -378,6 +379,20 @@ impl LiveBridge { .get("message_hash") .and_then(|v| v.as_str()) .unwrap_or(""); + let transient_id_hex = msg + .transient_id + .as_ref() + .map(hex::encode) + .unwrap_or_default(); + if msg.method == DeliveryMethod::Propagated { + tracing::info!( + target: "propagation-retrieve", + message_hash = %message_hash, + transient_id = %transient_id_hex, + from = %sender_hex, + "inbound LXMF delivered via propagation" + ); + } // Rate-limited warn so developer bundles can prove sidecar receipt without spam. rate_limited_inbound_lxmf_warn(&sender_hex, message_hash); // Contacts are manual-only in mesh-client; do not upsert on inbound LXMF. @@ -2389,6 +2404,7 @@ impl LiveBridge { let event_tx = self.event_tx.clone(); let propagation = self.propagation.clone(); let config_dir = self.config_dir.clone(); + let local_identity_hash = self.identity.hash; tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(2)); let mut known_path_hashes: HashSet = HashSet::new(); @@ -2582,7 +2598,23 @@ impl LiveBridge { } driver.process_tick(&mut router, &event_tx); let known_identities = driver.known_identities_for_propagation(); - propagation.tick(&known_identities); + let terminal = propagation.tick(&known_identities); + if terminal.is_some() { + driver.set_propagation_sync_target(None); + } + // Local Host: push inventory to peered PNs when the sync task is idle + // and no user Sync / deposit owns the PN Link (lxmd drive_pending parity). + if propagation.is_local_serving() + && !propagation.sync_active() + && driver.propagation_sync_target().is_none() + { + drive_local_host_peer_sync( + &propagation, + &mut router, + &mut driver, + local_identity_hash, + ); + } } // Skip tick when outbound is locked — never drain LRPROOF against an empty map. } @@ -3281,7 +3313,8 @@ impl LiveBridge { &self.handle.transport_tx, &self.identity, self.propagation.local_dest_hash_bytes(), - self.propagation.local_node(), + &self.propagation.local_node(), + &policy, ) { tracing::error!(target: "propagation-serve", "failed to start serve: {e}"); let mut router = self.router.lock().await; @@ -5267,6 +5300,81 @@ fn peer_route_fields_equal(a: &PeerRow, b: &PeerRow) -> bool { /// Pure announce classification for propagation sync targets. /// /// `entries` is `(dest_hash_hex, name_hash)` pairs from recent announces. +/// While Host PN is on, push inventory to peered PNs (lxmd `drive_pending_peer_syncs` parity). +/// +/// Skips when the shared sync task is busy or a user Sync/deposit owns the PN Link. +fn drive_local_host_peer_sync( + propagation: &Arc, + router: &mut LxmRouter, + driver: &mut LxmfOutboundDriver, + local_identity_hash: [u8; 16], +) { + propagation.drain_peering_key_results(router); + + let offer_generation = match propagation.local_node().lock() { + Ok(node) => node.offer_generation(), + Err(_) => return, + }; + + let policies = router.sync_peer_policies_for_store(offer_generation); + for policy in policies { + let peer_hash = policy.peer_hash; + let Some(peer) = router.peers.get(&peer_hash) else { + continue; + }; + if !peer.stamp_costs_known() { + continue; + } + if peer.peering_cost > 0 && !peer.peering_key_ready() { + if propagation.peering_key_job_inflight(&peer_hash) { + continue; + } + let peer_hex = hex::encode(peer_hash); + let Some(pub_key) = driver.public_key_for(&peer_hex) else { + tracing::debug!( + target: "propagation-sync", + peer = %peer_hex, + "host peer sync postponed until identity is known" + ); + continue; + }; + let Ok(peer_identity) = Identity::from_public_key(&pub_key) else { + continue; + }; + let peering_cost = peer.peering_cost; + propagation.spawn_peering_key_job( + peer_hash, + peering_cost, + peer_identity.hash, + local_identity_hash, + ); + continue; + } + + if driver.has_inflight_delivery_to(&peer_hash) { + continue; + } + + let ready_policy = OutboundOfferPolicy::from(peer); + if peer.peering_cost > 0 && ready_policy.peering_key.is_empty() { + continue; + } + + if propagation.start_sync_with_policy(ready_policy) { + if let Some(peer) = router.peers.get_mut(&peer_hash) { + peer.begin_sync(); + } + driver.set_propagation_sync_target(Some(peer_hash)); + tracing::info!( + target: "propagation-sync", + peer = %hex::encode(peer_hash), + "local host queued outbound peer inventory sync" + ); + return; + } + } +} + fn classify_propagation_target_name_hashes( destination_hex: &str, entries: &[(String, [u8; 10])], diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 5cdffbfeb..5d380c202 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -118,6 +118,9 @@ impl PathRequestGate { /// Bound on Direct→PN fallback hash tracking (one entry per outbound message). const PN_FALLBACK_ATTEMPTED_MAX: usize = 256; +/// Correlatable ids for an in-flight Propagated deposit (`pn_hash`, optional `transient_id`). +type PendingPnDeposit = ([u8; 16], Option<[u8; 32]>); + pub struct LxmfOutboundDriver { transport_tx: mpsc::Sender, link_delivery: LinkDeliveryManager, @@ -143,6 +146,8 @@ pub struct LxmfOutboundDriver { direct_path_failovers: HashMap<[u8; 32], DirectPathFailoverState>, /// When set, remote propagation sync holds a Link to this dest — do not race deposits. propagation_sync_target: Option<[u8; 16]>, + /// In-flight Propagated deposits: message_hash → (pn_hash, transient_id). + pending_pn_deposits: HashMap<[u8; 32], PendingPnDeposit>, self_lxmf_hash: String, self_display_name: String, } @@ -174,6 +179,7 @@ impl LxmfOutboundDriver { pn_fallback_attempted: HashSet::new(), direct_path_failovers: HashMap::new(), propagation_sync_target: None, + pending_pn_deposits: HashMap::new(), self_lxmf_hash: self_lxmf_hash.clone(), self_display_name, }; @@ -228,6 +234,11 @@ impl LxmfOutboundDriver { self.propagation_sync_target = dest; } + /// Remote PN currently reserved for user Sync / deposit (blocks host peer-sync). + pub fn propagation_sync_target(&self) -> Option<[u8; 16]> { + self.propagation_sync_target + } + /// True when a packed deposit / Direct session already holds a Link to `dest`. pub fn has_inflight_delivery_to(&self, dest: &[u8; 16]) -> bool { self.link_delivery.has_pending_to(dest) @@ -479,18 +490,30 @@ impl LxmfOutboundDriver { return; } let hops = route_hops_for(&self.route_hops, prop_hash); - tracing::debug!( - prop = %prop_hex, + let message_hash_hex = message.hash.as_ref().map(hex::encode); + let transient_id_hex = message.transient_id.as_ref().map(hex::encode); + if let Some(hash) = message.hash { + self.pending_pn_deposits + .insert(hash, (prop_hash, message.transient_id)); + } + tracing::info!( + target: "propagation-deposit", + message_hash = message_hash_hex.as_deref().unwrap_or(""), + transient_id = transient_id_hex.as_deref().unwrap_or(""), + pn_hash = %prop_hex, dest = %hex::encode(message.destination_hash), hops, packed_len = packed.len(), attempts, - "DeliverPropagated: starting packed delivery" + "outbound PN deposit starting packed delivery" ); if let Err(err) = self .link_delivery .start_packed_delivery(message, prop_hash, hops, packed, false) { + if let Some(hash) = err.message.hash { + self.pending_pn_deposits.remove(&hash); + } let reason = err.error.to_string(); tracing::warn!( prop = %prop_hex, @@ -825,7 +848,9 @@ impl LxmfOutboundDriver { match result { DeliveryResult::Complete { msg_hash, .. } => { if let Some(hash) = msg_hash { - let method = if self.pn_fallback_attempted.contains(&hash) { + let was_pn_fallback = self.pn_fallback_attempted.contains(&hash); + let pending_deposit = self.pending_pn_deposits.remove(&hash); + let method = if was_pn_fallback || pending_deposit.is_some() { Some("propagated") } else { None @@ -833,12 +858,28 @@ impl LxmfOutboundDriver { self.pn_fallback_attempted.remove(&hash); self.direct_path_failovers.remove(&hash); let _ = router.mark_outbound_delivered(&hash); + if let Some((pn_hash, transient_id)) = pending_deposit { + tracing::info!( + target: "propagation-deposit", + message_hash = %hex::encode(hash), + transient_id = %transient_id + .as_ref() + .map(hex::encode) + .unwrap_or_default(), + pn_hash = %hex::encode(pn_hash), + pn_fallback = was_pn_fallback, + "outbound PN deposit Completes" + ); + } emit_outbound_status_by_hash(event_tx, &hash, "delivered", method); } } DeliveryResult::Rejected { message, reason, .. } => { + if let Some(hash) = message.hash { + self.pending_pn_deposits.remove(&hash); + } tracing::warn!( dest = %hex::encode(message.destination_hash), method = %delivery_method_label(message.method), @@ -857,6 +898,9 @@ impl LxmfOutboundDriver { dest_hash, .. } => { + if let Some(hash) = message.hash { + self.pending_pn_deposits.remove(&hash); + } tracing::warn!( dest = %hex::encode(message.destination_hash), link_dest = %hex::encode(dest_hash), diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index d8db6319e..fcffc31b4 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -24,6 +24,8 @@ mod persistence; #[cfg(feature = "rns-stack")] mod pn_hosting_apply; mod pn_hosting_policy; +#[cfg(feature = "rns-stack")] +mod pn_inbound; pub mod rf_profiles; mod rmap_discovery; mod rrc_codec; diff --git a/reticulum-sidecar/src/stack/pn_inbound.rs b/reticulum-sidecar/src/stack/pn_inbound.rs new file mode 100644 index 000000000..439d7bd26 --- /dev/null +++ b/reticulum-sidecar/src/stack/pn_inbound.rs @@ -0,0 +1,690 @@ +//! Inbound LXMF propagation admission and Resource accounting (lxmd parity). +//! +//! Adapted from rsLXMF `lxmf-tools` `lxmd_pn.rs` for mesh-client local PN hosting. +//! Reticulum request Resources share a Link with ordinary propagation Resources, +//! so Link identity alone is not sufficient lifecycle ownership. Only the +//! `AcceptApp` callback may create an exact `(link_id, logical_resource_id)` +//! correlation. + +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +use lxmf_core::propagation_admission::{ + PnCandidateDiscardResult, PnInboundAdmission, PnInboundAdmissionConfig, PnInboundState, + PnOfferAdmission, PnOfferCandidate, PnOfferRejection, PnValidationResult, +}; +use lxmf_core::propagation_offer::PnOfferEvaluation; +use lxmf_core::sync::OfferResponse; +use rns_runtime::link_manager::{ + LinkManagerAccountingEvent, LinkResourceConclusion, LinkResourceDirection, LinkResourceEvent, +}; + +type LinkId = [u8; 16]; +type LogicalResourceId = [u8; 32]; +type ResourceKey = (LinkId, LogicalResourceId); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResourceOwner { + /// Resource promised by an accepted `WantAll` or `WantSome` offer. + Offered, + /// Ordinary Resource on a Link that previously proved a peering key. + ValidatedPeer, + /// Ordinary client/originator Resource without a peering-key proof. + Client, +} + +#[derive(Debug, Clone, Copy)] +struct ResourceCorrelation { + owner: ResourceOwner, + peer_destination_hash: Option<[u8; 16]>, + started: bool, + completion_dispatched: bool, +} + +#[derive(Debug, Clone, Copy)] +struct PendingValidation { + key: ResourceKey, + owner: ResourceOwner, + peer_destination_hash: Option<[u8; 16]>, + link_closed: bool, +} + +/// Opaque daemon-local identity for one dispatched validation job. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct PnValidationToken(u64); + +/// CPU-validation work emitted after an exact ordinary Resource completion. +#[derive(Debug)] +pub(crate) struct PnValidationJob { + token: PnValidationToken, + link_id: LinkId, + data: Vec, + allow_multiple: bool, +} + +impl PnValidationJob { + pub(crate) fn token(&self) -> PnValidationToken { + self.token + } + + pub(crate) fn link_id(&self) -> LinkId { + self.link_id + } + + pub(crate) fn into_data(self) -> Vec { + self.data + } + + pub(crate) fn allow_multiple(&self) -> bool { + self.allow_multiple + } + + #[cfg(test)] + pub(crate) fn for_test(data: Vec, allow_multiple: bool) -> Self { + Self { + token: PnValidationToken(1), + link_id: [1; 16], + data, + allow_multiple, + } + } +} + +/// Semantic result of parsing and stamp-validating one completed transfer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PnValidationOutcome { + Valid, + InvalidStamp, + UnauthorizedMultiple, + Failed, +} + +/// One accepted validation result. A missing claim means the result was stale, +/// duplicated, or did not match the token's original Link. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PnValidationClaim { + link_id: LinkId, + outcome: PnValidationOutcome, +} + +impl PnValidationClaim { + pub(crate) fn link_id(self) -> LinkId { + self.link_id + } + + pub(crate) fn outcome(self) -> PnValidationOutcome { + self.outcome + } + + pub(crate) fn should_close_link(self) -> bool { + matches!( + self.outcome, + PnValidationOutcome::InvalidStamp | PnValidationOutcome::UnauthorizedMultiple + ) + } +} + +/// One daemon-lifetime owner for inbound offer admission, peer-key +/// authorization, exact ordinary Resource correlations, and validation tokens. +pub(crate) struct PnInboundRuntime { + admission: PnInboundAdmission, + validated_links: HashMap, + quarantined_links: HashSet, + correlations: HashMap, + pending_validations: HashMap, + next_validation_token: u64, + max_resource_bytes: usize, + clock_origin: Instant, +} + +impl PnInboundRuntime { + pub(crate) fn new( + config: PnInboundAdmissionConfig, + static_peers: I, + max_resource_bytes: usize, + ) -> Self + where + I: IntoIterator, + { + let mut admission = PnInboundAdmission::new(config); + admission.set_static_peers(static_peers); + Self { + admission, + validated_links: HashMap::new(), + quarantined_links: HashSet::new(), + correlations: HashMap::new(), + pending_validations: HashMap::new(), + next_validation_token: 1, + max_resource_bytes, + clock_origin: Instant::now(), + } + } + + fn now(&self) -> Duration { + self.clock_origin.elapsed() + } + + /// Cheap policy preflight. The returned opaque candidate is externally + /// bound to this Link and peer identity for the peering-key evaluator. + pub(crate) fn preflight_offer( + &mut self, + link_id: LinkId, + peer_identity_hash: Option<[u8; 16]>, + ) -> Result { + if self.quarantined_links.contains(&link_id) { + return Err(OfferResponse::ErrorThrottled); + } + + if let Some(peer_identity_hash) = peer_identity_hash { + let peer_destination_hash = + PnInboundAdmission::peer_destination_hash(&peer_identity_hash); + if !self.resource_bypasses_limits(Some(peer_destination_hash)) { + let config = self.admission.config(); + if config.sequential_validation && !self.pending_validations.is_empty() { + return Err(OfferResponse::ErrorThrottled); + } + if self.active_limited_resource_count() >= config.max_inbound_syncs { + return Err(OfferResponse::ErrorThrottled); + } + } + } + + let now = self.now(); + self.admission + .preflight_offer(link_id, peer_identity_hash, now) + .map_err(offer_rejection_response) + } + + /// Commit the exact externally validated candidate and remember that its + /// Link has proved a peering key. A failed commit never grants Link-wide + /// multi-message authorization. + pub(crate) fn commit_offer( + &mut self, + candidate: PnOfferCandidate, + evaluation: &PnOfferEvaluation, + ) -> Result<(), OfferResponse> { + let link_id = candidate.link_id(); + let peer_destination_hash = candidate.peer_destination_hash(); + + // Match upstream's observable ordering: mutable concurrency gates are + // rechecked before a successful peering-key proof can authorize the + // Link. The core coordinator repeats its own record-based gates below. + if self.quarantined_links.contains(&link_id) + || (!self.resource_bypasses_limits(Some(peer_destination_hash)) + && ((self.admission.config().sequential_validation + && !self.pending_validations.is_empty()) + || self.active_limited_resource_count() + >= self.admission.config().max_inbound_syncs)) + { + self.admission.discard_candidate(candidate); + return Err(OfferResponse::ErrorThrottled); + } + + let now = self.now(); + match self + .admission + .commit_validated_offer(candidate, evaluation.admission_response(), now) + { + PnOfferAdmission::HaveAll | PnOfferAdmission::Accepted => { + self.validated_links.insert(link_id, peer_destination_hash); + Ok(()) + } + PnOfferAdmission::Rejected(rejection) => Err(offer_rejection_response(rejection)), + } + } + + pub(crate) fn discard_offer( + &mut self, + candidate: PnOfferCandidate, + ) -> PnCandidateDiscardResult { + self.admission.discard_candidate(candidate) + } + + pub(crate) fn is_link_quarantined(&self, link_id: &LinkId) -> bool { + self.quarantined_links.contains(link_id) + } + + /// Apply the Resource-advertisement policy and create exact ownership + /// before returning `true` to Reticulum's `AcceptApp` callback. + /// + /// Request/response Resources bypass this callback in Reticulum and can + /// therefore never acquire one of these correlations. + pub(crate) fn accept_resource( + &mut self, + link_id: LinkId, + logical_resource_id: LogicalResourceId, + data_size: usize, + peer_identity_hash: Option<[u8; 16]>, + ) -> bool { + if self.quarantined_links.contains(&link_id) { + return false; + } + + let key = (link_id, logical_resource_id); + let supplied_peer_destination = + peer_identity_hash.map(|identity| PnInboundAdmission::peer_destination_hash(&identity)); + + if let Some(existing) = self.correlations.get(&key) { + // A later split segment retains the original exact owner. If its + // advertisement is now rejected, the accounting conclusion owns + // terminal cleanup. + return self.resource_policy_allows(data_size, existing.peer_destination_hash); + } + + let admission_record = self + .admission + .record(&link_id) + .map(|record| (record.state(), *record.peer_destination_hash())); + let has_offered_correlation = + self.correlations + .iter() + .any(|((existing_link, _), correlation)| { + *existing_link == link_id && correlation.owner == ResourceOwner::Offered + }); + + let owner = match admission_record { + Some((PnInboundState::Accepted, _)) if !has_offered_correlation => { + // One accepted offer owns one logical Resource. A second + // unrelated Resource remains authorized by the already-proved + // peering key, but must not steal Offered ownership. + ResourceOwner::Offered + } + Some(( + PnInboundState::Accepted + | PnInboundState::Transferring + | PnInboundState::Validating, + _, + )) => ResourceOwner::ValidatedPeer, + None if self.validated_links.contains_key(&link_id) => ResourceOwner::ValidatedPeer, + None => ResourceOwner::Client, + }; + + let owner_peer_destination = match owner { + ResourceOwner::Offered => admission_record.map(|(_, peer)| peer), + ResourceOwner::ValidatedPeer => self + .validated_links + .get(&link_id) + .copied() + .or_else(|| admission_record.map(|(_, peer)| peer)), + ResourceOwner::Client => supplied_peer_destination, + }; + + let capacity_allows = self.resource_bypasses_limits(owner_peer_destination) + || self.active_limited_resource_count() < self.admission.config().max_inbound_syncs; + let policy_allows = + capacity_allows && self.resource_policy_allows(data_size, owner_peer_destination); + if !policy_allows { + // A first AcceptApp rejection creates no Reticulum lifecycle event, + // so the offer record must be released synchronously here. + if owner == ResourceOwner::Offered { + self.admission.resource_rejected(&link_id); + } + return false; + } + + self.correlations.insert( + key, + ResourceCorrelation { + owner, + peer_destination_hash: owner_peer_destination, + started: false, + completion_dispatched: false, + }, + ); + true + } + + fn resource_policy_allows( + &self, + data_size: usize, + peer_destination_hash: Option<[u8; 16]>, + ) -> bool { + data_size <= self.max_resource_bytes + && (!self.admission.config().from_static_only + || peer_destination_hash + .as_ref() + .is_some_and(|peer| self.admission.is_static_peer(peer))) + } + + fn resource_bypasses_limits(&self, peer_destination_hash: Option<[u8; 16]>) -> bool { + peer_destination_hash.is_some_and(|peer| { + self.admission.is_static_peer(&peer) && !self.admission.config().static_sequential + }) + } + + fn active_limited_resource_count(&self) -> usize { + let correlated = self + .correlations + .values() + .filter(|correlation| !self.resource_bypasses_limits(correlation.peer_destination_hash)) + .count(); + let validating = self + .pending_validations + .values() + .filter(|pending| { + !self.correlations.contains_key(&pending.key) + && !self.resource_bypasses_limits(pending.peer_destination_hash) + }) + .count(); + correlated.saturating_add(validating) + } + + /// Consume the lossless ordered Reticulum accounting stream. + pub(crate) fn handle_accounting_event( + &mut self, + event: LinkManagerAccountingEvent, + ) -> Option { + match event { + LinkManagerAccountingEvent::ResourceEvent(event) => { + self.handle_resource_event(event); + None + } + LinkManagerAccountingEvent::ResourceCompletion(completion) => self.resource_completed( + (completion.link_id, completion.resource_hash), + completion.data, + ), + LinkManagerAccountingEvent::LinkClosed { link_id } => { + self.link_closed(link_id); + None + } + _ => None, + } + } + + fn handle_resource_event(&mut self, event: LinkResourceEvent) { + match event { + LinkResourceEvent::Started { + link_id, + resource_id, + direction: LinkResourceDirection::Inbound, + .. + } => self.resource_started((link_id, resource_id)), + LinkResourceEvent::Concluded { + link_id, + resource_id, + direction: LinkResourceDirection::Inbound, + conclusion, + } => self.resource_concluded((link_id, resource_id), &conclusion), + LinkResourceEvent::Started { .. } + | LinkResourceEvent::Progress { .. } + | LinkResourceEvent::Concluded { .. } => {} + } + } + + fn resource_started(&mut self, key: ResourceKey) { + let Some(correlation) = self.correlations.get_mut(&key) else { + return; + }; + if correlation.started { + return; + } + if correlation.owner == ResourceOwner::Offered + && self.admission.resource_started(&key.0).is_err() + { + self.correlations.remove(&key); + self.admission.resource_failed(&key.0); + return; + } + correlation.started = true; + } + + fn resource_completed(&mut self, key: ResourceKey, data: Vec) -> Option { + let (owner, peer_destination_hash, started, already_dispatched) = + self.correlations.get(&key).map(|correlation| { + ( + correlation.owner, + correlation.peer_destination_hash, + correlation.started, + correlation.completion_dispatched, + ) + })?; + if already_dispatched { + return None; + } + + if !started { + self.correlations.remove(&key); + if owner == ResourceOwner::Offered { + self.admission.resource_failed(&key.0); + } + return None; + } + if owner == ResourceOwner::Offered && self.admission.resource_concluded(&key.0).is_err() { + self.correlations.remove(&key); + self.admission.resource_failed(&key.0); + return None; + } + + let token = self.allocate_validation_token(); + self.pending_validations.insert( + token, + PendingValidation { + key, + owner, + peer_destination_hash, + link_closed: false, + }, + ); + if let Some(correlation) = self.correlations.get_mut(&key) { + correlation.completion_dispatched = true; + } + + Some(PnValidationJob { + token, + link_id: key.0, + data, + allow_multiple: owner != ResourceOwner::Client, + }) + } + + fn resource_concluded(&mut self, key: ResourceKey, conclusion: &LinkResourceConclusion) { + let Some(correlation) = self.correlations.remove(&key) else { + return; + }; + + match conclusion { + LinkResourceConclusion::Complete if correlation.completion_dispatched => { + // ResourceCompletion already moved the offered record into + // Validating and owns the one pending validation token. + } + LinkResourceConclusion::Complete => { + // Defensive fail-clean: a complete conclusion without its + // preceding payload must not leave an accepted offer resident. + if correlation.owner == ResourceOwner::Offered { + self.admission.resource_failed(&key.0); + } + } + LinkResourceConclusion::Rejected => { + self.cancel_pending_for_key(key); + if correlation.owner == ResourceOwner::Offered { + self.admission.resource_rejected(&key.0); + } + } + LinkResourceConclusion::Cancelled => { + self.cancel_pending_for_key(key); + if correlation.owner == ResourceOwner::Offered { + self.admission.resource_cancelled(&key.0); + } + } + LinkResourceConclusion::Failed(_) => { + self.cancel_pending_for_key(key); + if correlation.owner == ResourceOwner::Offered { + self.admission.resource_failed(&key.0); + } + } + } + } + + fn cancel_pending_for_key(&mut self, key: ResourceKey) { + self.pending_validations + .retain(|_, pending| pending.key != key); + } + + fn link_closed(&mut self, link_id: LinkId) { + self.validated_links.remove(&link_id); + self.quarantined_links.remove(&link_id); + self.correlations + .retain(|(correlation_link, _), _| *correlation_link != link_id); + for pending in self.pending_validations.values_mut() { + if pending.key.0 == link_id { + pending.link_closed = true; + } + } + self.admission.link_closed(&link_id); + // Completed validation jobs intentionally survive Link closure. Their + // unique token still permits exactly one result and, for invalid + // stamps, a peer-specific throttle. + } + + /// Claim a worker result exactly once and perform terminal admission + /// cleanup. Valid message ingestion happens only after this succeeds. + pub(crate) fn conclude_validation( + &mut self, + token: PnValidationToken, + link_id: LinkId, + outcome: PnValidationOutcome, + ) -> Option { + let pending = self.pending_validations.get(&token).copied()?; + if pending.key.0 != link_id { + return None; + } + self.pending_validations.remove(&token); + + if matches!( + outcome, + PnValidationOutcome::InvalidStamp | PnValidationOutcome::UnauthorizedMultiple + ) && !pending.link_closed + { + self.quarantined_links.insert(link_id); + } + + let now = self.now(); + let validation_result = match outcome { + PnValidationOutcome::Valid => PnValidationResult::Valid, + PnValidationOutcome::InvalidStamp => PnValidationResult::InvalidStamp, + PnValidationOutcome::UnauthorizedMultiple | PnValidationOutcome::Failed => { + PnValidationResult::Failed + } + }; + + if pending.owner == ResourceOwner::Offered { + match self + .admission + .validation_concluded(&link_id, validation_result, now) + { + Ok(lxmf_core::propagation_admission::PnCleanupResult::NotTracked) + if outcome == PnValidationOutcome::InvalidStamp => + { + self.install_untracked_invalid_throttle(pending.peer_destination_hash, now); + } + Err(_) => { + self.admission.resource_failed(&link_id); + if outcome == PnValidationOutcome::InvalidStamp { + self.install_untracked_invalid_throttle(pending.peer_destination_hash, now); + } + } + Ok(_) => {} + } + } else if outcome == PnValidationOutcome::InvalidStamp { + self.install_untracked_invalid_throttle(pending.peer_destination_hash, now); + } + + Some(PnValidationClaim { link_id, outcome }) + } + + fn install_untracked_invalid_throttle( + &mut self, + peer_destination_hash: Option<[u8; 16]>, + now: Duration, + ) { + if let Some(peer_destination_hash) = peer_destination_hash { + let _ = self + .admission + .install_invalid_stamp_throttle(peer_destination_hash, now); + } + } + + fn allocate_validation_token(&mut self) -> PnValidationToken { + loop { + let token = PnValidationToken(self.next_validation_token); + self.next_validation_token = self.next_validation_token.wrapping_add(1).max(1); + if !self.pending_validations.contains_key(&token) { + return token; + } + } + } + + #[cfg(test)] + fn admission_state(&self, link_id: &LinkId) -> Option { + self.admission + .record(link_id) + .map(lxmf_core::propagation_admission::PnInboundRecord::state) + } + + #[cfg(test)] + fn correlation_count(&self) -> usize { + self.correlations.len() + } + + #[cfg(test)] + fn pending_validation_count(&self) -> usize { + self.pending_validations.len() + } + + #[cfg(test)] + fn throttle_count(&self) -> usize { + self.admission.throttle_count() + } +} + +pub(crate) fn logical_resource_id( + resource_hash: LogicalResourceId, + original_hash: LogicalResourceId, + split: bool, + total_segments: usize, +) -> LogicalResourceId { + if split || total_segments > 1 { + original_hash + } else { + resource_hash + } +} + +pub(crate) fn offer_rejection_response(rejection: PnOfferRejection) -> OfferResponse { + match rejection { + PnOfferRejection::NoIdentity => OfferResponse::ErrorNoIdentity, + PnOfferRejection::NoAccess => OfferResponse::ErrorNoAccess, + PnOfferRejection::InvalidStampThrottle + | PnOfferRejection::SequentialValidationActive + | PnOfferRejection::InboundSyncLimit + | PnOfferRejection::LinkAlreadyTracked + | PnOfferRejection::LinkCandidatePending + | PnOfferRejection::StaleCandidate => OfferResponse::ErrorThrottled, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lxmf_core::propagation_admission::PnInboundAdmissionConfig; + + #[test] + fn accept_resource_tracks_client_correlation() { + let config = PnInboundAdmissionConfig { + sequential_validation: true, + static_sequential: false, + max_inbound_syncs: 4, + from_static_only: false, + }; + let mut runtime = PnInboundRuntime::new(config, None, 64 * 1024); + let link_id = [0x11; 16]; + let resource_id = [0x22; 32]; + assert!(runtime.accept_resource(link_id, resource_id, 128, None)); + // Client deposits without a prior /offer leave no admission record. + assert_eq!(runtime.admission_state(&link_id), None); + assert_eq!(runtime.correlation_count(), 1); + assert_eq!(runtime.pending_validation_count(), 0); + assert_eq!(runtime.throttle_count(), 0); + let _ = PnValidationJob::for_test(vec![1, 2, 3], false); + } +} diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index 5474aed4a..93946cdd6 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -1,6 +1,6 @@ //! Live propagation node serving and sync against remote propagation nodes. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; @@ -14,6 +14,9 @@ use rns_identity::identity::Identity; use rns_transport::messages::TransportMessage; use tokio::sync::{broadcast, mpsc}; +/// Completed host-peer peering PoW (stamp, value) awaiting apply onto `LxmPeer`. +type PeeringKeyResult = ([u8; 16], [u8; 32], u32); + pub struct PropagationBridge { local_dest_hash: [u8; 16], local_node: Arc>, @@ -29,6 +32,9 @@ pub struct PropagationBridge { last_finished_ok: Mutex>, /// Peak sync progress seen before tip collapses Complete/Failed → Idle. peak_progress: Mutex, + /// In-flight peering-key PoW jobs for local-host outbound peer sync. + peering_key_jobs: Mutex>, + peering_key_results: Mutex>, } impl PropagationBridge { @@ -67,9 +73,86 @@ impl PropagationBridge { last_establish_error: Mutex::new(None), last_finished_ok: Mutex::new(None), peak_progress: Mutex::new(0.0), + peering_key_jobs: Mutex::new(HashSet::new()), + peering_key_results: Mutex::new(Vec::new()), }) } + pub fn peering_key_job_inflight(&self, peer_hash: &[u8; 16]) -> bool { + self.peering_key_jobs + .lock() + .map(|jobs| jobs.contains(peer_hash)) + .unwrap_or(false) + } + + pub fn spawn_peering_key_job( + self: &Arc, + peer_hash: [u8; 16], + peering_cost: u8, + peer_identity_hash: [u8; 16], + local_identity_hash: [u8; 16], + ) { + { + let Ok(mut jobs) = self.peering_key_jobs.lock() else { + return; + }; + if !jobs.insert(peer_hash) { + return; + } + } + let bridge = Arc::clone(self); + tokio::spawn(async move { + let result = tokio::task::spawn_blocking(move || { + let mut peering_id = Vec::with_capacity(32); + peering_id.extend_from_slice(&peer_identity_hash); + peering_id.extend_from_slice(&local_identity_hash); + lxmf_core::stamper::generate_stamp( + &peering_id, + peering_cost, + lxmf_core::constants::STAMP_WORKBLOCK_EXPAND_ROUNDS_PEERING, + ) + .map(|(stamp, value)| (peer_hash, stamp, value)) + }) + .await + .ok() + .flatten(); + if let Ok(mut jobs) = bridge.peering_key_jobs.lock() { + jobs.remove(&peer_hash); + } + if let Some(result) = result { + if let Ok(mut slot) = bridge.peering_key_results.lock() { + slot.push(result); + } + } else { + tracing::warn!( + target: "propagation-sync", + peer = %hex::encode(peer_hash), + peering_cost, + "host peer peering-key PoW failed" + ); + } + }); + } + + pub fn drain_peering_key_results(&self, router: &mut LxmRouter) { + let results = self + .peering_key_results + .lock() + .map(|mut slot| std::mem::take(&mut *slot)) + .unwrap_or_default(); + for (peer_hash, stamp, value) in results { + if let Some(peer) = router.peers.get_mut(&peer_hash) { + peer.peering_key = Some((stamp, value)); + tracing::info!( + target: "propagation-sync", + peer = %hex::encode(peer_hash), + value, + "host peer peering key ready" + ); + } + } + } + /// Map rsLXMF sync-task state to UI / probe progress (single source of truth). pub fn progress_for_state(state: SyncTaskState) -> f64 { match state { @@ -181,6 +264,15 @@ impl PropagationBridge { task.request_sync_now_with_policy(policy) } + /// Start outbound peer sync with a fully-built offer policy (local host peer loop). + pub fn start_sync_with_policy(&self, policy: OutboundOfferPolicy) -> bool { + let Ok(mut task) = self.sync_task.lock() else { + return false; + }; + self.clear_sticky_errors(); + task.request_sync_now_with_policy(policy) + } + pub fn cancel_sync(&self) { // Tip `cancel_peer_sync` leaves Idle + clears terminal_result. Do not force // Failed afterward — that blocks the next `request_sync_now_*` (Idle required). @@ -265,27 +357,44 @@ impl PropagationBridge { self.last_finished_ok.lock().ok().and_then(|slot| *slot) } - pub fn tick(&self, known_identities: &HashMap) { + /// Drain sync events and return `Some((success, peer_hash))` when a peer sync just finished. + pub fn tick(&self, known_identities: &HashMap) -> Option<(bool, [u8; 16])> { let terminal = if let Ok(mut task) = self.sync_task.lock() { // Sample before drain/tick: tip collapses Complete|Failed → Idle in tick(). self.note_peak_progress(Self::progress_for_state(task.state)); task.drain_events(known_identities); self.note_peak_progress(Self::progress_for_state(task.state)); task.tick(); - task.take_terminal_peer_result() - .map(|result| matches!(result.state, PeerSyncTerminalState::Complete)) + task.take_terminal_peer_result().map(|result| { + ( + matches!(result.state, PeerSyncTerminalState::Complete), + result.peer_hash, + ) + }) } else { None }; - if let Some(ok) = terminal { + if let Some((ok, peer_hash)) = terminal { if let Ok(mut slot) = self.last_finished_ok.lock() { *slot = Some(ok); } - if !ok { + if ok { + let peak = self.last_peak_progress(); + // Peak ≥ Transferring (70) means WantSome/WantAll pulled blobs; lower ≈ HaveAll. + let retrieve_mode = if peak >= 70.0 { "transfer" } else { "have_all" }; + tracing::info!( + target: "propagation-retrieve", + pn_hash = %hex::encode(peer_hash), + peak_progress = peak, + retrieve_mode, + "remote/host PN sync Completes" + ); + } else { let peak = self.last_peak_progress(); self.stamp_terminal_failure_from_peak(peak); } } + terminal } pub fn spawn_sync_progress_emitter( @@ -397,9 +506,13 @@ impl PropagationBridge { } if !active && (progress >= 99.0 || finished_ok.is_some()) { if finished_ok == Some(true) { + let peak = bridge.last_peak_progress(); + let retrieve_mode = if peak >= 70.0 { "transfer" } else { "have_all" }; tracing::info!( - target: "propagation-sync", + target: "propagation-retrieve", progress, + peak_progress = peak, + retrieve_mode, "propagation sync completed successfully" ); } else if let Some(ref msg) = fail_message { @@ -573,4 +686,32 @@ mod tests { assert_eq!(bridge.last_offer_error(), None); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn source_host_peer_sync_idle_gate_and_policy_start() { + let live = include_str!("live.rs"); + assert!( + live.contains("drive_local_host_peer_sync"), + "maintenance must drive host peer sync when serving" + ); + assert!( + live.contains("is_local_serving()") + && live.contains("!propagation.sync_active()") + && live.contains("propagation_sync_target().is_none()"), + "peer sync tick must require serving + idle + no user sync target" + ); + assert!( + live.contains("start_sync_with_policy"), + "host peer loop must start policy-aware sync" + ); + let bridge = include_str!("propagation_bridge.rs"); + assert!( + bridge.contains("start_sync_with_policy"), + "bridge must expose policy sync for host peer loop" + ); + assert!( + bridge.contains("propagation-retrieve"), + "sync Completes must log retrieve telemetry" + ); + } } diff --git a/reticulum-sidecar/src/stack/propagation_serve.rs b/reticulum-sidecar/src/stack/propagation_serve.rs index 40eceee24..1ee5b9214 100644 --- a/reticulum-sidecar/src/stack/propagation_serve.rs +++ b/reticulum-sidecar/src/stack/propagation_serve.rs @@ -1,22 +1,30 @@ -//! Network-visible LXMF propagation-node serve path (`/offer` + `/get`). +//! Network-visible LXMF propagation-node serve path (`/offer` + `/get` + Resource ingress). use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use lxmf_core::handlers::PropagationRequestHandler; +use lxmf_core::message::LxMessage; +use lxmf_core::propagation_admission::PnInboundAdmissionConfig; use lxmf_core::propagation_node::PropagationNode; +use lxmf_core::stamper; use rns_identity::destination::Destination; use rns_identity::identity::Identity; -use rns_runtime::link_manager::{LinkManager, register_destination}; +use rns_runtime::link_manager::{LinkManager, LinkManagerCommand, register_destination}; +use rns_runtime::prelude::{CloseReason, ResourceStrategy}; use rns_transport::messages::TransportMessage; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, watch}; + +use super::pn_hosting_policy::PnHostingPolicy; +use super::pn_inbound::{ + PnInboundRuntime, PnValidationJob, PnValidationOutcome, logical_resource_id, +}; pub const LXMF_PROPAGATION_APP: &str = "lxmf.propagation"; /// Owns the inbound LinkManager task for local PN hosting. pub struct PropagationServeHandle { active: AtomicBool, - stop_tx: Mutex>>, + stop_tx: Mutex>>, } impl PropagationServeHandle { @@ -32,19 +40,21 @@ impl PropagationServeHandle { if let Ok(mut slot) = self.stop_tx.lock() && let Some(tx) = slot.take() { - let _ = tx.send(()); + let _ = tx.send(true); } } - /// Register `lxmf.propagation` and spawn LinkManager with `/offer` + `/get` handlers. + /// Register `lxmf.propagation` and spawn LinkManager with `/offer`, `/get`, and Resource ingress. pub fn start( &self, transport_tx: &mpsc::Sender, identity: &Identity, propagation_dest_hash: [u8; 16], - local_node: Arc>, + local_node: &Arc>, + policy: &PnHostingPolicy, ) -> Result<(), String> { self.stop(); + let local_node = Arc::clone(local_node); let delivery_rx = register_destination(transport_tx, propagation_dest_hash, LXMF_PROPAGATION_APP); @@ -61,40 +71,96 @@ impl PropagationServeHandle { Some(prop_signing_key), ); - let (resource_tx, _resource_rx) = mpsc::channel::<(Vec, [u8; 16])>(256); - prop_link_mgr.set_resource_completed_channel(resource_tx); + let static_peers = parse_static_peer_hashes(&policy.static_peers); + let max_resource_bytes = policy.sync_limit_kb.saturating_mul(1000); + let min_stamp_cost = policy.min_stamp_cost(); + let admission_config = PnInboundAdmissionConfig { + sequential_validation: true, + static_sequential: false, + max_inbound_syncs: 4, + from_static_only: policy.from_static_only, + }; + let admission = Arc::new(Mutex::new(PnInboundRuntime::new( + admission_config, + static_peers, + max_resource_bytes, + ))); + + prop_link_mgr.set_resource_strategy(ResourceStrategy::AcceptApp); - let pn_for_handler = local_node; + let accept_link_identities = prop_link_mgr.link_identities_handle(); + let admission_for_resources = Arc::clone(&admission); + prop_link_mgr.set_resource_accept_handler(move |link_id, advertisement| { + let remote_identity_hash = accept_link_identities + .lock() + .ok() + .and_then(|identities| identities.get(&link_id).copied()); + let resource_id = logical_resource_id( + advertisement.resource_hash, + advertisement.original_hash, + advertisement.flags.split, + advertisement.total_segments, + ); + admission_for_resources + .lock() + .map(|mut runtime| { + runtime.accept_resource( + link_id, + resource_id, + advertisement.data_size, + remote_identity_hash, + ) + }) + .unwrap_or(false) + }); + + let (accounting_tx, mut accounting_rx) = mpsc::unbounded_channel(); + prop_link_mgr.set_accounting_event_channel(accounting_tx); + + let pn_for_handler = Arc::clone(&local_node); let offer_path_hash = rns_crypto::sha::truncated_hash(lxmf_core::constants::OFFER_REQUEST_PATH.as_bytes()); let get_path_hash = rns_crypto::sha::truncated_hash(lxmf_core::constants::MESSAGE_GET_PATH.as_bytes()); let link_identities = prop_link_mgr.link_identities_handle(); let local_identity_hash = identity.hash; + let admission_for_handler = Arc::clone(&admission); prop_link_mgr.set_request_handler(move |link_id, path_hash, data| { let remote_identity_hash = link_identities .lock() .ok() .and_then(|ids| ids.get(&link_id).copied()); let remote_identity_ref = remote_identity_hash.as_ref(); - let client_dest_hash = remote_identity_hash - .map(|identity_hash| { - Destination::hash_from_name_and_identity("lxmf.delivery", Some(&identity_hash)) - }) - .unwrap_or([0; 16]); - let handler = PropagationRequestHandler::new(local_identity_hash); if path_hash == offer_path_hash { tracing::info!(target: "propagation-serve", "handling /offer request"); - let Ok(mut node) = pn_for_handler.lock() else { - tracing::warn!( - target: "propagation-serve", - "pn lock failed; dropping /offer request" - ); + return handle_pn_offer_request( + &admission_for_handler, + &pn_for_handler, + local_identity_hash, + link_id, + remote_identity_hash, + &data, + ); + } + if path_hash == get_path_hash { + if admission_for_handler + .lock() + .map(|runtime| runtime.is_link_quarantined(&link_id)) + .unwrap_or(true) + { return None; - }; - Some(handler.handle_offer_request(remote_identity_ref, &data, &mut node)) - } else if path_hash == get_path_hash { + } tracing::info!(target: "propagation-serve", "handling /get request"); + let client_dest_hash = remote_identity_hash + .map(|identity_hash| { + Destination::hash_from_name_and_identity( + "lxmf.delivery", + Some(&identity_hash), + ) + }) + .unwrap_or([0; 16]); + let handler = + lxmf_core::handlers::PropagationRequestHandler::new(local_identity_hash); let action = { let Ok(mut node) = pn_for_handler.lock() else { tracing::warn!( @@ -110,35 +176,74 @@ impl PropagationServeHandle { &mut node, ) }; - Some(action.into_response()) - } else { - tracing::debug!( - target: "propagation-serve", - path = %hex::encode(path_hash), - "unknown request path" - ); - None + return Some(action.into_response()); } + tracing::debug!( + target: "propagation-serve", + path = %hex::encode(path_hash), + "unknown request path" + ); + None }); - let (stop_tx, mut stop_rx) = tokio::sync::oneshot::channel(); + let (link_cmd_tx, link_cmd_rx) = mpsc::channel::(256); + let (stop_tx, stop_rx) = watch::channel(false); if let Ok(mut slot) = self.stop_tx.lock() { *slot = Some(stop_tx); } self.active.store(true, Ordering::SeqCst); + let pn_hash_hex = hex::encode(propagation_dest_hash); + let admission_for_loop = Arc::clone(&admission); + let local_node_for_loop = Arc::clone(&local_node); + let link_cmd_for_close = link_cmd_tx; + let mut stop_rx_accounting = stop_rx.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + biased; + changed = stop_rx_accounting.changed() => { + if changed.is_err() || *stop_rx_accounting.borrow() { + break; + } + } + event = accounting_rx.recv() => { + let Some(event) = event else { break; }; + let job = admission_for_loop + .lock() + .ok() + .and_then(|mut runtime| runtime.handle_accounting_event(event)); + if let Some(job) = job { + spawn_propagation_validation( + job, + max_resource_bytes, + min_stamp_cost, + Arc::clone(&admission_for_loop), + Arc::clone(&local_node_for_loop), + pn_hash_hex.clone(), + link_cmd_for_close.clone(), + ); + } + } + } + } + tracing::info!(target: "propagation-serve", "accounting loop stopped"); + }); + + let mut stop_rx_link = stop_rx; tokio::spawn(async move { tokio::select! { - () = prop_link_mgr.run() => { + () = prop_link_mgr.run_with_commands(link_cmd_rx) => { tracing::warn!( target: "propagation-serve", "LinkManager run completed unexpectedly (not stop-requested)" ); } - _ = &mut stop_rx => { - tracing::info!(target: "propagation-serve", "LinkManager stop requested"); + changed = stop_rx_link.changed() => { + let _ = changed; } } + tracing::info!(target: "propagation-serve", "LinkManager stop requested"); }); Ok(()) @@ -150,3 +255,294 @@ impl Default for PropagationServeHandle { Self::new() } } + +fn parse_static_peer_hashes(peers: &[String]) -> Vec<[u8; 16]> { + peers + .iter() + .filter_map(|peer| { + let bytes = hex::decode(peer.trim()).ok()?; + <[u8; 16]>::try_from(bytes.as_slice()).ok() + }) + .collect() +} + +fn handle_pn_offer_request( + runtime: &Arc>, + node: &Arc>, + local_identity_hash: [u8; 16], + link_id: [u8; 16], + remote_identity_hash: Option<[u8; 16]>, + data: &[u8], +) -> Option> { + let candidate = runtime + .lock() + .ok()? + .preflight_offer(link_id, remote_identity_hash); + let candidate = match candidate { + Ok(candidate) => candidate, + Err(response) => return Some(PropagationNode::encode_offer_response(&response)), + }; + + let evaluation = if let Ok(node) = node.lock() { + node.evaluate_offer_request(data, &local_identity_hash, &candidate) + } else { + if let Ok(mut runtime) = runtime.lock() { + runtime.discard_offer(candidate); + } + return None; + }; + + match evaluation { + Ok(evaluation) => { + let response = match runtime.lock() { + Ok(mut runtime) => match runtime.commit_offer(candidate, &evaluation) { + Ok(()) => evaluation.into_wire_response(), + Err(response) => response, + }, + Err(_) => return None, + }; + Some(PropagationNode::encode_offer_response(&response)) + } + Err(error) => { + if let Ok(mut runtime) = runtime.lock() { + runtime.discard_offer(candidate); + } + Some(PropagationNode::encode_offer_response( + &error.wire_response(), + )) + } + } +} + +struct ValidatedPnEntry { + lxmf_data: Vec, + stamp_value: u32, + stamp_data: [u8; 32], + transient_id: [u8; 32], +} + +fn validate_pn_resource_job( + job: PnValidationJob, + max_transfer_bytes: usize, + min_cost: u8, +) -> (PnValidationOutcome, Vec, usize) { + let allow_multiple = job.allow_multiple(); + let data = job.into_data(); + + let (_, entries) = + match LxMessage::unpack_propagation_wrapper_bounded(&data, max_transfer_bytes) { + Ok(parsed) => parsed, + Err(error) => { + tracing::warn!( + target: "propagation-deposit", + error = %error, + "failed to unpack propagation Resource" + ); + return (PnValidationOutcome::Failed, Vec::new(), 0); + } + }; + + if !allow_multiple && entries.len() > 1 { + return ( + PnValidationOutcome::UnauthorizedMultiple, + Vec::new(), + entries.len(), + ); + } + + let mut validated = Vec::with_capacity(entries.len()); + let mut rejected = 0usize; + for entry in entries { + match stamper::validate_pn_stamp(&entry, min_cost) { + Some((transient_id, lxmf_data, stamp_value, stamp_data)) => { + validated.push(ValidatedPnEntry { + lxmf_data, + stamp_value, + stamp_data, + transient_id, + }); + } + None => rejected += 1, + } + } + + let outcome = if rejected == 0 { + PnValidationOutcome::Valid + } else { + PnValidationOutcome::InvalidStamp + }; + (outcome, validated, rejected) +} + +fn spawn_propagation_validation( + job: PnValidationJob, + max_transfer_bytes: usize, + min_cost: u8, + admission: Arc>, + local_node: Arc>, + pn_hash_hex: String, + link_cmd_tx: mpsc::Sender, +) { + let token = job.token(); + let link_id = job.link_id(); + tokio::spawn(async move { + let (outcome, entries, rejected) = match tokio::task::spawn_blocking(move || { + validate_pn_resource_job(job, max_transfer_bytes, min_cost) + }) + .await + { + Ok(result) => result, + Err(error) => { + tracing::warn!( + target: "propagation-deposit", + link_id = %hex::encode(link_id), + error = %error, + "propagation validation worker failed" + ); + (PnValidationOutcome::Failed, Vec::new(), 0) + } + }; + + let claim = admission + .lock() + .ok() + .and_then(|mut runtime| runtime.conclude_validation(token, link_id, outcome)); + let Some(claim) = claim else { + tracing::debug!( + target: "propagation-deposit", + link_id = %hex::encode(link_id), + "ignoring stale or duplicate propagation validation result" + ); + return; + }; + + let mut accepted = 0usize; + if let Ok(mut node) = local_node.lock() { + for entry in &entries { + let stamp_value = u8::try_from(entry.stamp_value).unwrap_or(u8::MAX); + if node.accept_stamped_propagated_blob( + &entry.lxmf_data, + &entry.stamp_data, + stamp_value, + ) { + accepted += 1; + tracing::info!( + target: "propagation-deposit", + pn_hash = %pn_hash_hex, + transient_id = %hex::encode(entry.transient_id), + stamp_value, + blob_len = entry.lxmf_data.len(), + "local PN accepted stamped propagated blob" + ); + } + } + } + + tracing::info!( + target: "propagation-deposit", + link_id = %hex::encode(claim.link_id()), + pn_hash = %pn_hash_hex, + accepted, + rejected, + outcome = ?claim.outcome(), + "processed inbound propagation Resource" + ); + + if claim.should_close_link() { + let link_id = claim.link_id(); + let _ = link_cmd_tx + .send(LinkManagerCommand::CloseLink { + link_id, + reason: CloseReason::DestinationClosed, + send_teardown: true, + }) + .await; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{SystemTime, UNIX_EPOCH}; + + #[test] + fn source_wires_resource_accept_and_consumes_accounting() { + let src = include_str!("propagation_serve.rs"); + assert!( + src.contains("set_resource_accept_handler"), + "serve must install AcceptApp resource handler" + ); + assert!( + src.contains("set_accounting_event_channel"), + "serve must drain accounting (ResourceCompletion)" + ); + // Discarded channel binding used the unused-prefix form (underscore + resource_rx). + let discarded = format!("_{}", "resource_rx"); + assert!( + !src.contains(&discarded), + "must not discard resource_rx; use accounting stream" + ); + assert!( + src.contains("accept_stamped_propagated_blob"), + "validated deposits must enter PropagationNode store" + ); + assert!( + src.contains("evaluate_offer_request"), + "/offer must go through admission + evaluate_offer_request" + ); + assert!( + src.contains("handle_pn_offer_request"), + "/offer must use PnInboundAdmission preflight/commit" + ); + } + + #[test] + fn parse_static_peers_skips_invalid() { + let peers = parse_static_peer_hashes(&[ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + "not-hex".into(), + "bb".into(), + ]); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0], [0xaa; 16]); + } + + #[test] + fn stamped_blob_enters_shared_store_and_bad_stamp_rejected() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let dir = std::env::temp_dir().join(format!("mesh-client-pn-ingress-{nanos}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + + let mut node = PropagationNode::with_storage( + lxmf_core::propagation_node::PropagationNodeConfig { + min_stamp_cost: 0, + ..Default::default() + }, + [0xAA; 16], + dir.clone(), + ) + .expect("node"); + + let mut lxmf_data = vec![0xBB; 16]; + lxmf_data.extend_from_slice(&[0xCC; 64]); + let stamp = [0x5A; 32]; + assert!(node.accept_stamped_propagated_blob(&lxmf_data, &stamp, 0)); + assert_eq!(node.message_count(), 1); + + // Truncated stamped entry fails validate_pn_stamp (needs ≥32-byte stamp trailer). + let bad = validate_pn_resource_job( + PnValidationJob::for_test(vec![0x01, 0x02, 0x03], false), + 10_000, + 0, + ); + assert_eq!(bad.0, PnValidationOutcome::Failed); + assert!(bad.1.is_empty()); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts b/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts index d408b3a06..770e4e426 100644 --- a/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts @@ -36,6 +36,13 @@ describe('useReticulumRuntime inbound LXMF catch-up wiring (source contract)', ( ); }); + it('catches up once after remote propagation sync Completes', () => { + expect(SOURCE).toMatch( + /wasSyncActive[\s\S]*?p\.active === false[\s\S]*?normalizedProgress >= 100[\s\S]*?catchUpRecentInboundLxmf\(\{ reason: 'propagation_sync' \}\)/, + ); + expect(SOURCE).toContain('propagation-retrieve catch-up after sync Completes'); + }); + it('schedules periodic catch-up while the stack is active', () => { expect(SOURCE).toMatch(/void catchUpRecentInboundLxmf\(\{/); expect(SOURCE).toContain("reason: 'periodic'"); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 334291c20..4e70941fd 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -698,7 +698,7 @@ export function useReticulumRuntime(): ProtocolRuntime { const catchUpRecentInboundLxmf = useCallback( async (opts?: { sinceTs?: number; sinceSeq?: number; reason?: string }) => { - if (!identityId) return; + if (!identityId) return null; const outcome = await runInboundLxmfCatchUp({ identityId, ingest: ingestLxmfPayload, @@ -706,11 +706,12 @@ export function useReticulumRuntime(): ProtocolRuntime { ...(opts?.sinceSeq != null ? { sinceSeq: opts.sinceSeq } : {}), ...(opts?.reason != null ? { reason: opts.reason } : {}), }); - if (!outcome) return; + if (!outcome) return null; noteReticulumInboundCatchUp(outcome.count); if (outcome.watermarkTs != null) { advanceReticulumInboundCatchUpWatermark(outcome.watermarkTs, outcome.watermarkSeq); } + return outcome; }, [identityId, ingestLxmfPayload], ); @@ -831,8 +832,39 @@ export function useReticulumRuntime(): ProtocolRuntime { typeof evt.payload === 'object' ) { const p = evt.payload as { progress?: number; active?: boolean; message?: string | null }; + const wasSyncActive = useReticulumPropagationStore.getState().sync.active; applyPropagationSyncEvent(p); scheduleDebouncedDiagnosticsRefresh(); + // Sync Completes can leave inbound LXMF only in the sidecar ring until the next + // periodic catch-up — pull immediately so Chat updates without waiting ~60s. + const normalizedProgress = + typeof p.progress === 'number' && Number.isFinite(p.progress) + ? p.progress <= 1 + ? p.progress * 100 + : Math.min(100, p.progress) + : 0; + if ( + wasSyncActive && + p.active === false && + normalizedProgress >= 100 && + (p.message == null || p.message === '') + ) { + void catchUpRecentInboundLxmf({ reason: 'propagation_sync' }) + .then((outcome) => { + // null = empty ring / no watermark advance (HaveAll with no new mail). + const count = outcome?.count ?? 0; + console.debug( + `[useReticulumRuntime] propagation-retrieve catch-up after sync Completes count=${count}${ + outcome == null ? ' (empty ring)' : '' + }`, + ); + }) + .catch((e: unknown) => { + console.warn( + '[useReticulumRuntime] propagation_sync catch-up failed ' + errLikeToLogString(e), + ); + }); + } } if (evt.type === 'propagation.discovered' && evt.payload && typeof evt.payload === 'object') { const p = evt.payload as { From 6a8d65b0d7dd5ba38984107a6f8dcf2faa8a18a8 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 16:44:35 -0600 Subject: [PATCH 3/6] fix(ci): stamp test-build installer filenames with -run{N} Same-semver Actions downloads were easy to mix up across runs. Rename test-only AppImage/deb/rpm/DMG/ZIP/Setup/Flatpak artifacts after pack (smoke), keep release/tag names clean, and embed Flatpak build info. --- .github/workflows/build.yaml | 8 +- .github/workflows/flatpak.yaml | 43 ++- .gitignore | 2 + docs/ci-cd.md | 34 +- docs/release-process.md | 4 +- docs/troubleshooting.md | 2 +- org.coloradomesh.MeshClient.yml | 8 +- scripts/check-flatpak.mjs | 57 +++ scripts/rename-test-build-artifacts.mjs | 344 +++++++++++++++++++ scripts/rename-test-build-artifacts.test.mjs | 167 +++++++++ scripts/test-win-nsis-install.mjs | 21 +- scripts/verify-win-packaging.mjs | 34 +- scripts/win-setup-installer-names.mjs | 71 ++++ scripts/win-setup-installer-names.test.mjs | 76 ++++ scripts/write-flatpak-ci-build-info.mjs | 102 ++++++ scripts/write-flatpak-ci-build-info.test.mjs | 75 ++++ 16 files changed, 990 insertions(+), 58 deletions(-) create mode 100644 scripts/rename-test-build-artifacts.mjs create mode 100644 scripts/rename-test-build-artifacts.test.mjs create mode 100644 scripts/win-setup-installer-names.mjs create mode 100644 scripts/win-setup-installer-names.test.mjs create mode 100644 scripts/write-flatpak-ci-build-info.mjs create mode 100644 scripts/write-flatpak-ci-build-info.test.mjs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 51f18054d..717925600 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -151,6 +151,12 @@ jobs: APPLE_TEAM_ID: ${{ matrix.os == 'macos-latest' && secrets.APPLE_TEAM_ID || '' }} run: ${{ matrix.build_script }} + # Stamp downloadable installer basenames with -run{N} so same-semver test + # builds do not overwrite each other after unzip. Runs after dist:* verify + # (unstamped names) and before upload (packaging-smoke downloads stamped names). + - name: Rename test-build installers with run number + run: node scripts/rename-test-build-artifacts.mjs --root release + # Stage under release/ so upload-artifact@v7's least-common-ancestor stays # release/ (paths outside release/ nest installers as release/release/*.exe and # break packaging-smoke, which downloads to path: release). @@ -198,7 +204,7 @@ jobs: with: name: mesh-client-windows-${{ github.sha }} path: | - # Per-arch NSIS installers: Mesh-client Setup {version}.exe + {version}-arm64.exe + # Per-arch NSIS installers (test builds: …-run{N}.exe / …-run{N}-arm64.exe) release/*.exe release/READ-ME-FIRST-test-build.md retention-days: 30 diff --git a/.github/workflows/flatpak.yaml b/.github/workflows/flatpak.yaml index 16cd1f146..7afa30a97 100644 --- a/.github/workflows/flatpak.yaml +++ b/.github/workflows/flatpak.yaml @@ -1,5 +1,6 @@ name: Build Flatpak -run-name: Build Flatpak${{ github.event_name == 'workflow_dispatch' && ' (no release)' || '' }} +# Dual-purpose: manual dispatch = test (no release); tag push = publish to GitHub Release. +run-name: ${{ github.event_name == 'workflow_dispatch' && 'Build Flatpak (no release)' || 'Build Flatpak' }} on: workflow_dispatch: @@ -27,9 +28,13 @@ jobs: id: compare env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_LABEL: >- + ${{ github.event_name == 'workflow_dispatch' + && 'Build Flatpak (no release)' + || 'Build Flatpak' }} run: > node scripts/ci-schema-release-compare.mjs - --workflow-label "Build Flatpak" + --workflow-label "${WORKFLOW_LABEL}" --write-readme READ-ME-FIRST-flatpak.md - name: Upload READ-ME-FIRST warning @@ -157,8 +162,22 @@ jobs: console.log('generated-sources storeDir YAML: ok'); EOF + # Embed buildChannel=test|release + Actions runUrl into the Flatpak main bundle. + - name: Stamp CI build info + env: + MESH_CLIENT_BUILD_CHANNEL: ${{ github.event_name == 'workflow_dispatch' && 'test' || 'release' }} + MESH_CLIENT_BUILD_WORKFLOW: >- + ${{ github.event_name == 'workflow_dispatch' + && 'Build Flatpak (no release)' + || 'Build Flatpak' }} + run: | + set -euo pipefail + node scripts/ci-write-build-info-env.mjs + node scripts/write-flatpak-ci-build-info.mjs + # flatpak/flatpak-github-actions v6 appends -${arch} to the artifact name on upload; # keep bundle arch-agnostic here to avoid org.coloradomesh.MeshClient-aarch64-aarch64.flatpak. + # upload-artifact: false — we upload once after smoke (+ optional -run{N} rename on dispatch). - uses: flatpak/flatpak-github-actions/flatpak-builder@401fe28a8384095fc1531b9d320b292f0ee45adb with: bundle: org.coloradomesh.MeshClient.flatpak @@ -166,7 +185,7 @@ jobs: arch: ${{ matrix.arch }} branch: stable cache-key: flatpak-builder-${{ matrix.arch }}-${{ github.sha }} - upload-artifact: true + upload-artifact: false - name: Smoke test Flatpak install run: | @@ -195,8 +214,22 @@ jobs: exit 1 fi - # flatpak-builder already uploaded the .flatpak; attach the schema warning beside it - # for workflow_dispatch test builds (and tag runs) downloading Actions artifacts. + # Test (dispatch) only: stamp downloadable basename with -run{N}. Tag releases stay clean. + - name: Rename test Flatpak bundle with run number + if: github.event_name == 'workflow_dispatch' + run: node scripts/rename-test-build-artifacts.mjs --flatpak . + + # Artifact name matches prior flatpak-builder upload style (bundle + -${arch}). + - name: Upload Flatpak bundle + uses: actions/upload-artifact@v7 + with: + name: org.coloradomesh.MeshClient.flatpak-${{ matrix.arch }} + path: | + org.coloradomesh.MeshClient*.flatpak + if-no-files-found: error + retention-days: 30 + + # Schema warning beside the bundle for Actions downloads. - name: Upload READ-ME-FIRST with Flatpak artifacts uses: actions/upload-artifact@v7 with: diff --git a/.gitignore b/.gitignore index 5e3446ce1..643127943 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,8 @@ coverage/ .rtk .githooks/bin/ flatpak/generated-sources.json +# Written by scripts/write-flatpak-ci-build-info.mjs in Flatpak CI +flatpak/ci-build-info.json # Rust reticulum sidecar (cargo build output) reticulum-sidecar/target/ diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 93d013e52..9ee1b0eb3 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -124,10 +124,11 @@ A matrix builds **x86_64** and **aarch64** in parallel. Both use the same privil 1. **`schema-release-compare`** — same compare as Build Binaries / Release; uploads `READ-ME-FIRST-flatpak.md` and feeds `write-schema-upgrade-notice.mjs` so bumped schemas embed `SCHEMA-UPGRADE.txt` under Flatpak `resources/` 2. Builds the Reticulum sidecar on bare Ubuntu runners, then generates `flatpak/generated-sources.json` via `flatpak-node-generator` -3. Builds from `org.coloradomesh.MeshClient.yml` with offline pnpm sources -4. Uploads `org.coloradomesh.MeshClient-{x86_64,aarch64}.flatpak` artifacts plus per-arch `flatpak-schema-warning-*` (the READ-ME-FIRST note for Actions downloads) +3. Stamps CI build info (`test` on dispatch / `release` on tag), builds from `org.coloradomesh.MeshClient.yml` with offline pnpm sources +4. Smoke-installs the unstamped local bundle; on **dispatch only**, renames to `org.coloradomesh.MeshClient-run{N}.flatpak` +5. Uploads `org.coloradomesh.MeshClient.flatpak-{x86_64,aarch64}` artifacts (file basename stamped on test builds) plus per-arch `flatpak-schema-warning-*` -On **version tag pushes**, a `publish` job attaches both bundles to the GitHub Release. aarch64 is the primary ARM Linux install path (release `build.yaml` only produces x86_64 AppImage/deb/rpm). +On **version tag pushes**, a `publish` job attaches both **clean-named** bundles to the GitHub Release. aarch64 is the primary ARM Linux install path (release `build.yaml` only produces x86_64 AppImage/deb/rpm). `flatpak/generated-sources.json` is generated automatically in CI by `flatpak-node-generator` before each build — it does not need to be committed to the repo. For local builds, generate it manually; see [development-environment.md](development-environment.md) for steps. If submitting to Flathub's dedicated submission repo, the file must be committed there. @@ -313,16 +314,31 @@ CI focuses on lint, typecheck, build, Flatpak metadata validation, and coverage ### Build channel stamp (test vs release) -**Build Binaries** (`build.yaml`) and **Release** (`release.yaml`) run `scripts/ci-write-build-info-env.mjs` before packaging. That writes a JSON `MESH_CLIENT_BUILD_INFO` blob into `$GITHUB_ENV`, which `scripts/esbuild-main-build.mjs` embeds via esbuild `--define` into the main process. +**Build Binaries** (`build.yaml`), **Release** (`release.yaml`), and **Build Flatpak** (`flatpak.yaml`) run `scripts/ci-write-build-info-env.mjs` before packaging. That writes a JSON `MESH_CLIENT_BUILD_INFO` blob into `$GITHUB_ENV`, which `scripts/esbuild-main-build.mjs` embeds via esbuild `--define` into the main process. Flatpak also writes `flatpak/ci-build-info.json` (gitignored) so the sandbox `pnpm run build` sees the same env. -| Channel | Workflow | Support-bundle `manifest.json` | -| --------- | ------------------------------ | --------------------------------------------------------- | -| `test` | Build Binaries (no release) | `buildChannel: "test"` + `buildInfo.runUrl` (Actions run) | -| `release` | Build/Release Electron App | `buildChannel: "release"` + `tag` + `buildInfo.runUrl` | -| `local` | unmarked `pnpm run dist` / dev | `buildChannel: "local"` only | +| Channel | Workflow | Support-bundle `manifest.json` | +| --------- | ------------------------------------------------------- | --------------------------------------------------------- | +| `test` | Build Binaries (no release); Build Flatpak (no release) | `buildChannel: "test"` + `buildInfo.runUrl` (Actions run) | +| `release` | Build/Release Electron App; Build Flatpak (tag) | `buildChannel: "release"` + `tag` + `buildInfo.runUrl` | +| `local` | unmarked `pnpm run dist` / dev / local Flatpak | `buildChannel: "local"` only | `appVersion` remains `package.json` semver (unchanged). Use `buildChannel` + `buildInfo.runUrl` when triaging Export for GitHub / Developer zips so a test binary is not mistaken for an official release. Startup logs include a compact fragment (`buildChannel=… run=… runId=… sha=…`). +**Which binary am I running?** If a tester says they downloaded Actions run N but the app reports a different run, open **Export for GitHub** → `manifest.json` → `buildInfo.runUrl` (authoritative), or the `[Startup] runtime … run=…` line in the app log. Same-semver test installers used to share identical filenames across runs; test builds now stamp `-run{N}` into downloadable basenames (see below). + +### Test-build installer filenames (`-run{N}`) + +**Test / one-off only** — never official GitHub Release assets: + +| Workflow | When | Filename stamp | +| -------------- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `build.yaml` | Always (dispatch-only) | After `dist:*`, `scripts/rename-test-build-artifacts.mjs` renames AppImage/deb/rpm/DMG/ZIP/Setup under `release/` to include `-run{GITHUB_RUN_NUMBER}` (e.g. `Mesh-client-5.26.0-run214.AppImage`, `Mesh-client Setup 5.26.0-run214.exe`) | +| `flatpak.yaml` | `workflow_dispatch` only | After in-job smoke, rename to `org.coloradomesh.MeshClient-run{N}.flatpak`, then upload | +| `flatpak.yaml` | tag `v*` (release publish) | Clean `org.coloradomesh.MeshClient.flatpak` (no `-run{N}`) | +| `release.yaml` | tag publish | Clean electron-builder names (no rename step) | + +`packaging-smoke` on Build Binaries downloads **stamped** names (Windows Setup matcher accepts default or `-run{N}`). Flatpak smoke always uses the unstamped local path **before** rename. Manual Flatpak runs use Actions run title **`Build Flatpak (no release)`**; tag runs use **`Build Flatpak`**. + ### Schema compare vs last official release **Build Binaries**, **Build Flatpak**, and **Release** start with a **`schema-release-compare`** job (`scripts/ci-schema-release-compare.mjs`) that: diff --git a/docs/release-process.md b/docs/release-process.md index e9fc190d0..f22aabab1 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -169,8 +169,8 @@ Build jobs also run `verify-reticulum-sidecar-staged.mjs` after staging sidecars 1. **`schema-release-compare`** — compares this SHA’s schema to the last published release; uploads `READ-ME-FIRST-flatpak.md` (included again beside Flatpak Actions artifacts) 2. **`reticulum-sidecar`** — builds `mesh-client-reticulum` per arch (x86_64 on `ubuntu-latest`, aarch64 on `ubuntu-24.04-arm`) with full RNS stack features -3. **`flatpak`** — writes schema upgrade notice into `resources/` when bumped, generates offline pnpm sources, builds `org.coloradomesh.MeshClient.flatpak` per arch inside the Flathub freedesktop 24.08 container, smoke-installs the bundle -4. **`publish`** — attaches both `.flatpak` files to the GitHub Release with **`draft: true`** (does not auto-publish an existing draft) +3. **`flatpak`** — stamps CI build info, writes schema upgrade notice when bumped, generates offline pnpm sources, builds `org.coloradomesh.MeshClient.flatpak` per arch inside the Flathub freedesktop 24.08 container, smoke-installs the unstamped bundle (manual **Build Flatpak (no release)** dispatch also renames downloadable artifacts to `…-run{N}.flatpak`; tag runs keep clean names) +4. **`publish`** (tag only) — attaches both clean-named `.flatpak` files to the GitHub Release with **`draft: true`** (does not auto-publish an existing draft) Both tag-triggered workflows must complete before the release is fully populated. Flatpak bundles often arrive a few minutes after the Electron artifacts. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a1fce1cb6..c11cc4725 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -32,7 +32,7 @@ Open the **Log** panel (right rail), enable **debug** if needed, reproduce the p Before opening a GitHub issue, use **App → Support / Bug reports → Export for GitHub**. This writes one zip with the debug snapshot JSON and application log file(s) — the same artifacts maintainers previously asked for in three separate steps. The snapshot includes **Reticulum** sidecar status, interface diagnostics, and config audit when the stack was running at export time (`reticulum` section in `debug-snapshot.json`; `[ReticulumSidecar]` lines in the log). -Open `manifest.json` first when triaging: `appVersion` is package semver; **`buildChannel`** is `test` (Build Binaries), `release` (official Release workflow), or `local` (unmarked local dist). For CI builds, `buildInfo.runUrl` links to the exact GitHub Actions run — do not assume `appVersion` alone means an official release. +Open `manifest.json` first when triaging: `appVersion` is package semver; **`buildChannel`** is `test` (Build Binaries / Flatpak no-release), `release` (official Release or Flatpak tag), or `local` (unmarked local dist). For CI builds, `buildInfo.runUrl` links to the exact GitHub Actions run — do not assume `appVersion` alone means an official release. Test-build downloadable installers include `-run{N}` in the filename; if the filename and `runUrl` disagree, trust `runUrl` / the `[Startup] runtime … run=` log line. **Do not attach Export for Developer or `mesh-client.db` to public GitHub issues.** The developer bundle includes your SQLite database, which may contain **saved passwords** (MeshCore room/repeater credentials, MQTT settings, etc.). It may also include **Reticulum** rnsd config and sidecar stack state under `reticulum/` — share only via a **private channel** when a maintainer requests **Export for Developer**. diff --git a/org.coloradomesh.MeshClient.yml b/org.coloradomesh.MeshClient.yml index 2fdbccfc7..1c210fc86 100644 --- a/org.coloradomesh.MeshClient.yml +++ b/org.coloradomesh.MeshClient.yml @@ -57,8 +57,12 @@ modules: # .npmrc (older) runs outside the sandbox, so $PWD there is the host cache # path, not the in-sandbox /run/build/mesh-client path. - node scripts/flatpak-pnpm-install.mjs - # Build renderer, main, and preload - - pnpm run build + # Build renderer, main, and preload (optional CI stamp from flatpak/ci-build-info.json) + - | + if [ -f flatpak/ci-build-info.json ]; then + export MESH_CLIENT_BUILD_INFO="$(cat flatpak/ci-build-info.json)" + fi + pnpm run build # Rebuild native addons (noble BLE, sqlite) against the bundled Electron ABI - pnpm run rebuild # Install app payload (Electron binary + app roots; zypak needs a real Chromium binary) diff --git a/scripts/check-flatpak.mjs b/scripts/check-flatpak.mjs index bddbec630..6671e5b94 100644 --- a/scripts/check-flatpak.mjs +++ b/scripts/check-flatpak.mjs @@ -9,6 +9,7 @@ import { storeVersionFromPackageManager, } from './flatpakPnpmStoreVersion.mjs'; import { metainfoVersionMismatchMessage } from './metainfoRelease.mjs'; +import { FLATPAK_BUILD_INFO_EXPORT_SNIPPET } from './write-flatpak-ci-build-info.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); @@ -448,6 +449,60 @@ function checkDesktopStartupWMClass(pkg) { return violations; } +function checkManifestCiBuildInfoExport() { + const violations = []; + if (!fs.existsSync(MANIFEST)) return violations; + const yaml = fs.readFileSync(MANIFEST, 'utf8'); + const rel = path.relative(ROOT, MANIFEST); + for (const line of FLATPAK_BUILD_INFO_EXPORT_SNIPPET.split('\n')) { + if (!yaml.includes(line)) { + violations.push({ + file: rel, + message: `manifest build must export MESH_CLIENT_BUILD_INFO from flatpak/ci-build-info.json (missing: ${line})`, + }); + break; + } + } + return violations; +} + +function checkFlatpakWorkflowTestBuildContracts() { + const violations = []; + if (!fs.existsSync(FLATPAK_WORKFLOW)) return violations; + const yaml = fs.readFileSync(FLATPAK_WORKFLOW, 'utf8'); + const rel = path.relative(ROOT, FLATPAK_WORKFLOW); + + if (!yaml.includes('Build Flatpak (no release)')) { + violations.push({ + file: rel, + message: + 'flatpak.yaml run-name / labels must include Build Flatpak (no release) for workflow_dispatch', + }); + } + if (!yaml.includes('write-flatpak-ci-build-info.mjs')) { + violations.push({ + file: rel, + message: + 'flatpak.yaml must write flatpak/ci-build-info.json via write-flatpak-ci-build-info.mjs', + }); + } + if (!/upload-artifact:\s*false/.test(yaml)) { + violations.push({ + file: rel, + message: + 'flatpak-builder must set upload-artifact: false so smoke/rename can run before a single upload', + }); + } + if (!yaml.includes('rename-test-build-artifacts.mjs --flatpak')) { + violations.push({ + file: rel, + message: + 'flatpak.yaml must rename dispatch bundles with rename-test-build-artifacts.mjs --flatpak', + }); + } + return violations; +} + function main() { const violations = [ ...checkMetainfoVersionMatchesPackage(PKG_JSON), @@ -456,6 +511,8 @@ function main() { ...checkManifestPnpmVersion(PKG_JSON), ...checkFlatpakWorkflowStoreVersion(PKG_JSON), ...checkManifestBranchAndElectronPayload(PKG_JSON), + ...checkManifestCiBuildInfoExport(), + ...checkFlatpakWorkflowTestBuildContracts(), ...checkManifestReticulumSidecarPayload(), ...checkWrapperLaunchPaths(), ...checkDesktopStartupWMClass(PKG_JSON), diff --git a/scripts/rename-test-build-artifacts.mjs b/scripts/rename-test-build-artifacts.mjs new file mode 100644 index 000000000..ce7ca6cbe --- /dev/null +++ b/scripts/rename-test-build-artifacts.mjs @@ -0,0 +1,344 @@ +#!/usr/bin/env node +/** + * Rename test-build installer artifacts to include `-run{GITHUB_RUN_NUMBER}`. + * + * Gate: MESH_CLIENT_BUILD_CHANNEL=test (or buildInfo.channel=test) with a finite runNumber. + * Official release / local builds: no-op. + * + * Pure helpers exported for unit tests. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); + +/** @type {ReadonlySet} */ +const INSTALLER_EXTENSIONS = new Set([ + '.AppImage', + '.deb', + '.rpm', + '.dmg', + '.zip', + '.flatpak', + '.exe', +]); + +/** + * @param {number} runNumber + * @returns {string} + */ +export function buildRunStampSuffix(runNumber) { + if (!Number.isFinite(runNumber) || runNumber < 0 || !Number.isInteger(runNumber)) { + throw new Error(`Invalid runNumber for stamp: ${String(runNumber)}`); + } + return `-run${runNumber}`; +} + +/** + * @param {string} name basename + * @returns {boolean} + */ +export function hasRunStamp(name) { + return /-run\d+/.test(name); +} + +/** + * @param {string} name basename + * @returns {boolean} + */ +export function shouldRenameInstaller(name) { + if (!name || name.startsWith('.')) return false; + if (name.startsWith('READ-ME-FIRST')) return false; + if (name.includes('blockmap') || name.endsWith('.blockmap')) return false; + if (name === 'Mesh-client.exe') return false; + if (name.includes('__uninstaller')) return false; + + const ext = path.extname(name); + if (!INSTALLER_EXTENSIONS.has(ext)) return false; + + if (ext === '.exe') { + return name.startsWith('Mesh-client Setup '); + } + return true; +} + +/** + * Insert `-run{N}` before the extension, keeping known arch suffixes after the stamp. + * + * @param {string} name basename + * @param {number} runNumber + * @returns {string} + */ +export function stampedInstallerName(name, runNumber) { + if (hasRunStamp(name)) return name; + const stamp = buildRunStampSuffix(runNumber); + + /** @type {RegExp[]} */ + const archBeforeExt = [ + /^(.+)(-arm64)(\.[^.]+)$/i, + /^(.+)(-aarch64)(\.[^.]+)$/i, + /^(.+)(_amd64)(\.[^.]+)$/i, + /^(.+)(_arm64)(\.[^.]+)$/i, + /^(.+)(\.x86_64)(\.[^.]+)$/i, + /^(.+)(\.aarch64)(\.[^.]+)$/i, + ]; + for (const re of archBeforeExt) { + const m = name.match(re); + if (m) { + return `${m[1]}${stamp}${m[2]}${m[3]}`; + } + } + + const ext = path.extname(name); + if (!ext) { + return `${name}${stamp}`; + } + const base = name.slice(0, -ext.length); + return `${base}${stamp}${ext}`; +} + +/** + * @param {string | undefined} raw + * @returns {{ channel?: string, runNumber?: number }} + */ +export function parseBuildInfoEnv(raw) { + if (raw == null || String(raw).trim() === '') return {}; + try { + const parsed = JSON.parse(String(raw)); + if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('MESH_CLIENT_BUILD_INFO must be a JSON object'); + } + /** @type {{ channel?: string, runNumber?: number }} */ + const out = {}; + if (typeof parsed.channel === 'string') out.channel = parsed.channel; + if (typeof parsed.runNumber === 'number' && Number.isFinite(parsed.runNumber)) { + out.runNumber = Math.floor(parsed.runNumber); + } else if (parsed.runNumber != null && parsed.runNumber !== '') { + const n = Number(parsed.runNumber); + if (!Number.isFinite(n)) { + throw new Error(`Invalid runNumber in MESH_CLIENT_BUILD_INFO: ${String(parsed.runNumber)}`); + } + out.runNumber = Math.floor(n); + } + return out; + } catch (e) { + if (e instanceof SyntaxError) { + throw new Error(`Invalid MESH_CLIENT_BUILD_INFO JSON: ${e.message}`, { cause: e }); + } + throw e; + } +} + +/** + * @param {{ + * channel?: string + * runNumber?: number + * buildInfoRaw?: string + * }} opts + * @returns {{ channel: string, runNumber: number } | null} + */ +export function resolveTestRenameStamp(opts) { + const fromEnv = parseBuildInfoEnv(opts.buildInfoRaw); + const channel = (opts.channel ?? fromEnv.channel ?? '').trim(); + if (channel !== 'test') { + return null; + } + const runNumber = opts.runNumber ?? fromEnv.runNumber; + if (runNumber == null || !Number.isFinite(runNumber)) { + throw new Error( + 'MESH_CLIENT_BUILD_CHANNEL=test requires a finite runNumber (MESH_CLIENT_BUILD_INFO.runNumber)', + ); + } + return { channel: 'test', runNumber: Math.floor(runNumber) }; +} + +/** + * @param {string} dir + * @param {{ recursive?: boolean }} [opts] + * @returns {string[]} absolute file paths + */ +export function listInstallerFiles(dir, opts = {}) { + const recursive = opts.recursive !== false; + if (!fs.existsSync(dir)) return []; + + /** @type {string[]} */ + const out = []; + + /** + * @param {string} current + */ + function walk(current) { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) { + if (recursive) walk(full); + continue; + } + if (!entry.isFile()) continue; + if (shouldRenameInstaller(entry.name)) { + out.push(full); + } + } + } + + walk(dir); + return out.sort(); +} + +/** + * @param {{ + * rootDir: string + * channel?: string + * runNumber?: number + * buildInfoRaw?: string + * recursive?: boolean + * dryRun?: boolean + * }} opts + * @returns {{ renamed: Array<{ from: string, to: string }>, skipped: boolean, reason?: string }} + */ +export function renameTestBuildArtifacts(opts) { + const stamp = resolveTestRenameStamp({ + channel: opts.channel, + runNumber: opts.runNumber, + buildInfoRaw: opts.buildInfoRaw, + }); + if (!stamp) { + return { renamed: [], skipped: true, reason: 'channel-not-test' }; + } + + const files = listInstallerFiles(opts.rootDir, { recursive: opts.recursive }); + /** @type {Array<{ from: string, to: string }>} */ + const renamed = []; + + for (const from of files) { + const base = path.basename(from); + const next = stampedInstallerName(base, stamp.runNumber); + if (next === base) continue; + const to = path.join(path.dirname(from), next); + if (fs.existsSync(to)) { + throw new Error(`Refusing to overwrite existing file: ${to}`); + } + if (!opts.dryRun) { + fs.renameSync(from, to); + } + renamed.push({ from, to }); + } + + return { renamed, skipped: false }; +} + +/** + * @param {string[]} argv + * @param {NodeJS.ProcessEnv} [env] + */ +export function parseRenameCliArgs(argv, env = process.env) { + /** @type {{ rootDir: string, recursive: boolean, dryRun: boolean, flatpakCwd: boolean, help: boolean }} */ + const out = { + rootDir: path.join(ROOT, 'release'), + recursive: true, + dryRun: false, + flatpakCwd: false, + help: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--root') { + const next = argv[++i]; + if (!next) throw new Error('--root requires a directory'); + out.rootDir = path.resolve(next); + } else if (arg === '--flatpak') { + out.flatpakCwd = true; + const maybeDir = argv[i + 1]; + if (maybeDir && !maybeDir.startsWith('-')) { + out.rootDir = path.resolve(maybeDir); + i++; + } else { + out.rootDir = path.resolve(process.cwd()); + } + out.recursive = false; + } else if (arg === '--dry-run') { + out.dryRun = true; + } else if (arg === '--help' || arg === '-h') { + out.help = true; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return { + ...out, + channel: env.MESH_CLIENT_BUILD_CHANNEL, + buildInfoRaw: env.MESH_CLIENT_BUILD_INFO, + }; +} + +function main() { + const parsed = parseRenameCliArgs(process.argv.slice(2)); + if (parsed.help) { + process.stdout.write( + 'Usage: node scripts/rename-test-build-artifacts.mjs [--root dir] [--flatpak [dir]] [--dry-run]\n', + ); + return; + } + + if (parsed.flatpakCwd) { + // Only touch Flatpak bundles in the given directory (non-recursive). + const files = fs + .readdirSync(parsed.rootDir) + .filter((n) => n.endsWith('.flatpak') && shouldRenameInstaller(n)); + const stamp = resolveTestRenameStamp({ + channel: parsed.channel, + buildInfoRaw: parsed.buildInfoRaw, + }); + if (!stamp) { + console.debug('[rename-test-build-artifacts] skip (channel-not-test)'); + return; + } + /** @type {Array<{ from: string, to: string }>} */ + const renamed = []; + for (const name of files) { + const from = path.join(parsed.rootDir, name); + const next = stampedInstallerName(name, stamp.runNumber); + if (next === name) continue; + const to = path.join(parsed.rootDir, next); + if (fs.existsSync(to)) { + throw new Error(`Refusing to overwrite existing file: ${to}`); + } + if (!parsed.dryRun) fs.renameSync(from, to); + renamed.push({ from, to }); + } + console.debug( + `[rename-test-build-artifacts] flatpak renamed ${renamed.length} file(s) run=${stamp.runNumber}`, + ); + for (const r of renamed) { + console.debug(` ${path.basename(r.from)} → ${path.basename(r.to)}`); + } + return; + } + + const result = renameTestBuildArtifacts({ + rootDir: parsed.rootDir, + channel: parsed.channel, + buildInfoRaw: parsed.buildInfoRaw, + recursive: parsed.recursive, + dryRun: parsed.dryRun, + }); + if (result.skipped) { + console.debug(`[rename-test-build-artifacts] skip (${result.reason ?? 'unknown'})`); + return; + } + console.debug(`[rename-test-build-artifacts] renamed ${result.renamed.length} file(s)`); + for (const r of result.renamed) { + console.debug(` ${path.relative(ROOT, r.from)} → ${path.relative(ROOT, r.to)}`); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + main(); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } +} diff --git a/scripts/rename-test-build-artifacts.test.mjs b/scripts/rename-test-build-artifacts.test.mjs new file mode 100644 index 000000000..bfb51abac --- /dev/null +++ b/scripts/rename-test-build-artifacts.test.mjs @@ -0,0 +1,167 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + buildRunStampSuffix, + hasRunStamp, + listInstallerFiles, + parseBuildInfoEnv, + renameTestBuildArtifacts, + resolveTestRenameStamp, + shouldRenameInstaller, + stampedInstallerName, +} from './rename-test-build-artifacts.mjs'; + +/** @type {string[]} */ +const tempDirs = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeTempDir() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rename-test-build-')); + tempDirs.push(dir); + return dir; +} + +describe('buildRunStampSuffix', () => { + it('formats -run{N}', () => { + expect(buildRunStampSuffix(214)).toBe('-run214'); + }); + + it('rejects non-integers', () => { + expect(() => buildRunStampSuffix(1.5)).toThrow(/Invalid runNumber/); + }); +}); + +describe('stampedInstallerName', () => { + it('stamps AppImage x64 and arm64', () => { + expect(stampedInstallerName('Mesh-client-5.26.0.AppImage', 214)).toBe( + 'Mesh-client-5.26.0-run214.AppImage', + ); + expect(stampedInstallerName('Mesh-client-5.26.0-arm64.AppImage', 214)).toBe( + 'Mesh-client-5.26.0-run214-arm64.AppImage', + ); + }); + + it('stamps deb/rpm arch markers', () => { + expect(stampedInstallerName('mesh-client_5.26.0_amd64.deb', 7)).toBe( + 'mesh-client_5.26.0-run7_amd64.deb', + ); + expect(stampedInstallerName('mesh-client-5.26.0.x86_64.rpm', 7)).toBe( + 'mesh-client-5.26.0-run7.x86_64.rpm', + ); + }); + + it('stamps Windows Setup installers', () => { + expect(stampedInstallerName('Mesh-client Setup 5.26.0.exe', 214)).toBe( + 'Mesh-client Setup 5.26.0-run214.exe', + ); + expect(stampedInstallerName('Mesh-client Setup 5.26.0-arm64.exe', 214)).toBe( + 'Mesh-client Setup 5.26.0-run214-arm64.exe', + ); + }); + + it('stamps Flatpak and is idempotent', () => { + expect(stampedInstallerName('org.coloradomesh.MeshClient.flatpak', 214)).toBe( + 'org.coloradomesh.MeshClient-run214.flatpak', + ); + expect(stampedInstallerName('org.coloradomesh.MeshClient-run214.flatpak', 214)).toBe( + 'org.coloradomesh.MeshClient-run214.flatpak', + ); + expect(hasRunStamp('org.coloradomesh.MeshClient-run214.flatpak')).toBe(true); + }); +}); + +describe('shouldRenameInstaller', () => { + it('accepts installers and skips non-installers', () => { + expect(shouldRenameInstaller('Mesh-client-5.26.0.AppImage')).toBe(true); + expect(shouldRenameInstaller('Mesh-client Setup 5.26.0.exe')).toBe(true); + expect(shouldRenameInstaller('org.coloradomesh.MeshClient.flatpak')).toBe(true); + expect(shouldRenameInstaller('READ-ME-FIRST-test-build.md')).toBe(false); + expect(shouldRenameInstaller('Mesh-client.exe')).toBe(false); + expect(shouldRenameInstaller('Mesh-client-5.26.0.AppImage.blockmap')).toBe(false); + }); +}); + +describe('resolveTestRenameStamp', () => { + it('no-ops when channel is not test', () => { + expect(resolveTestRenameStamp({ channel: 'release', runNumber: 1 })).toBeNull(); + expect( + resolveTestRenameStamp({ + buildInfoRaw: JSON.stringify({ channel: 'local', runNumber: 9 }), + }), + ).toBeNull(); + }); + + it('fails closed when test channel lacks runNumber', () => { + expect(() => resolveTestRenameStamp({ channel: 'test' })).toThrow(/runNumber/); + }); + + it('parses MESH_CLIENT_BUILD_INFO', () => { + expect(parseBuildInfoEnv(JSON.stringify({ channel: 'test', runNumber: 214 }))).toEqual({ + channel: 'test', + runNumber: 214, + }); + }); +}); + +describe('renameTestBuildArtifacts', () => { + it('renames installers under release/ and skips non-installers', () => { + const root = makeTempDir(); + const mac = path.join(root, 'mac'); + fs.mkdirSync(mac, { recursive: true }); + fs.writeFileSync(path.join(root, 'Mesh-client-5.26.0.AppImage'), 'x'); + fs.writeFileSync(path.join(root, 'Mesh-client-5.26.0-arm64.AppImage'), 'x'); + fs.writeFileSync(path.join(root, 'mesh-client_5.26.0_amd64.deb'), 'x'); + fs.writeFileSync(path.join(root, 'Mesh-client Setup 5.26.0.exe'), 'x'); + fs.writeFileSync(path.join(root, 'Mesh-client Setup 5.26.0-arm64.exe'), 'x'); + fs.writeFileSync(path.join(mac, 'Mesh-client-5.26.0.dmg'), 'x'); + fs.writeFileSync(path.join(root, 'READ-ME-FIRST-test-build.md'), 'note'); + fs.writeFileSync(path.join(root, 'Mesh-client.exe'), 'exe'); + + const result = renameTestBuildArtifacts({ + rootDir: root, + channel: 'test', + runNumber: 214, + }); + expect(result.skipped).toBe(false); + expect(result.renamed).toHaveLength(6); + expect(fs.existsSync(path.join(root, 'Mesh-client-5.26.0-run214.AppImage'))).toBe(true); + expect(fs.existsSync(path.join(root, 'Mesh-client-5.26.0-run214-arm64.AppImage'))).toBe(true); + expect(fs.existsSync(path.join(root, 'Mesh-client Setup 5.26.0-run214.exe'))).toBe(true); + expect(fs.existsSync(path.join(mac, 'Mesh-client-5.26.0-run214.dmg'))).toBe(true); + expect(fs.existsSync(path.join(root, 'READ-ME-FIRST-test-build.md'))).toBe(true); + expect(fs.existsSync(path.join(root, 'Mesh-client.exe'))).toBe(true); + + const again = renameTestBuildArtifacts({ + rootDir: root, + channel: 'test', + runNumber: 214, + }); + expect(again.renamed).toHaveLength(0); + }); + + it('skips when channel is not test', () => { + const root = makeTempDir(); + fs.writeFileSync(path.join(root, 'Mesh-client-5.26.0.AppImage'), 'x'); + const result = renameTestBuildArtifacts({ + rootDir: root, + buildInfoRaw: JSON.stringify({ channel: 'release', runNumber: 9 }), + }); + expect(result).toMatchObject({ skipped: true, reason: 'channel-not-test' }); + expect(fs.existsSync(path.join(root, 'Mesh-client-5.26.0.AppImage'))).toBe(true); + }); + + it('lists only installer files', () => { + const root = makeTempDir(); + fs.writeFileSync(path.join(root, 'a.AppImage'), 'x'); + fs.writeFileSync(path.join(root, 'note.md'), 'x'); + expect(listInstallerFiles(root).map((p) => path.basename(p))).toEqual(['a.AppImage']); + }); +}); diff --git a/scripts/test-win-nsis-install.mjs b/scripts/test-win-nsis-install.mjs index 4f6bb5fbe..5b575841b 100644 --- a/scripts/test-win-nsis-install.mjs +++ b/scripts/test-win-nsis-install.mjs @@ -16,6 +16,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { findAppArchive } from './find-nsis-app-archive.mjs'; import { assertBundledReticulumSidecarInBundle } from './assert-bundled-reticulum-sidecar.mjs'; +import { findWinSetupInstaller } from './win-setup-installer-names.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, '..'); @@ -35,12 +36,6 @@ function readVersion() { return packageJson.version; } -/** @param {'x64' | 'arm64'} arch */ -function installerName(version, arch) { - const base = `Mesh-client Setup ${version}`; - return arch === 'arm64' ? `${base}-arm64.exe` : `${base}.exe`; -} - /** @param {string} label @param {string} filePath */ function assertExe(label, filePath) { if (!existsSync(filePath)) { @@ -151,12 +146,18 @@ function main(arch, probe7z) { } const version = readVersion(); - const installer = installerName(version, arch); - const installerPath = path.join(releaseDir, installer); - if (!existsSync(installerPath)) { + if (!existsSync(releaseDir)) { + fail(`Missing release directory: ${releaseDir}`); + } + /** @type {string} */ + let installer; + try { + installer = findWinSetupInstaller(version, arch, readdirSync(releaseDir)); + } catch (e) { dumpDir('release dir (installer missing)', releaseDir, 2); - fail(`Installer not found: ${installerPath}`); + fail(e instanceof Error ? e.message : String(e)); } + const installerPath = path.join(releaseDir, installer); if (probe7z) { probe7zExtract(installerPath, path.join(tmpdir(), 'mesh-client-7z-probe'), arch); diff --git a/scripts/verify-win-packaging.mjs b/scripts/verify-win-packaging.mjs index bf008df83..425b54adb 100644 --- a/scripts/verify-win-packaging.mjs +++ b/scripts/verify-win-packaging.mjs @@ -11,6 +11,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; import { fileURLToPath } from 'url'; import path from 'path'; import { assertBundledReticulumSidecarInBundle } from './assert-bundled-reticulum-sidecar.mjs'; +import { collectWinSetupInstallers } from './win-setup-installer-names.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, '..'); @@ -51,35 +52,12 @@ function collectSetupInstallers(version) { process.exit(1); } - const prefix = `Mesh-client Setup ${version}`; - const installers = readdirSync(releaseDir).filter((name) => { - if (name.includes('__uninstaller')) return false; - return name === `${prefix}.exe` || name === `${prefix}-arm64.exe`; - }); - - const x64 = installers.filter((name) => !name.endsWith('-arm64.exe')); - const arm64 = installers.filter((name) => name.endsWith('-arm64.exe')); - - if (x64.length !== 1) { - console.error( - `[verify-win-packaging] Expected exactly one x64 NSIS installer, found ${x64.length}: ${x64.join(', ') || '(none)'}`, - ); - process.exit(1); + try { + return collectWinSetupInstallers(version, readdirSync(releaseDir)); + } catch (e) { + fail(e instanceof Error ? e.message : String(e)); + throw e; } - if (arm64.length !== 1) { - console.error( - `[verify-win-packaging] Expected exactly one arm64 NSIS installer, found ${arm64.length}: ${arm64.join(', ') || '(none)'}`, - ); - process.exit(1); - } - if (installers.length !== 2) { - console.error( - `[verify-win-packaging] Expected two per-arch installers (no universal build), found ${installers.length}: ${installers.join(', ')}`, - ); - process.exit(1); - } - - return { x64: x64[0], arm64: arm64[0] }; } function main() { diff --git a/scripts/win-setup-installer-names.mjs b/scripts/win-setup-installer-names.mjs new file mode 100644 index 000000000..c3f6a7c1c --- /dev/null +++ b/scripts/win-setup-installer-names.mjs @@ -0,0 +1,71 @@ +/** + * Match Mesh-client Windows NSIS Setup installer basenames. + * + * Accepts default electron-builder names and test-build stamped names: + * Mesh-client Setup 5.26.0.exe + * Mesh-client Setup 5.26.0-arm64.exe + * Mesh-client Setup 5.26.0-run214.exe + * Mesh-client Setup 5.26.0-run214-arm64.exe + */ + +/** + * @param {string} version package.json semver + * @param {string} name basename + * @returns {'x64' | 'arm64' | null} + */ +export function matchWinSetupInstallerArch(version, name) { + if (typeof name !== 'string' || name.includes('__uninstaller')) return null; + const prefix = `Mesh-client Setup ${version}`; + if (!name.startsWith(prefix) || !name.endsWith('.exe')) return null; + const rest = name.slice(prefix.length, -'.exe'.length); + // rest: '' | '-arm64' | '-run214' | '-run214-arm64' + if (rest === '') return 'x64'; + if (rest === '-arm64') return 'arm64'; + if (/^-run\d+$/.test(rest)) return 'x64'; + if (/^-run\d+-arm64$/.test(rest)) return 'arm64'; + return null; +} + +/** + * @param {string} version + * @param {string[]} names release/ basenames + * @returns {{ x64: string, arm64: string }} + */ +export function collectWinSetupInstallers(version, names) { + /** @type {string[]} */ + const x64 = []; + /** @type {string[]} */ + const arm64 = []; + for (const name of names) { + const arch = matchWinSetupInstallerArch(version, name); + if (arch === 'x64') x64.push(name); + else if (arch === 'arm64') arm64.push(name); + } + if (x64.length !== 1) { + throw new Error( + `Expected exactly one x64 NSIS installer, found ${x64.length}: ${x64.join(', ') || '(none)'}`, + ); + } + if (arm64.length !== 1) { + throw new Error( + `Expected exactly one arm64 NSIS installer, found ${arm64.length}: ${arm64.join(', ') || '(none)'}`, + ); + } + return { x64: x64[0], arm64: arm64[0] }; +} + +/** + * @param {string} version + * @param {'x64' | 'arm64'} arch + * @param {string[]} names + * @returns {string} + */ +export function findWinSetupInstaller(version, arch, names) { + const hits = names.filter((name) => matchWinSetupInstallerArch(version, name) === arch); + if (hits.length !== 1) { + throw new Error( + `Expected exactly one ${arch} NSIS installer, found ${hits.length}: ${hits.join(', ') || '(none)'}`, + ); + } + return hits[0]; +} diff --git a/scripts/win-setup-installer-names.test.mjs b/scripts/win-setup-installer-names.test.mjs new file mode 100644 index 000000000..fc4dbe3cf --- /dev/null +++ b/scripts/win-setup-installer-names.test.mjs @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectWinSetupInstallers, + findWinSetupInstaller, + matchWinSetupInstallerArch, +} from './win-setup-installer-names.mjs'; + +describe('matchWinSetupInstallerArch', () => { + it('matches default and stamped names', () => { + expect(matchWinSetupInstallerArch('5.26.0', 'Mesh-client Setup 5.26.0.exe')).toBe('x64'); + expect(matchWinSetupInstallerArch('5.26.0', 'Mesh-client Setup 5.26.0-arm64.exe')).toBe( + 'arm64', + ); + expect(matchWinSetupInstallerArch('5.26.0', 'Mesh-client Setup 5.26.0-run214.exe')).toBe('x64'); + expect(matchWinSetupInstallerArch('5.26.0', 'Mesh-client Setup 5.26.0-run214-arm64.exe')).toBe( + 'arm64', + ); + }); + + it('rejects wrong version and non-setup exes', () => { + expect(matchWinSetupInstallerArch('5.26.0', 'Mesh-client Setup 5.25.0.exe')).toBeNull(); + expect(matchWinSetupInstallerArch('5.26.0', 'Mesh-client.exe')).toBeNull(); + expect(matchWinSetupInstallerArch('5.26.0', 'Mesh-client Setup 5.26.0__uninstaller.exe')).toBe( + null, + ); + }); +}); + +describe('collectWinSetupInstallers', () => { + it('collects default pair', () => { + expect( + collectWinSetupInstallers('5.26.0', [ + 'Mesh-client Setup 5.26.0.exe', + 'Mesh-client Setup 5.26.0-arm64.exe', + 'READ-ME-FIRST-test-build.md', + ]), + ).toEqual({ + x64: 'Mesh-client Setup 5.26.0.exe', + arm64: 'Mesh-client Setup 5.26.0-arm64.exe', + }); + }); + + it('collects stamped pair', () => { + expect( + collectWinSetupInstallers('5.26.0', [ + 'Mesh-client Setup 5.26.0-run214.exe', + 'Mesh-client Setup 5.26.0-run214-arm64.exe', + ]), + ).toEqual({ + x64: 'Mesh-client Setup 5.26.0-run214.exe', + arm64: 'Mesh-client Setup 5.26.0-run214-arm64.exe', + }); + }); + + it('rejects duplicates', () => { + expect(() => + collectWinSetupInstallers('5.26.0', [ + 'Mesh-client Setup 5.26.0.exe', + 'Mesh-client Setup 5.26.0-run214.exe', + 'Mesh-client Setup 5.26.0-arm64.exe', + ]), + ).toThrow(/exactly one x64/); + }); +}); + +describe('findWinSetupInstaller', () => { + it('finds stamped arch', () => { + expect( + findWinSetupInstaller('5.26.0', 'arm64', [ + 'Mesh-client Setup 5.26.0-run9.exe', + 'Mesh-client Setup 5.26.0-run9-arm64.exe', + ]), + ).toBe('Mesh-client Setup 5.26.0-run9-arm64.exe'); + }); +}); diff --git a/scripts/write-flatpak-ci-build-info.mjs b/scripts/write-flatpak-ci-build-info.mjs new file mode 100644 index 000000000..fb24d7dee --- /dev/null +++ b/scripts/write-flatpak-ci-build-info.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node +/** + * Write flatpak/ci-build-info.json for the Flatpak sandbox build. + * + * The Flatpak manifest exports MESH_CLIENT_BUILD_INFO from this file before + * `pnpm run build` so esbuild embeds the same stamp as electron-builder CI. + * + * Prefer MESH_CLIENT_BUILD_INFO already in the environment (after + * ci-write-build-info-env.mjs). Otherwise builds the payload from Actions env. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + buildMeshClientBuildInfoPayload, + readReleaseTagFromPackageJson, +} from './ci-write-build-info-env.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +export const FLATPAK_CI_BUILD_INFO_REL = path.join('flatpak', 'ci-build-info.json'); + +/** + * @param {string} raw + * @returns {Record} + */ +export function parseBuildInfoJsonObject(raw) { + const parsed = JSON.parse(raw); + if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('MESH_CLIENT_BUILD_INFO must be a JSON object'); + } + return /** @type {Record} */ (parsed); +} + +/** + * @param {NodeJS.ProcessEnv} [env] + * @param {{ packageJsonPath?: string, outPath?: string }} [opts] + * @returns {{ payload: Record, outPath: string }} + */ +export function writeFlatpakCiBuildInfoFile(env = process.env, opts = {}) { + const outPath = opts.outPath ?? path.join(ROOT, FLATPAK_CI_BUILD_INFO_REL); + const existing = env.MESH_CLIENT_BUILD_INFO?.trim(); + /** @type {Record} */ + let payload; + if (existing) { + payload = parseBuildInfoJsonObject(existing); + } else { + const channel = env.MESH_CLIENT_BUILD_CHANNEL?.trim(); + if (channel !== 'test' && channel !== 'release') { + throw new Error( + `MESH_CLIENT_BUILD_CHANNEL must be test|release, got: ${String(env.MESH_CLIENT_BUILD_CHANNEL)}`, + ); + } + let tag = env.MESH_CLIENT_BUILD_TAG?.trim(); + if (channel === 'release' && !tag) { + tag = readReleaseTagFromPackageJson(opts.packageJsonPath); + } + payload = buildMeshClientBuildInfoPayload({ + channel, + workflow: env.MESH_CLIENT_BUILD_WORKFLOW, + runId: env.GITHUB_RUN_ID, + runNumber: env.GITHUB_RUN_NUMBER, + sha: env.GITHUB_SHA, + serverUrl: env.GITHUB_SERVER_URL, + repository: env.GITHUB_REPOSITORY, + tag, + }); + } + + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + fs.writeFileSync(outPath, `${JSON.stringify(payload)}\n`, 'utf8'); + return { payload, outPath }; +} + +/** + * Shell snippet used in org.coloradomesh.MeshClient.yml before pnpm run build. + * Kept as a constant so check-flatpak can assert the contract. + */ +export const FLATPAK_BUILD_INFO_EXPORT_SNIPPET = [ + 'if [ -f flatpak/ci-build-info.json ]; then', + ' export MESH_CLIENT_BUILD_INFO="$(cat flatpak/ci-build-info.json)"', + 'fi', + 'pnpm run build', +].join('\n'); + +function main() { + const { payload, outPath } = writeFlatpakCiBuildInfoFile(); + process.stdout.write( + `Wrote ${path.relative(ROOT, outPath)} channel=${payload.channel}` + + (payload.runNumber != null ? ` runNumber=${payload.runNumber}` : '') + + '\n', + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + main(); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + } +} diff --git a/scripts/write-flatpak-ci-build-info.test.mjs b/scripts/write-flatpak-ci-build-info.test.mjs new file mode 100644 index 000000000..1cedbd65e --- /dev/null +++ b/scripts/write-flatpak-ci-build-info.test.mjs @@ -0,0 +1,75 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + FLATPAK_BUILD_INFO_EXPORT_SNIPPET, + parseBuildInfoJsonObject, + writeFlatpakCiBuildInfoFile, +} from './write-flatpak-ci-build-info.mjs'; + +/** @type {string[]} */ +const tempDirs = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('FLATPAK_BUILD_INFO_EXPORT_SNIPPET', () => { + it('exports from flatpak/ci-build-info.json then builds', () => { + expect(FLATPAK_BUILD_INFO_EXPORT_SNIPPET).toContain('flatpak/ci-build-info.json'); + expect(FLATPAK_BUILD_INFO_EXPORT_SNIPPET).toContain('MESH_CLIENT_BUILD_INFO'); + expect(FLATPAK_BUILD_INFO_EXPORT_SNIPPET).toContain('pnpm run build'); + }); +}); + +describe('writeFlatpakCiBuildInfoFile', () => { + it('writes JSON from MESH_CLIENT_BUILD_INFO env', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flatpak-ci-build-info-')); + tempDirs.push(dir); + const outPath = path.join(dir, 'ci-build-info.json'); + const payload = { + channel: 'test', + runNumber: 214, + runId: '1', + sha: 'bd42368', + }; + const result = writeFlatpakCiBuildInfoFile( + { MESH_CLIENT_BUILD_INFO: JSON.stringify(payload) }, + { outPath }, + ); + expect(result.payload).toEqual(payload); + expect(JSON.parse(fs.readFileSync(outPath, 'utf8'))).toEqual(payload); + }); + + it('builds payload from Actions env when MESH_CLIENT_BUILD_INFO is absent', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'flatpak-ci-build-info-')); + tempDirs.push(dir); + const outPath = path.join(dir, 'ci-build-info.json'); + const result = writeFlatpakCiBuildInfoFile( + { + MESH_CLIENT_BUILD_CHANNEL: 'test', + MESH_CLIENT_BUILD_WORKFLOW: 'Build Flatpak (no release)', + GITHUB_RUN_ID: '99', + GITHUB_RUN_NUMBER: '214', + GITHUB_SHA: 'bd423682bafb610fd16b9131e52605aaf80f1728', + GITHUB_REPOSITORY: 'Colorado-Mesh/mesh-client', + GITHUB_SERVER_URL: 'https://github.com', + }, + { outPath }, + ); + expect(result.payload).toMatchObject({ + channel: 'test', + runNumber: 214, + sha: 'bd42368', + runUrl: 'https://github.com/Colorado-Mesh/mesh-client/actions/runs/99', + }); + }); + + it('parseBuildInfoJsonObject rejects non-objects', () => { + expect(() => parseBuildInfoJsonObject('[]')).toThrow(/JSON object/); + }); +}); From 7c5d77f5f4cbb609053547d0a35bc94df22811d0 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 17:11:03 -0600 Subject: [PATCH 4/6] fix: address PR review for CI stamps, Host PN, and Games encode Harden Flatpak artifact naming/publish matching, roll back Games state on encode failure, cap peering-key jobs, move PN accept off the async worker, and tighten rename/check-flatpak contracts with tests. --- .github/workflows/flatpak.yaml | 6 +- docs/ci-cd.md | 2 +- reticulum-sidecar/src/stack/games_session.rs | 86 ++++++--- reticulum-sidecar/src/stack/lxmf_outbound.rs | 1 + reticulum-sidecar/src/stack/pn_inbound.rs | 103 ++++++++++- .../src/stack/propagation_bridge.rs | 6 + .../src/stack/propagation_serve.rs | 63 ++++--- scripts/check-flatpak.mjs | 164 +++++++++++++++--- scripts/check-flatpak.test.mjs | 88 ++++++++++ scripts/rename-test-build-artifacts.mjs | 38 ++-- scripts/rename-test-build-artifacts.test.mjs | 18 ++ src/renderer/runtime/useReticulumRuntime.ts | 10 +- 12 files changed, 486 insertions(+), 99 deletions(-) create mode 100644 scripts/check-flatpak.test.mjs diff --git a/.github/workflows/flatpak.yaml b/.github/workflows/flatpak.yaml index 7afa30a97..2c4c8ed70 100644 --- a/.github/workflows/flatpak.yaml +++ b/.github/workflows/flatpak.yaml @@ -219,11 +219,11 @@ jobs: if: github.event_name == 'workflow_dispatch' run: node scripts/rename-test-build-artifacts.mjs --flatpak . - # Artifact name matches prior flatpak-builder upload style (bundle + -${arch}). + # Artifact name ends with .flatpak so publish flatten → flatpak-dist/*.flatpak matches. - name: Upload Flatpak bundle uses: actions/upload-artifact@v7 with: - name: org.coloradomesh.MeshClient.flatpak-${{ matrix.arch }} + name: org.coloradomesh.MeshClient.flatpak-${{ matrix.arch }}.flatpak path: | org.coloradomesh.MeshClient*.flatpak if-no-files-found: error @@ -252,7 +252,7 @@ jobs: - uses: actions/download-artifact@v8 with: - pattern: 'org.coloradomesh.MeshClient-*' + pattern: 'org.coloradomesh.MeshClient.flatpak-*.flatpak' path: flatpak-dist - name: Flatten arch-suffixed bundles for release diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 9ee1b0eb3..e119535e1 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -126,7 +126,7 @@ A matrix builds **x86_64** and **aarch64** in parallel. Both use the same privil 2. Builds the Reticulum sidecar on bare Ubuntu runners, then generates `flatpak/generated-sources.json` via `flatpak-node-generator` 3. Stamps CI build info (`test` on dispatch / `release` on tag), builds from `org.coloradomesh.MeshClient.yml` with offline pnpm sources 4. Smoke-installs the unstamped local bundle; on **dispatch only**, renames to `org.coloradomesh.MeshClient-run{N}.flatpak` -5. Uploads `org.coloradomesh.MeshClient.flatpak-{x86_64,aarch64}` artifacts (file basename stamped on test builds) plus per-arch `flatpak-schema-warning-*` +5. Uploads `org.coloradomesh.MeshClient.flatpak-{x86_64,aarch64}.flatpak` artifacts (file basename stamped on test builds) plus per-arch `flatpak-schema-warning-*` On **version tag pushes**, a `publish` job attaches both **clean-named** bundles to the GitHub Release. aarch64 is the primary ARM Linux install path (release `build.yaml` only produces x86_64 AppImage/deb/rpm). diff --git a/reticulum-sidecar/src/stack/games_session.rs b/reticulum-sidecar/src/stack/games_session.rs index 2b4dab7a0..66ce203aa 100644 --- a/reticulum-sidecar/src/stack/games_session.rs +++ b/reticulum-sidecar/src/stack/games_session.rs @@ -604,10 +604,42 @@ impl GamesSessionManager { ) .map_err(|e| format!("dispatch_error: {e}"))?; - let fields = transport::pack_into_preencoded_fields(&prepared.envelope) - .map_err(|e| format!("encode_error: {e}"))?; - let envelope_bytes = envelope::pack_to_bytes(&prepared.envelope) - .map_err(|e| format!("encode_error: {e}"))?; + let fields = match transport::pack_into_preencoded_fields(&prepared.envelope) { + Ok(fields) => fields, + Err(e) => { + let encode_error = format!("encode_error: {e}"); + if let Err(rb) = self.router.rollback_outgoing( + app_id, + &prepared.session_id, + &self.identity_id, + snapshot, + ) { + tracing::warn!( + target: "games", + "lrgp rollback_outgoing after encode failure: {rb}" + ); + } + return Err(encode_error); + } + }; + let envelope_bytes = match envelope::pack_to_bytes(&prepared.envelope) { + Ok(bytes) => bytes, + Err(e) => { + let encode_error = format!("encode_error: {e}"); + if let Err(rb) = self.router.rollback_outgoing( + app_id, + &prepared.session_id, + &self.identity_id, + snapshot, + ) { + tracing::warn!( + target: "games", + "lrgp rollback_outgoing after encode failure: {rb}" + ); + } + return Err(encode_error); + } + }; Ok(PreparedGameAction { app_id: app_id.to_string(), @@ -1275,29 +1307,31 @@ mod tests { assert!(manager.handle_inbound_lxmf(&accept_fields, &peer, "")); let _guard = hydrate_err_test_guard(); - // Coin-flip first turn: if we own the turn, play once so the peer owns it. - match manager.prepare_action( - &peer, - "ttt", - CMD_MOVE, - Some(&sid), - Some(&serde_json::json!({ "i": 0 })), - ) { - Err(e) => assert_eq!(e, ERR_NOT_YOUR_TURN), - Ok(action) => { - manager.commit_action(&action, Some("move1")); - let err = manager - .prepare_action( - &peer, - "ttt", - CMD_MOVE, - Some(&sid), - Some(&serde_json::json!({ "i": 1 })), - ) - .expect_err("expected not_your_turn after handing off"); - assert_eq!(err, ERR_NOT_YOUR_TURN); - } + // Coin-flip first turn: hand off if we own it, then assert a single not-your-turn. + let detail = manager.session_detail(&sid); + let turn = detail["session"]["metadata"]["turn"].as_str().unwrap_or(""); + if turn == self_id { + let action = manager + .prepare_action( + &peer, + "ttt", + CMD_MOVE, + Some(&sid), + Some(&serde_json::json!({ "i": 0 })), + ) + .expect("own-turn move should prepare"); + manager.commit_action(&action, Some("move1")); } + let err = manager + .prepare_action( + &peer, + "ttt", + CMD_MOVE, + Some(&sid), + Some(&serde_json::json!({ "i": 1 })), + ) + .expect_err("expected not_your_turn when peer owns the turn"); + assert_eq!(err, ERR_NOT_YOUR_TURN); } #[test] diff --git a/reticulum-sidecar/src/stack/lxmf_outbound.rs b/reticulum-sidecar/src/stack/lxmf_outbound.rs index 5d380c202..c97e606bb 100644 --- a/reticulum-sidecar/src/stack/lxmf_outbound.rs +++ b/reticulum-sidecar/src/stack/lxmf_outbound.rs @@ -723,6 +723,7 @@ impl LxmfOutboundDriver { if let Some(hash) = message.hash.or(message.message_id) { self.pn_fallback_attempted.remove(&hash); self.direct_path_failovers.remove(&hash); + self.pending_pn_deposits.remove(&hash); let _ = router.mark_outbound_failed(&hash); emit_outbound_status_by_hash(event_tx, &hash, "failed", Some(method)); } diff --git a/reticulum-sidecar/src/stack/pn_inbound.rs b/reticulum-sidecar/src/stack/pn_inbound.rs index 439d7bd26..4120d2fb0 100644 --- a/reticulum-sidecar/src/stack/pn_inbound.rs +++ b/reticulum-sidecar/src/stack/pn_inbound.rs @@ -668,15 +668,33 @@ mod tests { use super::*; use lxmf_core::propagation_admission::PnInboundAdmissionConfig; - #[test] - fn accept_resource_tracks_client_correlation() { + fn test_runtime() -> PnInboundRuntime { let config = PnInboundAdmissionConfig { sequential_validation: true, static_sequential: false, max_inbound_syncs: 4, from_static_only: false, }; - let mut runtime = PnInboundRuntime::new(config, None, 64 * 1024); + PnInboundRuntime::new(config, None, 64 * 1024) + } + + fn start_inbound( + runtime: &mut PnInboundRuntime, + link_id: LinkId, + resource_id: LogicalResourceId, + ) { + runtime.handle_resource_event(LinkResourceEvent::Started { + link_id, + resource_id, + direction: LinkResourceDirection::Inbound, + data_size: 128, + total_segments: 1, + }); + } + + #[test] + fn accept_resource_tracks_client_correlation() { + let mut runtime = test_runtime(); let link_id = [0x11; 16]; let resource_id = [0x22; 32]; assert!(runtime.accept_resource(link_id, resource_id, 128, None)); @@ -687,4 +705,83 @@ mod tests { assert_eq!(runtime.throttle_count(), 0); let _ = PnValidationJob::for_test(vec![1, 2, 3], false); } + + #[test] + fn resource_completed_without_start_returns_none() { + let mut runtime = test_runtime(); + let link_id = [0x11; 16]; + let resource_id = [0x22; 32]; + assert!(runtime.accept_resource(link_id, resource_id, 128, None)); + assert!( + runtime + .resource_completed((link_id, resource_id), vec![1, 2, 3]) + .is_none() + ); + assert_eq!(runtime.correlation_count(), 0); + assert_eq!(runtime.pending_validation_count(), 0); + } + + #[test] + fn client_owner_sets_allow_multiple_false() { + let mut runtime = test_runtime(); + let link_id = [0x11; 16]; + let resource_id = [0x22; 32]; + assert!(runtime.accept_resource(link_id, resource_id, 128, None)); + start_inbound(&mut runtime, link_id, resource_id); + let job = runtime + .resource_completed((link_id, resource_id), vec![9, 9, 9]) + .expect("client completion"); + assert!(!job.allow_multiple()); + } + + #[test] + fn conclude_validation_ignores_token_link_mismatch_and_duplicates() { + let mut runtime = test_runtime(); + let link_id = [0x11; 16]; + let other_link = [0x12; 16]; + let resource_id = [0x22; 32]; + assert!(runtime.accept_resource(link_id, resource_id, 128, None)); + start_inbound(&mut runtime, link_id, resource_id); + let job = runtime + .resource_completed((link_id, resource_id), vec![1]) + .expect("job"); + let token = job.token(); + + assert!( + runtime + .conclude_validation(token, other_link, PnValidationOutcome::Valid) + .is_none() + ); + let claim = runtime + .conclude_validation(token, link_id, PnValidationOutcome::Valid) + .expect("first claim"); + assert_eq!(claim.link_id(), link_id); + assert!( + runtime + .conclude_validation(token, link_id, PnValidationOutcome::Valid) + .is_none() + ); + } + + #[test] + fn invalid_stamp_quarantines_link() { + let mut runtime = test_runtime(); + let link_id = [0x33; 16]; + let resource_id = [0x44; 32]; + assert!(runtime.accept_resource(link_id, resource_id, 128, None)); + start_inbound(&mut runtime, link_id, resource_id); + let job = runtime + .resource_completed((link_id, resource_id), vec![1]) + .expect("job"); + let claim = runtime + .conclude_validation(job.token(), link_id, PnValidationOutcome::InvalidStamp) + .expect("claim"); + assert!(claim.should_close_link()); + assert!(runtime.is_link_quarantined(&link_id)); + assert!(!runtime.accept_resource(link_id, [0x55; 32], 64, None)); + assert!(matches!( + runtime.preflight_offer(link_id, None), + Err(OfferResponse::ErrorThrottled) + )); + } } diff --git a/reticulum-sidecar/src/stack/propagation_bridge.rs b/reticulum-sidecar/src/stack/propagation_bridge.rs index 93946cdd6..6de1573c9 100644 --- a/reticulum-sidecar/src/stack/propagation_bridge.rs +++ b/reticulum-sidecar/src/stack/propagation_bridge.rs @@ -17,6 +17,9 @@ use tokio::sync::{broadcast, mpsc}; /// Completed host-peer peering PoW (stamp, value) awaiting apply onto `LxmPeer`. type PeeringKeyResult = ([u8; 16], [u8; 32], u32); +/// Cap concurrent host-peer peering-key PoW jobs (CPU-heavy stamp generation). +const MAX_PEERING_KEY_JOBS: usize = 8; + pub struct PropagationBridge { local_dest_hash: [u8; 16], local_node: Arc>, @@ -96,6 +99,9 @@ impl PropagationBridge { let Ok(mut jobs) = self.peering_key_jobs.lock() else { return; }; + if jobs.len() >= MAX_PEERING_KEY_JOBS { + return; + } if !jobs.insert(peer_hash) { return; } diff --git a/reticulum-sidecar/src/stack/propagation_serve.rs b/reticulum-sidecar/src/stack/propagation_serve.rs index 1ee5b9214..96b753fb1 100644 --- a/reticulum-sidecar/src/stack/propagation_serve.rs +++ b/reticulum-sidecar/src/stack/propagation_serve.rs @@ -417,25 +417,43 @@ fn spawn_propagation_validation( }; let mut accepted = 0usize; - if let Ok(mut node) = local_node.lock() { - for entry in &entries { - let stamp_value = u8::try_from(entry.stamp_value).unwrap_or(u8::MAX); - if node.accept_stamped_propagated_blob( - &entry.lxmf_data, - &entry.stamp_data, - stamp_value, - ) { - accepted += 1; - tracing::info!( - target: "propagation-deposit", - pn_hash = %pn_hash_hex, - transient_id = %hex::encode(entry.transient_id), + let accept_local_node = Arc::clone(&local_node); + let accept_pn_hash_hex = pn_hash_hex.clone(); + match tokio::task::spawn_blocking(move || { + let mut accepted = 0usize; + if let Ok(mut node) = accept_local_node.lock() { + for entry in &entries { + let stamp_value = u8::try_from(entry.stamp_value).unwrap_or(u8::MAX); + if node.accept_stamped_propagated_blob( + &entry.lxmf_data, + &entry.stamp_data, stamp_value, - blob_len = entry.lxmf_data.len(), - "local PN accepted stamped propagated blob" - ); + ) { + accepted += 1; + tracing::info!( + target: "propagation-deposit", + pn_hash = %accept_pn_hash_hex, + transient_id = %hex::encode(entry.transient_id), + stamp_value, + blob_len = entry.lxmf_data.len(), + "local PN accepted stamped propagated blob" + ); + } } } + accepted + }) + .await + { + Ok(count) => accepted = count, + Err(error) => { + tracing::warn!( + target: "propagation-deposit", + link_id = %hex::encode(link_id), + error = %error, + "propagation accept worker failed" + ); + } } tracing::info!( @@ -464,7 +482,6 @@ fn spawn_propagation_validation( #[cfg(test)] mod tests { use super::*; - use std::time::{SystemTime, UNIX_EPOCH}; #[test] fn source_wires_resource_accept_and_consumes_accounting() { @@ -510,13 +527,7 @@ mod tests { #[test] fn stamped_blob_enters_shared_store_and_bad_stamp_rejected() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - let dir = std::env::temp_dir().join(format!("mesh-client-pn-ingress-{nanos}")); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).expect("temp dir"); + let dir = tempfile::tempdir().expect("tempdir"); let mut node = PropagationNode::with_storage( lxmf_core::propagation_node::PropagationNodeConfig { @@ -524,7 +535,7 @@ mod tests { ..Default::default() }, [0xAA; 16], - dir.clone(), + dir.path().to_path_buf(), ) .expect("node"); @@ -542,7 +553,5 @@ mod tests { ); assert_eq!(bad.0, PnValidationOutcome::Failed); assert!(bad.1.is_empty()); - - let _ = std::fs::remove_dir_all(&dir); } } diff --git a/scripts/check-flatpak.mjs b/scripts/check-flatpak.mjs index 6671e5b94..53ccac433 100644 --- a/scripts/check-flatpak.mjs +++ b/scripts/check-flatpak.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import fs from 'fs'; import path from 'path'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'url'; +import yaml from 'js-yaml'; import { offlinePnpmEnvContractViolations } from './flatpakOfflinePnpmEnv.mjs'; import { flatpakWorkflowGeneratorInstallViolations, @@ -449,15 +450,44 @@ function checkDesktopStartupWMClass(pkg) { return violations; } -function checkManifestCiBuildInfoExport() { +/** + * @param {unknown} doc + * @param {string} fileLabel + * @returns {{ file: string, message: string }[]} + */ +export function manifestCiBuildInfoExportViolations(doc, fileLabel) { const violations = []; - if (!fs.existsSync(MANIFEST)) return violations; - const yaml = fs.readFileSync(MANIFEST, 'utf8'); - const rel = path.relative(ROOT, MANIFEST); + const modules = doc && typeof doc === 'object' && !Array.isArray(doc) ? doc.modules : null; + if (!Array.isArray(modules)) { + violations.push({ + file: fileLabel, + message: 'manifest must define a modules array', + }); + return violations; + } + const meshModule = modules.find( + (m) => m && typeof m === 'object' && !Array.isArray(m) && m.name === 'mesh-client', + ); + if (!meshModule) { + violations.push({ + file: fileLabel, + message: 'manifest must include a mesh-client module', + }); + return violations; + } + const commands = meshModule['build-commands']; + if (!Array.isArray(commands) || !commands.every((c) => typeof c === 'string')) { + violations.push({ + file: fileLabel, + message: 'mesh-client module must define build-commands as a string array', + }); + return violations; + } + const joined = commands.join('\n'); for (const line of FLATPAK_BUILD_INFO_EXPORT_SNIPPET.split('\n')) { - if (!yaml.includes(line)) { + if (!joined.includes(line)) { violations.push({ - file: rel, + file: fileLabel, message: `manifest build must export MESH_CLIENT_BUILD_INFO from flatpak/ci-build-info.json (missing: ${line})`, }); break; @@ -466,43 +496,133 @@ function checkManifestCiBuildInfoExport() { return violations; } -function checkFlatpakWorkflowTestBuildContracts() { +/** + * @param {unknown} doc + * @param {string} fileLabel + * @returns {{ file: string, message: string }[]} + */ +export function flatpakWorkflowTestBuildContractViolations(doc, fileLabel) { const violations = []; - if (!fs.existsSync(FLATPAK_WORKFLOW)) return violations; - const yaml = fs.readFileSync(FLATPAK_WORKFLOW, 'utf8'); - const rel = path.relative(ROOT, FLATPAK_WORKFLOW); + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) { + violations.push({ file: fileLabel, message: 'flatpak.yaml must parse to a mapping' }); + return violations; + } - if (!yaml.includes('Build Flatpak (no release)')) { + const runName = typeof doc['run-name'] === 'string' ? doc['run-name'] : ''; + if (!runName.includes('Build Flatpak (no release)')) { violations.push({ - file: rel, + file: fileLabel, message: 'flatpak.yaml run-name / labels must include Build Flatpak (no release) for workflow_dispatch', }); } - if (!yaml.includes('write-flatpak-ci-build-info.mjs')) { + + const jobs = doc.jobs; + if (!jobs || typeof jobs !== 'object' || Array.isArray(jobs)) { + violations.push({ file: fileLabel, message: 'flatpak.yaml must define jobs' }); + return violations; + } + const flatpakJob = jobs.flatpak; + if (!flatpakJob || typeof flatpakJob !== 'object' || Array.isArray(flatpakJob)) { + violations.push({ file: fileLabel, message: 'flatpak.yaml must define a flatpak job' }); + return violations; + } + const steps = flatpakJob.steps; + if (!Array.isArray(steps)) { + violations.push({ file: fileLabel, message: 'flatpak job must define steps' }); + return violations; + } + + let sawBuildInfoWriter = false; + let sawDeferredUpload = false; + let sawDispatchRename = false; + + for (const step of steps) { + if (!step || typeof step !== 'object' || Array.isArray(step)) continue; + const run = typeof step.run === 'string' ? step.run : ''; + if (/(?:^|\n)\s*node\s+scripts\/write-flatpak-ci-build-info\.mjs\b/.test(run)) { + sawBuildInfoWriter = true; + } + if ( + /(?:^|\n)\s*node\s+scripts\/rename-test-build-artifacts\.mjs\b/.test(run) && + /(?:^|\s)--flatpak\b/.test(run) && + typeof step.if === 'string' && + step.if.includes('workflow_dispatch') + ) { + sawDispatchRename = true; + } + const withBlock = + step.with && typeof step.with === 'object' && !Array.isArray(step.with) ? step.with : null; + if ( + typeof step.uses === 'string' && + step.uses.includes('flatpak-builder') && + withBlock && + (withBlock['upload-artifact'] === false || withBlock['upload-artifact'] === 'false') + ) { + sawDeferredUpload = true; + } + } + + if (!sawBuildInfoWriter) { violations.push({ - file: rel, + file: fileLabel, message: 'flatpak.yaml must write flatpak/ci-build-info.json via write-flatpak-ci-build-info.mjs', }); } - if (!/upload-artifact:\s*false/.test(yaml)) { + if (!sawDeferredUpload) { violations.push({ - file: rel, + file: fileLabel, message: 'flatpak-builder must set upload-artifact: false so smoke/rename can run before a single upload', }); } - if (!yaml.includes('rename-test-build-artifacts.mjs --flatpak')) { + if (!sawDispatchRename) { violations.push({ - file: rel, + file: fileLabel, message: - 'flatpak.yaml must rename dispatch bundles with rename-test-build-artifacts.mjs --flatpak', + 'flatpak.yaml must rename dispatch bundles with rename-test-build-artifacts.mjs --flatpak gated on workflow_dispatch', }); } return violations; } +function checkManifestCiBuildInfoExport() { + const violations = []; + if (!fs.existsSync(MANIFEST)) return violations; + const rel = path.relative(ROOT, MANIFEST); + let doc; + try { + doc = yaml.load(fs.readFileSync(MANIFEST, 'utf8')); + } catch (e) { + return [ + { + file: rel, + message: `failed to parse manifest YAML: ${e instanceof Error ? e.message : String(e)}`, + }, + ]; + } + return manifestCiBuildInfoExportViolations(doc, rel); +} + +function checkFlatpakWorkflowTestBuildContracts() { + const violations = []; + if (!fs.existsSync(FLATPAK_WORKFLOW)) return violations; + const rel = path.relative(ROOT, FLATPAK_WORKFLOW); + let doc; + try { + doc = yaml.load(fs.readFileSync(FLATPAK_WORKFLOW, 'utf8')); + } catch (e) { + return [ + { + file: rel, + message: `failed to parse flatpak.yaml: ${e instanceof Error ? e.message : String(e)}`, + }, + ]; + } + return flatpakWorkflowTestBuildContractViolations(doc, rel); +} + function main() { const violations = [ ...checkMetainfoVersionMatchesPackage(PKG_JSON), @@ -531,4 +651,6 @@ function main() { process.exit(1); } -main(); +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + main(); +} diff --git a/scripts/check-flatpak.test.mjs b/scripts/check-flatpak.test.mjs new file mode 100644 index 000000000..b7161fb2b --- /dev/null +++ b/scripts/check-flatpak.test.mjs @@ -0,0 +1,88 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import yaml from 'js-yaml'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + flatpakWorkflowTestBuildContractViolations, + manifestCiBuildInfoExportViolations, +} from './check-flatpak.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +describe('manifestCiBuildInfoExportViolations', () => { + it('accepts the real Flatpak manifest', () => { + const doc = yaml.load( + fs.readFileSync(path.join(ROOT, 'org.coloradomesh.MeshClient.yml'), 'utf8'), + ); + expect(manifestCiBuildInfoExportViolations(doc, 'manifest')).toEqual([]); + }); + + it('rejects when export lives only in comments / decoy text (not build-commands)', () => { + const doc = { + modules: [ + { + name: 'mesh-client', + 'build-commands': [ + // Unrelated decoy that a raw-text search for MESH_CLIENT_BUILD_INFO might hit + 'echo "see docs: MESH_CLIENT_BUILD_INFO / flatpak/ci-build-info.json"', + 'pnpm run build', + ], + }, + ], + }; + const violations = manifestCiBuildInfoExportViolations(doc, 'fake.yml'); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0].message).toMatch(/MESH_CLIENT_BUILD_INFO/); + }); + + it('rejects missing mesh-client module', () => { + expect(manifestCiBuildInfoExportViolations({ modules: [] }, 'fake.yml')).toEqual([ + { + file: 'fake.yml', + message: 'manifest must include a mesh-client module', + }, + ]); + }); +}); + +describe('flatpakWorkflowTestBuildContractViolations', () => { + it('accepts the real flatpak workflow', () => { + const doc = yaml.load( + fs.readFileSync(path.join(ROOT, '.github/workflows/flatpak.yaml'), 'utf8'), + ); + expect(flatpakWorkflowTestBuildContractViolations(doc, 'flatpak.yaml')).toEqual([]); + }); + + it('ignores commented-out and unrelated string matches in raw YAML text', () => { + // Parsed document has no writer/rename/deferred-upload steps — only decoy strings + // that a raw-text search would falsely accept. + const doc = { + 'run-name': 'Build Flatpak', + jobs: { + flatpak: { + steps: [ + { + name: 'decoy', + run: '# write-flatpak-ci-build-info.mjs\necho rename-test-build-artifacts.mjs --flatpak', + }, + { + uses: 'flatpak/flatpak-github-actions/flatpak-builder@deadbeef', + with: { + // Comment-like decoy key must not satisfy upload-artifact: false + 'upload-artifact-comment': 'false', + }, + }, + ], + }, + }, + }; + const violations = flatpakWorkflowTestBuildContractViolations(doc, 'fake.yaml'); + expect(violations.map((v) => v.message).join('\n')).toMatch(/Build Flatpak \(no release\)/); + expect(violations.map((v) => v.message).join('\n')).toMatch(/write-flatpak-ci-build-info/); + expect(violations.map((v) => v.message).join('\n')).toMatch(/upload-artifact: false/); + expect(violations.map((v) => v.message).join('\n')).toMatch(/rename-test-build-artifacts/); + }); +}); diff --git a/scripts/rename-test-build-artifacts.mjs b/scripts/rename-test-build-artifacts.mjs index ce7ca6cbe..6fbee0312 100644 --- a/scripts/rename-test-build-artifacts.mjs +++ b/scripts/rename-test-build-artifacts.mjs @@ -113,14 +113,8 @@ export function parseBuildInfoEnv(raw) { /** @type {{ channel?: string, runNumber?: number }} */ const out = {}; if (typeof parsed.channel === 'string') out.channel = parsed.channel; - if (typeof parsed.runNumber === 'number' && Number.isFinite(parsed.runNumber)) { - out.runNumber = Math.floor(parsed.runNumber); - } else if (parsed.runNumber != null && parsed.runNumber !== '') { - const n = Number(parsed.runNumber); - if (!Number.isFinite(n)) { - throw new Error(`Invalid runNumber in MESH_CLIENT_BUILD_INFO: ${String(parsed.runNumber)}`); - } - out.runNumber = Math.floor(n); + if (parsed.runNumber != null && parsed.runNumber !== '') { + out.runNumber = parseStrictRunNumber(parsed.runNumber, 'MESH_CLIENT_BUILD_INFO.runNumber'); } return out; } catch (e) { @@ -131,6 +125,19 @@ export function parseBuildInfoEnv(raw) { } } +/** + * @param {unknown} value + * @param {string} label + * @returns {number} + */ +function parseStrictRunNumber(value, label) { + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) { + throw new Error(`${label} must be a finite non-negative integer (got ${String(value)})`); + } + return n; +} + /** * @param {{ * channel?: string @@ -146,12 +153,15 @@ export function resolveTestRenameStamp(opts) { return null; } const runNumber = opts.runNumber ?? fromEnv.runNumber; - if (runNumber == null || !Number.isFinite(runNumber)) { + if (runNumber == null) { throw new Error( 'MESH_CLIENT_BUILD_CHANNEL=test requires a finite runNumber (MESH_CLIENT_BUILD_INFO.runNumber)', ); } - return { channel: 'test', runNumber: Math.floor(runNumber) }; + return { + channel: 'test', + runNumber: parseStrictRunNumber(runNumber, 'runNumber'), + }; } /** @@ -285,8 +295,12 @@ function main() { if (parsed.flatpakCwd) { // Only touch Flatpak bundles in the given directory (non-recursive). const files = fs - .readdirSync(parsed.rootDir) - .filter((n) => n.endsWith('.flatpak') && shouldRenameInstaller(n)); + .readdirSync(parsed.rootDir, { withFileTypes: true }) + .filter( + (entry) => + entry.isFile() && entry.name.endsWith('.flatpak') && shouldRenameInstaller(entry.name), + ) + .map((entry) => entry.name); const stamp = resolveTestRenameStamp({ channel: parsed.channel, buildInfoRaw: parsed.buildInfoRaw, diff --git a/scripts/rename-test-build-artifacts.test.mjs b/scripts/rename-test-build-artifacts.test.mjs index bfb51abac..a61b22f7e 100644 --- a/scripts/rename-test-build-artifacts.test.mjs +++ b/scripts/rename-test-build-artifacts.test.mjs @@ -109,6 +109,24 @@ describe('resolveTestRenameStamp', () => { runNumber: 214, }); }); + + it('rejects fractional and negative runNumber in parseBuildInfoEnv', () => { + expect(() => parseBuildInfoEnv(JSON.stringify({ runNumber: 1.5 }))).toThrow( + /non-negative integer/, + ); + expect(() => parseBuildInfoEnv(JSON.stringify({ runNumber: -1 }))).toThrow( + /non-negative integer/, + ); + }); + + it('rejects fractional and negative runNumber in resolveTestRenameStamp', () => { + expect(() => resolveTestRenameStamp({ channel: 'test', runNumber: 2.5 })).toThrow( + /non-negative integer/, + ); + expect(() => resolveTestRenameStamp({ channel: 'test', runNumber: -3 })).toThrow( + /non-negative integer/, + ); + }); }); describe('renameTestBuildArtifacts', () => { diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 4e70941fd..2fbd8a665 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -82,6 +82,7 @@ import { import { shouldDeletePriorReticulumOutboundHash } from '@/renderer/lib/reticulum/reticulumOutboundRetry'; import { applyPropagationSyncEvent, + normalizePropagationSyncProgress, RETICULUM_PROPAGATION_SYNC_STALL_MS, } from '@/renderer/lib/reticulum/reticulumPropagationSync'; import { reticulumWireRowToEntry } from '@/renderer/lib/reticulum/reticulumRawPacketLog'; @@ -837,12 +838,9 @@ export function useReticulumRuntime(): ProtocolRuntime { scheduleDebouncedDiagnosticsRefresh(); // Sync Completes can leave inbound LXMF only in the sidecar ring until the next // periodic catch-up — pull immediately so Chat updates without waiting ~60s. - const normalizedProgress = - typeof p.progress === 'number' && Number.isFinite(p.progress) - ? p.progress <= 1 - ? p.progress * 100 - : Math.min(100, p.progress) - : 0; + const normalizedProgress = normalizePropagationSyncProgress( + typeof p.progress === 'number' ? p.progress : 0, + ); if ( wasSyncActive && p.active === false && From 76bf1ac549080d307f169cc7d4f407d1ba43a20b Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 17:25:53 -0600 Subject: [PATCH 5/6] fix(test): update Windows packaging contract for setup-name helper verify-win-packaging no longer embeds -arm64.exe literals after the shared naming helper extract; assert the helper contract instead so Coverage (main) passes. --- src/main/windows-packaging.contract.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/main/windows-packaging.contract.test.ts b/src/main/windows-packaging.contract.test.ts index 10412fe44..804aec13a 100644 --- a/src/main/windows-packaging.contract.test.ts +++ b/src/main/windows-packaging.contract.test.ts @@ -99,10 +99,18 @@ describe('Windows packaging (contract)', () => { 'utf-8', ); expect(verifyScript).toContain('win-arm64-unpacked'); - expect(verifyScript).toContain('-arm64.exe'); + expect(verifyScript).toContain('collectWinSetupInstallers'); + expect(verifyScript).toContain('win-setup-installer-names'); expect(verifyScript).toContain('reticulum-sidecar'); expect(verifyScript).toContain('assertBundledReticulumSidecarInBundle'); expect(verifyScript).not.toContain('resedit'); + + const setupNamesScript = readFileSync( + join(REPO_ROOT, 'scripts', 'win-setup-installer-names.mjs'), + 'utf-8', + ); + expect(setupNamesScript).toContain('-arm64.exe'); + expect(setupNamesScript).toContain('^-run\\d+-arm64$'); }); it('pins electron-builder to 26.15.4 or newer', () => { From 0ef51d15f1bc32eb2828a79b25a7891dcbfe0699 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Thu, 6 Aug 2026 17:39:15 -0600 Subject: [PATCH 6/6] chore(deps): bump meshcore.js to 1.14.0 and rebase patch Keeps pnpm update working after upstream companion v3; no app API changes required. --- docs/credits.md | 2 +- package.json | 6 +- ... => @liamcottle__meshcore.js@1.14.0.patch} | 12 +- patches/README.md | 8 +- pnpm-lock.yaml | 134 +++++++++--------- pnpm-workspace.yaml | 2 +- src/main/meshcorePatch.contract.test.ts | 2 +- 7 files changed, 83 insertions(+), 83 deletions(-) rename patches/{@liamcottle__meshcore.js@1.13.0.patch => @liamcottle__meshcore.js@1.14.0.patch} (92%) diff --git a/docs/credits.md b/docs/credits.md index 043d14495..6c690e004 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -80,7 +80,7 @@ Exact semver ranges live in [`package.json`](https://github.com/Colorado-Mesh/me | -------------------------------- | -------------------------------------------------- | --------------- | -------------------------------- | | @axe-core/react | ^4.12.1 | MPL-2.0 | Accessibility testing | | @eslint/js | ^10.0.1 | MIT | ESLint flat-config helpers | -| @liamcottle/meshcore.js | ^1.13.0 | MIT | MeshCore JS library | +| @liamcottle/meshcore.js | ^1.14.0 | MIT | MeshCore JS library | | @meshtastic/core | npm:@jsr/meshtastic\_\_core@^2.6.6 | Apache-2.0 | Meshtastic core | | @meshtastic/transport-http | npm:@jsr/meshtastic\_\_transport-http@^0.2.1 | Apache-2.0 | HTTP transport | | @meshtastic/transport-web-serial | npm:@jsr/meshtastic\_\_transport-web-serial@^0.2.5 | Apache-2.0 | Web Serial transport | diff --git a/package.json b/package.json index de1ff07e8..272ae1c6f 100644 --- a/package.json +++ b/package.json @@ -172,7 +172,7 @@ "devDependencies": { "@axe-core/react": "^4.12.1", "@eslint/js": "^10.0.1", - "@liamcottle/meshcore.js": "^1.13.0", + "@liamcottle/meshcore.js": "^1.14.0", "@meshtastic/core": "npm:@jsr/meshtastic__core@^2.6.6", "@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http@^0.2.1", "@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial@^0.2.5", @@ -214,7 +214,7 @@ "leaflet": "^1.9.4", "license-checker-rseidelsohn": "^4.4.2", "markdownlint-cli2": "^0.22.1", - "postcss": "^8.5.25", + "postcss": "^8.5.26", "prettier": "^3.9.6", "prettier-plugin-sh": "^0.18.1", "prettier-plugin-tailwindcss": "^0.7.4", @@ -226,7 +226,7 @@ "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "typescript-eslint": "^8.66.0", - "vite": "^8.2.0", + "vite": "^8.2.1", "vitest": "^4.1.10", "vitest-axe": "1.0.0-pre.5", "zustand": "^5.0.14" diff --git a/patches/@liamcottle__meshcore.js@1.13.0.patch b/patches/@liamcottle__meshcore.js@1.14.0.patch similarity index 92% rename from patches/@liamcottle__meshcore.js@1.13.0.patch rename to patches/@liamcottle__meshcore.js@1.14.0.patch index d5eeae03a..673139607 100644 --- a/patches/@liamcottle__meshcore.js@1.13.0.patch +++ b/patches/@liamcottle__meshcore.js@1.14.0.patch @@ -1,5 +1,5 @@ diff --git a/src/buffer_reader.js b/src/buffer_reader.js -index 9142fbc9fda24c07082d63ced24117a35b29dc93..2f8cf2d68d1c32c1d7cc489f33527c9ad89545d4 100644 +index 9142fbc..2f8cf2d 100644 --- a/src/buffer_reader.js +++ b/src/buffer_reader.js @@ -24,7 +24,10 @@ class BufferReader { @@ -15,7 +15,7 @@ index 9142fbc9fda24c07082d63ced24117a35b29dc93..2f8cf2d68d1c32c1d7cc489f33527c9a readCString(maxLength) { diff --git a/src/connection/connection.js b/src/connection/connection.js -index af6f6d670adc0948d3453df34acbbde46c1b4250..2fb915e4e5cf109afc7772dc380bb016e65af7dd 100644 +index 541ce73..50a3bef 100644 --- a/src/connection/connection.js +++ b/src/connection/connection.js @@ -338,6 +338,14 @@ class Connection extends EventEmitter { @@ -33,7 +33,7 @@ index af6f6d670adc0948d3453df34acbbde46c1b4250..2fb915e4e5cf109afc7772dc380bb016 onFrameReceived(frame) { // emit received frame -@@ -412,8 +420,14 @@ class Connection extends EventEmitter { +@@ -416,8 +424,14 @@ class Connection extends EventEmitter { this.onNewAdvertPush(bufferReader); } else if(responseCode === Constants.PushCodes.BinaryResponse){ this.onBinaryResponsePush(bufferReader); @@ -49,7 +49,7 @@ index af6f6d670adc0948d3453df34acbbde46c1b4250..2fb915e4e5cf109afc7772dc380bb016 } } -@@ -494,15 +508,25 @@ class Connection extends EventEmitter { +@@ -498,15 +512,25 @@ class Connection extends EventEmitter { onTraceDataPush(bufferReader) { const reserved = bufferReader.readByte(); const pathLen = bufferReader.readUInt8(); @@ -81,7 +81,7 @@ index af6f6d670adc0948d3453df34acbbde46c1b4250..2fb915e4e5cf109afc7772dc380bb016 }); } -@@ -582,11 +606,30 @@ class Connection extends EventEmitter { +@@ -586,11 +610,30 @@ class Connection extends EventEmitter { } onDeviceInfoResponse(bufferReader) { @@ -116,7 +116,7 @@ index af6f6d670adc0948d3453df34acbbde46c1b4250..2fb915e4e5cf109afc7772dc380bb016 }); } -@@ -2375,6 +2418,33 @@ class Connection extends EventEmitter { +@@ -2401,6 +2444,33 @@ class Connection extends EventEmitter { }); } diff --git a/patches/README.md b/patches/README.md index 5c06162ef..1cfe40737 100644 --- a/patches/README.md +++ b/patches/README.md @@ -4,16 +4,16 @@ Local overlays applied via `pnpm-workspace.yaml` → `patchedDependencies`. When | Patch | Upstream | Upstream PR / status | | ----- | -------- | -------------------- | -| `@liamcottle__meshcore.js@1.13.0.patch` | [meshcore-dev/meshcore.js](https://github.com/meshcore-dev/meshcore.js) | Open: [#30](https://github.com/meshcore-dev/meshcore.js/pull/30), [#31](https://github.com/meshcore-dev/meshcore.js/pull/31), [#33](https://github.com/meshcore-dev/meshcore.js/pull/33); [#29](https://github.com/meshcore-dev/meshcore.js/pull/29) closed (unnecessary); [#32](https://github.com/meshcore-dev/meshcore.js/pull/32) closed (not carried — firmware does not push LoginFail) | +| `@liamcottle__meshcore.js@1.14.0.patch` | [meshcore-dev/meshcore.js](https://github.com/meshcore-dev/meshcore.js) | Open: [#30](https://github.com/meshcore-dev/meshcore.js/pull/30), [#31](https://github.com/meshcore-dev/meshcore.js/pull/31), [#33](https://github.com/meshcore-dev/meshcore.js/pull/33); [#29](https://github.com/meshcore-dev/meshcore.js/pull/29) closed (unnecessary); [#32](https://github.com/meshcore-dev/meshcore.js/pull/32) closed (not carried — firmware does not push LoginFail). Upstream `1.14.0` adds companion protocol v3 Contact/ChannelMsgRecv (+ optional `snr`); patch rebased onto that release. | | `@jsr__meshtastic__core@2.6.6.patch` | [meshtastic/web](https://github.com/meshtastic/web) (`packages/sdk`) | [#1312](https://github.com/meshtastic/web/pull/1312) | | `@jsr__meshtastic__transport-web-serial@0.2.5.patch` | [meshtastic/web](https://github.com/meshtastic/web) (`packages/transport-web-serial`) | Fixed on upstream `main` (per-instance `toDeviceStream` + abort); keep patch until npm/`@jsr` package bump includes it | | `usb@2.18.0.patch` | [node-usb/node-usb](https://github.com/node-usb/node-usb) | [#964](https://github.com/node-usb/node-usb/pull/964) | | `readable-stream@4.7.0.patch` | [nodejs/readable-stream](https://github.com/nodejs/readable-stream) | **Intentionally local** — upstream uses `require('process/')` for browser bundlers; Electron/Node needs bare `process` | | `debug@4.4.3.patch` | [debug-js/debug](https://github.com/debug-js/debug) | **Intentionally local** — inlines `ms`/`humanize` so electron-vite does not fail resolving the `ms` dependency | -## @liamcottle/meshcore.js@1.13.0 +## @liamcottle/meshcore.js@1.14.0 -Protocol / companion-radio fixes. Upstreamed as focused PRs (npm package name remains `@liamcottle/meshcore.js`; repo lives under `meshcore-dev`). +Protocol / companion-radio fixes. Upstreamed as focused PRs (npm package name remains `@liamcottle/meshcore.js`; repo lives under `meshcore-dev`). Rebased from the prior `1.13.0` patch onto `1.14.0` (which adds companion protocol v3 `ContactMsgRecvV3` / `ChannelMsgRecvV3` and bumps `SupportedCompanionProtocolVersion` to 3). | PR | Change | Status | | -- | ------ | ------ | @@ -27,7 +27,7 @@ Protocol / companion-radio fixes. Upstreamed as focused PRs (npm package name re ### Sunset -When [#30](https://github.com/meshcore-dev/meshcore.js/pull/30), [#31](https://github.com/meshcore-dev/meshcore.js/pull/31), and [#33](https://github.com/meshcore-dev/meshcore.js/pull/33) merge and a release newer than `1.13.0` includes them, drop the corresponding hunks (or the whole patch if only local-only hunks remain), bump the dependency, and remove this entry from `WATCH_ENTRIES` if no patch remains. Do not re-add the #29 empty-password or #32 LoginFail hunks. +When [#30](https://github.com/meshcore-dev/meshcore.js/pull/30), [#31](https://github.com/meshcore-dev/meshcore.js/pull/31), and [#33](https://github.com/meshcore-dev/meshcore.js/pull/33) merge and a release newer than `1.14.0` includes them, drop the corresponding hunks (or the whole patch if only local-only hunks remain), bump the dependency, and remove this entry from `WATCH_ENTRIES` if no patch remains. Do not re-add the #29 empty-password or #32 LoginFail hunks. ## @jsr/meshtastic__core@2.6.6 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3a1f668e..663812f0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,7 +29,7 @@ overrides: patchedDependencies: '@jsr/meshtastic__core@2.6.6': 93604a080fa754cbde3dc78ef46ee50c2996c34d6d85720acacecfd6ebd7c652 '@jsr/meshtastic__transport-web-serial@0.2.5': 27a2418bae8605e0e5ab6f1fcfd391abd9dc3110433da5754e934f38475dab74 - '@liamcottle/meshcore.js@1.13.0': 9e66b8389ac6dcbd308a7498167c5440d25e51a670837a039cac0c0aca368e44 + '@liamcottle/meshcore.js@1.14.0': 3b4aec317b533236af763c913077fb9b2ba93d74e9b35a065b0b6f96d833cf11 debug@4.4.3: cf37fa96f5df733456b16c82c9e1c9054a92f6216692aa33d5e2e1e208888e37 readable-stream@4.7.0: 96023d9278085d7490d08bce1a5079dd202f7782a0daa66fa81c6b1424ef8ab1 usb@2.18.0: 6b746e2d49b9b006a88aec5bed7a13c629d7f5ba7b40e9f1e039136754c32533 @@ -127,8 +127,8 @@ importers: specifier: ^10.0.1 version: 10.0.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1)) '@liamcottle/meshcore.js': - specifier: ^1.13.0 - version: 1.13.0(patch_hash=9e66b8389ac6dcbd308a7498167c5440d25e51a670837a039cac0c0aca368e44)(supports-color@8.1.1) + specifier: ^1.14.0 + version: 1.14.0(patch_hash=3b4aec317b533236af763c913077fb9b2ba93d74e9b35a065b0b6f96d833cf11)(supports-color@8.1.1) '@meshtastic/core': specifier: npm:@jsr/meshtastic__core@^2.6.6 version: '@jsr/meshtastic__core@2.6.6(patch_hash=93604a080fa754cbde3dc78ef46ee50c2996c34d6d85720acacecfd6ebd7c652)(buffer@6.0.3)' @@ -188,7 +188,7 @@ importers: version: 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) '@vitejs/plugin-react': specifier: ^6.0.5 - version: 6.0.5(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 6.0.5(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/coverage-v8': specifier: ^4.1.10 version: 4.1.10(vitest@4.1.10) @@ -242,7 +242,7 @@ importers: version: 6.2.5 jsdom: specifier: ^29.1.1 - version: 29.1.1(@noble/hashes@2.2.0) + version: 29.1.1(@noble/hashes@2.3.0) leaflet: specifier: ^1.9.4 version: 1.9.4 @@ -254,7 +254,7 @@ importers: version: 0.22.1(supports-color@8.1.1) postcss: specifier: ^8.5.25 - version: 8.5.25 + version: 8.5.26 prettier: specifier: ^3.9.6 version: 3.9.6 @@ -289,17 +289,17 @@ importers: specifier: ^8.66.0 version: 8.66.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3) vite: - specifier: ^8.2.0 - version: 8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + specifier: ^8.2.1 + version: 8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) vitest-axe: specifier: 1.0.0-pre.5 version: 1.0.0-pre.5(vitest@4.1.10) zustand: specifier: ^5.0.14 - version: 5.0.14(@types/react@19.2.18)(immer@11.1.15)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) + version: 5.0.14(@types/react@19.2.18)(immer@11.1.16)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) packages: @@ -750,8 +750,8 @@ packages: '@jsr/meshtastic__transport-web-serial@0.2.5': resolution: {integrity: sha512-/9PinLtEzHNYj2wJfv0x1YhqZLbz7y7Flzj+83LHGpi6GQMOPELmHaYjqFJN8QOlsWDQnEvxAfL5NOz8qpxmVA==, tarball: https://npm.jsr.io/~/11/@jsr/meshtastic__transport-web-serial/0.2.5.tgz} - '@liamcottle/meshcore.js@1.13.0': - resolution: {integrity: sha512-/jyKDeN7Ntn90u9E6jX+SohTrDqoGX9lu4u+CPF+gGDTizhjdLtvdQIFMB1/9ITU8UR7w0n0q2QzCziJGXCUpQ==} + '@liamcottle/meshcore.js@1.14.0': + resolution: {integrity: sha512-WOJppqrFMXN8gx+by0M6OAD5x4smus49C+ro61735TsyEM8vLhnv6ZIDDB8Y/9m/B7QUrHgdVBHM+chz+wH2Yw==} '@malept/cross-spawn-promise@2.0.0': resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} @@ -780,8 +780,8 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} - '@noble/hashes@2.2.0': - resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} engines: {node: '>= 20.19.0'} '@nodelib/fs.scandir@2.1.5': @@ -1736,8 +1736,8 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} - caniuse-lite@1.0.30001806: - resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + caniuse-lite@1.0.30001807: + resolution: {integrity: sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -2077,8 +2077,8 @@ packages: electron-publish@26.15.3: resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} - electron-to-chromium@1.5.401: - resolution: {integrity: sha512-H6ViHN68nGYlChEvlIU67fn8O2/tpbWQPwck98yaJmh+08LSvHiydzDQ6oXNccLU3kNRVIRS9A4mA7CG+i6fLQ==} + electron-to-chromium@1.5.402: + resolution: {integrity: sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA==} electron-updater@6.8.9: resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} @@ -2652,8 +2652,8 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - immer@11.1.15: - resolution: {integrity: sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==} + immer@11.1.16: + resolution: {integrity: sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q==} imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} @@ -3492,8 +3492,8 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.52: - resolution: {integrity: sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==} + node-releases@2.0.53: + resolution: {integrity: sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==} engines: {node: '>=18'} nopt@7.2.1: @@ -3683,8 +3683,8 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} postject@1.0.0-alpha.6: @@ -4515,8 +4515,8 @@ packages: victory-vendor@37.3.6: resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} - vite@8.2.0: - resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -5159,9 +5159,9 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + '@exodus/bytes@1.15.1(@noble/hashes@2.3.0)': optionalDependencies: - '@noble/hashes': 2.2.0 + '@noble/hashes': 2.3.0 '@humanfs/core@0.19.2': dependencies: @@ -5237,7 +5237,7 @@ snapshots: transitivePeerDependencies: - buffer - '@liamcottle/meshcore.js@1.13.0(patch_hash=9e66b8389ac6dcbd308a7498167c5440d25e51a670837a039cac0c0aca368e44)(supports-color@8.1.1)': + '@liamcottle/meshcore.js@1.14.0(patch_hash=3b4aec317b533236af763c913077fb9b2ba93d74e9b35a065b0b6f96d833cf11)(supports-color@8.1.1)': dependencies: '@noble/curves': 1.9.7 serialport: 13.0.0(supports-color@8.1.1) @@ -5274,7 +5274,7 @@ snapshots: '@noble/hashes@1.8.0': {} - '@noble/hashes@2.2.0': {} + '@noble/hashes@2.3.0': {} '@nodelib/fs.scandir@2.1.5': dependencies: @@ -5335,7 +5335,7 @@ snapshots: dependencies: '@standard-schema/spec': 1.1.0 '@standard-schema/utils': 0.3.0 - immer: 11.1.15 + immer: 11.1.16 redux: 5.0.1 redux-thunk: 3.1.0(redux@5.0.1) reselect: 5.2.0 @@ -5616,7 +5616,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 - postcss: 8.5.25 + postcss: 8.5.26 tailwindcss: 4.3.3 '@tanstack/react-virtual@3.14.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': @@ -5873,10 +5873,10 @@ snapshots: '@typescript-eslint/types': 8.66.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.5(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) '@vitest/coverage-v8@4.1.10(vitest@4.1.10)': dependencies: @@ -5890,7 +5890,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/expect@4.1.10': dependencies: @@ -5901,13 +5901,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.1 - '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.7': dependencies: @@ -5999,7 +5999,7 @@ snapshots: '@electron/rebuild': 4.2.0(supports-color@8.1.1) '@electron/universal': 2.0.3(supports-color@8.1.1) '@malept/flatpak-bundler': 0.4.0(supports-color@8.1.1) - '@noble/hashes': 2.2.0 + '@noble/hashes': 2.3.0 '@peculiar/webcrypto': 1.7.1 '@types/fs-extra': 9.0.13 ajv: 8.20.0 @@ -6194,9 +6194,9 @@ snapshots: browserslist@4.28.7: dependencies: baseline-browser-mapping: 2.11.12 - caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.401 - node-releases: 2.0.52 + caniuse-lite: 1.0.30001807 + electron-to-chromium: 1.5.402 + node-releases: 2.0.53 update-browserslist-db: 1.2.3(browserslist@4.28.7) buffer-from@1.1.2: {} @@ -6265,7 +6265,7 @@ snapshots: camelcase@5.3.1: {} - caniuse-lite@1.0.30001806: {} + caniuse-lite@1.0.30001807: {} chai@6.2.2: {} @@ -6411,10 +6411,10 @@ snapshots: damerau-levenshtein@1.0.8: {} - data-urls@7.0.0(@noble/hashes@2.2.0): + data-urls@7.0.0(@noble/hashes@2.3.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@2.2.0) + whatwg-url: 16.0.1(@noble/hashes@2.3.0) transitivePeerDependencies: - '@noble/hashes' @@ -6598,7 +6598,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-to-chromium@1.5.401: {} + electron-to-chromium@1.5.402: {} electron-updater@6.8.9(supports-color@8.1.1): dependencies: @@ -7321,9 +7321,9 @@ snapshots: dependencies: lru-cache: 7.18.3 - html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): + html-encoding-sniffer@6.0.0(@noble/hashes@2.3.0): dependencies: - '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + '@exodus/bytes': 1.15.1(@noble/hashes@2.3.0) transitivePeerDependencies: - '@noble/hashes' @@ -7364,7 +7364,7 @@ snapshots: immediate@3.0.6: {} - immer@11.1.15: {} + immer@11.1.16: {} imurmurhash@0.1.4: {} @@ -7586,17 +7586,17 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@29.1.1(@noble/hashes@2.2.0): + jsdom@29.1.1(@noble/hashes@2.3.0): dependencies: '@asamuzakjp/css-color': 5.1.11 '@asamuzakjp/dom-selector': 7.1.1 '@bramus/specificity': 2.4.2 '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) - '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + '@exodus/bytes': 1.15.1(@noble/hashes@2.3.0) css-tree: 3.2.1 - data-urls: 7.0.0(@noble/hashes@2.2.0) + data-urls: 7.0.0(@noble/hashes@2.3.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) + html-encoding-sniffer: 6.0.0(@noble/hashes@2.3.0) is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.2 parse5: 8.0.1 @@ -7607,7 +7607,7 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1(@noble/hashes@2.2.0) + whatwg-url: 16.0.1(@noble/hashes@2.3.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -8263,7 +8263,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.52: {} + node-releases@2.0.53: {} nopt@7.2.1: dependencies: @@ -8472,7 +8472,7 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss@8.5.25: + postcss@8.5.26: dependencies: nanoid: 3.3.17 picocolors: 1.1.1 @@ -8655,7 +8655,7 @@ snapshots: decimal.js-light: 2.5.1 es-toolkit: 1.50.0 eventemitter3: 5.0.4 - immer: 11.1.15 + immer: 11.1.16 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) react-is: 17.0.2 @@ -9373,11 +9373,11 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0): + vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.25 + postcss: 8.5.26 rolldown: 1.2.3 tinyglobby: 0.2.17 optionalDependencies: @@ -9393,12 +9393,12 @@ snapshots: axe-core: 4.13.0 chalk: 5.6.2 lodash-es: 4.18.1 - vitest: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) - vitest@4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@25.9.5)(@vitest/coverage-v8@4.1.10)(jsdom@29.1.1(@noble/hashes@2.3.0))(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -9415,12 +9415,12 @@ snapshots: tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.1 - vite: 8.2.0(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.2.1(@types/node@25.9.5)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.5 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - jsdom: 29.1.1(@noble/hashes@2.2.0) + jsdom: 29.1.1(@noble/hashes@2.3.0) transitivePeerDependencies: - msw @@ -9440,9 +9440,9 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1(@noble/hashes@2.2.0): + whatwg-url@16.0.1(@noble/hashes@2.3.0): dependencies: - '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + '@exodus/bytes': 1.15.1(@noble/hashes@2.3.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: @@ -9626,9 +9626,9 @@ snapshots: zod@4.4.3: {} - zustand@5.0.14(@types/react@19.2.18)(immer@11.1.15)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): + zustand@5.0.14(@types/react@19.2.18)(immer@11.1.16)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): optionalDependencies: '@types/react': 19.2.18 - immer: 11.1.15 + immer: 11.1.16 react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6177de0da..58a5de2f5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -74,7 +74,7 @@ overrides: patchedDependencies: '@jsr/meshtastic__core@2.6.6': patches/@jsr__meshtastic__core@2.6.6.patch '@jsr/meshtastic__transport-web-serial@0.2.5': patches/@jsr__meshtastic__transport-web-serial@0.2.5.patch - '@liamcottle/meshcore.js@1.13.0': patches/@liamcottle__meshcore.js@1.13.0.patch + '@liamcottle/meshcore.js@1.14.0': patches/@liamcottle__meshcore.js@1.14.0.patch debug@4.4.3: patches/debug@4.4.3.patch readable-stream@4.7.0: patches/readable-stream@4.7.0.patch usb@2.18.0: patches/usb@2.18.0.patch diff --git a/src/main/meshcorePatch.contract.test.ts b/src/main/meshcorePatch.contract.test.ts index c59cb7b77..9661aea20 100644 --- a/src/main/meshcorePatch.contract.test.ts +++ b/src/main/meshcorePatch.contract.test.ts @@ -4,7 +4,7 @@ import { join } from 'path'; import { describe, expect, it } from 'vitest'; const PATCH = readFileSync( - join(__dirname, '../../patches/@liamcottle__meshcore.js@1.13.0.patch'), + join(__dirname, '../../patches/@liamcottle__meshcore.js@1.14.0.patch'), 'utf-8', );