722 lines
30 KiB
Rust
722 lines
30 KiB
Rust
//! The proposal board (F7 → the app). A Space's pinned playlist gets a
|
|
//! multi-writer board beside it, keyed by (playlist owner, uuid). Members
|
|
//! propose bounded edits — add, remove, move — against the EXACT revision
|
|
//! they were looking at; the playlist's owner accepts or rejects, and every
|
|
//! decision is a signed line in the audit trail. Accepting applies the ops
|
|
//! to the owner's local playlist and publishes it as the next revision; the
|
|
//! decision then names that revision's hash. The canonical playlist never
|
|
//! lives in here.
|
|
//!
|
|
//! The roster is the Space's: when the playlist owner opens the board and
|
|
//! the contributors differ from the Space's members, a new epoch is signed.
|
|
//! Dropping a member is revocation — their pending proposals leave the board
|
|
//! on every node, deterministically.
|
|
//!
|
|
//! The contract is a bundled commitment (`assets/proposal_contract.wasm`,
|
|
//! built from crates/proposal-contract; its code hash is pinned by a test).
|
|
|
|
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
|
use std::path::PathBuf;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use freenet_adapter::FreenetAdapter;
|
|
use library::{NewPlaylistItem, PlaylistItem};
|
|
use persona_state::playlist as pl;
|
|
use persona_state::proposals as pr;
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::{Emitter, Manager};
|
|
|
|
use crate::persona;
|
|
use crate::pipeline::LibraryState;
|
|
use crate::playlists;
|
|
use crate::shared_playlists::{self, Published};
|
|
use crate::inbox::{self, Cargo, ProposalPing};
|
|
use crate::ledger;
|
|
use crate::slsk_link;
|
|
use crate::spaces;
|
|
use crate::state::AppState;
|
|
|
|
const BOARD_WASM: &[u8] = include_bytes!("../assets/proposal_contract.wasm");
|
|
/// A proposal landed on a board of ours. The UI answers with a toast.
|
|
pub const EVENT: &str = "community:proposal";
|
|
|
|
#[derive(Default, Clone, Serialize, Deserialize)]
|
|
struct BoardRecord {
|
|
#[serde(default)]
|
|
last_epoch: u64,
|
|
#[serde(default)]
|
|
last_seq: u64,
|
|
}
|
|
|
|
#[derive(Default, Serialize, Deserialize)]
|
|
struct Ledger {
|
|
#[serde(default)]
|
|
boards: BTreeMap<String, BoardRecord>,
|
|
}
|
|
|
|
// ---------------------------------------------------------------- views
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct BoardEntry {
|
|
pub entry_id: String,
|
|
pub position: u32,
|
|
pub title: String,
|
|
pub artist: String,
|
|
pub album: String,
|
|
pub duration_secs: Option<u32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct OpView {
|
|
/// "add" | "remove" | "move"
|
|
pub kind: String,
|
|
pub position: Option<u32>,
|
|
pub entry_id: Option<String>,
|
|
pub title: String,
|
|
pub artist: String,
|
|
/// False when a remove/move names an entry the current revision no
|
|
/// longer has (a stale proposal).
|
|
pub known: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ProposalView {
|
|
pub id: String,
|
|
pub author: String,
|
|
pub username: Option<String>,
|
|
pub is_me: bool,
|
|
pub created_at: u64,
|
|
pub note: String,
|
|
pub base: String,
|
|
/// Its base is not the current revision: the owner can only reject it
|
|
/// and ask for a re-submit.
|
|
pub stale: bool,
|
|
pub ops: Vec<OpView>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DecisionView {
|
|
pub proposal_id: String,
|
|
pub accept: bool,
|
|
pub seq: u64,
|
|
pub reason: String,
|
|
pub result_hash: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ContributorView {
|
|
pub key: String,
|
|
pub username: Option<String>,
|
|
pub is_me: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct BoardView {
|
|
pub owner: String,
|
|
pub uuid: String,
|
|
pub name: String,
|
|
pub revision_seq: u64,
|
|
/// The current revision's content hash (b58) — what a proposal binds to.
|
|
pub base: String,
|
|
pub withdrawn: bool,
|
|
pub entries: Vec<BoardEntry>,
|
|
/// The board contract exists on the network.
|
|
pub reachable: bool,
|
|
pub epoch: u64,
|
|
pub contributors: Vec<ContributorView>,
|
|
pub i_am_owner: bool,
|
|
pub can_propose: bool,
|
|
pub proposals: Vec<ProposalView>,
|
|
/// Newest first.
|
|
pub decisions: Vec<DecisionView>,
|
|
pub pending_mine: u32,
|
|
}
|
|
|
|
/// One op as the composer sends it.
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
|
pub enum OpInput {
|
|
Add {
|
|
position: u32,
|
|
title: String,
|
|
artist: String,
|
|
#[serde(default)]
|
|
album: String,
|
|
#[serde(default)]
|
|
duration_secs: Option<u32>,
|
|
#[serde(default)]
|
|
track_id: Option<String>,
|
|
/// The composer's handle for this row ("new:3"), so a later remove
|
|
/// or move in the same proposal can name a track it just added.
|
|
#[serde(default)]
|
|
token: Option<String>,
|
|
},
|
|
Remove {
|
|
entry_id: String,
|
|
},
|
|
Move {
|
|
entry_id: String,
|
|
to_position: u32,
|
|
},
|
|
}
|
|
|
|
// ---------------------------------------------------------------- helpers
|
|
|
|
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 key_of(s: &str) -> Result<[u8; 32], String> {
|
|
let v = bs58::decode(s.trim()).into_vec().map_err(|_| "not a base58 key")?;
|
|
v.try_into().map_err(|_| "a key is 32 bytes".to_string())
|
|
}
|
|
fn id_of(s: &str) -> Result<[u8; 16], String> {
|
|
let v = bs58::decode(s.trim()).into_vec().map_err(|_| "not a base58 id")?;
|
|
v.try_into().map_err(|_| "an id is 16 bytes".to_string())
|
|
}
|
|
fn cap(s: &str, max: usize) -> String {
|
|
if s.len() <= max {
|
|
return s.to_string();
|
|
}
|
|
let mut end = max;
|
|
while !s.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
s[..end].to_string()
|
|
}
|
|
fn board_id(owner: &[u8; 32], uuid: &[u8; 16]) -> String {
|
|
format!("{}/{}", b58(owner), b58(uuid))
|
|
}
|
|
|
|
/// Both the file the ledger lives in and the key its write lock is taken on.
|
|
const LEDGER_FILE: &str = "boards.json";
|
|
|
|
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
|
Ok(crate::paths::app_data(app).map_err(|e| e.to_string())?.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_pretty(l).map_err(|e| e.to_string())?).map_err(|e| e.to_string())
|
|
}
|
|
|
|
async fn my_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
|
|
let adapter = app.state::<AppState>().freenet.clone();
|
|
let info = persona::current(&adapter).await?.ok_or("create a SoulStream identity first")?;
|
|
key_of(&info.public_key)
|
|
}
|
|
async fn sign(app: &tauri::AppHandle, payload: Vec<u8>) -> Result<Vec<u8>, String> {
|
|
let adapter = app.state::<AppState>().freenet.clone();
|
|
persona::sign(&adapter, pr::SIGN_DOMAIN, payload).await
|
|
}
|
|
|
|
async fn fetch_board(app: &tauri::AppHandle, owner: &[u8; 32], uuid: &[u8; 16]) -> Result<(pr::BoardState, bool), String> {
|
|
let adapter = app.state::<AppState>().freenet.clone();
|
|
let ids = FreenetAdapter::contract_ids(BOARD_WASM.to_vec(), &pr::make_params(owner, uuid));
|
|
match adapter.contract_get(&ids.instance, false).await {
|
|
Ok(bytes) => Ok((pr::merge_board(owner, uuid, &[&bytes]), true)),
|
|
Err(e) if e.to_string().contains("not found") => Ok((pr::BoardState::default(), false)),
|
|
Err(e) => Err(e.to_string()),
|
|
}
|
|
}
|
|
|
|
async fn offer_board(app: &tauri::AppHandle, owner: &[u8; 32], uuid: &[u8; 16], state: &pr::BoardState, first: bool) -> Result<(), String> {
|
|
let adapter = app.state::<AppState>().freenet.clone();
|
|
let bytes = bincode::serialize(state).map_err(|e| e.to_string())?;
|
|
let params = pr::make_params(owner, uuid);
|
|
if first {
|
|
adapter.contract_put(BOARD_WASM.to_vec(), ¶ms, bytes, false).await.map(|_| ())
|
|
} else {
|
|
adapter.get_then_update(BOARD_WASM.to_vec(), ¶ms, bytes).await.map(|_| ())
|
|
}
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
fn partial(owner: &[u8; 32], uuid: &[u8; 16], authority: Option<pr::AuthorityCheckpoint>, proposals: Vec<pr::Proposal>, decisions: Vec<pr::Decision>) -> pr::BoardState {
|
|
pr::BoardState { schema_version: pr::SCHEMA_VERSION, owner: *owner, playlist_uuid: *uuid, authority, proposals, decisions }
|
|
}
|
|
|
|
/// What a Space pins, plus its roster — the board's owner and contributors.
|
|
struct Pinned {
|
|
owner: [u8; 32],
|
|
uuid: [u8; 16],
|
|
members: Vec<[u8; 32]>,
|
|
}
|
|
|
|
async fn pinned_of(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str) -> Result<Pinned, String> {
|
|
let owner = key_of(space_owner_b58)?;
|
|
let space_id = id_of(space_b58)?;
|
|
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 p = c.pinned.ok_or("this Space has no pinned playlist — a board lives beside one")?;
|
|
Ok(Pinned { owner: p.owner, uuid: p.uuid, members: c.members.iter().map(|m| m.key).collect() })
|
|
}
|
|
|
|
type Current = (pl::ManifestV1, [u8; 32]);
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn view_of(
|
|
app: &tauri::AppHandle,
|
|
pin: &Pinned,
|
|
current: Option<&Current>,
|
|
board: &pr::BoardState,
|
|
me: &[u8; 32],
|
|
worn: &[u8; 32],
|
|
space: (&str, &str),
|
|
reachable: bool,
|
|
) -> BoardView {
|
|
// A room key has a name only on the device that seated it — the Space's
|
|
// owner. Everyone else sees the key, which is what unlinkable means.
|
|
let name_of = |k: &[u8; 32]| -> Option<String> {
|
|
let b = b58(k);
|
|
let persona = spaces::persona_behind(app, space.0, space.1, &b).unwrap_or(b);
|
|
slsk_link::username_for_key(app, &persona)
|
|
};
|
|
let entries: Vec<BoardEntry> = current
|
|
.map(|(m, _)| {
|
|
m.entries
|
|
.iter()
|
|
.map(|e| BoardEntry {
|
|
entry_id: b58(&e.entry_id),
|
|
position: e.position,
|
|
title: e.recording.title.clone(),
|
|
artist: e.recording.artists.join(", "),
|
|
album: e.recording.album.clone(),
|
|
duration_secs: e.recording.duration_ms.map(|ms| ms / 1000),
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
// A remove or move may name a row the same proposal added a step earlier.
|
|
let named = |p: &pr::Proposal, id: &[u8; 16]| -> Option<(String, String)> {
|
|
current
|
|
.and_then(|(m, _)| m.entries.iter().find(|e| &e.entry_id == id))
|
|
.or_else(|| {
|
|
p.ops.iter().find_map(|op| match op {
|
|
pr::ProposalOp::Add { entry, .. } if &entry.entry_id == id => Some(entry),
|
|
_ => None,
|
|
})
|
|
})
|
|
.map(|e| (e.recording.title.clone(), e.recording.artists.join(", ")))
|
|
};
|
|
let roster: Vec<[u8; 32]> = board.authority.as_ref().map(|a| a.contributors.clone()).unwrap_or_default();
|
|
let proposals = board
|
|
.proposals
|
|
.iter()
|
|
.map(|p| ProposalView {
|
|
id: b58(&p.proposal_id),
|
|
author: b58(&p.author),
|
|
username: name_of(&p.author),
|
|
is_me: &p.author == worn,
|
|
created_at: p.created_at,
|
|
note: p.note.clone(),
|
|
base: b58(&p.base_revision_hash),
|
|
stale: current.map(|(_, h)| h != &p.base_revision_hash).unwrap_or(true),
|
|
ops: p
|
|
.ops
|
|
.iter()
|
|
.map(|op| match op {
|
|
pr::ProposalOp::Add { position, entry } => OpView {
|
|
kind: "add".into(),
|
|
position: Some(*position),
|
|
entry_id: None,
|
|
title: entry.recording.title.clone(),
|
|
artist: entry.recording.artists.join(", "),
|
|
known: true,
|
|
},
|
|
pr::ProposalOp::Remove { entry_id } => {
|
|
let n = named(p, entry_id);
|
|
OpView {
|
|
kind: "remove".into(),
|
|
position: None,
|
|
entry_id: Some(b58(entry_id)),
|
|
title: n.as_ref().map(|x| x.0.clone()).unwrap_or_default(),
|
|
artist: n.as_ref().map(|x| x.1.clone()).unwrap_or_default(),
|
|
known: n.is_some(),
|
|
}
|
|
}
|
|
pr::ProposalOp::Move { entry_id, to_position } => {
|
|
let n = named(p, entry_id);
|
|
OpView {
|
|
kind: "move".into(),
|
|
position: Some(*to_position),
|
|
entry_id: Some(b58(entry_id)),
|
|
title: n.as_ref().map(|x| x.0.clone()).unwrap_or_default(),
|
|
artist: n.as_ref().map(|x| x.1.clone()).unwrap_or_default(),
|
|
known: n.is_some(),
|
|
}
|
|
}
|
|
})
|
|
.collect(),
|
|
})
|
|
.collect();
|
|
let decisions = board
|
|
.decisions
|
|
.iter()
|
|
.rev()
|
|
.map(|d| DecisionView {
|
|
proposal_id: b58(&d.proposal_id),
|
|
accept: d.accept,
|
|
seq: d.seq,
|
|
reason: d.reason.clone(),
|
|
result_hash: d.result_revision_hash.as_ref().map(|h| b58(h)),
|
|
})
|
|
.collect();
|
|
BoardView {
|
|
owner: b58(&pin.owner),
|
|
uuid: b58(&pin.uuid),
|
|
name: current.map(|(m, _)| m.name.clone()).unwrap_or_default(),
|
|
revision_seq: current.map(|(m, _)| m.revision_seq).unwrap_or(0),
|
|
base: current.map(|(_, h)| b58(h)).unwrap_or_default(),
|
|
withdrawn: current.is_none(),
|
|
entries,
|
|
reachable,
|
|
epoch: board.authority.as_ref().map(|a| a.epoch).unwrap_or(0),
|
|
contributors: roster
|
|
.iter()
|
|
.map(|k| ContributorView { key: b58(k), username: name_of(k), is_me: k == worn })
|
|
.collect(),
|
|
i_am_owner: &pin.owner == me,
|
|
can_propose: reachable && current.is_some() && roster.contains(worn),
|
|
proposals,
|
|
decisions,
|
|
// `worn`, not `me`: on someone else's Space we author under a
|
|
// room-local key, so counting against the persona counts nothing.
|
|
pending_mine: board.proposals.iter().filter(|p| &p.author == worn).count() as u32,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- actions
|
|
|
|
/// Read the board beside a Space's pinned playlist. The playlist's owner
|
|
/// also brings the roster in line with the Space's members here — a new
|
|
/// signed epoch whenever they differ.
|
|
pub async fn open(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str) -> Result<BoardView, String> {
|
|
let me = my_key(app).await?;
|
|
let pin = pinned_of(app, space_owner_b58, space_b58).await?;
|
|
let current = shared_playlists::current_revision(app, &pin.owner, &pin.uuid).await?;
|
|
let (mut board, mut exists) = fetch_board(app, &pin.owner, &pin.uuid).await?;
|
|
if pin.owner == me {
|
|
let want: Vec<[u8; 32]> = pin.members.iter().copied().take(pr::MAX_CONTRIBUTORS).collect();
|
|
let have: Vec<[u8; 32]> = board.authority.as_ref().map(|a| a.contributors.clone()).unwrap_or_default();
|
|
let same = want.iter().collect::<BTreeSet<_>>() == have.iter().collect::<BTreeSet<_>>();
|
|
if !same {
|
|
let _ledger = ledger::hold(LEDGER_FILE).await;
|
|
let bid = board_id(&pin.owner, &pin.uuid);
|
|
let mut l = load(app);
|
|
let rec = l.boards.get(&bid).cloned().unwrap_or_default();
|
|
let epoch = board.authority.as_ref().map(|a| a.epoch).unwrap_or(0).max(rec.last_epoch) + 1;
|
|
let mut c = pr::AuthorityCheckpoint { epoch, contributors: want, sig: vec![] };
|
|
c.sig = sign(app, pr::sign_payload(&pr::checkpoint_sig_body(&c))).await?;
|
|
let offer = partial(&pin.owner, &pin.uuid, Some(c), vec![], vec![]);
|
|
offer_board(app, &pin.owner, &pin.uuid, &offer, !exists).await?;
|
|
l.boards.insert(bid, BoardRecord { last_epoch: epoch, ..rec });
|
|
save(app, &l)?;
|
|
let a = bincode::serialize(&board).map_err(|e| e.to_string())?;
|
|
let b = bincode::serialize(&offer).map_err(|e| e.to_string())?;
|
|
board = pr::merge_board(&pin.owner, &pin.uuid, &[&a, &b]);
|
|
exists = true;
|
|
log::info!("proposals: roster epoch {} for board {}", epoch, b58(&pin.uuid));
|
|
}
|
|
}
|
|
let (worn, _) = spaces::worn_identity(app, space_owner_b58, space_b58, &me);
|
|
Ok(view_of(app, &pin, current.as_ref(), &board, &me, &worn, (space_owner_b58, space_b58), exists))
|
|
}
|
|
|
|
/// A member proposes: the ops are dry-run against the exact base first, so
|
|
/// a broken composition never leaves this device.
|
|
pub async fn propose(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str, base_b58: &str, ops: Vec<OpInput>, note: &str) -> Result<BoardView, String> {
|
|
let me = my_key(app).await?;
|
|
let pin = pinned_of(app, space_owner_b58, space_b58).await?;
|
|
if ops.is_empty() {
|
|
return Err("nothing proposed yet".into());
|
|
}
|
|
if ops.len() > pr::MAX_OPS_PER_PROPOSAL {
|
|
return Err(format!("over {} changes in one proposal", pr::MAX_OPS_PER_PROPOSAL));
|
|
}
|
|
let (manifest, hash) = shared_playlists::current_revision(app, &pin.owner, &pin.uuid)
|
|
.await?
|
|
.ok_or("the owner withdrew this playlist")?;
|
|
if b58(&hash) != base_b58.trim() {
|
|
return Err("the playlist moved to a newer revision while you were composing — reload the board and try again".into());
|
|
}
|
|
let (board, exists) = fetch_board(app, &pin.owner, &pin.uuid).await?;
|
|
if !exists {
|
|
return Err("the owner has not opened this board yet".into());
|
|
}
|
|
// The board's roster is the Space's roster, so we appear on it as the key
|
|
// we wear there — a room-local one unless the Space is ours.
|
|
let (worn, room) = spaces::worn_identity(app, space_owner_b58, space_b58, &me);
|
|
let auth = board.authority.as_ref().ok_or("the board has no roster yet")?;
|
|
if !auth.contributors.contains(&worn) {
|
|
return Err("you are not on this board's roster — the owner refreshes it from the Space's members".into());
|
|
}
|
|
let mut fresh: BTreeMap<String, [u8; 16]> = BTreeMap::new();
|
|
let resolve = |fresh: &BTreeMap<String, [u8; 16]>, s: &str| -> Result<[u8; 16], String> {
|
|
match fresh.get(s.trim()) {
|
|
Some(id) => Ok(*id),
|
|
None => id_of(s),
|
|
}
|
|
};
|
|
let mut pops = Vec::with_capacity(ops.len());
|
|
for op in ops {
|
|
pops.push(match op {
|
|
OpInput::Add { position, title, artist, album, duration_secs, track_id, token } => {
|
|
let title = cap(title.trim(), pl::MAX_FIELD_BYTES);
|
|
let artist = cap(artist.trim(), pl::MAX_FIELD_BYTES);
|
|
if title.is_empty() {
|
|
return Err("an added track needs a title".into());
|
|
}
|
|
let entry_id = rand16();
|
|
if let Some(t) = token {
|
|
fresh.insert(t, entry_id);
|
|
}
|
|
pr::ProposalOp::Add {
|
|
position,
|
|
entry: pl::EntryV1 {
|
|
entry_id,
|
|
position,
|
|
recording: pl::RecordingRef {
|
|
musicbrainz_recording_id: None,
|
|
isrcs: vec![],
|
|
deezer_id: track_id.filter(|t| !t.is_empty()),
|
|
apple_music_id: None,
|
|
title: title.clone(),
|
|
artists: vec![artist.clone()],
|
|
album: cap(album.trim(), pl::MAX_FIELD_BYTES),
|
|
duration_ms: duration_secs.map(|s| s.saturating_mul(1000)),
|
|
release_year: None,
|
|
},
|
|
note: None,
|
|
slsk_hint: Some(cap(&format!("{artist} {title}"), pl::MAX_FIELD_BYTES)),
|
|
},
|
|
}
|
|
}
|
|
OpInput::Remove { entry_id } => pr::ProposalOp::Remove { entry_id: resolve(&fresh, &entry_id)? },
|
|
OpInput::Move { entry_id, to_position } => pr::ProposalOp::Move { entry_id: resolve(&fresh, &entry_id)?, to_position },
|
|
});
|
|
}
|
|
let mut p = pr::Proposal {
|
|
proposal_id: rand16(),
|
|
author: worn,
|
|
authority_epoch: auth.epoch,
|
|
base_revision_hash: hash,
|
|
ops: pops,
|
|
note: cap(note.trim(), pl::MAX_DESC_BYTES),
|
|
created_at: now_secs(),
|
|
sig: vec![],
|
|
};
|
|
pr::apply_proposal(&manifest, &hash, &p).map_err(|e| e.to_string())?;
|
|
p.sig = match room {
|
|
// sign_payload() first: everything here signs blake3(body), and the
|
|
// verifier hashes before it checks. Handing over the raw body makes a
|
|
// signature that validates nowhere.
|
|
Some(secret) => {
|
|
persona_state::sign_local(pr::SIGN_DOMAIN, &secret, &pr::sign_payload(&pr::proposal_sig_body(&p)))
|
|
}
|
|
None => sign(app, pr::sign_payload(&pr::proposal_sig_body(&p))).await?,
|
|
};
|
|
if !pr::proposal_valid(&p) {
|
|
return Err("the proposal did not validate (a size or field cap)".into());
|
|
}
|
|
let pid = p.proposal_id;
|
|
offer_board(app, &pin.owner, &pin.uuid, &partial(&pin.owner, &pin.uuid, None, vec![p], vec![]), false).await?;
|
|
log::info!("proposals: offered a proposal to board {}", b58(&pin.uuid));
|
|
// The board only gets read on open and Refresh, so tell the owner it is
|
|
// there. Best-effort by design: it needs their published inbox, and the
|
|
// proposal stands on the board whether or not this lands.
|
|
if pin.owner != me {
|
|
let ping = Cargo::ProposalPing(ProposalPing {
|
|
playlist_uuid: pin.uuid,
|
|
name: manifest.name.clone(),
|
|
proposal_id: pid,
|
|
});
|
|
if let Err(e) = inbox::send(app, &pin.owner, &ping).await {
|
|
log::info!("proposals: no ping to the owner ({e}) — the board still holds it");
|
|
}
|
|
}
|
|
open(app, space_owner_b58, space_b58).await
|
|
}
|
|
|
|
/// The owner decides. Accepting brings the local playlist to the proposed
|
|
/// order, publishes it as the next revision, and the decision names that
|
|
/// revision; rejecting records the reason. Either way it is signed.
|
|
pub async fn decide(app: &tauri::AppHandle, space_owner_b58: &str, space_b58: &str, proposal_b58: &str, accept: bool, reason: &str) -> Result<BoardView, String> {
|
|
let me = my_key(app).await?;
|
|
let pin = pinned_of(app, space_owner_b58, space_b58).await?;
|
|
if pin.owner != me {
|
|
return Err("only the playlist's owner decides".into());
|
|
}
|
|
let pid = id_of(proposal_b58)?;
|
|
let (board, exists) = fetch_board(app, &pin.owner, &pin.uuid).await?;
|
|
if !exists {
|
|
return Err("the board has not reached this node".into());
|
|
}
|
|
let p = board.proposals.iter().find(|p| p.proposal_id == pid).cloned().ok_or("that proposal is no longer pending")?;
|
|
let ledger = ledger::hold(LEDGER_FILE).await;
|
|
let bid = board_id(&pin.owner, &pin.uuid);
|
|
let mut l = load(app);
|
|
let rec = l.boards.get(&bid).cloned().unwrap_or_default();
|
|
let seq = board.decisions.iter().map(|d| d.seq).max().unwrap_or(0).max(rec.last_seq) + 1;
|
|
let mut reason = cap(reason.trim(), pr::MAX_REASON_BYTES);
|
|
let result = if accept {
|
|
let (manifest, hash) = shared_playlists::current_revision(app, &pin.owner, &pin.uuid)
|
|
.await?
|
|
.ok_or("the playlist is withdrawn — publish it again before accepting")?;
|
|
let entries = pr::apply_proposal(&manifest, &hash, &p).map_err(|e| e.to_string())?;
|
|
let published = apply_locally(app, &pin.uuid, &manifest, entries).await?;
|
|
if reason.is_empty() {
|
|
reason = format!("applied as revision {}", published.revision_seq);
|
|
}
|
|
Some(key_of(published.current_hash.as_deref().unwrap_or_default())?)
|
|
} else {
|
|
if reason.is_empty() {
|
|
reason = "declined".into();
|
|
}
|
|
None
|
|
};
|
|
let mut d = pr::Decision { proposal_id: pid, accept, seq, reason, result_revision_hash: result, sig: vec![] };
|
|
d.sig = sign(app, pr::sign_payload(&pr::decision_sig_body(&d))).await?;
|
|
offer_board(app, &pin.owner, &pin.uuid, &partial(&pin.owner, &pin.uuid, None, vec![], vec![d]), false).await?;
|
|
l.boards.insert(bid, BoardRecord { last_seq: seq, ..rec });
|
|
save(app, &l)?;
|
|
log::info!("proposals: decision {} ({}) on board {}", seq, if accept { "accept" } else { "reject" }, b58(&pin.uuid));
|
|
// open() takes this same ledger; the write is done, so let go first.
|
|
drop(ledger);
|
|
open(app, space_owner_b58, space_b58).await
|
|
}
|
|
|
|
/// Sealed cargo from inbox.rs: someone proposed against a playlist of ours.
|
|
/// Nothing is stored — the board is the record; this only raises the toast.
|
|
pub(crate) fn on_inbox_proposal(app: &tauri::AppHandle, sender: [u8; 32], ping: ProposalPing) {
|
|
let key = b58(&sender);
|
|
let who = slsk_link::username_for_key(app, &key).unwrap_or(key);
|
|
log::info!("proposals: {who} proposed a change to {:?}", ping.name);
|
|
let _ = app.emit(
|
|
EVENT,
|
|
ProposalPingEvent { from: who, playlist: ping.name, proposal_id: b58(&ping.proposal_id) },
|
|
);
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize)]
|
|
struct ProposalPingEvent {
|
|
from: String,
|
|
playlist: String,
|
|
proposal_id: String,
|
|
}
|
|
|
|
/// Bring the owner's local playlist to `entries`, then publish it as the
|
|
/// next revision. Refused when the local copy has drifted from the base
|
|
/// revision — the exact-base rule holds on this side too.
|
|
async fn apply_locally(app: &tauri::AppHandle, uuid: &[u8; 16], base: &pl::ManifestV1, entries: Vec<pl::EntryV1>) -> Result<Published, String> {
|
|
let published = shared_playlists::published_for_uuid(app, &b58(uuid)).ok_or("this device did not publish that playlist")?;
|
|
let playlist_id = published.playlist_id;
|
|
let lib = app.state::<LibraryState>();
|
|
let l = lib.library.clone();
|
|
let items: Vec<PlaylistItem> = tauri::async_runtime::spawn_blocking(move || l.playlist_items(playlist_id).map_err(|e| e.to_string()))
|
|
.await
|
|
.map_err(|e| e.to_string())??;
|
|
let local_ids: Vec<[u8; 16]> = items.iter().map(|it| shared_playlists::entry_id_for(uuid, it.id)).collect();
|
|
let base_ids: Vec<[u8; 16]> = base.entries.iter().map(|e| e.entry_id).collect();
|
|
if local_ids != base_ids {
|
|
return Err(format!(
|
|
"your local copy of this playlist has changed since revision {} was published — publish it again, then ask for a re-submit",
|
|
base.revision_seq
|
|
));
|
|
}
|
|
let uuid = *uuid;
|
|
let keep: BTreeSet<[u8; 16]> = entries.iter().map(|e| e.entry_id).collect();
|
|
let remove: Vec<i64> = items.iter().zip(&local_ids).filter(|(_, id)| !keep.contains(*id)).map(|(it, _)| it.id).collect();
|
|
let existing: BTreeSet<[u8; 16]> = local_ids.iter().copied().collect();
|
|
let adds: Vec<NewPlaylistItem> = entries
|
|
.iter()
|
|
.filter(|e| !existing.contains(&e.entry_id))
|
|
.map(|e| NewPlaylistItem {
|
|
title: e.recording.title.clone(),
|
|
artist: e.recording.artists.join(", "),
|
|
album: Some(e.recording.album.clone()).filter(|a| !a.is_empty()),
|
|
duration_secs: e.recording.duration_ms.map(|ms| ms / 1000),
|
|
path: None,
|
|
art_path: None,
|
|
kind: String::new(),
|
|
source_line: e.slsk_hint.clone(),
|
|
start_ms: None,
|
|
note: e.note.clone(),
|
|
track_id: e.recording.deezer_id.clone(),
|
|
art_url: None,
|
|
})
|
|
.collect();
|
|
let before: BTreeSet<i64> = items.iter().map(|it| it.id).collect();
|
|
let now = now_secs() as i64;
|
|
let l = lib.library.clone();
|
|
let after: Vec<PlaylistItem> = tauri::async_runtime::spawn_blocking(move || -> Result<Vec<PlaylistItem>, String> {
|
|
if !remove.is_empty() {
|
|
l.remove_playlist_items(playlist_id, &remove, now).map_err(|e| e.to_string())?;
|
|
}
|
|
if !adds.is_empty() {
|
|
l.add_playlist_items(playlist_id, &adds, None, now).map_err(|e| e.to_string())?;
|
|
}
|
|
l.playlist_items(playlist_id).map_err(|e| e.to_string())
|
|
})
|
|
.await
|
|
.map_err(|e| e.to_string())??;
|
|
let mut fresh: VecDeque<i64> = after.iter().filter(|it| !before.contains(&it.id)).map(|it| it.id).collect();
|
|
let by_entry: BTreeMap<[u8; 16], i64> = after.iter().map(|it| (shared_playlists::entry_id_for(&uuid, it.id), it.id)).collect();
|
|
let mut ordered = Vec::with_capacity(entries.len());
|
|
for e in &entries {
|
|
match by_entry.get(&e.entry_id) {
|
|
Some(id) => ordered.push(*id),
|
|
None => ordered.push(fresh.pop_front().ok_or("the added rows did not come back from the library")?),
|
|
}
|
|
}
|
|
let l = lib.library.clone();
|
|
tauri::async_runtime::spawn_blocking(move || l.set_playlist_order(playlist_id, &ordered, now).map_err(|e| e.to_string()))
|
|
.await
|
|
.map_err(|e| e.to_string())??;
|
|
playlists::changed(app);
|
|
shared_playlists::publish(app, &lib, playlist_id).await
|
|
}
|
|
|
|
// ---------------------------------------------------------------- commands
|
|
|
|
#[tauri::command]
|
|
pub async fn space_board(app: tauri::AppHandle, owner: String, space: String) -> Result<BoardView, String> {
|
|
open(&app, &owner, &space).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn space_propose(app: tauri::AppHandle, owner: String, space: String, base: String, ops: Vec<OpInput>, note: String) -> Result<BoardView, String> {
|
|
propose(&app, &owner, &space, &base, ops, ¬e).await
|
|
}
|
|
#[tauri::command]
|
|
pub async fn space_decide(app: tauri::AppHandle, owner: String, space: String, proposal: String, accept: bool, reason: String) -> Result<BoardView, String> {
|
|
decide(&app, &owner, &space, &proposal, accept, &reason).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn contract_generation_is_pinned() {
|
|
let ids = FreenetAdapter::contract_ids(BOARD_WASM.to_vec(), &pr::make_params(&[7u8; 32], &[1u8; 16]));
|
|
assert_eq!(ids.code_hash, "3oBYSW1pP9GfX7v4Q1CtP2ikiKcc1yT48U7wxMnTTFho");
|
|
}
|
|
}
|