diff --git a/crates/persona-state/src/lib.rs b/crates/persona-state/src/lib.rs index 749f9db..9185139 100644 --- a/crates/persona-state/src/lib.rs +++ b/crates/persona-state/src/lib.rs @@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize}; pub mod slsk_link; pub mod friend; +pub mod playlist; pub const SCHEMA_VERSION: u32 = 1; pub const SIGN_DOMAIN: &str = "soulstream.profile/v1"; diff --git a/crates/persona-state/src/playlist.rs b/crates/persona-state/src/playlist.rs new file mode 100644 index 0000000..57dfa3f --- /dev/null +++ b/crates/persona-state/src/playlist.rs @@ -0,0 +1,342 @@ +//! Playlist publication — F3's exact shapes: an immutable, content-addressed +//! revision (the portable manifest: entry occurrences with recording ids and +//! display fallbacks, never a path) and an owner-signed pointer to the +//! current revision per (owner, playlist uuid). BYTE-COMPATIBILITY IS THE +//! CONTRACT: both shapes are what the bundled revision/pointer wasm validate +//! (built from research/freenet-f3, code hashes pinned by the app's tests). +//! +//! Signing: the delegate caps payloads, so what it signs is blake3(sig-body) +//! under `soulstream.playlist/v1` — the F3 convention, mirrored here. + +use serde::{Deserialize, Serialize}; + +pub const SIGN_DOMAIN: &str = "soulstream.playlist/v1"; +pub const SCHEMA_VERSION: u32 = 1; +pub const REV_PARAMS_FAMILY: &[u8] = b"soulstream.playlist-rev.v1"; +pub const PTR_PARAMS_FAMILY: &[u8] = b"soulstream.playlist-ptr.v1"; +pub const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024; +pub const MAX_ENTRIES: usize = 5000; +pub const MAX_NAME_BYTES: usize = 128; +pub const MAX_DESC_BYTES: usize = 2048; +pub const MAX_NOTE_BYTES: usize = 280; +pub const MAX_FIELD_BYTES: usize = 512; +pub const MAX_POINTER_BYTES: usize = 1024; + +// ------------------------------------------------------------ manifest + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordingRef { + pub musicbrainz_recording_id: Option, + pub isrcs: Vec, + pub deezer_id: Option, + pub apple_music_id: Option, + pub title: String, + pub artists: Vec, + pub album: String, + pub duration_ms: Option, + pub release_year: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntryV1 { + /// Occurrence id — duplicates of the same recording stay distinct. + pub entry_id: [u8; 16], + pub position: u32, + pub recording: RecordingRef, + pub note: Option, + /// Free-text resolution hint ("artist title"), NEVER a remote path. + pub slsk_hint: Option, +} + +/// Bounded artwork REFERENCE — hash + type + size only, no bytes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtworkRef { + pub content_hash: [u8; 32], + pub media_type: String, + pub byte_length: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManifestV1 { + pub schema_version: u32, + pub owner: [u8; 32], + pub playlist_uuid: [u8; 16], + pub revision_seq: u64, + /// Previous revision's content hash, None for the first. + pub prev_revision_hash: Option<[u8; 32]>, + pub name: String, + pub description: String, + pub artwork: Option, + pub entries: Vec, + pub sig: Vec, +} + +#[derive(Serialize)] +struct ManifestSigBody<'a> { + schema_version: u32, + owner: &'a [u8; 32], + playlist_uuid: &'a [u8; 16], + revision_seq: u64, + prev_revision_hash: &'a Option<[u8; 32]>, + name: &'a str, + description: &'a str, + artwork: &'a Option, + entries: &'a [EntryV1], +} + +/// The 32 bytes the delegate signs: blake3 of the serialized sig body. +pub fn manifest_sign_payload(m: &ManifestV1) -> Vec { + let body = bincode::serialize(&ManifestSigBody { + schema_version: m.schema_version, + owner: &m.owner, + playlist_uuid: &m.playlist_uuid, + revision_seq: m.revision_seq, + prev_revision_hash: &m.prev_revision_hash, + name: &m.name, + description: &m.description, + artwork: &m.artwork, + entries: &m.entries, + }) + .expect("sig body serializes"); + blake3::hash(&body).as_bytes().to_vec() +} + +fn verify(owner: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool { + crate::verify_sig_in(SIGN_DOMAIN, owner, payload, sig) +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Invalid { + Oversized(usize), + HashMismatch, + Malformed, + WrongSchema(u32), + WrongOwner, + TooManyEntries(usize), + FieldTooLong(&'static str, usize), + BadSignature, +} + +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::HashMismatch => write!(f, "bytes do not hash to their address"), + Invalid::Malformed => write!(f, "bytes do not parse"), + Invalid::WrongSchema(v) => write!(f, "unknown schema {v}"), + Invalid::WrongOwner => write!(f, "owner differs from the address"), + Invalid::TooManyEntries(n) => write!(f, "{n} entries is over the cap"), + Invalid::FieldTooLong(w, n) => write!(f, "{w} is {n} bytes, over its cap"), + Invalid::BadSignature => write!(f, "owner signature does not verify"), + } + } +} + +pub fn make_rev_params(owner: &[u8; 32], content_hash: &[u8; 32]) -> Vec { + let mut p = Vec::with_capacity(REV_PARAMS_FAMILY.len() + 64); + p.extend_from_slice(REV_PARAMS_FAMILY); + p.extend_from_slice(owner); + p.extend_from_slice(content_hash); + p +} + +/// Strict verification in the plan's order: size → content hash → parse → +/// schema → owner → caps → signature. All BEFORE any preview use. +pub fn validate_revision( + owner: &[u8; 32], + content_hash: &[u8; 32], + bytes: &[u8], +) -> Result { + if bytes.len() > MAX_MANIFEST_BYTES { + return Err(Invalid::Oversized(bytes.len())); + } + if blake3::hash(bytes).as_bytes() != content_hash { + return Err(Invalid::HashMismatch); + } + let m: ManifestV1 = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?; + if m.schema_version != SCHEMA_VERSION { + return Err(Invalid::WrongSchema(m.schema_version)); + } + if &m.owner != owner { + return Err(Invalid::WrongOwner); + } + if m.entries.len() > MAX_ENTRIES { + return Err(Invalid::TooManyEntries(m.entries.len())); + } + if m.name.len() > MAX_NAME_BYTES { + return Err(Invalid::FieldTooLong("name", m.name.len())); + } + if m.description.len() > MAX_DESC_BYTES { + return Err(Invalid::FieldTooLong("description", m.description.len())); + } + for e in &m.entries { + if e.note.as_deref().map(str::len).unwrap_or(0) > MAX_NOTE_BYTES { + return Err(Invalid::FieldTooLong("note", e.note.as_ref().unwrap().len())); + } + for s in [&e.recording.title, &e.recording.album] { + if s.len() > MAX_FIELD_BYTES { + return Err(Invalid::FieldTooLong("recording field", s.len())); + } + } + if e.slsk_hint.as_deref().map(str::len).unwrap_or(0) > MAX_FIELD_BYTES { + return Err(Invalid::FieldTooLong("slsk_hint", e.slsk_hint.as_ref().unwrap().len())); + } + } + if !verify(owner, &manifest_sign_payload(&m), &m.sig) { + return Err(Invalid::BadSignature); + } + Ok(m) +} + +// ------------------------------------------------------------ pointer + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RevisionRef { + pub content_hash: [u8; 32], + pub revision_seq: u64, + pub entry_count: u32, + pub byte_length: u32, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PointerState { + pub schema_version: u32, + pub owner: [u8; 32], + pub playlist_uuid: [u8; 16], + pub sequence: u64, + /// None + withdrawn = "removed from current state" — old revisions + /// remain fetchable; never claim network erasure. + pub current: Option, + pub withdrawn: bool, + pub sig: Vec, +} + +#[derive(Serialize)] +struct PointerSigBody<'a> { + schema_version: u32, + owner: &'a [u8; 32], + playlist_uuid: &'a [u8; 16], + sequence: u64, + current: &'a Option, + withdrawn: bool, +} + +pub fn pointer_sign_payload(p: &PointerState) -> Vec { + let body = bincode::serialize(&PointerSigBody { + schema_version: p.schema_version, + owner: &p.owner, + playlist_uuid: &p.playlist_uuid, + sequence: p.sequence, + current: &p.current, + withdrawn: p.withdrawn, + }) + .expect("sig body serializes"); + blake3::hash(&body).as_bytes().to_vec() +} + +pub fn make_ptr_params(owner: &[u8; 32], playlist_uuid: &[u8; 16]) -> Vec { + let mut p = Vec::with_capacity(PTR_PARAMS_FAMILY.len() + 48); + p.extend_from_slice(PTR_PARAMS_FAMILY); + p.extend_from_slice(owner); + p.extend_from_slice(playlist_uuid); + p +} + +pub fn validate_pointer( + owner: &[u8; 32], + uuid: &[u8; 16], + bytes: &[u8], +) -> Result { + if bytes.len() > MAX_POINTER_BYTES { + return Err(Invalid::Oversized(bytes.len())); + } + let p: PointerState = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?; + if p.schema_version != SCHEMA_VERSION { + return Err(Invalid::WrongSchema(p.schema_version)); + } + if &p.owner != owner || &p.playlist_uuid != uuid { + return Err(Invalid::WrongOwner); + } + if let Some(r) = &p.current { + if r.name.len() > MAX_NAME_BYTES { + return Err(Invalid::FieldTooLong("pointer name", r.name.len())); + } + } + if !verify(owner, &pointer_sign_payload(&p), &p.sig) { + return Err(Invalid::BadSignature); + } + Ok(p) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + fn sign(key: &SigningKey, payload: &[u8]) -> Vec { + key.sign(&crate::signable(SIGN_DOMAIN, payload)).to_bytes().to_vec() + } + + #[test] + fn a_revision_and_its_pointer_round_trip_and_refuse_tampering() { + let key = SigningKey::from_bytes(&[5u8; 32]); + let owner = key.verifying_key().to_bytes(); + let mut m = ManifestV1 { + schema_version: SCHEMA_VERSION, + owner, + playlist_uuid: [9; 16], + revision_seq: 1, + prev_revision_hash: None, + name: "Rainy Window".into(), + description: "long fades".into(), + artwork: None, + entries: vec![EntryV1 { + entry_id: [1; 16], + position: 0, + recording: RecordingRef { + musicbrainz_recording_id: None, + isrcs: vec![], + deezer_id: None, + apple_music_id: None, + title: "Airbag".into(), + artists: vec!["Radiohead".into()], + album: "OK Computer".into(), + duration_ms: Some(284_000), + release_year: Some(1997), + }, + note: None, + slsk_hint: Some("radiohead airbag".into()), + }], + sig: vec![], + }; + m.sig = sign(&key, &manifest_sign_payload(&m)); + let bytes = bincode::serialize(&m).unwrap(); + let hash = *blake3::hash(&bytes).as_bytes(); + assert_eq!(validate_revision(&owner, &hash, &bytes).unwrap(), m); + // content addressing: a flipped byte trips the hash BEFORE parse + let mut flipped = bytes.clone(); + flipped[10] ^= 1; + assert_eq!(validate_revision(&owner, &hash, &flipped), Err(Invalid::HashMismatch)); + // a re-signed edit by the wrong key is a bad signature + let mut edited = m.clone(); + edited.name = "Sunny Window".into(); + edited.sig = sign(&SigningKey::from_bytes(&[6u8; 32]), &manifest_sign_payload(&edited)); + let eb = bincode::serialize(&edited).unwrap(); + assert_eq!(validate_revision(&owner, blake3::hash(&eb).as_bytes(), &eb), Err(Invalid::BadSignature)); + + let mut p = PointerState { + schema_version: SCHEMA_VERSION, + owner, + playlist_uuid: [9; 16], + sequence: 1, + current: Some(RevisionRef { content_hash: hash, revision_seq: 1, entry_count: 1, byte_length: bytes.len() as u32, name: "Rainy Window".into() }), + withdrawn: false, + sig: vec![], + }; + p.sig = sign(&key, &pointer_sign_payload(&p)); + let pb = bincode::serialize(&p).unwrap(); + assert_eq!(validate_pointer(&owner, &[9; 16], &pb).unwrap(), p); + assert_eq!(validate_pointer(&owner, &[8; 16], &pb), Err(Invalid::WrongOwner)); + } +} diff --git a/src-tauri/assets/playlist_pointer_contract.wasm b/src-tauri/assets/playlist_pointer_contract.wasm new file mode 100644 index 0000000..3f6180e Binary files /dev/null and b/src-tauri/assets/playlist_pointer_contract.wasm differ diff --git a/src-tauri/assets/playlist_revision_contract.wasm b/src-tauri/assets/playlist_revision_contract.wasm new file mode 100644 index 0000000..072503f Binary files /dev/null and b/src-tauri/assets/playlist_revision_contract.wasm differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 0382de3..8e151cd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -930,6 +930,7 @@ pub async fn connect_and_login( // never reach the Inbox (slsk_link.rs). if crate::slsk_link::intercept_pm(&event_app, &username, &message).await || crate::friends::intercept_pm(&event_app, &username, &message) + || crate::shared_playlists::intercept_pm(&event_app, &username, &message) { continue; } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 70a0124..c6b99db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,6 +21,7 @@ mod playlists; mod provider_playlist; mod preview; mod setlist_scan; +mod shared_playlists; mod shazam; mod slsk_link; mod smtc; @@ -136,6 +137,14 @@ pub fn run() { friends::friend_decline, friends::friend_remove, friends::friend_forget, + shared_playlists::shared_playlists_mine, + shared_playlists::shared_playlist_publish, + shared_playlists::shared_playlist_withdraw, + shared_playlists::shared_playlist_fetch, + shared_playlists::shared_playlist_import, + shared_playlists::share_send, + shared_playlists::shares_inbox, + shared_playlists::share_dismiss, commands::buddy_list, commands::buddy_add, commands::buddy_remove, diff --git a/src-tauri/src/shared_playlists.rs b/src-tauri/src/shared_playlists.rs new file mode 100644 index 0000000..d345335 --- /dev/null +++ b/src-tauri/src/shared_playlists.rs @@ -0,0 +1,554 @@ +//! Shared playlists (F3 → the app): a playlist is published as an +//! immutable, content-addressed REVISION (the portable manifest — entry +//! occurrences with display fallbacks, never a path) plus an owner-signed +//! POINTER to the current revision per (owner, playlist uuid). Editing +//! publishes a new revision and bumps the pointer; withdrawing bumps the +//! pointer to "none", and the truthful line stays: published revisions +//! remain fetchable, nothing on the network is erased. +//! +//! Both contract wasms are bundled commitments like the persona ones +//! (`assets/playlist_revision_contract.wasm`, `assets/playlist_pointer_ +//! contract.wasm`, from research/freenet-f3; code hashes pinned by tests). +//! +//! Sharing WITH someone sends an address, nothing else — `SSSHARE1?` +//! over the same Soulseek PM channel the link proof and friends use. The +//! recipient sees a card, fetches the manifest through their own node, +//! verifies it against the owner's key, and imports it through the app's +//! normal playlist-import path (the review resolves tracks against their +//! library and the catalog, and unowned rows become Soulseek streams). + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use freenet_adapter::FreenetAdapter; +use persona_state as ps; +use persona_state::playlist as pl; +use persona_state::slsk_link::normalize_username; +use serde::{Deserialize, Serialize}; +use tauri::{Emitter, Manager, State}; + +use crate::persona; +use crate::pipeline::LibraryState; +use crate::playlist_formats::{ParsedEntry, ParsedPlaylist}; +use crate::playlists::{self, ImportDraft}; +use crate::state::AppState; + +const REV_WASM: &[u8] = include_bytes!("../assets/playlist_revision_contract.wasm"); +const PTR_WASM: &[u8] = include_bytes!("../assets/playlist_pointer_contract.wasm"); +const SHARE_PREFIX: &str = "SSSHARE1?"; +pub const EVENT: &str = "community:shares"; + +/// A playlist this device has published, by local playlist id. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Published { + pub playlist_id: i64, + pub uuid: String, + pub revision_seq: u64, + pub pointer_seq: u64, + /// The current revision's content hash (b58); stays after a withdraw so + /// the next publication still links to its predecessor. + pub current_hash: Option, + pub name: String, + pub entry_count: u32, + pub withdrawn: bool, + pub published_at: u64, +} + +/// A reference someone sent us: an address, plus what they said it was. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShareCard { + pub id: String, + pub from_username: String, + pub owner_key: String, + pub playlist_uuid: String, + pub name: String, + pub entry_count: u32, + pub received_at: u64, +} + +#[derive(Default, Serialize, Deserialize)] +struct Ledger { + #[serde(default)] + published: BTreeMap, + #[serde(default)] + inbox: Vec, +} + +/// What travels in a share: the address and a display hint. +#[derive(Serialize, Deserialize)] +struct ShareRef { + owner: [u8; 32], + playlist_uuid: [u8; 16], + name: String, + entry_count: u32, +} + +#[derive(Debug, Serialize)] +pub struct ShareEntry { + pub position: u32, + pub title: String, + pub artist: String, + pub album: String, + pub duration_secs: Option, + pub note: Option, +} + +/// A fetched, VERIFIED shared playlist as the reader sees it. +#[derive(Debug, Serialize)] +pub struct ShareView { + pub owner_key: String, + pub playlist_uuid: String, + pub name: String, + pub description: String, + pub revision_seq: u64, + pub content_hash: String, + pub withdrawn: bool, + pub entries: Vec, +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} +fn rand16() -> [u8; 16] { + use rand::RngCore; + let mut n = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut n); + n +} +fn b58(bytes: &[u8]) -> String { + bs58::encode(bytes).into_string() +} +fn bytes32(b58s: &str) -> Result<[u8; 32], String> { + bs58::decode(b58s) + .into_vec() + .map_err(|e| format!("bad key: {e}"))? + .as_slice() + .try_into() + .map_err(|_| "not 32 bytes".to_string()) +} +fn bytes16(b58s: &str) -> Result<[u8; 16], String> { + bs58::decode(b58s) + .into_vec() + .map_err(|e| format!("bad id: {e}"))? + .as_slice() + .try_into() + .map_err(|_| "not 16 bytes".to_string()) +} +/// Cut to a byte cap on a char boundary. +fn cap(s: &str, max: usize) -> String { + if s.len() <= max { + return s.to_string(); + } + let mut end = max; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s[..end].to_string() +} + +fn path(app: &tauri::AppHandle) -> Result { + Ok(app + .path() + .app_data_dir() + .map_err(|e| e.to_string())? + .join("freenet") + .join("playlists-ledger.json")) +} +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, ledger: &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_pretty(ledger).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let _ = app.emit(EVENT, ()); + Ok(()) +} + +async fn own_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> { + let adapter = app.state::().freenet.clone(); + let info = persona::current(&adapter) + .await? + .ok_or("create a SoulStream identity first")?; + bytes32(&info.public_key) +} +async fn send_pm(app: &tauri::AppHandle, to: &str, text: &str) -> Result<(), String> { + let st = app.state::(); + let guard = st.client.lock().await; + guard + .as_ref() + .ok_or("not connected to Soulseek")? + .message_user(to, text) + .map_err(|e| e.to_string()) +} + +// ---------------------------------------------------------------- publish + +/// Publish (or re-publish) a local playlist: a new signed revision and a +/// pointer bump. Nothing about local files leaves the machine — entries +/// carry titles, artists, albums, durations and a text hint only. +pub async fn publish(app: &tauri::AppHandle, lib: &LibraryState, playlist_id: i64) -> Result { + let adapter: FreenetAdapter = app.state::().freenet.clone(); + let owner = own_key(app).await?; + let l = lib.library.clone(); + let (meta, items) = tauri::async_runtime::spawn_blocking(move || -> Result<_, String> { + let meta = l + .list_playlists() + .map_err(|e| e.to_string())? + .into_iter() + .find(|p| p.id == playlist_id) + .ok_or("no such playlist")?; + let items = l.playlist_items(playlist_id).map_err(|e| e.to_string())?; + Ok((meta, items)) + }) + .await + .map_err(|e| e.to_string())??; + if items.len() > pl::MAX_ENTRIES { + return Err(format!("{} entries is over the {} cap", items.len(), pl::MAX_ENTRIES)); + } + let mut ledger = load(app); + let key = playlist_id.to_string(); + let prev = ledger.published.get(&key).cloned(); + let uuid = match &prev { + Some(p) => bytes16(&p.uuid)?, + None => rand16(), + }; + let revision_seq = prev.as_ref().map(|p| p.revision_seq + 1).unwrap_or(1); + let prev_hash = match prev.as_ref().and_then(|p| p.current_hash.as_deref()) { + Some(h) => Some(bytes32(h)?), + None => None, + }; + let entries: Vec = items + .iter() + .enumerate() + .map(|(i, it)| pl::EntryV1 { + entry_id: rand16(), + position: i as u32, + recording: pl::RecordingRef { + musicbrainz_recording_id: None, + isrcs: vec![], + deezer_id: None, + apple_music_id: None, + title: cap(&it.title, pl::MAX_FIELD_BYTES), + artists: vec![cap(&it.artist, pl::MAX_FIELD_BYTES)], + album: cap(it.album.as_deref().unwrap_or(""), pl::MAX_FIELD_BYTES), + duration_ms: it.duration_secs.map(|s| s.saturating_mul(1000)), + release_year: None, + }, + note: it.note.as_deref().map(|n| cap(n, pl::MAX_NOTE_BYTES)), + slsk_hint: Some(cap(&format!("{} {}", it.artist, it.title), pl::MAX_FIELD_BYTES)), + }) + .collect(); + let mut m = pl::ManifestV1 { + schema_version: pl::SCHEMA_VERSION, + owner, + playlist_uuid: uuid, + revision_seq, + prev_revision_hash: prev_hash, + name: cap(&meta.name, pl::MAX_NAME_BYTES), + description: cap(meta.description.as_deref().unwrap_or(""), pl::MAX_DESC_BYTES), + artwork: None, + entries, + sig: vec![], + }; + m.sig = persona::sign(&adapter, pl::SIGN_DOMAIN, pl::manifest_sign_payload(&m)).await?; + let bytes = bincode::serialize(&m).map_err(|e| e.to_string())?; + let hash = ps::content_hash(&bytes); + adapter + .contract_put(REV_WASM.to_vec(), &pl::make_rev_params(&owner, &hash), bytes.clone(), false) + .await + .map_err(|e| e.to_string())?; + let pointer_seq = prev.as_ref().map(|p| p.pointer_seq + 1).unwrap_or(1); + let mut p = pl::PointerState { + schema_version: pl::SCHEMA_VERSION, + owner, + playlist_uuid: uuid, + sequence: pointer_seq, + current: Some(pl::RevisionRef { + content_hash: hash, + revision_seq, + entry_count: m.entries.len() as u32, + byte_length: bytes.len() as u32, + name: m.name.clone(), + }), + withdrawn: false, + sig: vec![], + }; + p.sig = persona::sign(&adapter, pl::SIGN_DOMAIN, pl::pointer_sign_payload(&p)).await?; + let pb = bincode::serialize(&p).map_err(|e| e.to_string())?; + let params = pl::make_ptr_params(&owner, &uuid); + if prev.is_some() { + adapter.contract_update(PTR_WASM.to_vec(), ¶ms, pb).await.map(|_| ()) + } else { + adapter.contract_put(PTR_WASM.to_vec(), ¶ms, pb, false).await.map(|_| ()) + } + .map_err(|e| e.to_string())?; + let rec = Published { + playlist_id, + uuid: b58(&uuid), + revision_seq, + pointer_seq, + current_hash: Some(b58(&hash)), + name: m.name.clone(), + entry_count: m.entries.len() as u32, + withdrawn: false, + published_at: now_secs(), + }; + ledger.published.insert(key, rec.clone()); + save(app, &ledger)?; + log::info!("shared playlists: published {:?} rev {} ({} entries)", rec.name, revision_seq, rec.entry_count); + Ok(rec) +} + +/// Withdraw: the pointer moves to "none". Revisions already published stay +/// fetchable on the network — the ledger keeps saying so. +pub async fn withdraw(app: &tauri::AppHandle, playlist_id: i64) -> Result { + let adapter = app.state::().freenet.clone(); + let owner = own_key(app).await?; + let mut ledger = load(app); + let key = playlist_id.to_string(); + let prev = ledger.published.get(&key).cloned().ok_or("that playlist was never published")?; + if prev.withdrawn { + return Ok(prev); + } + let uuid = bytes16(&prev.uuid)?; + let mut p = pl::PointerState { + schema_version: pl::SCHEMA_VERSION, + owner, + playlist_uuid: uuid, + sequence: prev.pointer_seq + 1, + current: None, + withdrawn: true, + sig: vec![], + }; + p.sig = persona::sign(&adapter, pl::SIGN_DOMAIN, pl::pointer_sign_payload(&p)).await?; + let pb = bincode::serialize(&p).map_err(|e| e.to_string())?; + adapter + .contract_update(PTR_WASM.to_vec(), &pl::make_ptr_params(&owner, &uuid), pb) + .await + .map_err(|e| e.to_string())?; + let rec = Published { pointer_seq: prev.pointer_seq + 1, withdrawn: true, ..prev }; + ledger.published.insert(key, rec.clone()); + save(app, &ledger)?; + Ok(rec) +} + +// ---------------------------------------------------------------- read + +/// Resolve (owner, uuid) through the network: pointer, then the revision it +/// names, verified in the plan's order before anything is shown. +pub async fn fetch(app: &tauri::AppHandle, owner_b58: &str, uuid_b58: &str) -> Result { + let adapter = app.state::().freenet.clone(); + let owner = bytes32(owner_b58)?; + let uuid = bytes16(uuid_b58)?; + let ptr_ids = FreenetAdapter::contract_ids(PTR_WASM.to_vec(), &pl::make_ptr_params(&owner, &uuid)); + let pbytes = adapter.contract_get(&ptr_ids.instance, false).await.map_err(|e| { + if e.to_string().contains("not found") { + "no such shared playlist on the network (or not reachable yet)".to_string() + } else { + e.to_string() + } + })?; + let p = pl::validate_pointer(&owner, &uuid, &pbytes).map_err(|e| format!("pointer failed verification: {e}"))?; + let Some(r) = p.current.filter(|_| !p.withdrawn) else { + return Ok(ShareView { + owner_key: owner_b58.to_string(), + playlist_uuid: uuid_b58.to_string(), + name: String::new(), + description: String::new(), + revision_seq: 0, + content_hash: String::new(), + withdrawn: true, + entries: vec![], + }); + }; + let rev_ids = FreenetAdapter::contract_ids(REV_WASM.to_vec(), &pl::make_rev_params(&owner, &r.content_hash)); + let rbytes = adapter.contract_get(&rev_ids.instance, false).await.map_err(|e| e.to_string())?; + let m = pl::validate_revision(&owner, &r.content_hash, &rbytes) + .map_err(|e| format!("revision failed verification: {e}"))?; + Ok(ShareView { + owner_key: owner_b58.to_string(), + playlist_uuid: uuid_b58.to_string(), + name: m.name, + description: m.description, + revision_seq: m.revision_seq, + content_hash: b58(&r.content_hash), + withdrawn: false, + entries: m + .entries + .into_iter() + .map(|e| ShareEntry { + position: e.position, + title: e.recording.title, + artist: e.recording.artists.join(", "), + album: e.recording.album, + duration_secs: e.recording.duration_ms.map(|ms| ms / 1000), + note: e.note, + }) + .collect(), + }) +} + +/// A verified shared playlist staged through the app's own import path. +pub async fn import( + app: &tauri::AppHandle, + lib: &State<'_, LibraryState>, + owner_b58: &str, + uuid_b58: &str, +) -> Result { + let v = fetch(app, owner_b58, uuid_b58).await?; + if v.withdrawn { + return Err("the owner withdrew this playlist".into()); + } + let parsed = ParsedPlaylist { + format: "soulstream".into(), + title: Some(v.name.clone()), + description: Some(v.description.clone()).filter(|d| !d.trim().is_empty()), + entries: v + .entries + .iter() + .map(|e| ParsedEntry { + ordinal: e.position + 1, + title: Some(e.title.clone()), + artist: Some(e.artist.clone()), + album: Some(e.album.clone()).filter(|a| !a.trim().is_empty()), + duration_secs: e.duration_secs, + location: None, + start_ms: None, + raw: format!("{} - {}", e.artist, e.title), + }) + .collect(), + warnings: vec![], + }; + playlists::resolve_draft(lib, parsed, format!("SoulStream · {}", v.name), None).await +} + +// ---------------------------------------------------------------- share carry + +pub async fn send(app: &tauri::AppHandle, username: &str, playlist_id: i64) -> Result<(), String> { + let ledger = load(app); + let rec = ledger + .published + .get(&playlist_id.to_string()) + .ok_or("publish the playlist first")?; + if rec.withdrawn { + return Err("that playlist is withdrawn — publish it again first".into()); + } + let owner = own_key(app).await?; + let r = ShareRef { + owner, + playlist_uuid: bytes16(&rec.uuid)?, + name: rec.name.clone(), + entry_count: rec.entry_count, + }; + use base64::Engine; + let bytes = bincode::serialize(&r).map_err(|e| e.to_string())?; + let text = format!("{SHARE_PREFIX}{}", base64::engine::general_purpose::STANDARD.encode(bytes)); + send_pm(app, username, &text).await?; + log::info!("shared playlists: sent {:?} to {username}", rec.name); + Ok(()) +} + +/// The event pump hands private messages here after the link and friend +/// handshakes. True means it was a share reference, now a card in the Inbox. +pub fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool { + let Some(rest) = message.trim().strip_prefix(SHARE_PREFIX) else { return false }; + use base64::Engine; + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(rest.trim()) else { return true }; + let Ok(r) = bincode::deserialize::(&bytes) else { return true }; + let card = ShareCard { + id: format!("{}:{}:{}", normalize_username(from), b58(&r.owner), b58(&r.playlist_uuid)), + from_username: from.to_string(), + owner_key: b58(&r.owner), + playlist_uuid: b58(&r.playlist_uuid), + name: cap(&r.name, pl::MAX_NAME_BYTES), + entry_count: r.entry_count, + received_at: now_secs(), + }; + let mut ledger = load(app); + ledger.inbox.retain(|c| c.id != card.id); + ledger.inbox.push(card); + if let Err(e) = save(app, &ledger) { + log::warn!("shared playlists: could not keep the share from {from}: {e}"); + } else { + log::info!("shared playlists: share received from {from}"); + } + true +} + +// ---------------------------------------------------------------- commands + +#[tauri::command] +pub fn shared_playlists_mine(app: tauri::AppHandle) -> Vec { + let mut v: Vec<_> = load(&app).published.into_values().collect(); + v.sort_by(|a, b| b.published_at.cmp(&a.published_at)); + v +} +#[tauri::command] +pub async fn shared_playlist_publish( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + playlist_id: i64, +) -> Result { + publish(&app, &lib, playlist_id).await +} +#[tauri::command] +pub async fn shared_playlist_withdraw(app: tauri::AppHandle, playlist_id: i64) -> Result { + withdraw(&app, playlist_id).await +} +#[tauri::command] +pub async fn shared_playlist_fetch(app: tauri::AppHandle, owner: String, uuid: String) -> Result { + fetch(&app, &owner, &uuid).await +} +#[tauri::command] +pub async fn shared_playlist_import( + app: tauri::AppHandle, + lib: State<'_, LibraryState>, + owner: String, + uuid: String, +) -> Result { + import(&app, &lib, &owner, &uuid).await +} +#[tauri::command] +pub async fn share_send(app: tauri::AppHandle, username: String, playlist_id: i64) -> Result<(), String> { + send(&app, &username, playlist_id).await +} +#[tauri::command] +pub fn shares_inbox(app: tauri::AppHandle) -> Vec { + let mut v = load(&app).inbox; + v.sort_by(|a, b| b.received_at.cmp(&a.received_at)); + v +} +#[tauri::command] +pub fn share_dismiss(app: tauri::AppHandle, id: String) -> Result<(), String> { + let mut ledger = load(&app); + ledger.inbox.retain(|c| c.id != id); + save(&app, &ledger) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The bundled contracts are commitments (copy-forward is disabled + /// upstream): a changed wasm re-keys every published playlist. + #[test] + fn contract_generations_are_pinned() { + let owner = [7u8; 32]; + let rev = FreenetAdapter::contract_ids(REV_WASM.to_vec(), &pl::make_rev_params(&owner, &[1u8; 32])); + let ptr = FreenetAdapter::contract_ids(PTR_WASM.to_vec(), &pl::make_ptr_params(&owner, &[2u8; 16])); + assert_eq!(rev.code_hash, "2JYdHHe3j59CVwCCKKeicjaF3rqoY2qjEbrYc95uUjkk"); + assert_eq!(ptr.code_hash, "Daqn9EMctrTniK5s5x4m9QBHYixsf265UMUAmLPATg5S"); + } +} diff --git a/src/lib/api.js b/src/lib/api.js index f51a035..b0f2aa8 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -162,6 +162,14 @@ export const friendAccept = (id) => invoke("friend_accept", { id }); export const friendDecline = (id) => invoke("friend_decline", { id }); export const friendRemove = (id) => invoke("friend_remove", { id }); export const friendForget = (id) => invoke("friend_forget", { id }); +export const sharedPlaylistsMine = () => invoke("shared_playlists_mine"); +export const sharedPlaylistPublish = (playlistId) => invoke("shared_playlist_publish", { playlistId }); +export const sharedPlaylistWithdraw = (playlistId) => invoke("shared_playlist_withdraw", { playlistId }); +export const sharedPlaylistFetch = (owner, uuid) => invoke("shared_playlist_fetch", { owner, uuid }); +export const sharedPlaylistImport = (owner, uuid) => invoke("shared_playlist_import", { owner, uuid }); +export const shareSend = (username, playlistId) => invoke("share_send", { username, playlistId }); +export const sharesInbox = () => invoke("shares_inbox"); +export const shareDismiss = (id) => invoke("share_dismiss", { id }); export const buddyList = () => invoke("buddy_list"); export const buddyAdd = (username) => invoke("buddy_add", { username }); export const buddyRemove = (username) => invoke("buddy_remove", { username }); diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js index aec708c..7bfe4bc 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -65,6 +65,12 @@ export const community = $state({ linkErrors: {}, /** friends_list: every relationship this device holds, newest first. */ friends: [], + /** shares_inbox: playlist references people sent us, newest first. */ + shares: [], + /** card id → fetched ShareView | {pending} | {error} (the Verify button). */ + sharePreviews: {}, + /** shared_playlists_mine: what this device has published. */ + published: [], /** persona key → ProfileView | {none} | {error} | {pending}: other * people's signed profiles, read once per session when surfaced. */ remoteProfiles: {}, @@ -309,11 +315,15 @@ export async function initCommunity() { else if (community.persona) community.persona.available = false; }); api.onEvent("community:friends", () => void refreshFriends()); + api.onEvent("community:shares", () => { + void refreshShares(); + void refreshPublished(); + }); api.onEvent("soulseek:user-state", (p) => { if (p.status === 1 || p.status === 2) noteSeen(p.username); }); } - await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends()]); + await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished()]); await refreshPersona(); } @@ -419,7 +429,67 @@ export const friendCount = () => community.friends.filter((f) => f.state === "fr export const pendingIn = () => community.friends.filter((f) => f.state === "pending-in"); +// ---- shared playlists --------------------------------------------------------------- +export async function refreshShares() { + try { + community.shares = await api.sharesInbox(); + } catch { + // stays as it was + } +} +export async function refreshPublished() { + try { + community.published = await api.sharedPlaylistsMine(); + } catch { + // stays as it was + } +} +export const publishedFor = (playlistId) => community.published.find((p) => p.playlist_id === playlistId) ?? null; +export const sharedPublish = (playlistId) => + run("publish:" + playlistId, async () => { + const r = await api.sharedPlaylistPublish(playlistId); + await refreshPublished(); + return r; + }); +export const sharedWithdraw = (playlistId) => + run("publish:" + playlistId, async () => { + await api.sharedPlaylistWithdraw(playlistId); + await refreshPublished(); + }); +export const shareSend = (username, playlistId) => run("share:" + playlistId, () => api.shareSend(username, playlistId)); +export const shareDismiss = (id) => + run("share:" + id, async () => { + await api.shareDismiss(id); + await refreshShares(); + }); +/** Fetch and verify a shared playlist through our node — nothing imported. */ +export async function sharePreview(card) { + community.sharePreviews[card.id] = { pending: true }; + try { + community.sharePreviews[card.id] = await api.sharedPlaylistFetch(card.owner_key, card.playlist_uuid); + } catch (e) { + community.sharePreviews[card.id] = { error: String(e?.message ?? e) }; + } +} +/** Fetch, verify, stage through the app's import path, commit — the new + * playlist's id comes back; the card is dismissed. */ +export const shareImport = (card) => + run("import:" + card.id, async () => { + const draft = await api.sharedPlaylistImport(card.owner_key, card.playlist_uuid); + const id = await api.playlistImportCommit( + draft.name ?? card.name, + draft.description ?? null, + draft.kind ?? "manual", + draft.entries, + draft.cover_url ?? null, + ); + await api.shareDismiss(card.id); + await refreshShares(); + return id; + }); + + // Dev handle beside `__soulstream` (state.svelte.js): probes reach the store // through this, never through a bare import (which would be a second copy). if (import.meta.env.DEV) globalThis.__community = community; -if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends }; +if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished }; diff --git a/src/views/Community.svelte b/src/views/Community.svelte index 087244b..a71e6a1 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -72,6 +72,11 @@ friendshipFor, friendCount, pendingIn, + sharePreview, + shareImport, + shareDismiss, + sharedPublish, + sharedWithdraw, } from "../lib/community.svelte.js"; onMount(() => { @@ -423,6 +428,10 @@ const drawerLink = $derived(drawerUser ? linkFor(drawerUser) : null); const drawerRemote = $derived(drawerLink ? community.remoteProfiles[drawerLink.persona_key] : null); const drawerFriend = $derived(drawerUser ? friendshipFor(drawerUser) : null); + async function importShare(card) { + const id = await shareImport(card); + if (id) goToView("playlist", { playlistId: id, label: card.name }); + } const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase(); $effect(() => { const lk = drawerLink; @@ -569,7 +578,36 @@ {/each} {/if} - {#if !convos.length} + {#if community.shares.length} +
+ {#each community.shares as c (c.id)} + {@const pv = community.sharePreviews[c.id]} +
+
+ +
+
{c.name}
+
{c.entry_count} {c.entry_count === 1 ? "track" : "tracks"} · playlist · shared by {fmtWhen(c.received_at * 1000)}
+
+ SoulStream + signed by {fpShort(c.owner_key)} + {#if pv && !pv.pending && !pv.error && !pv.withdrawn}·verified · revision {pv.revision_seq}{/if} + {#if pv?.withdrawn}·withdrawn by the owner{/if} + {#if pv?.error}·{pv.error}{/if} +
+
+
+
+ + + + tracks resolve against your library first; the rest become Soulseek streams +
+
+ {/each} +
+ {/if} + {#if !convos.length && !community.shares.length && !pendingIn().length}
No conversations yet
@@ -1044,6 +1082,26 @@
{#if key}
+
+

Your public playlists {community.published.filter((p) => !p.withdrawn).length}

+ {#if !community.published.length} +

Nothing published yet. Open a playlist and choose Share on SoulStream: a signed manifest goes to the network under your ID — titles, artists, albums, durations, your notes; never a file, never a path.

+ {:else} + {#each community.published as p (p.playlist_id)} +
+ +
{p.name}
{p.entry_count} tracks · revision {p.revision_seq} · {p.withdrawn ? "withdrawn" : "published"} {fmtWhen(p.published_at * 1000)}
+ {#if p.withdrawn} + + {:else} + + {/if} + +
+ {/each} +

Withdrawing stops your pointer; copies others already fetched stay with them.

+ {/if} +

Your SoulStream ID

@@ -1704,6 +1762,10 @@ .rp .nm { font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 8px; } .friendrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; } .mark.fr { color: var(--accent-hover); } + .pubrow { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--hair); } + .pubrow .ptx { flex: 1; min-width: 0; } + .pubrow .nm { font-size: 13px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .mosaic-ph { width: 56px; height: 56px; border-radius: 8px; flex: none; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-hover); } .dsec { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair); } .dsec h4 { margin: 0 0 8px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; align-items: center; gap: 8px; } .dsec h4 .src, .dsec h4 .chip { margin-left: auto; } diff --git a/src/views/Playlist.svelte b/src/views/Playlist.svelte index a924b87..f96023c 100644 --- a/src/views/Playlist.svelte +++ b/src/views/Playlist.svelte @@ -26,6 +26,16 @@ import * as api from "../lib/api.js"; import { findCatalogTrack } from "../lib/catalogMatch.js"; import Icon from "../components/Icon.svelte"; + import { + community, + initCommunity, + refreshPublished, + publishedFor, + sharedPublish, + sharedWithdraw, + shareSend, + statusKind, + } from "../lib/community.svelte.js"; const playlist = $derived( playlistStore.playlists.find((p) => p.id === ui.playlistId) ?? null, @@ -37,6 +47,54 @@ let menuOpen = $state(false); let confirmDelete = $state(false); let exportOpen = $state(false); + // ---- Share on SoulStream (shared_playlists.rs) ---- + let shareOpen = $state(false); + let shareTo = $state(""); + let shareNote = $state(""); + const friendNames = $derived(community.friends.filter((f) => f.state === "friends").map((f) => f.username)); + const nodeUp = $derived(statusKind(community.freenet?.status) === "connected"); + const canPublish = $derived(nodeUp && !!community.persona?.persona && !community.busy["publish:" + playlist?.id]); + async function openShare() { + menuOpen = false; + shareOpen = true; + shareNote = ""; + await initCommunity(); + await refreshPublished(); + } + async function doPublish() { + shareNote = ""; + try { + const r = await sharedPublish(playlist.id); + shareNote = `Published revision ${r.revision_seq} — ${r.entry_count} tracks.`; + } catch (e) { + shareNote = String(e?.message ?? e); + } + } + async function doWithdraw() { + shareNote = ""; + try { + await sharedWithdraw(playlist.id); + shareNote = "Withdrawn. Copies others already fetched stay with them."; + } catch (e) { + shareNote = String(e?.message ?? e); + } + } + async function sendShare() { + const to = shareTo.trim(); + if (!to) return; + shareNote = ""; + try { + await shareSend(to, playlist.id); + shareNote = `Sent to ${to} over Soulseek.`; + } catch (e) { + shareNote = String(e?.message ?? e); + } + } + // The sheet escapes the content pane (a containing block) to the body. + function portal(node) { + document.body.appendChild(node); + return { destroy: () => node.remove() }; + } let renaming = $state(false); let nameInput = $state(""); let editingDesc = $state(false); @@ -782,6 +840,9 @@ > Download all tracks +

{/if} +{#if shareOpen && playlist} + {@const pub = publishedFor(playlist.id)} + +{/if} +