a second instance on one machine: its own data, its own peer port, and a join that says itself again when it was never seated

This commit is contained in:
KeelrLeek committed 2026-09-03 13:39:06 -05:00
1 parent 67e52697b2
commit 6f7452c803
17 files changed
+125 -56

No files matched your search

+3 -3
View File
@@ -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<PathBuf> {
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();
+9 -4
View File
@@ -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<NodeDirs, String> {
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<Child, String>
.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))
+1 -3
View File
@@ -110,9 +110,7 @@ fn key_of(b58s: &str) -> Result<[u8; 32], String> {
}
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
Ok(crate::paths::app_data(app)
.map_err(|e| e.to_string())?
.join("freenet")
.join(LEDGER_FILE))
+1 -3
View File
@@ -166,9 +166,7 @@ pub(crate) fn key_of(b58s: &str) -> Result<[u8; 32], String> {
}
fn dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
Ok(crate::paths::app_data(app)
.map_err(|e| e.to_string())?
.join("freenet"))
}
+5 -1
View File
@@ -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 {
+2 -4
View File
@@ -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<Option<Engine>> = Mutex::new(None);
pub(crate) fn models_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
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())?;
+1 -1
View File
@@ -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<PathBuf, String> {
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)
+36
View File
@@ -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<PathBuf, String> {
#[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())
}
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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<PathBuf> {
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 {
+1 -4
View File
@@ -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<Self, String> {
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") })
+1 -1
View File
@@ -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<PathBuf, String> {
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)
+1 -2
View File
@@ -587,8 +587,7 @@ fn listen_key(audio: &std::path::Path) -> String {
}
fn listen_artifact_path(app: &tauri::AppHandle, key: &str) -> Option<std::path::PathBuf> {
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")))
}
+2 -4
View File
@@ -166,9 +166,7 @@ fn cap(s: &str, max: usize) -> String {
}
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
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<PathBuf> {
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
+1 -3
View File
@@ -74,9 +74,7 @@ pub struct LinkStatus {
}
fn links_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
Ok(crate::paths::app_data(app)
.map_err(|e| e.to_string())?
.join("freenet")
.join("soulseek-links.json"))
+55 -13
View File
@@ -61,6 +61,12 @@ struct SpaceRecord {
/// OWNER ONLY: personas we have invited and will accept a join from.
#[serde(default)]
invited: BTreeMap<String, u64>,
/// 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<PathBuf, String> {
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,
}
}
+3 -7
View File
@@ -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<PathBuf, String> {
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<serde_json::Value, S
/// Where a video's fetched audio lives; keyed by video id so a re-scan of
/// the same set never re-downloads.
fn audio_cache_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
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())?;