forked from Keelrleek/SoulStream
holdings: a member of a Space publishes what they hold as room-salted album keys, sealed to the room, so "who has this" is answered while they are offline
This commit is contained in:
1 parent
08ca09586a
commit
a98c750908
12 files changed
+2955
-8
No files matched your search
Generated
+1449
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "soulstream-holdings-contract"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "The SoulStream holdings contract — one member's album list in one Space, a thin shell over persona_state::holdings. Built to wasm32-unknown-unknown and bundled into the app; its code hash is a commitment (see src-tauri/assets)."
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
freenet-stdlib = { version = "=0.8.5" }
|
||||||
|
persona-state = { path = "../persona-state" }
|
||||||
|
bincode = "1"
|
||||||
|
blake3 = "1"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["freenet-main-contract", "contract"]
|
||||||
|
freenet-main-contract = []
|
||||||
|
contract = ["freenet-stdlib/contract"]
|
||||||
|
trace = ["freenet-stdlib/trace"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
|
||||||
|
# Its own workspace: this crate targets wasm32 and must never join the
|
||||||
|
# app's host build.
|
||||||
|
[workspace]
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
//! The SoulStream holdings contract. Single writer: one member's latest
|
||||||
|
//! signed album list in one Space, at the address (space id, member key).
|
||||||
|
//! Merge is the pointer contract's total order, (sequence, blake3) —
|
||||||
|
//! `persona_state::holdings::merge` decides, so every delivery order
|
||||||
|
//! converges, and a stale replay cannot bring a withdrawn list back.
|
||||||
|
//!
|
||||||
|
//! The body is opaque here: sealed to the room's epoch key in a private
|
||||||
|
//! Space, and the contract only ever checks the envelope and its signature.
|
||||||
|
//!
|
||||||
|
//! Build: `cargo build --release --target wasm32-unknown-unknown` in this
|
||||||
|
//! directory; copy `target/wasm32-unknown-unknown/release/
|
||||||
|
//! soulstream_holdings_contract.wasm` to `src-tauri/assets/holdings_contract.wasm`
|
||||||
|
//! and re-pin the code hash in `src-tauri/src/holdings.rs`.
|
||||||
|
|
||||||
|
use freenet_stdlib::prelude::*;
|
||||||
|
use persona_state::holdings as t;
|
||||||
|
|
||||||
|
pub struct Contract;
|
||||||
|
|
||||||
|
fn parts(parameters: &Parameters<'static>) -> Result<([u8; 16], [u8; 32]), ContractError> {
|
||||||
|
t::params_parts(parameters.as_ref())
|
||||||
|
.ok_or_else(|| ContractError::Other("params are not family+space+member".into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summary_of(bytes: &[u8], s: &t::HoldingsState) -> Vec<u8> {
|
||||||
|
let (seq, hash) = t::rank(bytes, s);
|
||||||
|
let mut out = Vec::with_capacity(40);
|
||||||
|
out.extend_from_slice(&seq.to_le_bytes());
|
||||||
|
out.extend_from_slice(&hash);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[contract]
|
||||||
|
impl ContractInterface for Contract {
|
||||||
|
fn validate_state(
|
||||||
|
parameters: Parameters<'static>,
|
||||||
|
state: State<'static>,
|
||||||
|
_related: RelatedContracts<'static>,
|
||||||
|
) -> Result<ValidateResult, ContractError> {
|
||||||
|
let (space_id, member) = parts(¶meters)?;
|
||||||
|
if state.as_ref().is_empty() {
|
||||||
|
return Ok(ValidateResult::Valid);
|
||||||
|
}
|
||||||
|
match t::validate(&space_id, &member, state.as_ref()) {
|
||||||
|
Ok(_) => Ok(ValidateResult::Valid),
|
||||||
|
Err(e) => Err(ContractError::Other(format!("invalid holdings: {e}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_state(
|
||||||
|
parameters: Parameters<'static>,
|
||||||
|
state: State<'static>,
|
||||||
|
data: Vec<UpdateData<'static>>,
|
||||||
|
) -> Result<UpdateModification<'static>, ContractError> {
|
||||||
|
let (space_id, member) = parts(¶meters)?;
|
||||||
|
let mut candidates: Vec<Vec<u8>> = Vec::new();
|
||||||
|
if !state.as_ref().is_empty() {
|
||||||
|
candidates.push(state.as_ref().to_vec());
|
||||||
|
}
|
||||||
|
let mut incoming_valid = 0usize;
|
||||||
|
for ud in &data {
|
||||||
|
let mut push = |bytes: &[u8]| {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if t::validate(&space_id, &member, bytes).is_ok() {
|
||||||
|
incoming_valid += 1;
|
||||||
|
}
|
||||||
|
candidates.push(bytes.to_vec());
|
||||||
|
};
|
||||||
|
match ud {
|
||||||
|
UpdateData::State(s) => push(s.as_ref()),
|
||||||
|
UpdateData::Delta(d) => push(d.as_ref()),
|
||||||
|
UpdateData::StateAndDelta { state, delta } => {
|
||||||
|
push(state.as_ref());
|
||||||
|
push(delta.as_ref());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if incoming_valid == 0 && !data.is_empty() {
|
||||||
|
return Err(ContractError::InvalidUpdate);
|
||||||
|
}
|
||||||
|
match t::merge(&space_id, &member, candidates.iter().map(|v| v.as_slice())) {
|
||||||
|
Some(bytes) => Ok(UpdateModification::valid(State::from(bytes))),
|
||||||
|
None => Ok(UpdateModification::valid(state)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn summarize_state(
|
||||||
|
parameters: Parameters<'static>,
|
||||||
|
state: State<'static>,
|
||||||
|
) -> Result<StateSummary<'static>, ContractError> {
|
||||||
|
let (space_id, member) = parts(¶meters)?;
|
||||||
|
if state.as_ref().is_empty() {
|
||||||
|
return Ok(StateSummary::from(Vec::new()));
|
||||||
|
}
|
||||||
|
let s = t::validate(&space_id, &member, state.as_ref())
|
||||||
|
.map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?;
|
||||||
|
Ok(StateSummary::from(summary_of(state.as_ref(), &s)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_state_delta(
|
||||||
|
parameters: Parameters<'static>,
|
||||||
|
state: State<'static>,
|
||||||
|
summary: StateSummary<'static>,
|
||||||
|
) -> Result<StateDelta<'static>, ContractError> {
|
||||||
|
let (space_id, member) = parts(¶meters)?;
|
||||||
|
if state.as_ref().is_empty() {
|
||||||
|
return Ok(StateDelta::from(Vec::new()));
|
||||||
|
}
|
||||||
|
let s = t::validate(&space_id, &member, state.as_ref())
|
||||||
|
.map_err(|e| ContractError::Other(format!("invalid holdings: {e}")))?;
|
||||||
|
let ours = t::rank(state.as_ref(), &s);
|
||||||
|
let theirs = {
|
||||||
|
let b = summary.as_ref();
|
||||||
|
if b.len() == 40 {
|
||||||
|
let seq = u64::from_le_bytes(b[..8].try_into().unwrap());
|
||||||
|
let mut h = [0u8; 32];
|
||||||
|
h.copy_from_slice(&b[8..40]);
|
||||||
|
Some((seq, h))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let newer = match theirs {
|
||||||
|
Some(t) => ours > t,
|
||||||
|
None => true,
|
||||||
|
};
|
||||||
|
Ok(StateDelta::from(if newer { state.as_ref().to_vec() } else { Vec::new() }))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -811,6 +811,14 @@ impl Library {
|
|||||||
Ok(rows.filter_map(|r| r.ok()).collect())
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Album id → MusicBrainz release id, for the albums that have one.
|
||||||
|
pub fn album_mbids(&self) -> Result<std::collections::BTreeMap<i64, String>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT id, mbid FROM albums WHERE mbid IS NOT NULL AND mbid != ''")?;
|
||||||
|
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
|
||||||
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
/// Per-album aggregates for the Library page: what the FILES add up to.
|
/// Per-album aggregates for the Library page: what the FILES add up to.
|
||||||
/// One call for the whole library, because the page derives its stat row,
|
/// One call for the whole library, because the page derives its stat row,
|
||||||
/// its Format grouping and four of its sorts (length, size, bitrate,
|
/// its Format grouping and four of its sorts (length, size, bitrate,
|
||||||
|
|||||||
@@ -0,0 +1,446 @@
|
|||||||
|
//! Holdings — what a member of a Space holds, as album keys and never as
|
||||||
|
//! files. One contract per (space, member): the member's latest signed list,
|
||||||
|
//! sealed to the room's epoch key in a private Space. Merge is the pointer
|
||||||
|
//! contract's total order, (sequence, blake3): a withdrawal is just a newer
|
||||||
|
//! sequence with an empty body, and a stale replay cannot resurrect a list.
|
||||||
|
//!
|
||||||
|
//! An album is named by a key, not a name: blake3 over the room's id and the
|
||||||
|
//! same normalised (artist, album) the library-overlap comparison uses, so a
|
||||||
|
//! holding matches exactly when overlap would have said "you own this". The
|
||||||
|
//! room id salts it — the same album hashes differently in every room, so
|
||||||
|
//! two rooms' lists cannot be joined. A member can still learn whether you
|
||||||
|
//! hold any album they can NAME, by hashing it and looking; that is the
|
||||||
|
//! feature, and the switch's disclosure says so.
|
||||||
|
//!
|
||||||
|
//! Nothing here reads a library or a network; the app does both.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const SCHEMA_VERSION: u32 = 1;
|
||||||
|
pub const PARAMS_FAMILY: &[u8] = b"soulstream.holdings.v1";
|
||||||
|
/// The Space family's domain, not one of its own: the identity delegate
|
||||||
|
/// signs only under an allowlist, and a new domain would be a delegate
|
||||||
|
/// generation (a migration event) for the one case — an owner signing their
|
||||||
|
/// own list — that needs it. The signed payload's shape keeps a holdings
|
||||||
|
/// signature from ever reading as a Space message or a checkpoint.
|
||||||
|
pub const SIGN_DOMAIN: &str = crate::space::SIGN_DOMAIN;
|
||||||
|
/// The contract's gate: envelope plus body, sealed or not.
|
||||||
|
pub const MAX_STATE_BYTES: usize = 1024 * 1024;
|
||||||
|
/// Entries per list. A `Holding` is at most 66 bytes on the wire, so this
|
||||||
|
/// keeps a full list — with its username and the envelope — under the gate
|
||||||
|
/// with room to spare. A library above it publishes its newest entries.
|
||||||
|
pub const MAX_ENTRIES: usize = 14_000;
|
||||||
|
pub const MAX_USERNAME_BYTES: usize = 64;
|
||||||
|
|
||||||
|
pub const TIER_UNKNOWN: u8 = 0;
|
||||||
|
pub const TIER_LOSSY: u8 = 1;
|
||||||
|
pub const TIER_LOSSLESS: u8 = 2;
|
||||||
|
|
||||||
|
/// One album, as the room sees it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
pub struct Holding {
|
||||||
|
/// `album_key` of the normalised (artist, album), salted with the room.
|
||||||
|
pub key: [u8; 32],
|
||||||
|
/// `mbid_key` when the library knows the release's MusicBrainz id.
|
||||||
|
pub mbid: Option<[u8; 32]>,
|
||||||
|
/// One word about quality: lossless, lossy, or unknown. Never a format,
|
||||||
|
/// a bitrate, or a size.
|
||||||
|
pub tier: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The plaintext a list carries. Sealed in a private Space, so the Soulseek
|
||||||
|
/// name inside it is readable by members of that epoch only — it is what
|
||||||
|
/// lets "who has this" become "get it from them".
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct HoldingsBody {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub slsk_username: Option<String>,
|
||||||
|
/// Sorted by key, no duplicates — `encode_body` enforces it.
|
||||||
|
pub entries: Vec<Holding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The contract state: the member's latest list, signed by the key they
|
||||||
|
/// wear in the room.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct HoldingsState {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub space_id: [u8; 16],
|
||||||
|
pub member: [u8; 32],
|
||||||
|
pub sequence: u64,
|
||||||
|
/// The epoch whose key sealed `body`; readers look their key up by it.
|
||||||
|
pub epoch: u64,
|
||||||
|
/// False in a public Space: `body` is the plain encoding.
|
||||||
|
pub sealed: bool,
|
||||||
|
pub published_at: u64,
|
||||||
|
/// Withdrawn: the list is gone from current state (`body` empty). Copies
|
||||||
|
/// members already read stay with them — never claim erasure.
|
||||||
|
pub withdrawn: bool,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
pub sig: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Invalid {
|
||||||
|
Oversized(usize),
|
||||||
|
Malformed,
|
||||||
|
WrongSchema(u32),
|
||||||
|
WrongAddress,
|
||||||
|
BadSignature,
|
||||||
|
TooManyEntries(usize),
|
||||||
|
FieldTooLong(&'static str, usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for Invalid {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Invalid::Oversized(n) => write!(f, "{n} bytes is over the cap"),
|
||||||
|
Invalid::Malformed => write!(f, "does not decode"),
|
||||||
|
Invalid::WrongSchema(v) => write!(f, "schema {v} is not {SCHEMA_VERSION}"),
|
||||||
|
Invalid::WrongAddress => write!(f, "names another room or member"),
|
||||||
|
Invalid::BadSignature => write!(f, "the signature does not verify"),
|
||||||
|
Invalid::TooManyEntries(n) => write!(f, "{n} entries is over the cap of {MAX_ENTRIES}"),
|
||||||
|
Invalid::FieldTooLong(what, n) => write!(f, "{what} is {n} bytes, over its cap"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- params
|
||||||
|
|
||||||
|
pub fn make_params(space_id: &[u8; 16], member: &[u8; 32]) -> Vec<u8> {
|
||||||
|
let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48);
|
||||||
|
p.extend_from_slice(PARAMS_FAMILY);
|
||||||
|
p.extend_from_slice(space_id);
|
||||||
|
p.extend_from_slice(member);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn params_parts(params: &[u8]) -> Option<([u8; 16], [u8; 32])> {
|
||||||
|
let t = PARAMS_FAMILY.len();
|
||||||
|
if params.len() != t + 48 || ¶ms[..t] != PARAMS_FAMILY {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((params[t..t + 16].try_into().unwrap(), params[t + 16..t + 48].try_into().unwrap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- keys
|
||||||
|
|
||||||
|
/// The key an album is held under in ONE room. `artist` and `album` are
|
||||||
|
/// the overlap normalisation's output (lowercase alphanumerics, brackets
|
||||||
|
/// gone, a leading "the" and a filing year dropped) — the caller's job, so
|
||||||
|
/// a wasm build of this module never carries the normaliser.
|
||||||
|
pub fn album_key(space_id: &[u8; 16], artist: &str, album: &str) -> [u8; 32] {
|
||||||
|
let mut h = blake3::Hasher::new_derive_key("soulstream.holding.v1");
|
||||||
|
h.update(space_id);
|
||||||
|
h.update(artist.as_bytes());
|
||||||
|
h.update(&[0]);
|
||||||
|
h.update(album.as_bytes());
|
||||||
|
*h.finalize().as_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same album by its MusicBrainz release id, for the day identity is
|
||||||
|
/// anchored rather than named.
|
||||||
|
pub fn mbid_key(space_id: &[u8; 16], mbid: &str) -> [u8; 32] {
|
||||||
|
let mut h = blake3::Hasher::new_derive_key("soulstream.holding-mbid.v1");
|
||||||
|
h.update(space_id);
|
||||||
|
h.update(mbid.trim().to_lowercase().as_bytes());
|
||||||
|
*h.finalize().as_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- body
|
||||||
|
|
||||||
|
/// Encode a list for the wire: sorted by key, duplicates folded (the better
|
||||||
|
/// tier wins), caps enforced.
|
||||||
|
pub fn encode_body(mut b: HoldingsBody) -> Result<Vec<u8>, Invalid> {
|
||||||
|
if let Some(u) = &b.slsk_username {
|
||||||
|
if u.len() > MAX_USERNAME_BYTES {
|
||||||
|
return Err(Invalid::FieldTooLong("slsk_username", u.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.schema_version = SCHEMA_VERSION;
|
||||||
|
b.entries.sort_by(|x, y| x.key.cmp(&y.key).then(y.tier.cmp(&x.tier)));
|
||||||
|
b.entries.dedup_by(|later, earlier| {
|
||||||
|
if later.key != earlier.key {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The first of a run has the higher tier already; keep its mbid if
|
||||||
|
// the later one had none.
|
||||||
|
if earlier.mbid.is_none() {
|
||||||
|
earlier.mbid = later.mbid;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
});
|
||||||
|
if b.entries.len() > MAX_ENTRIES {
|
||||||
|
return Err(Invalid::TooManyEntries(b.entries.len()));
|
||||||
|
}
|
||||||
|
bincode::serialize(&b).map_err(|_| Invalid::Malformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode_body(bytes: &[u8]) -> Option<HoldingsBody> {
|
||||||
|
let b: HoldingsBody = bincode::deserialize(bytes).ok()?;
|
||||||
|
if b.schema_version != SCHEMA_VERSION || b.entries.len() > MAX_ENTRIES {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- sealing
|
||||||
|
|
||||||
|
fn body_key(space_key: &[u8; 32]) -> [u8; 32] {
|
||||||
|
blake3::derive_key("soulstream.holdings-body.v1", space_key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The AAD binds a body to its envelope: another member's sealed bytes
|
||||||
|
/// under this address, or this member's older sequence, do not open.
|
||||||
|
fn aad(s: &HoldingsState) -> Vec<u8> {
|
||||||
|
let mut v = Vec::with_capacity(64);
|
||||||
|
v.extend_from_slice(&s.space_id);
|
||||||
|
v.extend_from_slice(&s.member);
|
||||||
|
v.extend_from_slice(&s.sequence.to_le_bytes());
|
||||||
|
v.extend_from_slice(&s.epoch.to_le_bytes());
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unique per (member, sequence) under one room — a sequence is never
|
||||||
|
/// published twice by the same key, which is the nonce's whole contract.
|
||||||
|
fn nonce(s: &HoldingsState) -> [u8; 12] {
|
||||||
|
blake3::hash(&aad(s)).as_bytes()[..12].try_into().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seal `s.body` (the plain encoding going in) under the room's epoch key.
|
||||||
|
/// Call BEFORE signing; the signature covers the sealed bytes.
|
||||||
|
pub fn seal(space_key: &[u8; 32], s: &HoldingsState) -> Vec<u8> {
|
||||||
|
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||||
|
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||||
|
let key = body_key(space_key);
|
||||||
|
ChaCha20Poly1305::new(Key::from_slice(&key))
|
||||||
|
.encrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) })
|
||||||
|
.expect("seal")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a sealed body. None = wrong key for this epoch, or tampered.
|
||||||
|
pub fn open(space_key: &[u8; 32], s: &HoldingsState) -> Option<Vec<u8>> {
|
||||||
|
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
||||||
|
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
||||||
|
let key = body_key(space_key);
|
||||||
|
ChaCha20Poly1305::new(Key::from_slice(&key))
|
||||||
|
.decrypt(Nonce::from_slice(&nonce(s)), Payload { msg: &s.body, aad: &aad(s) })
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- signing
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SigBody<'a> {
|
||||||
|
schema_version: u32,
|
||||||
|
space_id: &'a [u8; 16],
|
||||||
|
member: &'a [u8; 32],
|
||||||
|
sequence: u64,
|
||||||
|
epoch: u64,
|
||||||
|
sealed: bool,
|
||||||
|
published_at: u64,
|
||||||
|
withdrawn: bool,
|
||||||
|
body: &'a [u8],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the member signs: blake3 of the envelope, the sealed body included.
|
||||||
|
pub fn sign_payload(s: &HoldingsState) -> Vec<u8> {
|
||||||
|
let body = bincode::serialize(&SigBody {
|
||||||
|
schema_version: s.schema_version,
|
||||||
|
space_id: &s.space_id,
|
||||||
|
member: &s.member,
|
||||||
|
sequence: s.sequence,
|
||||||
|
epoch: s.epoch,
|
||||||
|
sealed: s.sealed,
|
||||||
|
published_at: s.published_at,
|
||||||
|
withdrawn: s.withdrawn,
|
||||||
|
body: &s.body,
|
||||||
|
})
|
||||||
|
.expect("sig body serializes");
|
||||||
|
blake3::hash(&body).as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign as the key worn in the room (a room-local member key, or an
|
||||||
|
/// owner's persona through the delegate — the app decides which).
|
||||||
|
pub fn sign_with(secret: &[u8; 32], s: &HoldingsState) -> Vec<u8> {
|
||||||
|
crate::sign_local(SIGN_DOMAIN, secret, &sign_payload(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- validate / merge
|
||||||
|
|
||||||
|
/// Strict, in the plan's order: size → parse → schema → address → caps →
|
||||||
|
/// signature. A state that passes is exactly what its member published.
|
||||||
|
pub fn validate(space_id: &[u8; 16], member: &[u8; 32], bytes: &[u8]) -> Result<HoldingsState, Invalid> {
|
||||||
|
if bytes.len() > MAX_STATE_BYTES {
|
||||||
|
return Err(Invalid::Oversized(bytes.len()));
|
||||||
|
}
|
||||||
|
let s: HoldingsState = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?;
|
||||||
|
if s.schema_version != SCHEMA_VERSION {
|
||||||
|
return Err(Invalid::WrongSchema(s.schema_version));
|
||||||
|
}
|
||||||
|
if &s.space_id != space_id || &s.member != member {
|
||||||
|
return Err(Invalid::WrongAddress);
|
||||||
|
}
|
||||||
|
if s.withdrawn && !s.body.is_empty() {
|
||||||
|
return Err(Invalid::Malformed);
|
||||||
|
}
|
||||||
|
if !crate::verify_sig_in(SIGN_DOMAIN, member, &sign_payload(&s), &s.sig) {
|
||||||
|
return Err(Invalid::BadSignature);
|
||||||
|
}
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The order every replica agrees on: highest sequence, then the byte hash.
|
||||||
|
pub fn rank(bytes: &[u8], s: &HoldingsState) -> (u64, [u8; 32]) {
|
||||||
|
(s.sequence, *blake3::hash(bytes).as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The winning candidate's bytes, or None when nothing valid was offered.
|
||||||
|
pub fn merge<'a>(space_id: &[u8; 16], member: &[u8; 32], candidates: impl IntoIterator<Item = &'a [u8]>) -> Option<Vec<u8>> {
|
||||||
|
let mut best: Option<((u64, [u8; 32]), &[u8])> = None;
|
||||||
|
for bytes in candidates {
|
||||||
|
let Ok(s) = validate(space_id, member, bytes) else { continue };
|
||||||
|
let r = rank(bytes, &s);
|
||||||
|
if best.as_ref().is_none_or(|(b, _)| r > *b) {
|
||||||
|
best = Some((r, bytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, b)| b.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn secret(n: u8) -> [u8; 32] {
|
||||||
|
[n; 32]
|
||||||
|
}
|
||||||
|
fn state(secret: &[u8; 32], seq: u64, body: Vec<u8>, withdrawn: bool) -> HoldingsState {
|
||||||
|
let mut s = HoldingsState {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
space_id: [3; 16],
|
||||||
|
member: crate::public_of(secret),
|
||||||
|
sequence: seq,
|
||||||
|
epoch: 4,
|
||||||
|
sealed: false,
|
||||||
|
published_at: 1_700_000_000,
|
||||||
|
withdrawn,
|
||||||
|
body,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
s.sig = sign_with(secret, &s);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
fn bytes(s: &HoldingsState) -> Vec<u8> {
|
||||||
|
bincode::serialize(s).unwrap()
|
||||||
|
}
|
||||||
|
fn holding(n: u8, tier: u8) -> Holding {
|
||||||
|
Holding { key: [n; 32], mbid: None, tier }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_list_round_trips_and_refuses_tampering() {
|
||||||
|
let k = secret(7);
|
||||||
|
let member = crate::public_of(&k);
|
||||||
|
let body = encode_body(HoldingsBody { schema_version: 0, slsk_username: Some("zoocone".into()), entries: vec![holding(2, TIER_LOSSY), holding(1, TIER_LOSSLESS)] }).unwrap();
|
||||||
|
let s = state(&k, 1, body.clone(), false);
|
||||||
|
let b = bytes(&s);
|
||||||
|
let back = validate(&[3; 16], &member, &b).unwrap();
|
||||||
|
assert_eq!(back, s);
|
||||||
|
let d = decode_body(&back.body).unwrap();
|
||||||
|
assert_eq!(d.entries.iter().map(|h| h.key[0]).collect::<Vec<_>>(), [1, 2], "sorted by key");
|
||||||
|
assert_eq!(d.slsk_username.as_deref(), Some("zoocone"));
|
||||||
|
|
||||||
|
// another room, another member, a flipped byte, a foreign signature
|
||||||
|
assert_eq!(validate(&[4; 16], &member, &b), Err(Invalid::WrongAddress));
|
||||||
|
assert_eq!(validate(&[3; 16], &crate::public_of(&secret(8)), &b), Err(Invalid::WrongAddress));
|
||||||
|
let mut t = s.clone();
|
||||||
|
t.sequence = 9;
|
||||||
|
assert_eq!(validate(&[3; 16], &member, &bytes(&t)), Err(Invalid::BadSignature));
|
||||||
|
let mut f = s.clone();
|
||||||
|
f.sig = sign_with(&secret(8), &f);
|
||||||
|
assert_eq!(validate(&[3; 16], &member, &bytes(&f)), Err(Invalid::BadSignature));
|
||||||
|
let mut w = state(&k, 2, body, true);
|
||||||
|
w.sig = sign_with(&k, &w);
|
||||||
|
assert_eq!(validate(&[3; 16], &member, &bytes(&w)), Err(Invalid::Malformed), "withdrawn keeps no body");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_is_by_sequence_then_hash_and_a_replay_cannot_resurrect() {
|
||||||
|
let k = secret(5);
|
||||||
|
let member = crate::public_of(&k);
|
||||||
|
let one = bytes(&state(&k, 1, vec![1], false));
|
||||||
|
let two = bytes(&state(&k, 2, vec![2], false));
|
||||||
|
let gone = bytes(&state(&k, 3, vec![], true));
|
||||||
|
let stranger = bytes(&state(&secret(6), 9, vec![9], false));
|
||||||
|
let sid = [3; 16];
|
||||||
|
assert_eq!(merge(&sid, &member, [one.as_slice(), two.as_slice()]), Some(two.clone()));
|
||||||
|
assert_eq!(merge(&sid, &member, [two.as_slice(), one.as_slice()]), Some(two.clone()));
|
||||||
|
assert_eq!(merge(&sid, &member, [gone.as_slice(), two.as_slice(), one.as_slice()]), Some(gone.clone()));
|
||||||
|
assert_eq!(merge(&sid, &member, [stranger.as_slice()]), None);
|
||||||
|
assert_eq!(merge(&sid, &member, [stranger.as_slice(), one.as_slice()]), Some(one.clone()));
|
||||||
|
// same sequence twice: the hash breaks the tie the same way in any order
|
||||||
|
let a = bytes(&state(&k, 4, vec![1, 1], false));
|
||||||
|
let b = bytes(&state(&k, 4, vec![2, 2], false));
|
||||||
|
let ab = merge(&sid, &member, [a.as_slice(), b.as_slice()]);
|
||||||
|
let ba = merge(&sid, &member, [b.as_slice(), a.as_slice()]);
|
||||||
|
assert_eq!(ab, ba);
|
||||||
|
assert!(ab == Some(a.clone()) || ab == Some(b.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_private_list_opens_only_with_the_epoch_key_and_only_in_its_envelope() {
|
||||||
|
let k = secret(2);
|
||||||
|
let room_key = [11u8; 32];
|
||||||
|
let plain = encode_body(HoldingsBody { schema_version: 0, slsk_username: None, entries: vec![holding(1, TIER_LOSSLESS)] }).unwrap();
|
||||||
|
let mut s = state(&k, 1, plain.clone(), false);
|
||||||
|
s.sealed = true;
|
||||||
|
s.body = seal(&room_key, &s);
|
||||||
|
s.sig = sign_with(&k, &s);
|
||||||
|
assert_ne!(s.body, plain);
|
||||||
|
assert_eq!(open(&room_key, &s), Some(plain.clone()));
|
||||||
|
assert_eq!(open(&[12u8; 32], &s), None);
|
||||||
|
// the same ciphertext under another sequence is not this list
|
||||||
|
let mut moved = s.clone();
|
||||||
|
moved.sequence = 2;
|
||||||
|
assert_eq!(open(&room_key, &moved), None);
|
||||||
|
// validate does not need the key: the envelope stands on its own
|
||||||
|
assert!(validate(&[3; 16], &crate::public_of(&k), &bytes(&s)).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_are_salted_by_room_and_the_body_folds_duplicates_to_the_better_tier() {
|
||||||
|
let a = album_key(&[1; 16], "broadcast", "tenderbuttons");
|
||||||
|
let b = album_key(&[2; 16], "broadcast", "tenderbuttons");
|
||||||
|
assert_ne!(a, b);
|
||||||
|
assert_eq!(a, album_key(&[1; 16], "broadcast", "tenderbuttons"));
|
||||||
|
assert_ne!(a, album_key(&[1; 16], "broadcasttender", "buttons"), "the separator keeps the two names apart");
|
||||||
|
assert_ne!(mbid_key(&[1; 16], "abc"), mbid_key(&[2; 16], "ABC"));
|
||||||
|
assert_eq!(mbid_key(&[1; 16], " abc "), mbid_key(&[1; 16], "ABC"));
|
||||||
|
|
||||||
|
let body = encode_body(HoldingsBody {
|
||||||
|
schema_version: 0,
|
||||||
|
slsk_username: None,
|
||||||
|
entries: vec![
|
||||||
|
Holding { key: [1; 32], mbid: None, tier: TIER_LOSSY },
|
||||||
|
Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS },
|
||||||
|
Holding { key: [1; 32], mbid: None, tier: TIER_UNKNOWN },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let d = decode_body(&body).unwrap();
|
||||||
|
assert_eq!(d.entries, vec![Holding { key: [1; 32], mbid: Some([9; 32]), tier: TIER_LOSSLESS }]);
|
||||||
|
|
||||||
|
let too_many = HoldingsBody { schema_version: 0, slsk_username: None, entries: (0..MAX_ENTRIES + 1).map(|i| Holding { key: blake3::hash(&i.to_le_bytes()).into(), mbid: None, tier: 0 }).collect() };
|
||||||
|
assert!(matches!(encode_body(too_many), Err(Invalid::TooManyEntries(_))));
|
||||||
|
let long = HoldingsBody { schema_version: 0, slsk_username: Some("x".repeat(65)), entries: vec![] };
|
||||||
|
assert!(matches!(encode_body(long), Err(Invalid::FieldTooLong("slsk_username", 65))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_round_trip() {
|
||||||
|
let p = make_params(&[3; 16], &[4; 32]);
|
||||||
|
assert_eq!(params_parts(&p), Some(([3; 16], [4; 32])));
|
||||||
|
assert_eq!(params_parts(&p[..p.len() - 1]), None);
|
||||||
|
assert_eq!(params_parts(&crate::space::make_params(&[4; 32], &[3; 16])), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ pub mod playlist;
|
|||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
pub mod space;
|
pub mod space;
|
||||||
pub mod proposals;
|
pub mod proposals;
|
||||||
|
pub mod holdings;
|
||||||
|
|
||||||
pub const SCHEMA_VERSION: u32 = 1;
|
pub const SCHEMA_VERSION: u32 = 1;
|
||||||
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
|
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,809 @@
|
|||||||
|
//! Holdings — "who has this, and get it from them".
|
||||||
|
//!
|
||||||
|
//! Each member of a Space may publish what they HOLD: the albums of their
|
||||||
|
//! library as room-salted keys (`persona_state::holdings::album_key` over
|
||||||
|
//! `overlap::norm`), one quality word each, and their Soulseek name — sealed
|
||||||
|
//! to the room's epoch key in a private Space, signed by the key they wear
|
||||||
|
//! there, one contract per (room, member). Never files, paths, sizes or
|
||||||
|
//! bitrates: the same rule the overlap shelf lives by, and the switch's
|
||||||
|
//! disclosure says exactly what a member can learn.
|
||||||
|
//!
|
||||||
|
//! Reading a room caches each member's list on disk (`holdings.json`, the
|
||||||
|
//! overlap ledger's pattern), so the ASK is local and instant and works with
|
||||||
|
//! the holders offline: hash the album per room, look it up, name the
|
||||||
|
//! members. "Get it" is the hub's album grab aimed at that member's share —
|
||||||
|
//! `find_folder` walks their browse tree with overlap's `pair_of` and hands
|
||||||
|
//! the files back for the frontend to queue; a holder who is offline goes on
|
||||||
|
//! a local wait list the frontend drains when their presence flips.
|
||||||
|
//!
|
||||||
|
//! Nothing is ever pushed to a holder. The only new network object is the
|
||||||
|
//! list, and only the room's members can read it.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use freenet_adapter::FreenetAdapter;
|
||||||
|
use persona_state::holdings as hd;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
|
use crate::pipeline::LibraryState;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use crate::{ledger, overlap, persona, slsk_link, spaces};
|
||||||
|
|
||||||
|
const HOLDINGS_WASM: &[u8] = include_bytes!("../assets/holdings_contract.wasm");
|
||||||
|
/// The ledger changed (a publish, a read, a wait): the page re-reads.
|
||||||
|
pub const EVENT: &str = "community:holdings";
|
||||||
|
const LEDGER_FILE: &str = "holdings.json";
|
||||||
|
/// A member's cached list is re-read after this long, or on Refresh.
|
||||||
|
const FRESH_SECS: u64 = 10 * 60;
|
||||||
|
/// A member with no list is asked again after this long: a GET for a
|
||||||
|
/// contract nobody put is a ring search that costs seconds.
|
||||||
|
const ABSENT_SECS: u64 = 60 * 60;
|
||||||
|
const LOSSLESS: &[&str] = &["flac", "alac", "wav", "aiff", "aif", "ape", "wv", "dsf", "dff", "tta", "shn"];
|
||||||
|
const LOSSY: &[&str] = &["mp3", "ogg", "oga", "opus", "m4a", "aac", "wma", "mpc"];
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- ledger
|
||||||
|
|
||||||
|
/// What this device last published into one room.
|
||||||
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Mine {
|
||||||
|
pub sequence: u64,
|
||||||
|
pub epoch: u64,
|
||||||
|
pub published_at: u64,
|
||||||
|
pub count: u32,
|
||||||
|
/// blake3 over the sorted keys, so a library that has not changed does
|
||||||
|
/// not republish.
|
||||||
|
pub digest: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub withdrawn: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One member's list as this device last read it.
|
||||||
|
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||||
|
struct Cached {
|
||||||
|
sequence: u64,
|
||||||
|
epoch: u64,
|
||||||
|
read_at: u64,
|
||||||
|
/// No contract under that address when last asked.
|
||||||
|
absent: bool,
|
||||||
|
/// Sealed to an epoch this device has no key for.
|
||||||
|
unreadable: bool,
|
||||||
|
withdrawn: bool,
|
||||||
|
slsk_username: Option<String>,
|
||||||
|
/// (key b58, mbid key b58, tier) — the same three the wire carries.
|
||||||
|
entries: Vec<(String, Option<String>, u8)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An album to fetch from someone the moment they come online.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Wait {
|
||||||
|
pub username: String,
|
||||||
|
pub artist: String,
|
||||||
|
pub album: String,
|
||||||
|
pub since: u64,
|
||||||
|
/// The room the holder was found in, for the row's provenance.
|
||||||
|
#[serde(default)]
|
||||||
|
pub via: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
struct Ledger {
|
||||||
|
#[serde(default)]
|
||||||
|
mine: BTreeMap<String, Mine>,
|
||||||
|
/// room record id → member key (b58) → the list.
|
||||||
|
#[serde(default)]
|
||||||
|
cache: BTreeMap<String, BTreeMap<String, Cached>>,
|
||||||
|
#[serde(default)]
|
||||||
|
waits: Vec<Wait>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(crate::paths::app_data(app)?.join("freenet").join(LEDGER_FILE))
|
||||||
|
}
|
||||||
|
fn load(app: &tauri::AppHandle) -> Ledger {
|
||||||
|
path(app)
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| std::fs::read(p).ok())
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> {
|
||||||
|
let p = path(app)?;
|
||||||
|
if let Some(dir) = p.parent() {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
std::fs::write(&p, serde_json::to_vec(l).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||||
|
let _ = app.emit(EVENT, ());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn now_secs() -> u64 {
|
||||||
|
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||||
|
}
|
||||||
|
fn b58(bytes: &[u8]) -> String {
|
||||||
|
bs58::encode(bytes).into_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- my list
|
||||||
|
|
||||||
|
/// The tier a dominant extension speaks for.
|
||||||
|
fn tier_of(ext: &str) -> u8 {
|
||||||
|
let e = ext.to_lowercase();
|
||||||
|
if LOSSLESS.contains(&e.as_str()) {
|
||||||
|
hd::TIER_LOSSLESS
|
||||||
|
} else if LOSSY.contains(&e.as_str()) {
|
||||||
|
hd::TIER_LOSSY
|
||||||
|
} else {
|
||||||
|
hd::TIER_UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The library as one room would hold it: keyed, tiered, sorted. Reads the
|
||||||
|
/// database, so it runs off the async runtime.
|
||||||
|
fn my_list(lib: &library::Library, space_id: &[u8; 16]) -> Result<(Vec<hd::Holding>, u32), String> {
|
||||||
|
let albums = lib.list_albums().map_err(|e| e.to_string())?;
|
||||||
|
let mbids = lib.album_mbids().unwrap_or_default();
|
||||||
|
let tiers: BTreeMap<i64, u8> = lib
|
||||||
|
.album_stats(0)
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| (s.album_id, s.formats.first().map(|f| tier_of(f)).unwrap_or(hd::TIER_UNKNOWN)))
|
||||||
|
.collect();
|
||||||
|
let total = albums.len() as u32;
|
||||||
|
// Newest first is the library's own order, so a list over the cap keeps
|
||||||
|
// what was added last.
|
||||||
|
let entries: Vec<hd::Holding> = albums
|
||||||
|
.iter()
|
||||||
|
.filter_map(|a| {
|
||||||
|
let (na, nt) = (overlap::norm(&a.artist), overlap::norm(&a.title));
|
||||||
|
if na.is_empty() || nt.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(hd::Holding {
|
||||||
|
key: hd::album_key(space_id, &na, &nt),
|
||||||
|
mbid: mbids.get(&a.id).map(|m| hd::mbid_key(space_id, m)),
|
||||||
|
tier: tiers.get(&a.id).copied().unwrap_or(hd::TIER_UNKNOWN),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.take(hd::MAX_ENTRIES)
|
||||||
|
.collect();
|
||||||
|
Ok((entries, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn digest_of(entries: &[hd::Holding]) -> String {
|
||||||
|
let mut buf = Vec::with_capacity(entries.len() * 33);
|
||||||
|
for e in entries {
|
||||||
|
buf.extend_from_slice(&e.key);
|
||||||
|
buf.push(e.tier);
|
||||||
|
}
|
||||||
|
b58(&persona_state::content_hash(&buf))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- network
|
||||||
|
|
||||||
|
fn adapter(app: &tauri::AppHandle) -> FreenetAdapter {
|
||||||
|
app.state::<AppState>().freenet.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The list at one address, verified. None when nothing was ever put there.
|
||||||
|
async fn fetch(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32]) -> Result<Option<hd::HoldingsState>, String> {
|
||||||
|
let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(space_id, member));
|
||||||
|
match adapter(app).contract_get(&ids.instance, false).await {
|
||||||
|
Ok(bytes) if bytes.is_empty() => Ok(None),
|
||||||
|
Ok(bytes) => hd::validate(space_id, member, &bytes)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| format!("holdings failed verification: {e}")),
|
||||||
|
Err(e) if e.to_string().contains("not found") => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn offer(app: &tauri::AppHandle, space_id: &[u8; 16], member: &[u8; 32], state: &hd::HoldingsState, first: bool) -> Result<(), String> {
|
||||||
|
let bytes = bincode::serialize(state).map_err(|e| e.to_string())?;
|
||||||
|
let params = hd::make_params(space_id, member);
|
||||||
|
let a = adapter(app);
|
||||||
|
if first {
|
||||||
|
a.contract_put(HOLDINGS_WASM.to_vec(), ¶ms, bytes, false).await.map(|_| ())
|
||||||
|
} else {
|
||||||
|
a.get_then_update(HOLDINGS_WASM.to_vec(), ¶ms, bytes).await.map(|_| ())
|
||||||
|
}
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign as the key worn in the room: the room secret for a member, the
|
||||||
|
/// delegate for an owner (their persona is their key there).
|
||||||
|
async fn sign(app: &tauri::AppHandle, me: &[u8; 32], worn: &[u8; 32], room_secret: Option<[u8; 32]>, s: &hd::HoldingsState) -> Result<Vec<u8>, String> {
|
||||||
|
match room_secret {
|
||||||
|
Some(secret) if worn != me => Ok(hd::sign_with(&secret, s)),
|
||||||
|
_ => persona::sign(&adapter(app), hd::SIGN_DOMAIN, hd::sign_payload(s)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything a publish or a withdrawal needs to know about where it goes.
|
||||||
|
struct Seat {
|
||||||
|
me: [u8; 32],
|
||||||
|
worn: [u8; 32],
|
||||||
|
room_secret: Option<[u8; 32]>,
|
||||||
|
space_id: [u8; 16],
|
||||||
|
epoch: u64,
|
||||||
|
epoch_key: Option<[u8; 32]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seat(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<Seat, String> {
|
||||||
|
let me = spaces::my_key(app).await?;
|
||||||
|
let owner = spaces::key_of(owner_b58)?;
|
||||||
|
let space_id = spaces::id_of(space_b58)?;
|
||||||
|
spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?;
|
||||||
|
let (worn, room_secret) = spaces::worn_identity(app, owner_b58, space_b58, &me);
|
||||||
|
let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?;
|
||||||
|
let c = state.checkpoint.ok_or("the Space has not reached this node yet")?;
|
||||||
|
if !c.members.iter().any(|m| m.key == worn) {
|
||||||
|
return Err("you are not in this Space's roster".into());
|
||||||
|
}
|
||||||
|
let epoch_key = if c.private {
|
||||||
|
Some(spaces::epoch_key_for(app, owner_b58, space_b58, c.epoch).ok_or("no key for this epoch yet — the owner has to hand it to you")?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(Seat { me, worn, room_secret, space_id, epoch: c.epoch, epoch_key })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Publish this device's list into one room. Read-and-bump: the sequence
|
||||||
|
/// follows whatever is on the network, so a device that lost its ledger
|
||||||
|
/// cannot publish under a sequence the room has already seen.
|
||||||
|
pub async fn publish(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<Mine, String> {
|
||||||
|
let st = seat(app, owner_b58, space_b58).await?;
|
||||||
|
let lib = app.state::<LibraryState>().library.clone();
|
||||||
|
let sid = st.space_id;
|
||||||
|
let (entries, total) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())??;
|
||||||
|
let count = entries.len() as u32;
|
||||||
|
let digest = digest_of(&entries);
|
||||||
|
let body = hd::encode_body(hd::HoldingsBody {
|
||||||
|
schema_version: hd::SCHEMA_VERSION,
|
||||||
|
slsk_username: slsk_link::own_linked_username(app),
|
||||||
|
entries,
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rid = spaces::record_id(owner_b58, space_b58);
|
||||||
|
let on_net = fetch(app, &st.space_id, &st.worn).await?;
|
||||||
|
let known = load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0);
|
||||||
|
let sequence = on_net.as_ref().map(|s| s.sequence).unwrap_or(0).max(known) + 1;
|
||||||
|
let mut s = hd::HoldingsState {
|
||||||
|
schema_version: hd::SCHEMA_VERSION,
|
||||||
|
space_id: st.space_id,
|
||||||
|
member: st.worn,
|
||||||
|
sequence,
|
||||||
|
epoch: st.epoch,
|
||||||
|
sealed: st.epoch_key.is_some(),
|
||||||
|
published_at: now_secs(),
|
||||||
|
withdrawn: false,
|
||||||
|
body,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
if let Some(k) = &st.epoch_key {
|
||||||
|
s.body = hd::seal(k, &s);
|
||||||
|
}
|
||||||
|
s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?;
|
||||||
|
offer(app, &st.space_id, &st.worn, &s, on_net.is_none()).await?;
|
||||||
|
let mine = Mine { sequence, epoch: st.epoch, published_at: s.published_at, count, digest, withdrawn: false };
|
||||||
|
{
|
||||||
|
let _guard = ledger::hold(LEDGER_FILE).await;
|
||||||
|
let mut l = load(app);
|
||||||
|
l.mine.insert(rid, mine.clone());
|
||||||
|
save(app, &l)?;
|
||||||
|
}
|
||||||
|
log::info!("holdings: published {count} of {total} albums into {space_b58} as sequence {sequence} (epoch {})", st.epoch);
|
||||||
|
Ok(mine)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take the list out of current state. Copies members already read stay
|
||||||
|
/// with them — never claim erasure.
|
||||||
|
pub async fn withdraw(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
|
||||||
|
let st = seat(app, owner_b58, space_b58).await?;
|
||||||
|
let rid = spaces::record_id(owner_b58, space_b58);
|
||||||
|
let on_net = fetch(app, &st.space_id, &st.worn).await?;
|
||||||
|
let Some(current) = on_net else {
|
||||||
|
// Nothing there to withdraw; forget what the ledger thought.
|
||||||
|
let _guard = ledger::hold(LEDGER_FILE).await;
|
||||||
|
let mut l = load(app);
|
||||||
|
l.mine.remove(&rid);
|
||||||
|
return save(app, &l);
|
||||||
|
};
|
||||||
|
if current.withdrawn {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let sequence = current.sequence.max(load(app).mine.get(&rid).map(|m| m.sequence).unwrap_or(0)) + 1;
|
||||||
|
let mut s = hd::HoldingsState {
|
||||||
|
schema_version: hd::SCHEMA_VERSION,
|
||||||
|
space_id: st.space_id,
|
||||||
|
member: st.worn,
|
||||||
|
sequence,
|
||||||
|
epoch: st.epoch,
|
||||||
|
sealed: false,
|
||||||
|
published_at: now_secs(),
|
||||||
|
withdrawn: true,
|
||||||
|
body: vec![],
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
s.sig = sign(app, &st.me, &st.worn, st.room_secret, &s).await?;
|
||||||
|
offer(app, &st.space_id, &st.worn, &s, false).await?;
|
||||||
|
{
|
||||||
|
let _guard = ledger::hold(LEDGER_FILE).await;
|
||||||
|
let mut l = load(app);
|
||||||
|
l.mine.insert(rid, Mine { sequence, epoch: st.epoch, published_at: s.published_at, count: 0, digest: String::new(), withdrawn: true });
|
||||||
|
save(app, &l)?;
|
||||||
|
}
|
||||||
|
log::info!("holdings: withdrawn from {space_b58} as sequence {sequence}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The switch. On: publish now. Off: withdraw. Either way the flag is set
|
||||||
|
/// first, so a publish that fails on the network is retried by the next
|
||||||
|
/// library change rather than forgotten.
|
||||||
|
pub async fn set_share(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<(), String> {
|
||||||
|
spaces::set_share_holdings_flag(app, owner_b58, space_b58, on).await?;
|
||||||
|
if on {
|
||||||
|
publish(app, owner_b58, space_b58).await.map(|_| ())
|
||||||
|
} else {
|
||||||
|
withdraw(app, owner_b58, space_b58).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The library changed: every room with the switch on gets the new list,
|
||||||
|
/// unless the list is byte-for-byte what that room already has. Quiet per
|
||||||
|
/// room — a failed republish is logged and tried again next change.
|
||||||
|
pub async fn republish_all(app: &tauri::AppHandle) -> Result<u32, String> {
|
||||||
|
let rooms = spaces::rooms_sharing_holdings(app);
|
||||||
|
let mut n = 0;
|
||||||
|
for (owner, space) in rooms {
|
||||||
|
let stale = {
|
||||||
|
let lib = app.state::<LibraryState>().library.clone();
|
||||||
|
let sid = spaces::id_of(&space)?;
|
||||||
|
let (entries, _) = tauri::async_runtime::spawn_blocking(move || my_list(&lib, &sid))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())??;
|
||||||
|
let l = load(app);
|
||||||
|
l.mine.get(&spaces::record_id(&owner, &space)).map(|m| m.withdrawn || m.digest != digest_of(&entries)).unwrap_or(true)
|
||||||
|
};
|
||||||
|
if !stale {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match publish(app, &owner, &space).await {
|
||||||
|
Ok(_) => n += 1,
|
||||||
|
Err(e) => log::info!("holdings: could not republish into {space}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- reading a room
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct MemberHoldings {
|
||||||
|
pub key: String,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub is_me: bool,
|
||||||
|
/// "shared" | "none" | "sealed" (no key for its epoch) | "withdrawn" | "unread"
|
||||||
|
pub state: String,
|
||||||
|
pub count: u32,
|
||||||
|
pub sequence: u64,
|
||||||
|
pub read_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RoomHoldings {
|
||||||
|
pub owner: String,
|
||||||
|
pub space_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub share_holdings: bool,
|
||||||
|
pub mine: Option<Mine>,
|
||||||
|
pub members: Vec<MemberHoldings>,
|
||||||
|
pub sharing: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The room's lists as this device knows them, reading the network for
|
||||||
|
/// members whose cached copy is stale (or all of them, on `refresh`). The
|
||||||
|
/// reads are sequential ring searches — seconds each for a member who
|
||||||
|
/// never published — so this runs from a spawned task and the page hears
|
||||||
|
/// the result by event; it never blocks the Space opening.
|
||||||
|
pub async fn read_room(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, refresh: bool) -> Result<RoomHoldings, String> {
|
||||||
|
let me = spaces::my_key(app).await?;
|
||||||
|
let owner = spaces::key_of(owner_b58)?;
|
||||||
|
let space_id = spaces::id_of(space_b58)?;
|
||||||
|
let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?;
|
||||||
|
let (worn, _) = spaces::worn_identity(app, owner_b58, space_b58, &me);
|
||||||
|
let (state, _) = spaces::fetch_state(app, &owner, &space_id).await?;
|
||||||
|
let c = state.checkpoint.ok_or("the Space has not reached this node yet")?;
|
||||||
|
let rid = spaces::record_id(owner_b58, space_b58);
|
||||||
|
let now = now_secs();
|
||||||
|
let members: Vec<[u8; 32]> = c.members.iter().map(|m| m.key).filter(|k| *k != worn).collect();
|
||||||
|
for member in members {
|
||||||
|
let mb58 = b58(&member);
|
||||||
|
let cached = load(app).cache.get(&rid).and_then(|m| m.get(&mb58)).cloned();
|
||||||
|
let fresh = cached.as_ref().is_some_and(|c| {
|
||||||
|
let age = now.saturating_sub(c.read_at);
|
||||||
|
if c.absent { age < ABSENT_SECS } else { age < FRESH_SECS }
|
||||||
|
});
|
||||||
|
if fresh && !refresh {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let entry = match fetch(app, &space_id, &member).await {
|
||||||
|
Ok(None) => Cached { read_at: now, absent: true, ..Default::default() },
|
||||||
|
Ok(Some(s)) if s.withdrawn => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, withdrawn: true, ..Default::default() },
|
||||||
|
Ok(Some(s)) => {
|
||||||
|
let plain = if s.sealed {
|
||||||
|
match spaces::epoch_key_for(app, owner_b58, space_b58, s.epoch) {
|
||||||
|
Some(k) => hd::open(&k, &s),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Some(s.body.clone())
|
||||||
|
};
|
||||||
|
match plain.as_deref().and_then(hd::decode_body) {
|
||||||
|
Some(b) => Cached {
|
||||||
|
sequence: s.sequence,
|
||||||
|
epoch: s.epoch,
|
||||||
|
read_at: now,
|
||||||
|
slsk_username: b.slsk_username,
|
||||||
|
entries: b.entries.iter().map(|h| (b58(&h.key), h.mbid.map(|m| b58(&m)), h.tier)).collect(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
None => Cached { sequence: s.sequence, epoch: s.epoch, read_at: now, unreadable: true, ..Default::default() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::info!("holdings: could not read {mb58} in {space_b58}: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _guard = ledger::hold(LEDGER_FILE).await;
|
||||||
|
let mut l = load(app);
|
||||||
|
l.cache.entry(rid.clone()).or_default().insert(mb58, entry);
|
||||||
|
save(app, &l)?;
|
||||||
|
}
|
||||||
|
Ok(room_view(app, owner_b58, space_b58, &facts, &c, &worn))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The room as the cache has it — no network.
|
||||||
|
pub fn room_now(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<RoomHoldings, String> {
|
||||||
|
let facts = spaces::room_facts(app, owner_b58, space_b58).ok_or("not a Space this device knows")?;
|
||||||
|
let l = load(app);
|
||||||
|
let rid = spaces::record_id(owner_b58, space_b58);
|
||||||
|
let mine = l.mine.get(&rid).cloned();
|
||||||
|
let cache = l.cache.get(&rid).cloned().unwrap_or_default();
|
||||||
|
let members = cache
|
||||||
|
.iter()
|
||||||
|
.map(|(k, c)| MemberHoldings {
|
||||||
|
key: k.clone(),
|
||||||
|
username: c.slsk_username.clone().or_else(|| spaces::username_behind(app, owner_b58, space_b58, k)),
|
||||||
|
is_me: false,
|
||||||
|
state: state_word(c).into(),
|
||||||
|
count: c.entries.len() as u32,
|
||||||
|
sequence: c.sequence,
|
||||||
|
read_at: c.read_at,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let sharing = members.iter().filter(|m| m.state == "shared").count() as u32 + u32::from(mine.as_ref().is_some_and(|m| !m.withdrawn));
|
||||||
|
Ok(RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name, share_holdings: facts.share_holdings, mine, members, sharing })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_word(c: &Cached) -> &'static str {
|
||||||
|
if c.absent {
|
||||||
|
"none"
|
||||||
|
} else if c.withdrawn {
|
||||||
|
"withdrawn"
|
||||||
|
} else if c.unreadable {
|
||||||
|
"sealed"
|
||||||
|
} else {
|
||||||
|
"shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn room_view(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, facts: &spaces::RoomFacts, c: &persona_state::space::Checkpoint, worn: &[u8; 32]) -> RoomHoldings {
|
||||||
|
let l = load(app);
|
||||||
|
let rid = spaces::record_id(owner_b58, space_b58);
|
||||||
|
let mine = l.mine.get(&rid).cloned();
|
||||||
|
let cache = l.cache.get(&rid).cloned().unwrap_or_default();
|
||||||
|
let members: Vec<MemberHoldings> = c
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let k = b58(&m.key);
|
||||||
|
let is_me = &m.key == worn;
|
||||||
|
let cached = cache.get(&k);
|
||||||
|
MemberHoldings {
|
||||||
|
username: cached.and_then(|c| c.slsk_username.clone()).or_else(|| spaces::username_behind(app, owner_b58, space_b58, &k)),
|
||||||
|
is_me,
|
||||||
|
state: if is_me {
|
||||||
|
match &mine {
|
||||||
|
Some(m) if !m.withdrawn => "shared",
|
||||||
|
_ => "none",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cached.map(state_word).unwrap_or("unread")
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
count: if is_me { mine.as_ref().map(|m| m.count).unwrap_or(0) } else { cached.map(|c| c.entries.len() as u32).unwrap_or(0) },
|
||||||
|
sequence: if is_me { mine.as_ref().map(|m| m.sequence).unwrap_or(0) } else { cached.map(|c| c.sequence).unwrap_or(0) },
|
||||||
|
read_at: cached.map(|c| c.read_at).unwrap_or(0),
|
||||||
|
key: k,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let sharing = members.iter().filter(|m| m.state == "shared").count() as u32;
|
||||||
|
RoomHoldings { owner: owner_b58.into(), space_id: space_b58.into(), name: facts.name.clone(), share_holdings: facts.share_holdings, mine, members, sharing }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- the ask
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AskItem {
|
||||||
|
pub artist: String,
|
||||||
|
pub album: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mbid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Holder {
|
||||||
|
pub owner: String,
|
||||||
|
pub space_id: String,
|
||||||
|
pub space_name: String,
|
||||||
|
pub member: String,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub tier: u8,
|
||||||
|
/// How the holding matched: "name" | "mbid".
|
||||||
|
pub matched_by: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct Answer {
|
||||||
|
pub artist: String,
|
||||||
|
pub album: String,
|
||||||
|
pub holders: Vec<Holder>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who holds each album, across every room this device is in — from the
|
||||||
|
/// cache, no network. A person in two rooms is one holder per room; the
|
||||||
|
/// frontend folds by username when it wants to.
|
||||||
|
pub fn ask(app: &tauri::AppHandle, items: &[AskItem]) -> Vec<Answer> {
|
||||||
|
let l = load(app);
|
||||||
|
let rooms = spaces::rooms(app);
|
||||||
|
// Per room: member → (key set, mbid set, username, tier by key).
|
||||||
|
struct RoomIndex {
|
||||||
|
owner: String,
|
||||||
|
space_id: String,
|
||||||
|
name: String,
|
||||||
|
sid: [u8; 16],
|
||||||
|
members: Vec<(String, Option<String>, BTreeMap<String, u8>, BTreeSet<String>)>,
|
||||||
|
}
|
||||||
|
let index: Vec<RoomIndex> = rooms
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(owner, space, name)| {
|
||||||
|
let sid = spaces::id_of(&space).ok()?;
|
||||||
|
let cache = l.cache.get(&spaces::record_id(&owner, &space))?;
|
||||||
|
let members = cache
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, c)| !c.absent && !c.withdrawn && !c.unreadable && !c.entries.is_empty())
|
||||||
|
.map(|(k, c)| {
|
||||||
|
let mut keys = BTreeMap::new();
|
||||||
|
let mut mbids = BTreeSet::new();
|
||||||
|
for (key, mbid, tier) in &c.entries {
|
||||||
|
keys.insert(key.clone(), *tier);
|
||||||
|
if let Some(m) = mbid {
|
||||||
|
mbids.insert(m.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(k.clone(), c.slsk_username.clone().or_else(|| spaces::username_behind(app, &owner, &space, k)), keys, mbids)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Some(RoomIndex { owner, space_id: space, name, sid, members })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|it| {
|
||||||
|
let (na, nt) = (overlap::norm(&it.artist), overlap::norm(&it.album));
|
||||||
|
let mut holders = Vec::new();
|
||||||
|
if !na.is_empty() && !nt.is_empty() {
|
||||||
|
for r in &index {
|
||||||
|
let key = b58(&hd::album_key(&r.sid, &na, &nt));
|
||||||
|
let mk = it.mbid.as_deref().filter(|m| !m.trim().is_empty()).map(|m| b58(&hd::mbid_key(&r.sid, m)));
|
||||||
|
for (member, username, keys, mbids) in &r.members {
|
||||||
|
let by_name = keys.get(&key).copied();
|
||||||
|
let by_mbid = mk.as_ref().filter(|m| mbids.contains(*m)).map(|_| hd::TIER_UNKNOWN);
|
||||||
|
if let Some((tier, how)) = by_name.map(|t| (t, "name")).or(by_mbid.map(|t| (t, "mbid"))) {
|
||||||
|
holders.push(Holder {
|
||||||
|
owner: r.owner.clone(),
|
||||||
|
space_id: r.space_id.clone(),
|
||||||
|
space_name: r.name.clone(),
|
||||||
|
member: member.clone(),
|
||||||
|
username: username.clone(),
|
||||||
|
tier,
|
||||||
|
matched_by: how,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Lossless first, then those who can actually be asked.
|
||||||
|
holders.sort_by(|a, b| b.tier.cmp(&a.tier).then(b.username.is_some().cmp(&a.username.is_some())));
|
||||||
|
Answer { artist: it.artist.clone(), album: it.album.clone(), holders }
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- getting it
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FileRef {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FolderMatch {
|
||||||
|
pub username: String,
|
||||||
|
pub folder: String,
|
||||||
|
pub files: Vec<FileRef>,
|
||||||
|
pub tier: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browse a holder's share and find the folder that names this album — the
|
||||||
|
/// overlap walk, so what the room said they hold is what gets grabbed. Disc
|
||||||
|
/// subfolders come along. None when their share does not name it (they
|
||||||
|
/// moved it, or the room's word was a near-name).
|
||||||
|
pub async fn find_folder(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result<Option<FolderMatch>, String> {
|
||||||
|
let (na, nt) = (overlap::norm(artist), overlap::norm(album));
|
||||||
|
if na.is_empty() || nt.is_empty() {
|
||||||
|
return Err("that album has no name to look for".into());
|
||||||
|
}
|
||||||
|
let fut = {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let guard = state.client.lock().await;
|
||||||
|
let client = guard.as_ref().ok_or("not connected to Soulseek")?;
|
||||||
|
client.browse(username)
|
||||||
|
};
|
||||||
|
let dirs = fut.await.map_err(|e| e.to_string())?;
|
||||||
|
overlap::note_browse(app, username, &dirs);
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut folder: Option<String> = None;
|
||||||
|
for d in &dirs {
|
||||||
|
let Some((a, b)) = overlap::pair_of(&d.name) else { continue };
|
||||||
|
let (da, db) = (overlap::norm(&a), overlap::norm(&b));
|
||||||
|
let hit = da == na && (db == nt || (nt.len() >= 5 && db.len() >= 5 && (db.contains(nt.as_str()) || nt.contains(db.as_str()))));
|
||||||
|
if !hit {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let audio: Vec<&soulseek_proto::types::File> = d.files.iter().filter(|f| overlap::is_audio(f)).collect();
|
||||||
|
if audio.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if folder.is_none() {
|
||||||
|
folder = Some(d.name.clone());
|
||||||
|
}
|
||||||
|
for f in audio {
|
||||||
|
files.push(FileRef { path: format!("{}\\{}", d.name, f.filename), name: f.filename.clone(), size: f.size });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(folder) = folder else { return Ok(None) };
|
||||||
|
let tier = files
|
||||||
|
.iter()
|
||||||
|
.map(|f| tier_of(f.name.rsplit('.').next().unwrap_or_default()))
|
||||||
|
.max()
|
||||||
|
.unwrap_or(hd::TIER_UNKNOWN);
|
||||||
|
Ok(Some(FolderMatch { username: username.to_string(), folder, files, tier }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- waits
|
||||||
|
|
||||||
|
pub async fn wait_add(app: &tauri::AppHandle, w: Wait) -> Result<Vec<Wait>, String> {
|
||||||
|
let _guard = ledger::hold(LEDGER_FILE).await;
|
||||||
|
let mut l = load(app);
|
||||||
|
let same = |x: &Wait| x.username.eq_ignore_ascii_case(&w.username) && overlap::norm(&x.artist) == overlap::norm(&w.artist) && overlap::norm(&x.album) == overlap::norm(&w.album);
|
||||||
|
if !l.waits.iter().any(same) {
|
||||||
|
l.waits.push(Wait { since: now_secs(), ..w });
|
||||||
|
save(app, &l)?;
|
||||||
|
}
|
||||||
|
Ok(l.waits)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn wait_remove(app: &tauri::AppHandle, username: &str, artist: &str, album: &str) -> Result<Vec<Wait>, String> {
|
||||||
|
let _guard = ledger::hold(LEDGER_FILE).await;
|
||||||
|
let mut l = load(app);
|
||||||
|
let (na, nt) = (overlap::norm(artist), overlap::norm(album));
|
||||||
|
l.waits.retain(|x| !(x.username.eq_ignore_ascii_case(username) && overlap::norm(&x.artist) == na && overlap::norm(&x.album) == nt));
|
||||||
|
save(app, &l)?;
|
||||||
|
Ok(l.waits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- commands
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn holdings_set_share(app: tauri::AppHandle, owner: String, space: String, on: bool) -> Result<RoomHoldings, String> {
|
||||||
|
set_share(&app, &owner, &space, on).await?;
|
||||||
|
room_now(&app, &owner, &space)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The room's lists. `refresh` re-reads every member; otherwise only stale
|
||||||
|
/// ones, and only from a spawned task so the answer is immediate — the
|
||||||
|
/// page hears the read finish by event.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn holdings_room(app: tauri::AppHandle, owner: String, space: String, refresh: bool) -> Result<RoomHoldings, String> {
|
||||||
|
let now = room_now(&app, &owner, &space)?;
|
||||||
|
let app2 = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(e) = read_room(&app2, &owner, &space, refresh).await {
|
||||||
|
log::info!("holdings: reading {space}: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(now)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn holdings_ask(app: tauri::AppHandle, items: Vec<AskItem>) -> Vec<Answer> {
|
||||||
|
ask(&app, &items)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn holdings_find_folder(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result<Option<FolderMatch>, String> {
|
||||||
|
find_folder(&app, &username, &artist, &album).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn holdings_republish(app: tauri::AppHandle) -> Result<u32, String> {
|
||||||
|
republish_all(&app).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn holdings_waits(app: tauri::AppHandle) -> Vec<Wait> {
|
||||||
|
load(&app).waits
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn holdings_wait_add(app: tauri::AppHandle, username: String, artist: String, album: String, via: Option<String>) -> Result<Vec<Wait>, String> {
|
||||||
|
wait_add(&app, Wait { username, artist, album, since: 0, via }).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn holdings_wait_remove(app: tauri::AppHandle, username: String, artist: String, album: String) -> Result<Vec<Wait>, String> {
|
||||||
|
wait_remove(&app, &username, &artist, &album).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The bundled contract is a commitment: a changed wasm re-keys every
|
||||||
|
/// list on the network.
|
||||||
|
#[test]
|
||||||
|
fn contract_generation_is_pinned() {
|
||||||
|
let ids = FreenetAdapter::contract_ids(HOLDINGS_WASM.to_vec(), &hd::make_params(&[1u8; 16], &[7u8; 32]));
|
||||||
|
assert_eq!(ids.code_hash, "3roNW8VMniLcQVWSzrAZ9Vum8KvFY7DD2dFkrXy43h9Q");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dominant_format_speaks_for_the_tier() {
|
||||||
|
assert_eq!(tier_of("flac"), hd::TIER_LOSSLESS);
|
||||||
|
assert_eq!(tier_of("FLAC"), hd::TIER_LOSSLESS);
|
||||||
|
assert_eq!(tier_of("mp3"), hd::TIER_LOSSY);
|
||||||
|
assert_eq!(tier_of("opus"), hd::TIER_LOSSY);
|
||||||
|
assert_eq!(tier_of("bin"), hd::TIER_UNKNOWN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An older ledger loads, and a wait is one per (person, album) however
|
||||||
|
/// the names are spelled.
|
||||||
|
#[test]
|
||||||
|
fn the_ledger_reads_older_files_and_waits_fold_by_name() {
|
||||||
|
let l: Ledger = serde_json::from_str("{}").unwrap();
|
||||||
|
assert!(l.mine.is_empty() && l.cache.is_empty() && l.waits.is_empty());
|
||||||
|
let w = Wait { username: "Kraven".into(), artist: "Stars of the Lid".into(), album: "And Their Refinement of the Decline".into(), since: 1, via: None };
|
||||||
|
let same = Wait { username: "kraven".into(), artist: "stars of the lid".into(), album: "And Their Refinement Of The Decline (2007) [FLAC]".into(), since: 2, via: None };
|
||||||
|
assert_eq!(overlap::norm(&w.album), overlap::norm(&same.album));
|
||||||
|
assert!(w.username.eq_ignore_ascii_case(&same.username));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ mod smtc;
|
|||||||
mod spaces;
|
mod spaces;
|
||||||
mod proposals;
|
mod proposals;
|
||||||
mod overlap;
|
mod overlap;
|
||||||
|
mod holdings;
|
||||||
mod state;
|
mod state;
|
||||||
mod thumbbar;
|
mod thumbbar;
|
||||||
mod vocal_sep;
|
mod vocal_sep;
|
||||||
@@ -186,6 +187,14 @@ pub fn run() {
|
|||||||
overlap::overlap_list,
|
overlap::overlap_list,
|
||||||
overlap::overlap_compute,
|
overlap::overlap_compute,
|
||||||
overlap::overlap_forget,
|
overlap::overlap_forget,
|
||||||
|
holdings::holdings_set_share,
|
||||||
|
holdings::holdings_room,
|
||||||
|
holdings::holdings_ask,
|
||||||
|
holdings::holdings_find_folder,
|
||||||
|
holdings::holdings_republish,
|
||||||
|
holdings::holdings_waits,
|
||||||
|
holdings::holdings_wait_add,
|
||||||
|
holdings::holdings_wait_remove,
|
||||||
commands::buddy_list,
|
commands::buddy_list,
|
||||||
commands::buddy_add,
|
commands::buddy_add,
|
||||||
commands::buddy_remove,
|
commands::buddy_remove,
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ fn key(username: &str) -> String {
|
|||||||
/// Bracketed chunks go — "(2019)", "[FLAC]", "{web}" — then only letters and
|
/// Bracketed chunks go — "(2019)", "[FLAC]", "{web}" — then only letters and
|
||||||
/// digits remain, lowercased; a leading "the" goes too. "Sigur Rós" and
|
/// digits remain, lowercased; a leading "the" goes too. "Sigur Rós" and
|
||||||
/// "sigur ros" are not the same string here, and that is a known miss.
|
/// "sigur ros" are not the same string here, and that is a known miss.
|
||||||
fn norm(s: &str) -> String {
|
pub(crate) fn norm(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len());
|
let mut out = String::with_capacity(s.len());
|
||||||
let mut depth = 0i32;
|
let mut depth = 0i32;
|
||||||
for c in s.chars() {
|
for c in s.chars() {
|
||||||
@@ -127,7 +127,7 @@ fn is_disc_folder(s: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_audio(f: &soulseek_proto::types::File) -> bool {
|
pub(crate) fn is_audio(f: &soulseek_proto::types::File) -> bool {
|
||||||
let ext = if f.extension.is_empty() {
|
let ext = if f.extension.is_empty() {
|
||||||
f.filename.rsplit('.').next().unwrap_or_default()
|
f.filename.rsplit('.').next().unwrap_or_default()
|
||||||
} else {
|
} else {
|
||||||
@@ -138,7 +138,7 @@ fn is_audio(f: &soulseek_proto::types::File) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The (artist, album) a share folder names, with display spellings.
|
/// The (artist, album) a share folder names, with display spellings.
|
||||||
fn pair_of(dir: &str) -> Option<(String, String)> {
|
pub(crate) fn pair_of(dir: &str) -> Option<(String, String)> {
|
||||||
let segs: Vec<&str> = dir
|
let segs: Vec<&str> = dir
|
||||||
.split(['\\', '/'])
|
.split(['\\', '/'])
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
|
|||||||
@@ -293,6 +293,12 @@ pub(crate) fn remember(app: &tauri::AppHandle, link: KnownLink) {
|
|||||||
|
|
||||||
/// The Soulseek account a verified persona key belongs to, if this device
|
/// The Soulseek account a verified persona key belongs to, if this device
|
||||||
/// verified one (our own included).
|
/// verified one (our own included).
|
||||||
|
/// The Soulseek name this device has VERIFIED as its own through the link
|
||||||
|
/// handshake, if any — not merely the account signed in right now.
|
||||||
|
pub(crate) fn own_linked_username(app: &tauri::AppHandle) -> Option<String> {
|
||||||
|
load(app).own.map(|o| o.username)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn username_for_key(app: &tauri::AppHandle, persona_key: &str) -> Option<String> {
|
pub(crate) fn username_for_key(app: &tauri::AppHandle, persona_key: &str) -> Option<String> {
|
||||||
let links = load(app);
|
let links = load(app);
|
||||||
if let Some(o) = &links.own {
|
if let Some(o) = &links.own {
|
||||||
|
|||||||
+64
-5
@@ -66,6 +66,11 @@ struct SpaceRecord {
|
|||||||
/// room is not consent to be watched in it.
|
/// room is not consent to be watched in it.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
share_listens: bool,
|
share_listens: bool,
|
||||||
|
/// Whether this device publishes what it HOLDS into this Space — album
|
||||||
|
/// keys sealed to the room, never files (`holdings.rs`). Per Space and
|
||||||
|
/// off until asked, for the same reason as listens.
|
||||||
|
#[serde(default)]
|
||||||
|
share_holdings: bool,
|
||||||
/// The last listen posted here — what, and when. A reload or a restart
|
/// The last listen posted here — what, and when. A reload or a restart
|
||||||
/// re-reports whatever is already past the player's threshold, and this
|
/// re-reports whatever is already past the player's threshold, and this
|
||||||
/// is the only side that knows what a room was last told.
|
/// is the only side that knows what a room was last told.
|
||||||
@@ -153,6 +158,8 @@ pub struct SpaceView {
|
|||||||
pub can_post: bool,
|
pub can_post: bool,
|
||||||
/// Whether THIS device posts what it plays here.
|
/// Whether THIS device posts what it plays here.
|
||||||
pub share_listens: bool,
|
pub share_listens: bool,
|
||||||
|
/// Whether THIS device publishes its holdings here.
|
||||||
|
pub share_holdings: bool,
|
||||||
pub has_key: bool,
|
pub has_key: bool,
|
||||||
/// The contract exists on the network (a Space someone invited us to
|
/// The contract exists on the network (a Space someone invited us to
|
||||||
/// may not have propagated to this node yet).
|
/// may not have propagated to this node yet).
|
||||||
@@ -226,13 +233,13 @@ fn rand32() -> [u8; 32] {
|
|||||||
rand::thread_rng().fill_bytes(&mut n);
|
rand::thread_rng().fill_bytes(&mut n);
|
||||||
n
|
n
|
||||||
}
|
}
|
||||||
fn b58(bytes: &[u8]) -> String {
|
pub(crate) fn b58(bytes: &[u8]) -> String {
|
||||||
bs58::encode(bytes).into_string()
|
bs58::encode(bytes).into_string()
|
||||||
}
|
}
|
||||||
fn key_of(s: &str) -> Result<[u8; 32], String> {
|
pub(crate) fn key_of(s: &str) -> Result<[u8; 32], String> {
|
||||||
inbox::key_of(s)
|
inbox::key_of(s)
|
||||||
}
|
}
|
||||||
fn id_of(s: &str) -> Result<[u8; 16], String> {
|
pub(crate) fn id_of(s: &str) -> Result<[u8; 16], String> {
|
||||||
bs58::decode(s)
|
bs58::decode(s)
|
||||||
.into_vec()
|
.into_vec()
|
||||||
.map_err(|e| format!("bad id: {e}"))?
|
.map_err(|e| format!("bad id: {e}"))?
|
||||||
@@ -250,7 +257,7 @@ fn cap(s: &str, max: usize) -> String {
|
|||||||
}
|
}
|
||||||
s[..end].to_string()
|
s[..end].to_string()
|
||||||
}
|
}
|
||||||
fn record_id(owner: &str, space_id: &str) -> String {
|
pub(crate) fn record_id(owner: &str, space_id: &str) -> String {
|
||||||
format!("{owner}:{space_id}")
|
format!("{owner}:{space_id}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,7 +288,7 @@ fn save(app: &tauri::AppHandle, l: &Ledger) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
|
pub(crate) async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
|
||||||
let adapter = app.state::<AppState>().freenet.clone();
|
let adapter = app.state::<AppState>().freenet.clone();
|
||||||
let info = persona::current(&adapter)
|
let info = persona::current(&adapter)
|
||||||
.await?
|
.await?
|
||||||
@@ -404,6 +411,7 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
|
|||||||
listens: vec![],
|
listens: vec![],
|
||||||
can_post: false,
|
can_post: false,
|
||||||
share_listens: rec.share_listens,
|
share_listens: rec.share_listens,
|
||||||
|
share_holdings: rec.share_holdings,
|
||||||
has_key: false,
|
has_key: false,
|
||||||
reachable,
|
reachable,
|
||||||
};
|
};
|
||||||
@@ -490,6 +498,7 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
|
|||||||
.collect(),
|
.collect(),
|
||||||
can_post: is_member && has_key,
|
can_post: is_member && has_key,
|
||||||
share_listens: rec.share_listens,
|
share_listens: rec.share_listens,
|
||||||
|
share_holdings: rec.share_holdings,
|
||||||
has_key,
|
has_key,
|
||||||
reachable,
|
reachable,
|
||||||
}
|
}
|
||||||
@@ -563,6 +572,7 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva
|
|||||||
roster: BTreeMap::new(),
|
roster: BTreeMap::new(),
|
||||||
invited: BTreeMap::new(),
|
invited: BTreeMap::new(),
|
||||||
share_listens: false,
|
share_listens: false,
|
||||||
|
share_holdings: false,
|
||||||
last_listen: None,
|
last_listen: None,
|
||||||
join_sent_at: 0,
|
join_sent_at: 0,
|
||||||
};
|
};
|
||||||
@@ -826,6 +836,53 @@ pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str
|
|||||||
|
|
||||||
/// Turn posting-what-I-play on or off for one Space. Nothing is published
|
/// Turn posting-what-I-play on or off for one Space. Nothing is published
|
||||||
/// by flipping it; it only decides what the next track does.
|
/// by flipping it; it only decides what the next track does.
|
||||||
|
/// What `holdings.rs` needs to know about a room, without the record.
|
||||||
|
pub(crate) struct RoomFacts {
|
||||||
|
pub name: String,
|
||||||
|
pub private: bool,
|
||||||
|
pub my_role: String,
|
||||||
|
pub share_holdings: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn room_facts(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Option<RoomFacts> {
|
||||||
|
let l = load(app);
|
||||||
|
let rec = l.spaces.get(&record_id(owner_b58, space_b58))?;
|
||||||
|
Some(RoomFacts { name: rec.name.clone(), private: rec.private, my_role: rec.my_role.clone(), share_holdings: rec.share_holdings })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The room's key for one epoch, if this device was handed it.
|
||||||
|
pub(crate) fn epoch_key_for(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, epoch: u64) -> Option<[u8; 32]> {
|
||||||
|
let l = load(app);
|
||||||
|
let rec = l.spaces.get(&record_id(owner_b58, space_b58))?;
|
||||||
|
rec.keys.get(&epoch.to_string()).and_then(|k| key_of(k).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every room this device is in: (owner, space id, name).
|
||||||
|
pub(crate) fn rooms(app: &tauri::AppHandle) -> Vec<(String, String, String)> {
|
||||||
|
load(app).spaces.values().map(|r| (r.owner.clone(), r.space_id.clone(), r.name.clone())).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rooms this device publishes its holdings into.
|
||||||
|
pub(crate) fn rooms_sharing_holdings(app: &tauri::AppHandle) -> Vec<(String, String)> {
|
||||||
|
load(app).spaces.values().filter(|r| r.share_holdings).map(|r| (r.owner.clone(), r.space_id.clone())).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Soulseek name behind a room key as this device knows it: through the
|
||||||
|
/// owner's private roster, or the key itself when it is a persona (an owner's).
|
||||||
|
pub(crate) fn username_behind(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, member_b58: &str) -> Option<String> {
|
||||||
|
let l = load(app);
|
||||||
|
let rec = l.spaces.get(&record_id(owner_b58, space_b58))?;
|
||||||
|
slsk_link::username_for_key(app, rec.roster.get(member_b58).map(String::as_str).unwrap_or(member_b58))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn set_share_holdings_flag(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<(), String> {
|
||||||
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
||||||
|
let mut l = load(app);
|
||||||
|
let rec = l.spaces.get_mut(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?;
|
||||||
|
rec.share_holdings = on;
|
||||||
|
save(app, &l)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_share_listens(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<SpaceView, String> {
|
pub async fn set_share_listens(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<SpaceView, String> {
|
||||||
{
|
{
|
||||||
let _ledger = ledger::hold(LEDGER_FILE).await;
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
||||||
@@ -946,6 +1003,7 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in
|
|||||||
roster: BTreeMap::new(),
|
roster: BTreeMap::new(),
|
||||||
invited: BTreeMap::new(),
|
invited: BTreeMap::new(),
|
||||||
share_listens: false,
|
share_listens: false,
|
||||||
|
share_holdings: false,
|
||||||
last_listen: None,
|
last_listen: None,
|
||||||
join_sent_at: 0,
|
join_sent_at: 0,
|
||||||
});
|
});
|
||||||
@@ -1197,6 +1255,7 @@ mod tests {
|
|||||||
roster: BTreeMap::new(),
|
roster: BTreeMap::new(),
|
||||||
invited: BTreeMap::new(),
|
invited: BTreeMap::new(),
|
||||||
share_listens: false,
|
share_listens: false,
|
||||||
|
share_holdings: false,
|
||||||
last_listen: None,
|
last_listen: None,
|
||||||
join_sent_at: 0,
|
join_sent_at: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user