mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
mesh-llm: offer envelope (sprout-core) + desktop building blocks
B0 — sprout-core/src/mesh_llm.rs (new): MeshLlmOffer envelope, the
content of a kind:31990 event. Schema versioned (v: u32), with
deny_unknown_fields at the top level and a freeform 'extra' Value
escape hatch. ResourceCaps + ModelOffer sub-structs. d_tag charset is
limited to [A-Za-z0-9_-] (NIP-33 stability). 9 unit tests covering
round-trip JSON, optional caps, unknown-field rejection, d_tag
validation, is_publishable rule set.
B2 — desktop/src-tauri/src/mesh_llm/endpoint.rs: persists the iroh
endpoint keypair to {app_data_dir}/mesh_iroh.key as 32 hex bytes.
Atomic write via tempfile.persist; corrupt files quarantined to
.bad.{epoch} (same pattern as identity.key). 2 unit tests.
Design note in the module doc: we deliberately do NOT derive the
iroh key from the Nostr key, because that would couple key rotation
(rotating the Nostr key would silently break active offers) and
invent a new key-custody convention. Separate file, same Tauri
sandbox.
B3 — desktop/src-tauri/src/mesh_llm/nip98.rs: build_nip98_bearer(keys,
iroh_relay_public_url) signs a kind:27235 event with the user's Nostr
key over the canonical relay URL (sprout_auth::nip98_canonical_url
with path '/relay'), base64-encodes the event JSON. This is the exact
token the relay's iroh_relay::verify_bearer decodes + verifies.
3 unit tests.
B-offer prefs — desktop/src-tauri/src/mesh_llm/offer.rs: persisted
ComputeSharingPrefs (the avatar-menu sliders). Default is disabled,
1 concurrent consumer cap. build_offer() returns None when disabled
so callers know to *delete* any prior offer rather than re-publish.
JSON round-trip + helper tests, 4 tests.
Workspace deps added:
- desktop pulls sprout-auth (for the canonical URL helper, nostr-free
at the API surface so the 0.36/0.37 nostr split doesn't matter).
- desktop pulls iroh-base = =1.0.0-rc.0 with the 'key' feature for
SecretKey/PublicKey/EndpointId.
- desktop pulls thiserror = '2'.
Tests: 9 desktop mesh_llm tests pass + 9 sprout-core mesh_llm tests
pass. Workspace clippy + fmt clean (relay side; desktop has expected
dead_code warnings until B4-B6 wire these in).
Note: desktop crate requires sidecar binary stubs in
desktop/src-tauri/binaries/ to typecheck; created via the existing
'just _ensure-sidecar-stubs' helper.
Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -20,6 +20,8 @@ pub mod filter;
|
||||
pub mod git_perms;
|
||||
/// Sprout kind number registry — custom event type constants.
|
||||
pub mod kind;
|
||||
/// Mesh-LLM compute-offer envelope (kind:31990 event content).
|
||||
pub mod mesh_llm;
|
||||
/// Network utilities — SSRF-safe IP classification.
|
||||
pub mod network;
|
||||
/// Agent observer frame helpers.
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Mesh-LLM compute offer envelope (kind:31990 event content).
|
||||
//!
|
||||
//! Published by Sprout members willing to share their local LLM/compute with
|
||||
//! the rest of the relay. Consumers (other Sprout members) subscribe to
|
||||
//! kind:31990 events scoped to relay membership and pick an offer that
|
||||
//! matches their request.
|
||||
//!
|
||||
//! # Schema
|
||||
//!
|
||||
//! The event content is a JSON-serialised [`MeshLlmOffer`]. The event itself
|
||||
//! is a NIP-33 parameterized-replaceable event addressed by
|
||||
//! `(pubkey, kind:31990, d_tag)` where `d_tag` is the [`MeshLlmOffer::d_tag`].
|
||||
//! This means a member can replace their own offer atomically (e.g. when the
|
||||
//! VRAM cap changes or a model is loaded/unloaded) without leaking dangling
|
||||
//! stale offers.
|
||||
//!
|
||||
//! # Trust model
|
||||
//!
|
||||
//! The signing pubkey of the kind:31990 event is the Nostr identity of the
|
||||
//! offering member; the event flows through the existing NIP-43 fan-out, so
|
||||
//! only relay members ever see it. The iroh [`endpoint_id`](MeshLlmOffer::endpoint_id)
|
||||
//! is a separate ed25519 keypair under the same member's control — the
|
||||
//! Nostr signature on the kind:31990 event is what binds those two
|
||||
//! identities together.
|
||||
//!
|
||||
//! When a consumer connects to the offered iroh endpoint, the consumer's own
|
||||
//! NIP-98 bearer (signed with its Nostr key, NOT its iroh key) is what the
|
||||
//! receiving relay uses to gate admission. So the chain of trust is:
|
||||
//!
|
||||
//! - The 31990 event proves "Nostr pubkey N offers compute via iroh endpoint E".
|
||||
//! - The NIP-98 bearer on the iroh connection proves "Nostr pubkey N' is the
|
||||
//! connecting party".
|
||||
//! - Sprout's [`check_relay_membership`] confirms N' is a relay member.
|
||||
//!
|
||||
//! There is no need to also bind N' ↔ iroh-client-endpoint cryptographically:
|
||||
//! once the membership decision allows the connection, the QUIC stream itself
|
||||
//! is end-to-end-encrypted between the two iroh endpoints. The offering side
|
||||
//! sees only `(member-pubkey N', iroh-endpoint E')`, both authenticated.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The full content of a kind:31990 event.
|
||||
///
|
||||
/// Serialized to JSON and placed in the event's `content` field. The event's
|
||||
/// `d` tag should equal [`MeshLlmOffer::d_tag`] so the event is a stable
|
||||
/// addressable replacement target.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MeshLlmOffer {
|
||||
/// Schema version. Bumped on breaking changes. Current: `1`.
|
||||
pub v: u32,
|
||||
|
||||
/// Stable identifier for *this offering node* under the publisher's
|
||||
/// pubkey. A member may publish multiple offers (e.g. one per host they
|
||||
/// own, or one per GPU); each gets a distinct `d_tag`.
|
||||
///
|
||||
/// MUST be ≤64 chars, ASCII alphanumeric + `-` + `_`. The same value
|
||||
/// must be used as the kind:31990 event's `d` tag so replaces are
|
||||
/// atomic.
|
||||
pub d_tag: String,
|
||||
|
||||
/// Iroh endpoint id (ed25519 public key, base32 z-base form as iroh
|
||||
/// renders it) of the offering node's iroh endpoint. Consumers dial
|
||||
/// this through an iroh `NodeAddr`.
|
||||
pub endpoint_id: String,
|
||||
|
||||
/// Iroh relay URL through which the offering endpoint is reachable.
|
||||
///
|
||||
/// This is the *Sprout-hosted* iroh-relay URL — copied verbatim from
|
||||
/// the publisher's view of NIP-11 `iroh_relay_url`. If multiple Sprout
|
||||
/// relays are bridged into the same membership scope in the future,
|
||||
/// this lets a consumer reach an offer behind a different host.
|
||||
pub iroh_relay_url: String,
|
||||
|
||||
/// Resource caps the offering side promises to honour for any single
|
||||
/// consumer at a time. The publisher should re-publish (replacing the
|
||||
/// previous event) whenever these change materially.
|
||||
pub caps: ResourceCaps,
|
||||
|
||||
/// Models this node is willing to serve. Empty list = "negotiate at
|
||||
/// connect time"; non-empty = the consumer should pick one of these.
|
||||
#[serde(default)]
|
||||
pub models: Vec<ModelOffer>,
|
||||
|
||||
/// Free-form opaque metadata field, reserved for future extensions
|
||||
/// (e.g. region, accelerator type, presence-style state).
|
||||
///
|
||||
/// Stored as `serde_json::Value` so additions don't require a schema
|
||||
/// bump. `deny_unknown_fields` above keeps the *top-level* schema
|
||||
/// strict; freeform extension lives here.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub extra: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Resource caps the offering side commits to for a single consumer.
|
||||
///
|
||||
/// Caps are *per-consumer* upper bounds — the offering side may host
|
||||
/// multiple concurrent consumers, each subject to these caps. The
|
||||
/// `max_concurrency` field expresses how many concurrent consumers the node
|
||||
/// will accept across all consumers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResourceCaps {
|
||||
/// Max VRAM (megabytes) the offering side will commit to a single
|
||||
/// request. `None` = no cap advertised (consumer decides whether to
|
||||
/// proceed).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_vram_mb: Option<u32>,
|
||||
|
||||
/// Max system RAM (megabytes) the offering side will commit to a
|
||||
/// single request.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_ram_mb: Option<u32>,
|
||||
|
||||
/// Max number of concurrent consumers the offering node will accept
|
||||
/// across all currently-running requests.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub max_concurrency: Option<u32>,
|
||||
}
|
||||
|
||||
/// A single model the offering node is prepared to serve.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ModelOffer {
|
||||
/// Model identifier. Convention: HuggingFace-style `org/name[:tag]`,
|
||||
/// or `local:<filename>` for ad-hoc local files. Free-form string;
|
||||
/// the consumer side is responsible for matching this against its own
|
||||
/// requested model.
|
||||
pub id: String,
|
||||
|
||||
/// Optional human-readable label for UI surfaces.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub label: Option<String>,
|
||||
|
||||
/// Approximate context window this model serves (tokens). Used for
|
||||
/// UI hints; not enforced.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub context_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
impl MeshLlmOffer {
|
||||
/// Maximum length of a `d_tag` string. Mirrors NIP-33's general rule
|
||||
/// that `d` tags should be short and stable.
|
||||
pub const MAX_D_TAG_LEN: usize = 64;
|
||||
|
||||
/// Validate that a `d_tag` is well-formed: ≤64 chars, ASCII
|
||||
/// alphanumeric / `-` / `_`.
|
||||
pub fn is_valid_d_tag(d_tag: &str) -> bool {
|
||||
!d_tag.is_empty()
|
||||
&& d_tag.len() <= Self::MAX_D_TAG_LEN
|
||||
&& d_tag
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
/// Returns true if every required field is well-formed for publishing.
|
||||
///
|
||||
/// This is a *publisher-side* sanity check; consumers should be
|
||||
/// permissive in what they accept as long as serde-deserialization
|
||||
/// succeeds.
|
||||
pub fn is_publishable(&self) -> bool {
|
||||
self.v == 1
|
||||
&& Self::is_valid_d_tag(&self.d_tag)
|
||||
&& !self.endpoint_id.is_empty()
|
||||
&& !self.iroh_relay_url.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> MeshLlmOffer {
|
||||
MeshLlmOffer {
|
||||
v: 1,
|
||||
d_tag: "node-1".to_string(),
|
||||
endpoint_id: "1234abcd".to_string(),
|
||||
iroh_relay_url: "https://relay.example.com/iroh".to_string(),
|
||||
caps: ResourceCaps {
|
||||
max_vram_mb: Some(24_000),
|
||||
max_ram_mb: Some(64_000),
|
||||
max_concurrency: Some(2),
|
||||
},
|
||||
models: vec![ModelOffer {
|
||||
id: "meta-llama/Llama-3-8B".to_string(),
|
||||
label: Some("Llama 3 8B".to_string()),
|
||||
context_tokens: Some(8192),
|
||||
}],
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_via_json() {
|
||||
let offer = sample();
|
||||
let s = serde_json::to_string(&offer).expect("serialise");
|
||||
let back: MeshLlmOffer = serde_json::from_str(&s).expect("deserialise");
|
||||
assert_eq!(offer, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_caps_default_to_none() {
|
||||
let s = r#"{
|
||||
"v": 1,
|
||||
"d_tag": "x",
|
||||
"endpoint_id": "abc",
|
||||
"iroh_relay_url": "https://r/",
|
||||
"caps": {}
|
||||
}"#;
|
||||
let offer: MeshLlmOffer = serde_json::from_str(s).expect("deserialise minimal");
|
||||
assert!(offer.caps.max_vram_mb.is_none());
|
||||
assert!(offer.caps.max_ram_mb.is_none());
|
||||
assert!(offer.caps.max_concurrency.is_none());
|
||||
assert!(offer.models.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_top_level_field_rejected() {
|
||||
// deny_unknown_fields catches schema drift.
|
||||
let s = r#"{
|
||||
"v": 1,
|
||||
"d_tag": "x",
|
||||
"endpoint_id": "abc",
|
||||
"iroh_relay_url": "https://r",
|
||||
"caps": {},
|
||||
"wat": "lol"
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<MeshLlmOffer>(s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_caps_field_rejected() {
|
||||
let s = r#"{
|
||||
"v": 1,
|
||||
"d_tag": "x",
|
||||
"endpoint_id": "abc",
|
||||
"iroh_relay_url": "https://r",
|
||||
"caps": { "wat": 7 }
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<MeshLlmOffer>(s).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_freeform_passes_through() {
|
||||
let offer = MeshLlmOffer {
|
||||
extra: Some(serde_json::json!({"region": "us-east", "gpu": "H100"})),
|
||||
..sample()
|
||||
};
|
||||
let s = serde_json::to_string(&offer).unwrap();
|
||||
let back: MeshLlmOffer = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(offer, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn d_tag_validation() {
|
||||
assert!(MeshLlmOffer::is_valid_d_tag("node-1"));
|
||||
assert!(MeshLlmOffer::is_valid_d_tag("a"));
|
||||
assert!(MeshLlmOffer::is_valid_d_tag(&"a".repeat(64)));
|
||||
assert!(!MeshLlmOffer::is_valid_d_tag(""));
|
||||
assert!(!MeshLlmOffer::is_valid_d_tag(&"a".repeat(65)));
|
||||
assert!(!MeshLlmOffer::is_valid_d_tag("node 1"));
|
||||
assert!(!MeshLlmOffer::is_valid_d_tag("node/1"));
|
||||
assert!(!MeshLlmOffer::is_valid_d_tag("nodé"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_publishable_rejects_bad_d_tag() {
|
||||
let mut offer = sample();
|
||||
offer.d_tag = "bad tag with spaces".to_string();
|
||||
assert!(!offer.is_publishable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_publishable_rejects_wrong_version() {
|
||||
let mut offer = sample();
|
||||
offer.v = 2;
|
||||
assert!(!offer.is_publishable());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_publishable_rejects_empty_endpoint() {
|
||||
let mut offer = sample();
|
||||
offer.endpoint_id = String::new();
|
||||
assert!(!offer.is_publishable());
|
||||
}
|
||||
}
|
||||
Generated
+221
-3
@@ -403,6 +403,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6"
|
||||
|
||||
[[package]]
|
||||
name = "base58ck"
|
||||
version = "0.1.0"
|
||||
@@ -854,6 +860,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746"
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -911,6 +923,15 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
@@ -1154,6 +1175,44 @@ version = "0.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "5.0.0-pre.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "335f1947f241137a14106b6f5acc5918a5ede29c9d71d3f2cb1678d5075d9fc3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest 0.11.2",
|
||||
"fiat-crypto",
|
||||
"rand_core 0.10.1",
|
||||
"rustc_version",
|
||||
"serde",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1200,6 +1259,26 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding-macro"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"data-encoding-macro-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding-macro-internal"
|
||||
version = "0.1.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.11"
|
||||
@@ -1244,7 +1323,7 @@ version = "0.99.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"convert_case 0.4.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
@@ -1266,10 +1345,12 @@ version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
|
||||
dependencies = [
|
||||
"convert_case 0.10.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"syn 2.0.117",
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1292,6 +1373,7 @@ dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.1",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1445,6 +1527,31 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
|
||||
dependencies = [
|
||||
"serdect",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "3.0.0-pre.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20449acd54b660981ae5caa2bcb56d1fe7f25f2e37a38ec507400fab034d4bb6"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"rand_core 0.10.1",
|
||||
"serde",
|
||||
"sha2 0.11.0",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.8"
|
||||
@@ -1570,6 +1677,12 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1934,11 +2047,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2201,6 +2316,15 @@ dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.29.1"
|
||||
@@ -2559,6 +2683,28 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iroh-base"
|
||||
version = "1.0.0-rc.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2160a45265eba3bd290ce698f584c9b088bee47e518e9ec4460d5e5888ef660e"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"data-encoding",
|
||||
"data-encoding-macro",
|
||||
"derive_more 2.1.1",
|
||||
"digest 0.11.2",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.4.2",
|
||||
"n0-error",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"sha2 0.11.0",
|
||||
"url",
|
||||
"zeroize",
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
@@ -2995,6 +3141,27 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "n0-error"
|
||||
version = "1.0.0-rc.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "223e946a84aa91644507a6b7865cfebbb9a231ace499041c747ab0fd30408212"
|
||||
dependencies = [
|
||||
"n0-error-macros",
|
||||
"spez",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "n0-error-macros"
|
||||
version = "1.0.0-rc.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "565305a21e6b3bf26640ad98f05a0fda12d3ab4315394566b52a7bddb8b34828"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk"
|
||||
version = "0.9.0"
|
||||
@@ -3649,7 +3816,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5024,6 +5191,16 @@ dependencies = [
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serdect"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e"
|
||||
dependencies = [
|
||||
"base16ct",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -5136,6 +5313,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
@@ -5224,6 +5407,17 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spez"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sprout"
|
||||
version = "0.1.0"
|
||||
@@ -5239,6 +5433,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
"infer",
|
||||
"iroh-base",
|
||||
"libc",
|
||||
"neteq",
|
||||
"nostr 0.36.0",
|
||||
@@ -5253,6 +5448,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sherpa-onnx",
|
||||
"sprout-auth",
|
||||
"sprout-core",
|
||||
"sprout-persona",
|
||||
"sprout-sdk",
|
||||
@@ -5270,6 +5466,7 @@ dependencies = [
|
||||
"tauri-plugin-websocket",
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tokio-util",
|
||||
@@ -5280,17 +5477,37 @@ dependencies = [
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sprout-auth"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"nostr 0.36.0",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"sprout-core",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sprout-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"nostr 0.36.0",
|
||||
"percent-encoding",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"subtle",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
@@ -6301,6 +6518,7 @@ dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -8085,7 +8303,7 @@ dependencies = [
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"indexmap 2.14.0",
|
||||
"lzma-rs",
|
||||
"memchr",
|
||||
|
||||
@@ -52,9 +52,11 @@ nostr-compat = { package = "nostr", version = "0.36" }
|
||||
zeroize = "1"
|
||||
reqwest = { version = "0.13", features = ["json", "query", "stream"] }
|
||||
url = "2"
|
||||
sprout-auth = { path = "../../crates/sprout-auth" }
|
||||
sprout-core = { path = "../../crates/sprout-core" }
|
||||
sprout-persona = { path = "../../crates/sprout-persona" }
|
||||
sprout-sdk = { path = "../../crates/sprout-sdk" }
|
||||
iroh-base = { version = "=1.0.0-rc.0", features = ["key"] }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.11"
|
||||
tar = "0.4"
|
||||
@@ -73,5 +75,6 @@ earshot = "1.0"
|
||||
rubato = "2.0"
|
||||
audioadapter-buffers = "3.0"
|
||||
tempfile = "3"
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -4,6 +4,7 @@ mod events;
|
||||
mod huddle;
|
||||
mod managed_agents;
|
||||
mod media_proxy;
|
||||
mod mesh_llm;
|
||||
mod migration;
|
||||
mod models;
|
||||
pub mod nostr_convert;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Iroh endpoint keypair: persisted per Sprout install.
|
||||
//!
|
||||
//! The user's Nostr identity (`identity.key`) is separate from the iroh
|
||||
//! endpoint identity. The Nostr key signs kind:31990 offers and the NIP-98
|
||||
//! admission bearer; the iroh key proves possession of the iroh `EndpointId`
|
||||
//! during QUIC handshake. The kind:31990 event's Nostr signature binds the
|
||||
//! two identities together — anyone who trusts the Nostr pubkey can trust
|
||||
//! the advertised endpoint id, because nobody else could have signed that
|
||||
//! offer.
|
||||
//!
|
||||
//! We deliberately do **not** derive the iroh key from the Nostr key:
|
||||
//!
|
||||
//! - It would couple key rotation: rotating the Nostr key would silently
|
||||
//! change the iroh endpoint id, breaking active offers.
|
||||
//! - It would force a particular HKDF over the Nostr seckey, picking a new
|
||||
//! custody convention nobody else implements.
|
||||
//! - The iroh key is generated once, never leaves the desktop, and is
|
||||
//! already inside the same Tauri sandbox as `identity.key`. Two files,
|
||||
//! one trust boundary.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use iroh_base::SecretKey;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const KEY_FILENAME: &str = "mesh_iroh.key";
|
||||
|
||||
/// Errors loading or creating the iroh endpoint keypair.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EndpointKeyError {
|
||||
/// Couldn't determine the Tauri app data dir.
|
||||
#[error("app data dir: {0}")]
|
||||
AppDataDir(String),
|
||||
/// Filesystem I/O failure.
|
||||
#[error("filesystem: {0}")]
|
||||
Io(String),
|
||||
/// On-disk key file exists but is malformed.
|
||||
#[error("malformed key file: {0}")]
|
||||
MalformedKeyFile(String),
|
||||
}
|
||||
|
||||
/// Resolve the iroh endpoint key file path under the Tauri app data dir.
|
||||
fn key_path(app: &AppHandle) -> Result<PathBuf, EndpointKeyError> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| EndpointKeyError::AppDataDir(e.to_string()))?;
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| EndpointKeyError::Io(e.to_string()))?;
|
||||
Ok(data_dir.join(KEY_FILENAME))
|
||||
}
|
||||
|
||||
/// Load the persisted iroh endpoint keypair, generating + saving one on
|
||||
/// first run. Mirrors the pattern in [`crate::app_state::resolve_persisted_identity`].
|
||||
///
|
||||
/// File format: 32 raw secret-key bytes encoded as lower-case hex on a single
|
||||
/// line. Matches what `iroh_base::SecretKey`'s `FromStr` accepts.
|
||||
pub fn load_or_create_endpoint_key(app: &AppHandle) -> Result<SecretKey, EndpointKeyError> {
|
||||
let path = key_path(app)?;
|
||||
|
||||
if path.exists() {
|
||||
match load_key_file(&path) {
|
||||
Ok(k) => return Ok(k),
|
||||
Err(e) => {
|
||||
// Quarantine corrupt files so we never overwrite a usable
|
||||
// backup — same pattern as `identity.key`.
|
||||
let ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let bad = path.with_extension(format!("bad.{ts}"));
|
||||
let _ = std::fs::rename(&path, &bad);
|
||||
eprintln!(
|
||||
"sprout-desktop: corrupt mesh_iroh.key ({e}), quarantined to {}",
|
||||
bad.display(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let key = SecretKey::generate();
|
||||
save_key_file(&path, &key)?;
|
||||
eprintln!(
|
||||
"sprout-desktop: generated and saved mesh iroh endpoint pubkey {}",
|
||||
key.public(),
|
||||
);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn load_key_file(path: &Path) -> Result<SecretKey, EndpointKeyError> {
|
||||
let content =
|
||||
std::fs::read_to_string(path).map_err(|e| EndpointKeyError::Io(e.to_string()))?;
|
||||
let trimmed = content.trim();
|
||||
trimmed
|
||||
.parse::<SecretKey>()
|
||||
.map_err(|e| EndpointKeyError::MalformedKeyFile(e.to_string()))
|
||||
}
|
||||
|
||||
fn save_key_file(path: &Path, key: &SecretKey) -> Result<(), EndpointKeyError> {
|
||||
let bytes = key.to_bytes();
|
||||
let hex = hex::encode(bytes);
|
||||
// Atomic write: write to a temp file in the same dir, fsync, rename.
|
||||
let dir = path
|
||||
.parent()
|
||||
.ok_or_else(|| EndpointKeyError::Io("key path has no parent".to_string()))?;
|
||||
let tmp = tempfile::NamedTempFile::new_in(dir)
|
||||
.map_err(|e| EndpointKeyError::Io(format!("temp file: {e}")))?;
|
||||
std::fs::write(tmp.path(), hex.as_bytes())
|
||||
.map_err(|e| EndpointKeyError::Io(format!("write temp: {e}")))?;
|
||||
tmp.persist(path)
|
||||
.map_err(|e| EndpointKeyError::Io(format!("rename temp: {e}")))?;
|
||||
// No fsync of the directory here — matches the existing identity.key path.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
/// Round-trip a generated key through the save/load functions.
|
||||
#[test]
|
||||
fn round_trip_save_load() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("mesh_iroh.key");
|
||||
let original = SecretKey::generate();
|
||||
save_key_file(&path, &original).expect("save");
|
||||
let loaded = load_key_file(&path).expect("load");
|
||||
assert_eq!(original.to_bytes(), loaded.to_bytes());
|
||||
}
|
||||
|
||||
/// A truncated/corrupted file is rejected with `MalformedKeyFile`.
|
||||
#[test]
|
||||
fn corrupt_file_returns_malformed() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("mesh_iroh.key");
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
f.write_all(b"not a valid hex key").unwrap();
|
||||
drop(f);
|
||||
let err = load_key_file(&path).expect_err("should fail");
|
||||
match err {
|
||||
EndpointKeyError::MalformedKeyFile(_) => {}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Mesh-LLM client: discover, dial, and publish kind:31990 offers.
|
||||
//!
|
||||
//! This module is the desktop-side counterpart to `sprout-relay`'s embedded
|
||||
//! iroh-relay (see `crates/sprout-relay/src/iroh_relay.rs`). The relay gates
|
||||
//! admission with NIP-98 + relay membership; this module signs that bearer
|
||||
//! token, dials offers advertised under kind:31990, and publishes our own
|
||||
//! offer when the user enables compute-sharing.
|
||||
//!
|
||||
//! ## Submodules
|
||||
//!
|
||||
//! - [`endpoint`]: long-lived iroh endpoint keypair persisted at
|
||||
//! `{app_data_dir}/mesh_iroh.key`.
|
||||
//! - [`nip98`]: build the NIP-98 bearer event signed with the user's Nostr
|
||||
//! key for a given canonical relay URL.
|
||||
//! - [`offer`]: load/save the user's mesh-LLM offer preferences
|
||||
//! (VRAM/RAM/concurrency caps, models).
|
||||
|
||||
pub mod endpoint;
|
||||
pub mod nip98;
|
||||
pub mod offer;
|
||||
|
||||
pub use endpoint::{load_or_create_endpoint_key, EndpointKeyError};
|
||||
pub use nip98::{build_nip98_bearer, Nip98BearerError};
|
||||
pub use offer::{ComputeSharingPrefs, OfferPrefsError};
|
||||
@@ -0,0 +1,105 @@
|
||||
//! NIP-98 bearer token builder for iroh-relay admission.
|
||||
//!
|
||||
//! Signs a kind:27235 event over the canonical iroh-relay URL using the
|
||||
//! user's Nostr identity, then base64-encodes the event JSON. The receiving
|
||||
//! relay's `sprout_relay::iroh_relay` access callback decodes + verifies
|
||||
//! this exact bearer string.
|
||||
//!
|
||||
//! Both sides use the same `sprout_auth::nip98_canonical_url` helper, so
|
||||
//! path-prefix / trailing-slash / localhost-vs-127.0.0.1 drift cannot
|
||||
//! create undebuggable per-connection denials.
|
||||
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use base64::Engine;
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
|
||||
|
||||
const IROH_RELAY_PATH: &str = "/relay";
|
||||
const NIP98_METHOD: &str = "GET";
|
||||
|
||||
/// Errors produced while building a NIP-98 bearer.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Nip98BearerError {
|
||||
/// `iroh_relay_url` from NIP-11 wasn't a parseable URL.
|
||||
#[error("invalid iroh relay URL: {0}")]
|
||||
InvalidUrl(String),
|
||||
/// `nostr` library failed to construct/sign the event.
|
||||
#[error("event signing failed: {0}")]
|
||||
Sign(String),
|
||||
/// Tag construction failed (should never happen for static "u"/"method").
|
||||
#[error("tag construction failed: {0}")]
|
||||
Tag(String),
|
||||
}
|
||||
|
||||
/// Build the `Authorization: Bearer <token>` value for an iroh-relay
|
||||
/// admission request.
|
||||
///
|
||||
/// `iroh_relay_public_url` is the value taken verbatim from the target
|
||||
/// relay's NIP-11 `iroh_relay_url` field. We canonicalise it the same way
|
||||
/// the relay does before signing the `u` tag.
|
||||
pub fn build_nip98_bearer(
|
||||
keys: &Keys,
|
||||
iroh_relay_public_url: &str,
|
||||
) -> Result<String, Nip98BearerError> {
|
||||
let canonical = sprout_auth::nip98_canonical_url(iroh_relay_public_url, IROH_RELAY_PATH)
|
||||
.ok_or_else(|| Nip98BearerError::InvalidUrl(iroh_relay_public_url.to_string()))?;
|
||||
|
||||
let tags = vec![
|
||||
Tag::parse(["u", &canonical]).map_err(|e| Nip98BearerError::Tag(e.to_string()))?,
|
||||
Tag::parse(["method", NIP98_METHOD]).map_err(|e| Nip98BearerError::Tag(e.to_string()))?,
|
||||
];
|
||||
|
||||
let event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(keys)
|
||||
.map_err(|e| Nip98BearerError::Sign(e.to_string()))?;
|
||||
|
||||
let json = event.as_json();
|
||||
Ok(STANDARD.encode(json))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signs_for_canonical_url() {
|
||||
let keys = Keys::generate();
|
||||
let token =
|
||||
build_nip98_bearer(&keys, "https://relay.example.com/iroh").expect("build bearer");
|
||||
// Round-trip through base64 -> JSON to confirm the event has the
|
||||
// canonical URL in its `u` tag.
|
||||
let bytes = STANDARD.decode(&token).expect("base64 decode");
|
||||
let json = String::from_utf8(bytes).expect("utf8");
|
||||
assert!(
|
||||
json.contains("\"u\""),
|
||||
"bearer event should carry `u` tag: {json}",
|
||||
);
|
||||
assert!(
|
||||
json.contains("https://relay.example.com/iroh/relay"),
|
||||
"bearer event should canonicalise the URL: {json}",
|
||||
);
|
||||
assert!(
|
||||
json.contains("\"method\""),
|
||||
"bearer event should carry `method` tag",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unparseable_url() {
|
||||
let keys = Keys::generate();
|
||||
let err = build_nip98_bearer(&keys, "definitely not a url").expect_err("should fail");
|
||||
match err {
|
||||
Nip98BearerError::InvalidUrl(_) => {}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_keys_produce_different_bearers() {
|
||||
let a = Keys::generate();
|
||||
let b = Keys::generate();
|
||||
let ta = build_nip98_bearer(&a, "https://relay.example.com/iroh").unwrap();
|
||||
let tb = build_nip98_bearer(&b, "https://relay.example.com/iroh").unwrap();
|
||||
assert_ne!(ta, tb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Persisted compute-sharing preferences (the avatar-menu sliders).
|
||||
//!
|
||||
//! When the user turns on compute sharing and dials the VRAM/RAM/concurrency
|
||||
//! sliders in the bottom-left avatar menu, those preferences live in
|
||||
//! `{app_data_dir}/mesh_offer.json`. The publisher reads this file when it
|
||||
//! builds a kind:31990 event; the settings UI reads + writes it via Tauri
|
||||
//! commands.
|
||||
//!
|
||||
//! Keeping the prefs as a plain JSON file (rather than baking them into the
|
||||
//! kind:31990 event directly) lets the user toggle sharing without
|
||||
//! republishing on every restart and makes the file inspectable for support.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sprout_core::mesh_llm::{MeshLlmOffer, ModelOffer, ResourceCaps};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const OFFER_FILENAME: &str = "mesh_offer.json";
|
||||
const DEFAULT_D_TAG: &str = "default";
|
||||
|
||||
/// Errors loading or saving offer preferences.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OfferPrefsError {
|
||||
/// Couldn't determine the Tauri app data dir.
|
||||
#[error("app data dir: {0}")]
|
||||
AppDataDir(String),
|
||||
/// Filesystem I/O failure.
|
||||
#[error("filesystem: {0}")]
|
||||
Io(String),
|
||||
/// JSON parse / serialize failure.
|
||||
#[error("json: {0}")]
|
||||
Json(String),
|
||||
}
|
||||
|
||||
/// Persisted compute-sharing preferences.
|
||||
///
|
||||
/// `enabled = false` is the default; the publisher must skip publishing in
|
||||
/// that case and **must delete any previously-published offer** (NIP-09 or
|
||||
/// kind:31990 with empty content per NIP-33's replace-with-empty convention).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ComputeSharingPrefs {
|
||||
/// Whether the user has opted in to sharing compute. Default: `false`.
|
||||
pub enabled: bool,
|
||||
|
||||
/// Caps the user wants to advertise. `None` on a field = "no cap"; the
|
||||
/// publisher passes `Some(0)` through unchanged because the schema
|
||||
/// allows it (consumers should treat 0 as "explicit zero").
|
||||
pub caps: ResourceCaps,
|
||||
|
||||
/// Models the user wants to advertise. May be empty.
|
||||
pub models: Vec<ModelOffer>,
|
||||
|
||||
/// Persistent `d_tag` for the user's offer. Generated once on first
|
||||
/// enable and re-used so replaces target the same address.
|
||||
pub d_tag: String,
|
||||
}
|
||||
|
||||
impl Default for ComputeSharingPrefs {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
caps: ResourceCaps {
|
||||
max_vram_mb: None,
|
||||
max_ram_mb: None,
|
||||
max_concurrency: Some(1),
|
||||
},
|
||||
models: vec![],
|
||||
d_tag: DEFAULT_D_TAG.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ComputeSharingPrefs {
|
||||
/// Builds the kind:31990 offer envelope to publish. Returns `None` if
|
||||
/// sharing is disabled; the publisher should then *delete* any prior
|
||||
/// offer rather than calling this.
|
||||
pub fn build_offer(
|
||||
&self,
|
||||
endpoint_id: &str,
|
||||
iroh_relay_url: &str,
|
||||
) -> Option<MeshLlmOffer> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
Some(MeshLlmOffer {
|
||||
v: 1,
|
||||
d_tag: self.d_tag.clone(),
|
||||
endpoint_id: endpoint_id.to_string(),
|
||||
iroh_relay_url: iroh_relay_url.to_string(),
|
||||
caps: self.caps.clone(),
|
||||
models: self.models.clone(),
|
||||
extra: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path(app: &AppHandle) -> Result<PathBuf, OfferPrefsError> {
|
||||
let data_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| OfferPrefsError::AppDataDir(e.to_string()))?;
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| OfferPrefsError::Io(e.to_string()))?;
|
||||
Ok(data_dir.join(OFFER_FILENAME))
|
||||
}
|
||||
|
||||
/// Load persisted prefs; returns [`ComputeSharingPrefs::default`] if the file
|
||||
/// is absent. On parse errors, returns the error verbatim — callers should
|
||||
/// surface it in the settings UI rather than silently resetting.
|
||||
pub fn load_prefs(app: &AppHandle) -> Result<ComputeSharingPrefs, OfferPrefsError> {
|
||||
let path = prefs_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok(ComputeSharingPrefs::default());
|
||||
}
|
||||
let content = std::fs::read_to_string(&path).map_err(|e| OfferPrefsError::Io(e.to_string()))?;
|
||||
serde_json::from_str(&content).map_err(|e| OfferPrefsError::Json(e.to_string()))
|
||||
}
|
||||
|
||||
/// Atomically replace the on-disk prefs file.
|
||||
pub fn save_prefs(app: &AppHandle, prefs: &ComputeSharingPrefs) -> Result<(), OfferPrefsError> {
|
||||
let path = prefs_path(app)?;
|
||||
let dir = path
|
||||
.parent()
|
||||
.ok_or_else(|| OfferPrefsError::Io("prefs path has no parent".to_string()))?;
|
||||
let json = serde_json::to_string_pretty(prefs).map_err(|e| OfferPrefsError::Json(e.to_string()))?;
|
||||
let tmp = tempfile::NamedTempFile::new_in(dir)
|
||||
.map_err(|e| OfferPrefsError::Io(format!("temp file: {e}")))?;
|
||||
std::fs::write(tmp.path(), json.as_bytes())
|
||||
.map_err(|e| OfferPrefsError::Io(format!("write temp: {e}")))?;
|
||||
tmp.persist(&path)
|
||||
.map_err(|e| OfferPrefsError::Io(format!("rename temp: {e}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_is_disabled() {
|
||||
let prefs = ComputeSharingPrefs::default();
|
||||
assert!(!prefs.enabled);
|
||||
assert_eq!(prefs.caps.max_concurrency, Some(1));
|
||||
assert_eq!(prefs.d_tag, DEFAULT_D_TAG);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_offer_returns_none_when_disabled() {
|
||||
let prefs = ComputeSharingPrefs::default();
|
||||
assert!(
|
||||
prefs
|
||||
.build_offer("endpoint", "https://relay/iroh")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_offer_returns_envelope_when_enabled() {
|
||||
let prefs = ComputeSharingPrefs {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
let offer = prefs
|
||||
.build_offer("endpoint-id-hex", "https://relay.example.com/iroh")
|
||||
.expect("offer");
|
||||
assert_eq!(offer.endpoint_id, "endpoint-id-hex");
|
||||
assert_eq!(offer.iroh_relay_url, "https://relay.example.com/iroh");
|
||||
assert!(offer.is_publishable());
|
||||
}
|
||||
|
||||
/// Round-trip prefs through serde so the on-disk format stays stable.
|
||||
#[test]
|
||||
fn round_trip_via_json() {
|
||||
let prefs = ComputeSharingPrefs {
|
||||
enabled: true,
|
||||
caps: ResourceCaps {
|
||||
max_vram_mb: Some(8192),
|
||||
max_ram_mb: Some(16_000),
|
||||
max_concurrency: Some(3),
|
||||
},
|
||||
models: vec![ModelOffer {
|
||||
id: "qwen/Qwen2.5-7B-Instruct".to_string(),
|
||||
label: Some("Qwen 2.5 7B".to_string()),
|
||||
context_tokens: Some(32_768),
|
||||
}],
|
||||
d_tag: "node-laptop".to_string(),
|
||||
};
|
||||
let s = serde_json::to_string(&prefs).unwrap();
|
||||
let back: ComputeSharingPrefs = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(prefs, back);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user