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:
1 parent
67e52697b2
commit
6f7452c803
17 files changed
+125
-56
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
Reference in new issue
Block a user