diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 074fafe..94f0a8e 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -240,7 +240,7 @@ pub fn word_timing(cfg: &AppConfig) -> bool { /// the old dir into place so the library DB, config and stream cache /// survive. Never overwrites existing data at the new location. pub fn migrate_app_data(app: &tauri::AppHandle) { - let Ok(new_dir) = app.path().app_data_dir() else { + let Ok(new_dir) = crate::paths::app_data(app) else { return; }; let Some(parent) = new_dir.parent() else { @@ -270,7 +270,7 @@ pub fn migrate_app_data(app: &tauri::AppHandle) { } fn config_path(app: &tauri::AppHandle) -> Option { - let dir = app.path().app_data_dir().ok()?; + let dir = crate::paths::app_data(app).ok()?; std::fs::create_dir_all(&dir).ok()?; Some(dir.join("config.json")) } @@ -378,7 +378,7 @@ pub fn migrate_library_state_into_library(app: &tauri::AppHandle, cfg: &AppConfi let Ok(dest_root) = ensure_library_data_dir(app, cfg) else { return Vec::new(); }; - let Ok(src_root) = app.path().app_data_dir() else { + let Ok(src_root) = crate::paths::app_data(app) else { return Vec::new(); }; let mut moved = Vec::new(); diff --git a/src-tauri/src/freenet.rs b/src-tauri/src/freenet.rs index 1aa0e9f..ce29a21 100644 --- a/src-tauri/src/freenet.rs +++ b/src-tauri/src/freenet.rs @@ -148,15 +148,18 @@ struct NodeDirs { base: PathBuf, config: PathBuf, data: PathBuf, + /// The client API port this peer must listen on — the same one the + /// adapter dials. Left implicit, a second instance's peer takes the + /// default and quietly binds the first instance's port. + ws_port: u16, } fn node_dirs(app: &tauri::AppHandle) -> Result { - let base = app - .path() - .app_data_dir() + let base = crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet"); - Ok(NodeDirs { config: base.join("config"), data: base.join("data"), base }) + let ws_port = config::load(app).freenet_ws_port.unwrap_or(freenet_adapter::DEFAULT_WS_PORT); + Ok(NodeDirs { config: base.join("config"), data: base.join("data"), base, ws_port }) } /// No console window for child processes on Windows. @@ -224,6 +227,8 @@ fn spawn_node(bin: &Path, dirs: &NodeDirs, log: &Path) -> Result .arg(&dirs.config) .arg("--data-dir") .arg(&dirs.data) + .arg("--ws-api-port") + .arg(dirs.ws_port.to_string()) .stdin(Stdio::null()) .stdout(Stdio::from(out)) .stderr(Stdio::from(err)) diff --git a/src-tauri/src/friends.rs b/src-tauri/src/friends.rs index 665ca39..f8694bb 100644 --- a/src-tauri/src/friends.rs +++ b/src-tauri/src/friends.rs @@ -110,9 +110,7 @@ fn key_of(b58s: &str) -> Result<[u8; 32], String> { } fn path(app: &tauri::AppHandle) -> Result { - Ok(app - .path() - .app_data_dir() + Ok(crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet") .join(LEDGER_FILE)) diff --git a/src-tauri/src/inbox.rs b/src-tauri/src/inbox.rs index bbfcd5a..f1b23d1 100644 --- a/src-tauri/src/inbox.rs +++ b/src-tauri/src/inbox.rs @@ -166,9 +166,7 @@ pub(crate) fn key_of(b58s: &str) -> Result<[u8; 32], String> { } fn dir(app: &tauri::AppHandle) -> Result { - Ok(app - .path() - .app_data_dir() + Ok(crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet")) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 194b756..870e245 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ mod loudness; mod lyric_align; mod lyric_prep; mod lyrics; +mod paths; mod pipeline; mod playback; mod profile; @@ -344,7 +345,10 @@ pub fn run() { --enable-smooth-scrolling \ --force-color-profile=srgb"; #[cfg(debug_assertions)] - let args = format!("{base} --remote-debugging-port=9222"); + // A second instance needs its own CDP port, or it silently fails + // to bind and cannot be driven at all. + let cdp = std::env::var("SOULSTREAM_CDP_PORT").unwrap_or_else(|_| "9222".into()); + let args = format!("{base} --remote-debugging-port={cdp}"); #[cfg(not(debug_assertions))] let args = base.to_string(); for window in &mut context.config_mut().app.windows { diff --git a/src-tauri/src/lyric_align.rs b/src-tauri/src/lyric_align.rs index 04d7e2b..7598ca0 100644 --- a/src-tauri/src/lyric_align.rs +++ b/src-tauri/src/lyric_align.rs @@ -19,7 +19,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Mutex; -use tauri::{Emitter, Manager}; +use tauri::Emitter; use windows::{ core::{Interface, HSTRING}, AI::MachineLearning::{ @@ -51,9 +51,7 @@ struct Engine { static ENGINE: Mutex> = Mutex::new(None); pub(crate) fn models_dir(app: &tauri::AppHandle) -> Result { - let dir = app - .path() - .app_data_dir() + let dir = crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("lyric-models"); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/overlap.rs b/src-tauri/src/overlap.rs index aa143d4..80040af 100644 --- a/src-tauri/src/overlap.rs +++ b/src-tauri/src/overlap.rs @@ -65,7 +65,7 @@ fn now_secs() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } fn path(app: &tauri::AppHandle) -> Result { - Ok(app.path().app_data_dir().map_err(|e| e.to_string())?.join("overlap.json")) + Ok(crate::paths::app_data(app).map_err(|e| e.to_string())?.join("overlap.json")) } fn load(app: &tauri::AppHandle) -> Ledger { path(app) diff --git a/src-tauri/src/paths.rs b/src-tauri/src/paths.rs new file mode 100644 index 0000000..5ba2317 --- /dev/null +++ b/src-tauri/src/paths.rs @@ -0,0 +1,36 @@ +//! Where this instance keeps everything it writes. +//! +//! One directory holds the library, the caches and the whole Freenet side — +//! persona, ledgers, node config and data. In a **debug build** the +//! `SOULSTREAM_DATA_DIR` environment variable moves it, and that is what makes +//! a second instance possible on one machine. +//! +//! That matters more than a convenience: most of the Community track is +//! two-sided and cannot be exercised with one account at all. Accepting a +//! friend request, a Space invitation and the join that answers it, removing a +//! member, rotating an epoch key, a proposal ping actually arriving — every one +//! of those had only unit tests until there was a second instance to talk to. +//! +//! A release build ignores the variable. An app whose data directory can be +//! redirected from the environment is an app whose identity can be redirected +//! from the environment, and the persona lives down there. +//! +//! A second instance also needs its own **node port** (`freenet_ws_port` in its +//! config, which the app now passes through to the peer it spawns) and, for +//! CDP, its own `SOULSTREAM_CDP_PORT`. Nothing else is shared: two instances +//! must never point at one library.db. + +use std::path::PathBuf; + +use tauri::Manager; + +/// The root this instance writes under. +pub fn app_data(app: &tauri::AppHandle) -> Result { + #[cfg(debug_assertions)] + if let Some(dir) = std::env::var_os("SOULSTREAM_DATA_DIR") { + let dir = PathBuf::from(dir); + std::fs::create_dir_all(&dir).map_err(|e| format!("SOULSTREAM_DATA_DIR: {e}"))?; + return Ok(dir); + } + app.path().app_data_dir().map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/pipeline.rs b/src-tauri/src/pipeline.rs index 3c64a2f..67e57f5 100644 --- a/src-tauri/src/pipeline.rs +++ b/src-tauri/src/pipeline.rs @@ -141,7 +141,7 @@ pub fn init(app: &tauri::AppHandle) -> Result<(), String> { // AppData keeps only what is about the APP on THIS machine — the cache of // remote provider responses. Everything describing the LIBRARY lives in // the library folder so it travels with the music. - let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let app_dir = crate::paths::app_data(app).map_err(|e| e.to_string())?; std::fs::create_dir_all(&app_dir).map_err(|e| e.to_string())?; let cfg = crate::config::load(app); diff --git a/src-tauri/src/playback.rs b/src-tauri/src/playback.rs index 61797e2..86d6395 100644 --- a/src-tauri/src/playback.rs +++ b/src-tauri/src/playback.rs @@ -348,7 +348,7 @@ impl PlayerState { /// Stable cache filename for a remote file: short hash + original name. pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) -> Option { - let dir = app.path().app_data_dir().ok()?.join("stream-cache"); + let dir = crate::paths::app_data(app).ok()?.join("stream-cache"); std::fs::create_dir_all(&dir).ok()?; let mut hash: u64 = 0xcbf29ce484222325; @@ -398,7 +398,7 @@ pub fn prune_cache(app: &tauri::AppHandle) { // a transfer buffer — age expiry off, size cap still applies. let age_prune = !crate::config::stream_keep_forever(&cfg); - let Ok(dir) = app.path().app_data_dir().map(|d| d.join("stream-cache")) else { + let Ok(dir) = crate::paths::app_data(app).map(|d| d.join("stream-cache")) else { return; }; let Ok(entries) = std::fs::read_dir(&dir) else { diff --git a/src-tauri/src/profile.rs b/src-tauri/src/profile.rs index 1ec9a21..1b6c6a2 100644 --- a/src-tauri/src/profile.rs +++ b/src-tauri/src/profile.rs @@ -26,7 +26,6 @@ use freenet_adapter::FreenetAdapter; use image::{imageops::FilterType, ImageEncoder, ImageReader, Limits}; use persona_state as ps; use serde::{Deserialize, Serialize}; -use tauri::Manager; use crate::state::AppState; use crate::{config, persona}; @@ -197,9 +196,7 @@ pub struct Local { impl Local { pub fn for_app(app: &tauri::AppHandle) -> Result { - let base = app - .path() - .app_data_dir() + let base = crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet"); Ok(Self { ledger: base.join("profile-ledger.json"), avatars: base.join("avatars") }) diff --git a/src-tauri/src/proposals.rs b/src-tauri/src/proposals.rs index f5dbd27..9617cab 100644 --- a/src-tauri/src/proposals.rs +++ b/src-tauri/src/proposals.rs @@ -200,7 +200,7 @@ fn board_id(owner: &[u8; 32], uuid: &[u8; 16]) -> String { const LEDGER_FILE: &str = "boards.json"; fn path(app: &tauri::AppHandle) -> Result { - Ok(app.path().app_data_dir().map_err(|e| e.to_string())?.join("freenet").join(LEDGER_FILE)) + 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) diff --git a/src-tauri/src/setlist_scan.rs b/src-tauri/src/setlist_scan.rs index 2aeed19..9ed9cae 100644 --- a/src-tauri/src/setlist_scan.rs +++ b/src-tauri/src/setlist_scan.rs @@ -587,8 +587,7 @@ fn listen_key(audio: &std::path::Path) -> String { } fn listen_artifact_path(app: &tauri::AppHandle, key: &str) -> Option { - use tauri::Manager as _; - let dir = app.path().app_data_dir().ok()?.join("setlist-listen"); + let dir = crate::paths::app_data(app).ok()?.join("setlist-listen"); std::fs::create_dir_all(&dir).ok()?; Some(dir.join(format!("{key}.json"))) } diff --git a/src-tauri/src/shared_playlists.rs b/src-tauri/src/shared_playlists.rs index 803bdb3..9ff0290 100644 --- a/src-tauri/src/shared_playlists.rs +++ b/src-tauri/src/shared_playlists.rs @@ -166,9 +166,7 @@ fn cap(s: &str, max: usize) -> String { } fn path(app: &tauri::AppHandle) -> Result { - Ok(app - .path() - .app_data_dir() + Ok(crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet") .join(LEDGER_FILE)) @@ -431,7 +429,7 @@ pub(crate) fn entry_id_for(uuid: &[u8; 16], item_id: i64) -> [u8; 16] { } fn revisions_dir(app: &tauri::AppHandle) -> Option { - app.path().app_data_dir().ok().map(|d| d.join("freenet").join("revisions")) + crate::paths::app_data(app).ok().map(|d| d.join("freenet").join("revisions")) } /// Revisions are kept on disk by hash: the owner's accept path needs the diff --git a/src-tauri/src/slsk_link.rs b/src-tauri/src/slsk_link.rs index 52b6f46..a476753 100644 --- a/src-tauri/src/slsk_link.rs +++ b/src-tauri/src/slsk_link.rs @@ -74,9 +74,7 @@ pub struct LinkStatus { } fn links_path(app: &tauri::AppHandle) -> Result { - Ok(app - .path() - .app_data_dir() + Ok(crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet") .join("soulseek-links.json")) diff --git a/src-tauri/src/spaces.rs b/src-tauri/src/spaces.rs index 2ade4d3..59eaa40 100644 --- a/src-tauri/src/spaces.rs +++ b/src-tauri/src/spaces.rs @@ -61,6 +61,12 @@ struct SpaceRecord { /// OWNER ONLY: personas we have invited and will accept a join from. #[serde(default)] invited: BTreeMap, + /// When we last told the owner which key we would wear. The answer is one + /// sealed message and it can simply fail to land — the owner's descriptor + /// may not resolve on a young node — and a member who is never seated has + /// no way back in. Opening the Space re-sends after this goes stale. + #[serde(default)] + join_sent_at: u64, } #[derive(Default, Serialize, Deserialize)] @@ -170,9 +176,7 @@ fn record_id(owner: &str, space_id: &str) -> String { } fn path(app: &tauri::AppHandle) -> Result { - Ok(app - .path() - .app_data_dir() + Ok(crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("freenet") .join(LEDGER_FILE)) @@ -433,6 +437,7 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva room_secret: None, roster: BTreeMap::new(), invited: BTreeMap::new(), + join_sent_at: 0, }; let _ledger = ledger::hold(LEDGER_FILE).await; let mut l = load(app); @@ -470,7 +475,10 @@ pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> R } } let rec = l.spaces.get(&rid).cloned().unwrap_or(rec); - Ok(view_of(app, &rec, &state, &worn_key(&rec, &me), reachable)) + let worn = worn_key(&rec, &me); + let seated = state.checkpoint.as_ref().is_some_and(|c| c.members.iter().any(|m| m.key == worn)); + retry_join_if_stranded(app, &rec, seated); + Ok(view_of(app, &rec, &state, &worn, reachable)) } pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text: &str) -> Result<(), String> { @@ -685,6 +693,7 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in room_secret: None, roster: BTreeMap::new(), invited: BTreeMap::new(), + join_sent_at: 0, }); rec.name = cap(&inv.name, sp::MAX_NAME_BYTES); rec.private = inv.private; @@ -708,23 +717,55 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in secret }; log::info!("spaces: invited to {:?} by {}", inv.name, b58(&inv.owner)); - // The answer: the key we will wear, and a persona signature so the owner - // knows it is us. Sealed to the owner — this pairing is exactly what must - // never be published. - let room_key = sp::member_public(&room); - let proof = match sign(app, sp::join_proof_payload(&inv.space_id, &inv.owner, &room_key)).await { + send_join(app, &inv.owner, &inv.space_id, &room).await; +} + +/// Tell the owner which key we will wear: the key itself, and a persona +/// signature over it so they know it is us. Sealed — that pairing is exactly +/// what must never be published. Best-effort, and stamped so `open` can try +/// again; being un-seated is the one state a member cannot fix by hand. +async fn send_join(app: &tauri::AppHandle, owner: &[u8; 32], space_id: &[u8; 16], room: &[u8; 32]) { + let room_key = sp::member_public(room); + let proof = match sign(app, sp::join_proof_payload(space_id, owner, &room_key)).await { Ok(p) => p, Err(e) => { - log::warn!("spaces: could not sign the join for {:?}: {e}", inv.name); + log::warn!("spaces: could not sign a join: {e}"); return; } }; - let join = Cargo::SpaceJoin(SpaceJoin { space_id: inv.space_id, owner: inv.owner, room_key, proof }); - if let Err(e) = inbox::send(app, &inv.owner, &join).await { - log::warn!("spaces: could not answer the invitation to {:?}: {e}", inv.name); + let join = Cargo::SpaceJoin(SpaceJoin { space_id: *space_id, owner: *owner, room_key, proof }); + match inbox::send(app, owner, &join).await { + Ok(()) => { + let _ledger = ledger::hold(LEDGER_FILE).await; + let mut l = load(app); + if let Some(rec) = l.spaces.get_mut(&record_id(&b58(owner), &b58(space_id))) { + rec.join_sent_at = now_secs(); + let _ = save(app, &l); + } + log::info!("spaces: told {} which key we will wear", b58(owner)); + } + Err(e) => log::warn!("spaces: could not answer an invitation: {e} — will try again on open"), } } +/// A member who holds a room key but is not on the roster never got seated. +/// Say so again, spaced out, rather than leaving them stranded. +const JOIN_RETRY_SECS: u64 = 120; +fn retry_join_if_stranded(app: &tauri::AppHandle, rec: &SpaceRecord, seated: bool) { + if seated || rec.my_role == "owner" { + return; + } + let Some(room) = room_secret_of(rec) else { return }; + if now_secs().saturating_sub(rec.join_sent_at) < JOIN_RETRY_SECS { + return; + } + let (Ok(owner), Ok(space_id)) = (key_of(&rec.owner), id_of(&rec.space_id)) else { return }; + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + send_join(&handle, &owner, &space_id, &room).await; + }); +} + /// The owner's side of the handshake: someone we invited says which key they /// will wear. Verify it is really them, then put THAT key — and only that key /// — on the published roster. @@ -843,6 +884,7 @@ mod tests { room_secret: secret.map(|s| b58(&s)), roster: BTreeMap::new(), invited: BTreeMap::new(), + join_sent_at: 0, } } diff --git a/src-tauri/src/ytdlp.rs b/src-tauri/src/ytdlp.rs index 88d8b21..32a2b67 100644 --- a/src-tauri/src/ytdlp.rs +++ b/src-tauri/src/ytdlp.rs @@ -16,7 +16,7 @@ use std::path::PathBuf; use std::process::Stdio; -use tauri::{Emitter, Manager}; +use tauri::Emitter; /// The NIGHTLY channel, deliberately: as of 2026-08 the stable release /// 403s on YouTube (extractor drift — burned live TWICE the same evening, @@ -36,9 +36,7 @@ const DENO_RELEASE_URL: &str = const CREATE_NO_WINDOW: u32 = 0x0800_0000; fn tools_dir(app: &tauri::AppHandle) -> Result { - let dir = app - .path() - .app_data_dir() + let dir = crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("tools"); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; @@ -280,9 +278,7 @@ pub async fn deno_download(app: tauri::AppHandle) -> Result Result { - let dir = app - .path() - .app_data_dir() + let dir = crate::paths::app_data(app) .map_err(|e| e.to_string())? .join("setlist-audio"); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;