diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index fc90e6ab14a..f53b51bbe89 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -32,16 +32,15 @@ pub struct AppState { /// response (surfaced as an error) so the auth token never leaves the /// validated relay origin. pub media_fetch_client: reqwest::Client, - /// Workspace-provided relay URL override. Set by `apply_workspace` on app - /// init and takes priority over env vars and compile-time defaults. + /// Workspace relay override set by `apply_workspace`; wins over defaults. pub relay_url_override: Mutex>, - /// Set during backend setup when managed agents are eligible for launch - /// restore. `apply_workspace` consumes it after installing the workspace - /// relay and identity, so agents never start against the fallback relay. + /// Highest workspace transition generation plus synchronous commit lock. + pub workspace_transition: crate::commands::WorkspaceTransitionState, + /// Backend setup defers managed-agent launch restore until `apply_workspace` + /// installs the workspace relay and identity. pub managed_agent_restore_pending: AtomicBool, - /// Whether desktop may repair managed-agent kind:0 profiles from its local - /// records. Disabled by the agent-managed profiles experiment so an agent's - /// own profile updates are not overwritten on start or restore. + /// Whether desktop may repair managed-agent kind:0 profiles. Disabled by + /// agent-managed profiles so agent updates are not overwritten on restore. pub managed_agent_profile_reconcile_enabled: AtomicBool, /// Shared shutdown signal checked by launch-time agent restoration. pub shutdown_started: AtomicBool, @@ -207,6 +206,7 @@ pub fn build_app_state() -> AppState { header across origins (redirect-hop SSRF)", ), relay_url_override: Mutex::new(None), + workspace_transition: Default::default(), managed_agent_restore_pending: AtomicBool::new(false), managed_agent_profile_reconcile_enabled: AtomicBool::new(true), shutdown_started: AtomicBool::new(false), diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 453bb81fb0c..a66b7393871 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1365,7 +1365,7 @@ use deploy::{deploy_payload_json, DeployProjections}; use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider}; #[path = "agents_profile.rs"] -mod profile; +pub(crate) mod profile; #[cfg(test)] use profile::{profile_needs_sync, resolve_legacy_avatar}; pub(crate) use profile::{reconcile_agent_profile, ProfileReconcileData}; diff --git a/desktop/src-tauri/src/commands/agents_profile.rs b/desktop/src-tauri/src/commands/agents_profile.rs index 0675d4c48f4..4f692a167ae 100644 --- a/desktop/src-tauri/src/commands/agents_profile.rs +++ b/desktop/src-tauri/src/commands/agents_profile.rs @@ -71,19 +71,37 @@ pub(crate) async fn reconcile_agent_profile( app: &AppHandle, agent_pubkey: &str, data: &ProfileReconcileData, +) -> Result<(), String> { + let workspace_relay = relay_ws_url_with_override(state); + reconcile_agent_profile_for_workspace(state, app, agent_pubkey, data, &workspace_relay, None) + .await +} + +pub(crate) async fn reconcile_agent_profile_for_workspace( + state: &AppState, + app: &AppHandle, + agent_pubkey: &str, + data: &ProfileReconcileData, + workspace_relay: &str, + transition_generation: Option, ) -> Result<(), String> { use crate::relay::{query_agent_profile, sync_managed_agent_profile}; - // An explicit per-agent relay wins; an empty one falls back to the active - // workspace relay. Resolved once and used for both the read and write-back. - let relay_url = crate::relay::effective_agent_relay_url( - &data.relay_url, - &relay_ws_url_with_override(state), - ); + let transition_is_current = || { + state + .workspace_transition + .allows_profile_reconcile(transition_generation) + }; + + // An explicit per-agent relay wins; an empty one falls back to the captured + // workspace relay. Restore callers also carry the transition generation so + // a superseded restore cannot publish into the winning workspace. + let relay_url = crate::relay::effective_agent_relay_url(&data.relay_url, workspace_relay); - if !state - .managed_agent_profile_reconcile_enabled - .load(std::sync::atomic::Ordering::Acquire) + if !transition_is_current() + || !state + .managed_agent_profile_reconcile_enabled + .load(std::sync::atomic::Ordering::Acquire) { return Ok(()); } @@ -143,9 +161,10 @@ pub(crate) async fn reconcile_agent_profile( let agent_keys = Keys::parse(&data.private_key_nsec) .map_err(|e| format!("failed to parse agent keys: {e}"))?; - if !state - .managed_agent_profile_reconcile_enabled - .load(std::sync::atomic::Ordering::Acquire) + if !transition_is_current() + || !state + .managed_agent_profile_reconcile_enabled + .load(std::sync::atomic::Ordering::Acquire) { return Ok(()); } diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 7356cd7fc0c..82a93ae7b26 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -337,7 +337,11 @@ async fn resolve_buzz_mesh_startup_at( } } -pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> CmdResult<()> { +pub(crate) async fn restore_mesh_sharing( + app: &AppHandle, + state: &AppState, + owner: Option>, +) -> CmdResult<()> { let Some(mut config) = load_mesh_sharing_config(app)? else { return Ok(()); }; @@ -345,23 +349,59 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C return Ok(()); } config.model_id = mesh_llm::canonical_curated_model_id(&config.model_id).to_string(); - if state.mesh_llm_runtime.lock().await.is_some() { - return Ok(()); - } let relay_url = config .relay_url .clone() .unwrap_or_else(|| relay::relay_ws_url_with_override(state)); + let runtime_matches = |runtime: &mesh_llm::DesktopMeshRuntime| { + runtime + .start_request() + .relay_url + .as_deref() + .is_some_and(|bound| bound == relay_url) + }; + { + let mut runtime = state.mesh_llm_runtime.lock().await; + if runtime.as_ref().is_some_and(runtime_matches) { + return Ok(()); + } + let stale = match owner { + Some(owner) => owner.take_if_current_and(&mut runtime, |_| true), + None => runtime.take(), + }; + drop(runtime); + if let Some(stale) = stale { + stale.stop().await.map_err(|error| error.to_string())?; + } + } + if owner.is_some_and(|owner| !owner.is_current()) { + return Ok(()); + } let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup_at(state, &relay_url).await; + if owner.is_some_and(|owner| !owner.is_current()) { + return Ok(()); + } let mut runtime = state.mesh_llm_runtime.lock().await; - if runtime.is_some() { + if runtime.as_ref().is_some_and(runtime_matches) { return Ok(()); } + let stale = match owner { + Some(owner) => owner.take_if_current_and(&mut runtime, |_| true), + None => runtime.take(), + }; + drop(runtime); + if let Some(stale) = stale { + stale.stop().await.map_err(|error| error.to_string())?; + } + if owner.is_some_and(|owner| !owner.is_current()) { + return Ok(()); + } + runtime = state.mesh_llm_runtime.lock().await; if config.start_on_next_launch { - // Consume a role-switch request before doing any potentially long model - // work. If Buzz exits during that work, the next launch stays stopped. + // Keep the role-switch checkpoint armed until the still-current + // workspace owner has installed the runtime. A superseded restore must + // not consume another transition's retry authority. config = pending_new_start_checkpoint(&config); - save_mesh_sharing_config(app, &config)?; } // This is restoration of a previously inference-ready serving node. Keep // the enabled checkpoint armed while restoring so a transient startup @@ -378,6 +418,22 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C let started = mesh_llm::DesktopMeshRuntime::start(request) .await .map_err(|error| format!("failed to restore Share Compute: {error:#}"))?; + if let Some(owner) = owner { + let Some(_transition_guard) = owner.lock_if_current() else { + drop(runtime); + started.stop().await.map_err(|error| error.to_string())?; + return Ok(()); + }; + *runtime = Some(started); + config.enabled = true; + config.start_on_next_launch = false; + save_mesh_sharing_config(app, &config)?; + } else { + *runtime = Some(started); + config.enabled = true; + config.start_on_next_launch = false; + save_mesh_sharing_config(app, &config)?; + } // Install the restored runtime immediately: it is tracked by AppState from // here on, so it can never be orphaned. Restoring a previously // inference-ready node still has to load ~tens of GB of weights and may @@ -387,10 +443,6 @@ pub(crate) async fn restore_mesh_sharing(app: &AppHandle, state: &AppState) -> C // tore down a node that was simply still warming up. The checkpoint stays // armed (`enabled`), so a genuinely broken restore is retried next launch // rather than silently turning Share Compute off. - *runtime = Some(started); - config.enabled = true; - config.start_on_next_launch = false; - save_mesh_sharing_config(app, &config)?; drop(runtime); if let Err(error) = wait_for_mesh_inference(&config.model_id).await { eprintln!( @@ -739,7 +791,7 @@ pub(crate) async fn ensure_relay_mesh_for_record( if load_mesh_sharing_config(app)? .is_some_and(|config| config.enabled && !config.model_id.trim().is_empty()) { - restore_mesh_sharing(app, &state).await?; + restore_mesh_sharing(app, &state, None).await?; return wait_for_mesh_inference(model_id).await; } diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 52473716465..a4dce806d55 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -10,7 +10,7 @@ mod agent_models_env; mod agent_providers; mod agent_settings; mod agent_update_rollback; -mod agents; +pub(crate) mod agents; mod canvas; mod channel_templates; mod channel_window; diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index aa88bfe39ac..665e39f937b 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -1,6 +1,9 @@ use nostr::Keys; use serde::{Deserialize, Serialize}; -use std::sync::atomic::Ordering; +use std::sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Mutex, +}; use tauri::{AppHandle, Emitter, Manager, State}; use crate::app_state::AppState; @@ -110,6 +113,133 @@ pub async fn validate_repos_dir(dir: String) -> Result<(), String> { .map_err(|e| format!("spawn_blocking failed: {e}"))? } +#[derive(Default)] +pub(crate) struct WorkspaceTransitionState { + generation: AtomicU64, + commit: Mutex<()>, + /// Provider deployments are externally last-write-wins. Serialize the + /// complete reconcile so a newer transition waits for stale network work + /// to drain, rechecks ownership, and is guaranteed to publish last. + provider_reconcile: tokio::sync::Mutex<()>, +} + +#[derive(Clone, Copy)] +pub(crate) struct WorkspaceTransitionOwner<'a> { + transition: &'a WorkspaceTransitionState, + generation: u64, +} + +impl<'a> WorkspaceTransitionOwner<'a> { + pub(crate) fn is_current(self) -> bool { + self.transition.is_current(self.generation) + } + + pub(crate) fn generation(self) -> u64 { + self.generation + } + + pub(crate) fn lock_if_current(self) -> Option> { + let guard = self + .transition + .commit + .lock() + .unwrap_or_else(|error| error.into_inner()); + self.is_current().then_some(guard) + } + + pub(crate) fn while_current(self, action: impl FnOnce() -> T) -> Option { + let _guard = self.lock_if_current()?; + Some(action()) + } + + #[cfg(any(feature = "mesh-llm", test))] + pub(crate) fn take_if_current_and( + self, + slot: &mut Option, + should_take: impl FnOnce(&T) -> bool, + ) -> Option { + let _guard = self.lock_if_current()?; + if slot.as_ref().is_some_and(should_take) { + slot.take() + } else { + None + } + } +} + +impl WorkspaceTransitionState { + pub(crate) fn claim_next(&self) -> u64 { + let _commit_guard = self.commit.lock().unwrap_or_else(|e| e.into_inner()); + self.generation.fetch_add(1, Ordering::AcqRel) + 1 + } + + pub(crate) fn is_current(&self, generation: u64) -> bool { + self.generation.load(Ordering::Acquire) == generation + } + + pub(crate) fn allows_profile_reconcile(&self, generation: Option) -> bool { + generation.is_none_or(|generation| self.is_current(generation)) + } + + pub(crate) fn current_generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + /// Run an action against the current generation while excluding a + /// concurrent transition claim. This makes generation capture and any + /// state publication performed by `action` one atomic transition step. + pub(crate) fn with_current_generation(&self, action: impl FnOnce(u64) -> T) -> T { + let _commit_guard = self.commit.lock().unwrap_or_else(|e| e.into_inner()); + action(self.current_generation()) + } + + fn owner(&self, generation: u64) -> WorkspaceTransitionOwner<'_> { + WorkspaceTransitionOwner { + transition: self, + generation, + } + } + + async fn reconcile_provider_if_current( + &self, + generation: u64, + reconcile: F, + ) -> Option + where + F: FnOnce() -> Fut, + Fut: std::future::Future, + { + let _guard = self.provider_reconcile.lock().await; + if !self.is_current(generation) { + return None; + } + Some(reconcile().await) + } + + fn restore_pending_for_current(&self, generation: u64, pending: &AtomicBool) -> bool { + self.is_current(generation) && pending.load(Ordering::Acquire) + } + + fn complete_restore_if_current(&self, generation: u64, pending: &AtomicBool) { + let _commit_guard = self.commit.lock().unwrap_or_else(|e| e.into_inner()); + if self.is_current(generation) { + pending.store(false, Ordering::Release); + } + } +} + +fn workspace_transition_is_current(state: &AppState, generation: u64) -> bool { + state.workspace_transition.is_current(generation) +} + +/// Allocate process-lifetime ownership for a frontend workspace transition +/// before it begins async teardown. The native authority survives webview and +/// React remounts, so callers cannot restart generation numbering at one. +#[tauri::command] +pub fn claim_workspace_transition(state: State<'_, AppState>) -> u64 { + state.workspace_transition.claim_next() +} + /// Apply a workspace's configuration to the backend session. /// /// Called by the frontend on app init (after reload) to configure the @@ -129,10 +259,18 @@ pub async fn apply_workspace( nsec: Option, repos_dir: Option, agent_managed_profiles: Option, + transition_generation: u64, app: AppHandle, ) -> Result<(), String> { + let state = app.state::(); + // The token was allocated by `claim_workspace_transition`. An apply may + // use it only while it remains the newest process-lifetime intent. + if !workspace_transition_is_current(&state, transition_generation) { + return Ok(()); + } + let restore_app = app.clone(); - tokio::task::spawn_blocking(move || { + let true = tokio::task::spawn_blocking(move || { let state = app.state::(); // ── Validate before mutating ────────────────────────────────────────── @@ -163,6 +301,18 @@ pub async fn apply_workspace( None => None, }; + // Commit all synchronous state and filesystem changes under one lock. + // A newer command claims its generation before waiting here, so this + // final check prevents a superseded apply from mutating any authority. + let _commit_guard = state + .workspace_transition + .commit + .lock() + .map_err(|e| e.to_string())?; + if !workspace_transition_is_current(&state, transition_generation) { + return Ok::(false); + } + // ── Apply all state changes (nothing below can fail) ────────────────── { let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; @@ -206,13 +356,31 @@ pub async fn apply_workspace( try_regenerate_nest(&app); - Ok::<(), String>(()) + Ok::(true) }) .await - .map_err(|e| format!("spawn_blocking failed: {e}"))??; + .map_err(|e| format!("spawn_blocking failed: {e}"))?? + else { + return Ok(()); + }; let state = restore_app.state::(); - super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?; + if !workspace_transition_is_current(&state, transition_generation) { + return Ok(()); + } + let Some(reconcile_result) = state + .workspace_transition + .reconcile_provider_if_current(transition_generation, || { + super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state) + }) + .await + else { + return Ok(()); + }; + reconcile_result?; + if !workspace_transition_is_current(&state, transition_generation) { + return Ok(()); + } // Backfill this exact relay+owner scope only after the workspace has been // applied. Running at process boot would target the fallback relay and @@ -236,8 +404,8 @@ pub async fn apply_workspace( } let restore_pending = state - .managed_agent_restore_pending - .swap(false, Ordering::AcqRel); + .workspace_transition + .restore_pending_for_current(transition_generation, &state.managed_agent_restore_pending); // The coordinator starts before React applies the selected workspace, so // its startup publication may have used the fallback relay and placeholder @@ -251,19 +419,39 @@ pub async fn apply_workspace( let app = restore_app.clone(); tauri::async_runtime::spawn(async move { let state = app.state::(); + if !workspace_transition_is_current(&state, transition_generation) { + return; + } if restore_pending { - if let Err(error) = - crate::commands::mesh_llm::restore_mesh_sharing(&app, &state).await + if let Err(error) = crate::commands::mesh_llm::restore_mesh_sharing( + &app, + &state, + Some(state.workspace_transition.owner(transition_generation)), + ) + .await { eprintln!("buzz-desktop: failed to restore Share Compute: {error}"); } } crate::mesh_llm::publish_current_status_once(&app, "workspace apply").await; + if !workspace_transition_is_current(&state, transition_generation) { + return; + } if restore_pending { - if let Err(error) = - restore_managed_agents_on_launch(&app, &state.shutdown_started).await + match restore_managed_agents_on_launch( + &app, + &state.shutdown_started, + state.workspace_transition.owner(transition_generation), + ) + .await { - eprintln!("buzz-desktop: failed to restore managed agents: {error}"); + Ok(()) => state.workspace_transition.complete_restore_if_current( + transition_generation, + &state.managed_agent_restore_pending, + ), + Err(error) => { + eprintln!("buzz-desktop: failed to restore managed agents: {error}"); + } } } }); @@ -274,13 +462,201 @@ pub async fn apply_workspace( let app = restore_app.clone(); tauri::async_runtime::spawn(async move { let state = app.state::(); - if let Err(error) = - restore_managed_agents_on_launch(&app, &state.shutdown_started).await + if !workspace_transition_is_current(&state, transition_generation) { + return; + } + match restore_managed_agents_on_launch( + &app, + &state.shutdown_started, + state.workspace_transition.owner(transition_generation), + ) + .await { - eprintln!("buzz-desktop: failed to restore managed agents: {error}"); + Ok(()) => state.workspace_transition.complete_restore_if_current( + transition_generation, + &state.managed_agent_restore_pending, + ), + Err(error) => { + eprintln!("buzz-desktop: failed to restore managed agents: {error}"); + } } }); } Ok(()) } + +#[cfg(test)] +mod tests { + use super::WorkspaceTransitionState; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[test] + fn workspace_claims_remain_monotonic_across_frontend_epochs() { + let transition = WorkspaceTransitionState::default(); + let first_mount = transition.claim_next(); + let first_mount_switch = transition.claim_next(); + assert_eq!(first_mount, 1); + assert_eq!(first_mount_switch, 2); + assert!(!transition.is_current(first_mount)); + assert!(transition.is_current(first_mount_switch)); + + // A recreated frontend asks native state for a fresh token rather than + // restarting its own counter at one. + let remounted_frontend = transition.claim_next(); + assert_eq!(remounted_frontend, 3); + assert!(!transition.is_current(first_mount_switch)); + assert!(transition.is_current(remounted_frontend)); + } + + #[test] + fn superseded_restore_does_not_consume_launch_pending() { + let transition = WorkspaceTransitionState::default(); + let pending = AtomicBool::new(true); + let first = transition.claim_next(); + assert!(transition.restore_pending_for_current(first, &pending)); + + let winner = transition.claim_next(); + transition.complete_restore_if_current(first, &pending); + assert!(pending.load(Ordering::Acquire)); + assert!(transition.restore_pending_for_current(winner, &pending)); + + transition.complete_restore_if_current(winner, &pending); + assert!(!pending.load(Ordering::Acquire)); + } + + #[test] + fn supersession_during_managed_agent_restore_blocks_spawn_commit() { + let transition = WorkspaceTransitionState::default(); + let stale = transition.claim_next(); + let stale_owner = transition.owner(stale); + assert!(stale_owner.is_current()); + + let winner = transition.claim_next(); + let mut installed = false; + assert_eq!(stale_owner.while_current(|| installed = true), None); + assert!(!installed); + assert!(transition.owner(winner).is_current()); + } + + #[test] + fn unowned_profile_reconcile_remains_enabled() { + let transition = WorkspaceTransitionState::default(); + assert!(transition.allows_profile_reconcile(None)); + } + + #[test] + fn superseded_restore_profile_tail_stops_before_relay_query() { + let transition = WorkspaceTransitionState::default(); + let stale_owner = transition.owner(transition.claim_next()); + let stale_generation = stale_owner.generation(); + transition.claim_next(); + + let mut queried = false; + if transition.allows_profile_reconcile(Some(stale_generation)) { + queried = true; + } + + assert!(!queried); + } + + #[test] + fn superseded_restore_profile_tail_stops_before_relay_publish() { + let transition = WorkspaceTransitionState::default(); + let stale_owner = transition.owner(transition.claim_next()); + let stale_generation = stale_owner.generation(); + assert!(transition.allows_profile_reconcile(Some(stale_generation))); + + // The query can await while a newer workspace claims ownership. The + // restore tail must check the same generation again before publishing. + transition.claim_next(); + let mut published = false; + if transition.allows_profile_reconcile(Some(stale_generation)) { + published = true; + } + + assert!(!published); + } + + #[test] + fn stale_owner_cannot_take_winners_mesh_runtime() { + let transition = WorkspaceTransitionState::default(); + let stale_owner = transition.owner(transition.claim_next()); + transition.claim_next(); + let mut runtime = Some("winner"); + + assert_eq!( + stale_owner.take_if_current_and(&mut runtime, |_| true), + None + ); + assert_eq!(runtime, Some("winner")); + } + + #[test] + fn owner_superseded_while_waiting_cannot_take_winners_mesh_runtime() { + use std::sync::Arc; + + let transition = Arc::new(WorkspaceTransitionState::default()); + let stale_owner = transition.owner(transition.claim_next()); + let held = transition.commit.lock().unwrap(); + let transition_for_claim = transition.clone(); + let claim = std::thread::spawn(move || transition_for_claim.claim_next()); + drop(held); + let winner = claim.join().unwrap(); + let mut runtime = Some("winner"); + + assert_eq!( + stale_owner.take_if_current_and(&mut runtime, |_| true), + None + ); + assert_eq!(runtime, Some("winner")); + assert!(transition.owner(winner).is_current()); + } + + #[tokio::test] + async fn newer_provider_reconcile_runs_last_after_delayed_stale_request() { + use std::sync::Arc; + use tokio::sync::Notify; + + let transition = Arc::new(WorkspaceTransitionState::default()); + let stale_generation = transition.claim_next(); + let stale_started = Arc::new(Notify::new()); + let release_stale = Arc::new(Notify::new()); + let writes = Arc::new(tokio::sync::Mutex::new(Vec::new())); + + let stale_task = { + let transition = transition.clone(); + let stale_started = stale_started.clone(); + let release_stale = release_stale.clone(); + let writes = writes.clone(); + tokio::spawn(async move { + transition + .reconcile_provider_if_current(stale_generation, || async move { + stale_started.notify_one(); + release_stale.notified().await; + writes.lock().await.push("stale"); + }) + .await + }) + }; + stale_started.notified().await; + + let winner_generation = transition.claim_next(); + let winner_task = { + let transition = transition.clone(); + let writes = writes.clone(); + tokio::spawn(async move { + transition + .reconcile_provider_if_current(winner_generation, || async move { + writes.lock().await.push("winner"); + }) + .await + }) + }; + release_stale.notify_one(); + stale_task.await.unwrap(); + winner_task.await.unwrap(); + + assert_eq!(*writes.lock().await, vec!["stale", "winner"]); + } +} diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc367..ad87912df4f 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -20,6 +20,85 @@ pub(crate) struct PendingCommunityDeepLink { #[derive(Default)] pub(crate) struct PendingCommunityDeepLinks(Mutex>); +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingNavigationDeepLink { + id: String, + kind: String, + channel_id: String, + message_id: Option, + thread_root_id: Option, + workspace_generation: u64, +} + +#[derive(Default)] +pub(crate) struct PendingNavigationDeepLinks(Mutex>); + +impl PendingNavigationDeepLinks { + fn lock(&self) -> std::sync::MutexGuard<'_, VecDeque> { + self.0.lock().unwrap_or_else(|poisoned| { + eprintln!("buzz-desktop: recovering poisoned pending navigation deep-link queue"); + poisoned.into_inner() + }) + } + + fn enqueue(&self, pending: PendingNavigationDeepLink) { + let mut queue = self.lock(); + if queue.iter().any(|item| { + item.kind == pending.kind + && item.channel_id == pending.channel_id + && item.message_id == pending.message_id + && item.thread_root_id == pending.thread_root_id + && item.workspace_generation == pending.workspace_generation + }) { + return; + } + queue.push_back(pending); + } + + fn clear_before(&self, workspace_generation: u64) { + self.lock() + .retain(|pending| pending.workspace_generation >= workspace_generation); + } + + fn first(&self) -> Option { + self.lock().front().cloned() + } + + fn acknowledge(&self, id: &str) -> bool { + let mut queue = self.lock(); + if queue.front().is_some_and(|item| item.id == id) { + queue.pop_front(); + true + } else { + false + } + } +} + +#[tauri::command] +pub(crate) fn clear_pending_navigation_deep_links( + workspace_generation: u64, + pending: State<'_, PendingNavigationDeepLinks>, +) { + pending.clear_before(workspace_generation); +} + +#[tauri::command] +pub(crate) fn take_pending_navigation_deep_link( + pending: State<'_, PendingNavigationDeepLinks>, +) -> Option { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_navigation_deep_link( + id: String, + pending: State<'_, PendingNavigationDeepLinks>, +) -> bool { + pending.acknowledge(&id) +} + impl PendingCommunityDeepLinks { fn enqueue(&self, pending: PendingCommunityDeepLink) { let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); @@ -88,6 +167,36 @@ fn queue_community_deep_link( }); } +fn enqueue_navigation_for_current_workspace( + transition: &crate::commands::WorkspaceTransitionState, + queue: &PendingNavigationDeepLinks, + pending: PendingNavigationDeepLink, +) { + transition.with_current_generation(|workspace_generation| { + queue.enqueue(PendingNavigationDeepLink { + workspace_generation, + ..pending + }); + }); +} + +fn queue_navigation_deep_link(app: &tauri::AppHandle, kind: &str, payload: &serde_json::Value) { + let Some(channel_id) = payload["channelId"].as_str() else { + return; + }; + let pending = PendingNavigationDeepLink { + id: uuid::Uuid::new_v4().to_string(), + kind: kind.to_owned(), + channel_id: channel_id.to_owned(), + message_id: payload["messageId"].as_str().map(str::to_owned), + thread_root_id: payload["threadRootId"].as_str().map(str::to_owned), + workspace_generation: 0, + }; + let state = app.state::(); + let queue = app.state::(); + enqueue_navigation_for_current_workspace(&state.workspace_transition, &queue, pending); +} + fn activate_main_window(app: &tauri::AppHandle) { let Some(window) = app.get_webview_window("main") else { return; @@ -104,6 +213,19 @@ fn activate_main_window(app: &tauri::AppHandle) { } } +fn parse_channel_deep_link(url: &Url) -> Option { + if url.query().is_some() || url.fragment().is_some() || !url.username().is_empty() { + return None; + } + let mut segments = url.path_segments()?; + let channel_id = segments.next()?; + if segments.next().is_some() { + return None; + } + let channel_id = uuid::Uuid::parse_str(channel_id).ok()?.to_string(); + Some(serde_json::json!({ "channelId": channel_id })) +} + /// Parse the query string of a `buzz://message?…` URL into the JSON /// payload emitted on `deep-link-message`. Returns `None` when a required /// param (`channel`, `id`) is missing or empty — mirroring the validation @@ -350,6 +472,15 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { ); let _ = app.emit("deep-link-add-community", payload); } + Some("channel") => { + let Some(payload) = parse_channel_deep_link(&url) else { + eprintln!("buzz-desktop: channel deep link missing/invalid channel: {url_str}"); + return; + }; + activate_main_window(app); + queue_navigation_deep_link(app, "channel", &payload); + let _ = app.emit("deep-link-channel", payload); + } Some("message") => { // `buzz://message?channel=&id=[&thread=]` // @@ -364,6 +495,7 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { return; }; activate_main_window(app); + queue_navigation_deep_link(app, "message", &payload); let _ = app.emit("deep-link-message", payload); } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { @@ -385,327 +517,5 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { } #[cfg(test)] -mod tests { - use url::Url; - - use super::{ - parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, - parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, - }; - - fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { - PendingCommunityDeepLink { - id: id.to_owned(), - kind: if code.is_some() { "join" } else { "connect" }.to_owned(), - relay_url: relay_url.to_owned(), - code: code.map(str::to_owned), - policy_receipt: None, - name: None, - } - } - - #[test] - fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { - let mut link = pending("join", "wss://relay.example", Some("invite")); - link.policy_receipt = Some("relay-signed-receipt".to_owned()); - - let payload = serde_json::to_value(link).unwrap(); - assert_eq!(payload["policyReceipt"], "relay-signed-receipt"); - } - - #[test] - fn pending_community_links_are_fifo_and_acknowledged_in_order() { - let queue = PendingCommunityDeepLinks::default(); - queue.enqueue(pending("first", "wss://one.example", Some("one"))); - queue.enqueue(pending("second", "wss://two.example", Some("two"))); - assert_eq!(queue.first().unwrap().id, "first"); - assert!(!queue.acknowledge("second")); - assert!(queue.acknowledge("first")); - assert_eq!(queue.first().unwrap().id, "second"); - } - - #[test] - fn pending_community_links_dedupe_exact_intents() { - let queue = PendingCommunityDeepLinks::default(); - queue.enqueue(pending("first", "wss://one.example", Some("one"))); - queue.enqueue(pending("duplicate", "wss://one.example", Some("one"))); - assert!(queue.acknowledge("first")); - assert!(queue.first().is_none()); - } - - fn valid_nostr_bind_url() -> Url { - Url::parse( - "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", - ) - .unwrap() - } - - #[test] - fn parse_add_community_deep_link_extracts_relay_and_name() { - let url = Url::parse( - "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", - ) - .unwrap(); - let payload = parse_add_community_deep_link(&url).unwrap(); - assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); - assert_eq!(payload.name.as_deref(), Some("Acme Team")); - } - - #[test] - fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { - for raw in [ - "buzz://add-community?relay=wss%3A%2F%2Facme.example", - "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", - ] { - assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) - .unwrap() - .name - .is_none()); - } - } - - #[test] - fn parse_add_community_deep_link_rejects_invalid_relays() { - for raw in [ - "buzz://add-community", - "buzz://add-community?relay=", - "buzz://add-community?relay=not-a-url", - "buzz://add-community?relay=https%3A%2F%2Facme.example", - "buzz://add-community?relay=wss%3A%2F%2F", - ] { - assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); - } - } - - #[test] - fn parse_message_deep_link_extracts_required_params() { - let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["channelId"], "abc"); - assert_eq!(payload["messageId"], "xyz"); - assert!(payload["threadRootId"].is_null()); - } - - #[test] - fn parse_message_deep_link_accepts_buzz_scheme() { - let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["channelId"], "abc"); - assert_eq!(payload["messageId"], "xyz"); - } - - #[test] - fn parse_message_deep_link_includes_thread_root() { - let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert_eq!(payload["threadRootId"], "root1"); - } - - #[test] - fn parse_message_deep_link_rejects_missing_id() { - let url = Url::parse("buzz://message?channel=abc").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_rejects_empty_channel() { - // Regression: `channel=&id=foo` previously produced channelId: "". - let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_rejects_empty_id() { - let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); - assert!(parse_message_deep_link(&url).is_none()); - } - - #[test] - fn parse_message_deep_link_treats_empty_thread_as_absent() { - let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); - let payload = parse_message_deep_link(&url).expect("required params present"); - assert!(payload["threadRootId"].is_null()); - } - - #[test] - fn parse_join_deep_link_extracts_relay_and_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); - let payload = parse_join_deep_link(&url).expect("required params present"); - assert_eq!(payload["relayUrl"], "wss://relay.example"); - assert_eq!(payload["code"], "abc.def"); - assert!(payload["policyReceipt"].is_null()); - } - - #[test] - fn parse_join_deep_link_extracts_policy_receipt() { - let url = Url::parse( - "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", - ) - .unwrap(); - let payload = parse_join_deep_link(&url).expect("required params present"); - assert_eq!(payload["policyReceipt"], "receipt.value"); - } - - #[test] - fn parse_join_deep_link_rejects_missing_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_empty_code() { - let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_missing_relay() { - let url = Url::parse("buzz://join?code=abc.def").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_join_deep_link_rejects_non_websocket_relay() { - let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); - assert!(parse_join_deep_link(&url).is_none()); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_valid_url() { - let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); - assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); - assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); - assert_eq!(payload.verification_code, "123456"); - assert_eq!(payload.audience, "buzz:nostr-identity"); - assert_eq!(payload.action, "bind_nostr_identity"); - assert_eq!(payload.protocol, "buzz-nostr-identity"); - assert_eq!(payload.version, "1"); - assert_eq!(payload.origin, "https://example.com"); - assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); - assert_eq!(payload.return_mode, "clipboard"); - assert_eq!(payload.callback_url, None); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - assert_eq!( - payload.callback_url.as_deref(), - Some("https://example.com/buzz?mockSession=1") - ); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - - assert_eq!(payload.return_mode, "browser_fragment_v1"); - assert_eq!( - payload.callback_url.as_deref(), - Some("https://example.com/buzz") - ); - } - - #[test] - fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); - - assert_eq!( - parse_nostr_bind_deep_link(&url).unwrap_err(), - "browser_fragment_v1 requires callback_url" - ); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_http_callback_url() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { - let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_empty_nonce() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_short_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_long_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_wrong_action() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_wrong_audience() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_non_https_origin() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_origin_with_path() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); - assert!(parse_nostr_bind_deep_link(&url).is_err()); - } - - #[test] - fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { - let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); - let payload = parse_nostr_bind_deep_link(&url).unwrap(); - assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); - } -} +#[path = "deep_link_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/deep_link_tests.rs b/desktop/src-tauri/src/deep_link_tests.rs new file mode 100644 index 00000000000..c6f7ecb493e --- /dev/null +++ b/desktop/src-tauri/src/deep_link_tests.rs @@ -0,0 +1,508 @@ +use url::Url; + +use super::{ + parse_add_community_deep_link, parse_channel_deep_link, parse_join_deep_link, + parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink, + PendingCommunityDeepLinks, PendingNavigationDeepLink, PendingNavigationDeepLinks, +}; + +fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { + PendingCommunityDeepLink { + id: id.to_owned(), + kind: if code.is_some() { "join" } else { "connect" }.to_owned(), + relay_url: relay_url.to_owned(), + code: code.map(str::to_owned), + policy_receipt: None, + name: None, + } +} + +fn pending_navigation( + id: &str, + kind: &str, + channel_id: &str, + message_id: Option<&str>, + thread_root_id: Option<&str>, +) -> PendingNavigationDeepLink { + pending_navigation_at(id, kind, channel_id, message_id, thread_root_id, 0) +} + +fn pending_navigation_at( + id: &str, + kind: &str, + channel_id: &str, + message_id: Option<&str>, + thread_root_id: Option<&str>, + workspace_generation: u64, +) -> PendingNavigationDeepLink { + PendingNavigationDeepLink { + id: id.to_owned(), + kind: kind.to_owned(), + channel_id: channel_id.to_owned(), + message_id: message_id.map(str::to_owned), + thread_root_id: thread_root_id.map(str::to_owned), + workspace_generation, + } +} + +#[test] +fn pending_navigation_links_are_fifo_acknowledged_and_deduplicated() { + let queue = PendingNavigationDeepLinks::default(); + queue.enqueue(pending_navigation( + "first", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "duplicate", + "channel", + "channel-1", + None, + None, + )); + queue.enqueue(pending_navigation( + "second", + "message", + "channel-1", + Some("message-1"), + Some("root-1"), + )); + + assert_eq!(queue.first().unwrap().id, "first"); + assert!(!queue.acknowledge("second")); + assert!(queue.acknowledge("first")); + assert_eq!(queue.first().unwrap().id, "second"); + assert!(queue.acknowledge("second")); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_navigation_links_can_be_cleared_without_dropping_new_generation() { + let queue = PendingNavigationDeepLinks::default(); + queue.enqueue(pending_navigation_at( + "stale", + "channel", + "channel-1", + None, + None, + 1, + )); + queue.enqueue(pending_navigation_at( + "fresh", + "channel", + "channel-1", + None, + None, + 2, + )); + + queue.clear_before(2); + assert_eq!(queue.first().unwrap().id, "fresh"); + assert!(queue.acknowledge("fresh")); + assert!(queue.first().is_none()); +} + +#[test] +fn navigation_enqueue_holds_transition_ownership_through_queue_insertion() { + let transition = std::sync::Arc::new(crate::commands::WorkspaceTransitionState::default()); + let queue = PendingNavigationDeepLinks::default(); + assert_eq!(transition.claim_next(), 1); + + let transition_for_claim = std::sync::Arc::clone(&transition); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (claimed_tx, claimed_rx) = std::sync::mpsc::channel(); + transition + .with_current_generation(|workspace_generation| { + let claim = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + claimed_tx.send(transition_for_claim.claim_next()).unwrap(); + }); + started_rx.recv().unwrap(); + assert!(claimed_rx.try_recv().is_err()); + queue.enqueue(pending_navigation_at( + "queued", + "channel", + "channel-1", + None, + None, + workspace_generation, + )); + claim + }) + .join() + .unwrap(); + + assert_eq!(claimed_rx.recv().unwrap(), 2); + queue.clear_before(2); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_navigation_queue_recovers_after_mutex_poisoning() { + let queue = std::sync::Arc::new(PendingNavigationDeepLinks::default()); + let poisoner = std::sync::Arc::clone(&queue); + assert!(std::thread::spawn(move || { + let _guard = poisoner.0.lock().unwrap(); + panic!("poison queue for recovery regression"); + }) + .join() + .is_err()); + + queue.enqueue(pending_navigation( + "after-poison", + "channel", + "channel-1", + None, + None, + )); + assert_eq!(queue.first().unwrap().id, "after-poison"); + assert!(queue.acknowledge("after-poison")); + assert!(queue.first().is_none()); +} + +#[test] +fn pending_join_serializes_policy_receipt_for_cold_launch_recovery() { + let mut link = pending("join", "wss://relay.example", Some("invite")); + link.policy_receipt = Some("relay-signed-receipt".to_owned()); + + let payload = serde_json::to_value(link).unwrap(); + assert_eq!(payload["policyReceipt"], "relay-signed-receipt"); +} + +#[test] +fn pending_community_links_are_fifo_and_acknowledged_in_order() { + let queue = PendingCommunityDeepLinks::default(); + queue.enqueue(pending("first", "wss://one.example", Some("one"))); + queue.enqueue(pending("second", "wss://two.example", Some("two"))); + assert_eq!(queue.first().unwrap().id, "first"); + assert!(!queue.acknowledge("second")); + assert!(queue.acknowledge("first")); + assert_eq!(queue.first().unwrap().id, "second"); +} + +#[test] +fn pending_community_links_dedupe_exact_intents() { + let queue = PendingCommunityDeepLinks::default(); + queue.enqueue(pending("first", "wss://one.example", Some("one"))); + queue.enqueue(pending("duplicate", "wss://one.example", Some("one"))); + assert!(queue.acknowledge("first")); + assert!(queue.first().is_none()); +} + +fn valid_nostr_bind_url() -> Url { + Url::parse( + "buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard", + ) + .unwrap() +} + +#[test] +fn parse_add_community_deep_link_extracts_relay_and_name() { + let url = Url::parse( + "buzz://add-community?relay=wss%3A%2F%2Facme.communities.buzz.xyz&name=Acme%20Team&ignored=value", + ) + .unwrap(); + let payload = parse_add_community_deep_link(&url).unwrap(); + assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); + assert_eq!(payload.name.as_deref(), Some("Acme Team")); +} + +#[test] +fn parse_add_community_deep_link_accepts_an_omitted_or_empty_name() { + for raw in [ + "buzz://add-community?relay=wss%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2Facme.example&name=", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()) + .unwrap() + .name + .is_none()); + } +} + +#[test] +fn parse_add_community_deep_link_rejects_invalid_relays() { + for raw in [ + "buzz://add-community", + "buzz://add-community?relay=", + "buzz://add-community?relay=not-a-url", + "buzz://add-community?relay=https%3A%2F%2Facme.example", + "buzz://add-community?relay=wss%3A%2F%2F", + ] { + assert!(parse_add_community_deep_link(&Url::parse(raw).unwrap()).is_none()); + } +} + +#[test] +fn parse_channel_deep_link_accepts_one_path_segment() { + let url = Url::parse("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32").unwrap(); + let payload = parse_channel_deep_link(&url).unwrap(); + assert_eq!(payload["channelId"], "580ca78b-9dae-46f3-8854-bd671853ba32"); +} + +#[test] +fn parse_channel_deep_link_accepts_v7_and_normalizes_uppercase() { + for (raw, expected) in [ + ( + "buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", + "018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9", + ), + ( + "buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32", + "580ca78b-9dae-46f3-8854-bd671853ba32", + ), + ] { + let payload = parse_channel_deep_link(&Url::parse(raw).unwrap()).unwrap(); + assert_eq!(payload["channelId"], expected); + } +} + +#[test] +fn parse_channel_deep_link_rejects_malformed_forms() { + for raw in [ + "buzz://channel", + "buzz://channel/", + "buzz://channel/one/two", + "buzz://channel/one?extra=true", + "buzz://channel/one#fragment", + "buzz://channel/not-a-uuid", + "buzz://channel/%2F", + "buzz://channel/%00", + ] { + assert!(parse_channel_deep_link(&Url::parse(raw).unwrap()).is_none()); + } +} + +#[test] +fn parse_message_deep_link_extracts_required_params() { + let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["messageId"], "xyz"); + assert!(payload["threadRootId"].is_null()); +} + +#[test] +fn parse_message_deep_link_accepts_buzz_scheme() { + let url = Url::parse("buzz://message?channel=abc&id=xyz").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["channelId"], "abc"); + assert_eq!(payload["messageId"], "xyz"); +} + +#[test] +fn parse_message_deep_link_includes_thread_root() { + let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=root1").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert_eq!(payload["threadRootId"], "root1"); +} + +#[test] +fn parse_message_deep_link_rejects_missing_id() { + let url = Url::parse("buzz://message?channel=abc").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_rejects_empty_channel() { + // Regression: `channel=&id=foo` previously produced channelId: "". + let url = Url::parse("buzz://message?channel=&id=foo").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_rejects_empty_id() { + let url = Url::parse("buzz://message?channel=abc&id=").unwrap(); + assert!(parse_message_deep_link(&url).is_none()); +} + +#[test] +fn parse_message_deep_link_treats_empty_thread_as_absent() { + let url = Url::parse("buzz://message?channel=abc&id=xyz&thread=").unwrap(); + let payload = parse_message_deep_link(&url).expect("required params present"); + assert!(payload["threadRootId"].is_null()); +} + +#[test] +fn parse_join_deep_link_extracts_relay_and_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def").unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["relayUrl"], "wss://relay.example"); + assert_eq!(payload["code"], "abc.def"); + assert!(payload["policyReceipt"].is_null()); +} + +#[test] +fn parse_join_deep_link_extracts_policy_receipt() { + let url = Url::parse( + "buzz://join?relay=wss%3A%2F%2Frelay.example&code=abc.def&policy_receipt=receipt.value", + ) + .unwrap(); + let payload = parse_join_deep_link(&url).expect("required params present"); + assert_eq!(payload["policyReceipt"], "receipt.value"); +} + +#[test] +fn parse_join_deep_link_rejects_missing_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_empty_code() { + let url = Url::parse("buzz://join?relay=wss%3A%2F%2Frelay.example&code=").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_missing_relay() { + let url = Url::parse("buzz://join?code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_join_deep_link_rejects_non_websocket_relay() { + let url = Url::parse("buzz://join?relay=https%3A%2F%2Frelay.example&code=abc.def").unwrap(); + assert!(parse_join_deep_link(&url).is_none()); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_valid_url() { + let payload = parse_nostr_bind_deep_link(&valid_nostr_bind_url()).unwrap(); + assert_eq!(payload.challenge_id, "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(payload.nonce, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567"); + assert_eq!(payload.verification_code, "123456"); + assert_eq!(payload.audience, "buzz:nostr-identity"); + assert_eq!(payload.action, "bind_nostr_identity"); + assert_eq!(payload.protocol, "buzz-nostr-identity"); + assert_eq!(payload.version, "1"); + assert_eq!(payload.origin, "https://example.com"); + assert_eq!(payload.expires_at, "2999-01-01T00:00:00Z"); + assert_eq!(payload.return_mode, "clipboard"); + assert_eq!(payload.callback_url, None); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_same_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fexample.com%2Fbuzz%3FmockSession%3D1").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz?mockSession=1") + ); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1&callback_url=https%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + + assert_eq!(payload.return_mode, "browser_fragment_v1"); + assert_eq!( + payload.callback_url.as_deref(), + Some("https://example.com/buzz") + ); +} + +#[test] +fn parse_nostr_bind_deep_link_requires_callback_for_browser_fragment_return() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=browser_fragment_v1").unwrap(); + + assert_eq!( + parse_nostr_bind_deep_link(&url).unwrap_err(), + "browser_fragment_v1 requires callback_url" + ); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_cross_origin_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=https%3A%2F%2Fevil.example%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_http_callback_url() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard&callback_url=http%3A%2F%2Fexample.com%2Fbuzz").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_missing_challenge_id() { + let url = Url::parse("buzz://nostr-bind?nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_empty_nonce() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_missing_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_short_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_long_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=1234567&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_non_digit_verification_code() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=12345a&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_wrong_action() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=wrong&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_wrong_audience() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=other&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_non_https_origin() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=http%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_origin_with_path() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com%2Fbind&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_origin_with_credentials() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fuser%40example.com&expires_at=2999-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_rejects_unsupported_return_mode() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2999-01-01T00%3A00%3A00Z&return=callback").unwrap(); + assert!(parse_nostr_bind_deep_link(&url).is_err()); +} + +#[test] +fn parse_nostr_bind_deep_link_accepts_expired_link_for_user_facing_error() { + let url = Url::parse("buzz://nostr-bind?challenge_id=550e8400-e29b-41d4-a716-446655440000&nonce=ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghi01234567&verification_code=123456&audience=buzz%3Anostr-identity&action=bind_nostr_identity&protocol=buzz-nostr-identity&version=1&origin=https%3A%2F%2Fexample.com&expires_at=2000-01-01T00%3A00%3A00Z&return=clipboard").unwrap(); + let payload = parse_nostr_bind_deep_link(&url).unwrap(); + assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8e6..5c7dd53f763 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -49,8 +49,9 @@ use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; use commands::*; use deep_link::{ - acknowledge_pending_community_deep_link, handle_deep_link_url, - take_pending_community_deep_link, PendingCommunityDeepLinks, + acknowledge_pending_community_deep_link, acknowledge_pending_navigation_deep_link, + clear_pending_navigation_deep_links, handle_deep_link_url, take_pending_community_deep_link, + take_pending_navigation_deep_link, PendingCommunityDeepLinks, PendingNavigationDeepLinks, }; use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, @@ -291,7 +292,6 @@ pub fn run() { } else { builder.plugin(tauri_plugin_updater::Builder::new().build()) }; - let app = app_menu::install(builder) .register_asynchronous_uri_scheme_protocol("buzz-media", |ctx, request, responder| { let app = ctx.app_handle().clone(); @@ -303,6 +303,7 @@ pub fn run() { .manage(build_app_state()) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) + .manage(PendingNavigationDeepLinks::default()) .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) @@ -615,6 +616,9 @@ pub fn run() { terminal_runtime::terminal_focus, take_pending_community_deep_link, acknowledge_pending_community_deep_link, + take_pending_navigation_deep_link, + acknowledge_pending_navigation_deep_link, + clear_pending_navigation_deep_links, start_builderlab_login, cancel_builderlab_login, get_builderlab_auth, @@ -882,6 +886,7 @@ pub fn run() { start_identity_recovery_pairing, confirm_pairing_sas, cancel_pairing, + claim_workspace_transition, apply_workspace, validate_repos_dir, get_active_workspace, diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 25dadbeec60..767fdad21a6 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -94,12 +94,17 @@ pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> pub async fn restore_managed_agents_on_launch( app: &tauri::AppHandle, shutdown_started: &AtomicBool, + owner: crate::commands::WorkspaceTransitionOwner<'_>, ) -> Result<(), String> { - if shutdown_started.load(Ordering::SeqCst) { + if shutdown_started.load(Ordering::SeqCst) || !owner.is_current() { return Ok(()); } let state = app.state::(); + // Capture the winning workspace relay once. Every candidate key and spawn + // in this restore is resolved from this immutable snapshot; mutable global + // workspace state is never consulted after an await or supersession. + let workspace_relay = crate::relay::relay_ws_url_with_override(&state); // ── Phase A (under lock): housekeeping + collect agents to restore ── let mut agents_to_start: Vec; @@ -112,6 +117,10 @@ pub async fn restore_managed_agents_on_launch( if shutdown_started.load(Ordering::SeqCst) { return Ok(()); } + let _phase_a_owner = match owner.lock_if_current() { + Some(guard) => guard, + None => return Ok(()), + }; let mut records = load_managed_agents(app)?; let mut runtimes = state @@ -173,23 +182,46 @@ pub async fn restore_managed_agents_on_launch( let mut to_start = Vec::new(); for pubkey in &candidates { - if let Some(runtime) = runtimes - .iter_mut() - .find(|(key, _)| key.pubkey == *pubkey) - .map(|(_, runtime)| runtime) - { - if runtime.child.try_wait().ok().flatten().is_none() { + let Some(record) = records + .iter() + .find(|record| record.pubkey == *pubkey) + .cloned() + else { + continue; + }; + let expected_key = { + let relay_url = + crate::relay::effective_agent_relay_url(&record.relay_url, &workspace_relay); + super::ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url).ok() + }; + let stale_keys: Vec<_> = runtimes + .keys() + .filter(|key| key.pubkey == *pubkey && Some(*key) != expected_key.as_ref()) + .cloned() + .collect(); + if !stale_keys.is_empty() { + let Some(record) = records.iter_mut().find(|record| record.pubkey == *pubkey) + else { continue; + }; + for stale_key in stale_keys { + super::stop_managed_agent_pair(app, record, &mut runtimes, &stale_key)?; } + changed = true; + } + if expected_key.as_ref().is_some_and(|key| { + runtimes + .get_mut(key) + .is_some_and(|runtime| runtime.child.try_wait().ok().flatten().is_none()) + }) { + continue; } - if let Some(record) = records.iter().find(|r| r.pubkey == *pubkey) { - if let Some(pid) = record.runtime_pid { - if super::process_is_running(pid) { - continue; - } + if let Some(pid) = record.runtime_pid { + if super::process_is_running(pid) { + continue; } - to_start.push(record.clone()); } + to_start.push(record.clone()); } agents_to_start = to_start; @@ -284,23 +316,22 @@ pub async fn restore_managed_agents_on_launch( .managed_agent_runtime_transition .lock() .map_err(|error| error.to_string())?; - if shutdown_started.load(Ordering::SeqCst) { + if shutdown_started.load(Ordering::SeqCst) || !owner.is_current() { return Ok(()); } // ── Phase B (transition lock held): resolve commands and spawn in parallel ── let spawn_results: Vec = std::thread::scope(|scope| { let owner_hex_ref = owner_hex.as_deref(); + let workspace_relay_ref = workspace_relay.as_str(); let handles: Vec<_> = agents_to_start .iter() - .filter(|_| !shutdown_started.load(Ordering::SeqCst)) + .filter(|_| !shutdown_started.load(Ordering::SeqCst) && owner.is_current()) .map(|record| { let handle = scope.spawn(move || { - let workspace_relay = - crate::relay::relay_ws_url_with_override(&app.state::()); let relay_url = crate::relay::effective_agent_relay_url( &record.relay_url, - &workspace_relay, + workspace_relay_ref, ); let outcome = match super::ManagedAgentRuntimeKey::new(record.pubkey.clone(), &relay_url) @@ -321,24 +352,30 @@ pub async fn restore_managed_agents_on_launch( }) }) .unwrap_or(false); - if already_live { + if !owner.is_current() || already_live { SpawnOutcome::Skipped } else { - match super::terminate_untracked_pair_runtime(app, &key) - .and_then(|()| { - // F1: restore spawns lazy, matching - // reconcile and manual start. Eager on - // restore buys nothing — a crashed - // mid-turn session is not resumed by an - // eager child — and silently reintroduces - // N idle brains on every launch. - spawn_agent_child( - app, - record, - &key.relay_url, - true, - owner_hex_ref, - ) + match owner + .while_current(|| { + super::terminate_untracked_pair_runtime(app, &key) + .and_then(|()| { + // F1: restore spawns lazy, matching + // reconcile and manual start. Eager on + // restore buys nothing — a crashed + // mid-turn session is not resumed by an + // eager child — and silently reintroduces + // N idle brains on every launch. + spawn_agent_child( + app, + record, + &key.relay_url, + true, + owner_hex_ref, + ) + }) + }) + .unwrap_or_else(|| { + Err("workspace restore superseded".into()) }) { Ok(process) => { SpawnOutcome::Spawned(key, Box::new(process)) @@ -376,6 +413,13 @@ pub async fn restore_managed_agents_on_launch( let mut successfully_spawned: Vec = Vec::new(); for (pubkey, outcome) in spawn_results { + let Some(_install_owner) = owner.lock_if_current() else { + if let SpawnOutcome::Spawned(_, mut process) = outcome { + let _ = super::terminate_process(process.child.id()); + let _ = process.child.wait(); + } + continue; + }; match outcome { // Skipped means a concurrent reconcile already owns a live child for // this pair; leave its runtime and record state untouched. @@ -456,13 +500,21 @@ pub async fn restore_managed_agents_on_launch( // ── Profile reconciliation (fire-and-forget) ──────────────────────────── // Spawn background tasks to ensure each restored agent's kind:0 profile is // published on the relay. Same pattern as the UI start path. + let reconcile_generation = owner.generation(); for (pubkey, data) in reconcile_items { let reconcile_app = app.clone(); + let reconcile_workspace_relay = workspace_relay.clone(); tauri::async_runtime::spawn(async move { let state = reconcile_app.state::(); - if let Err(e) = - crate::commands::reconcile_agent_profile(&state, &reconcile_app, &pubkey, &data) - .await + if let Err(e) = crate::commands::agents::profile::reconcile_agent_profile_for_workspace( + &state, + &reconcile_app, + &pubkey, + &data, + &reconcile_workspace_relay, + Some(reconcile_generation), + ) + .await { eprintln!("buzz-desktop: profile reconciliation failed for agent {pubkey}: {e}"); } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b1c342e9955..86274633fd0 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -27,7 +27,7 @@ pub(crate) use metadata::{ }; mod stop; -pub(crate) use stop::managed_agent_runtime_keys; +pub(crate) use stop::{managed_agent_runtime_keys, stop_managed_agent_pair}; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; diff --git a/desktop/src-tauri/src/managed_agents/runtime/stop.rs b/desktop/src-tauri/src/managed_agents/runtime/stop.rs index 08bca15febb..df3fc45e572 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/stop.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/stop.rs @@ -37,7 +37,7 @@ pub(crate) fn managed_agent_runtime_relay_urls( /// runtime is reinserted so the pair stays visible and stoppable instead of /// becoming an invisible orphan. Touches no other pair for the agent and /// does no record-level stop bookkeeping — callers own that. -fn stop_managed_agent_pair( +pub(crate) fn stop_managed_agent_pair( app: &AppHandle, record: &mut ManagedAgentRecord, runtimes: &mut HashMap, diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 0c27ab0541f..b4d524bdb5b 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -5,16 +5,20 @@ import { isMacPlatform } from "@/shared/lib/platform"; import { relayClient } from "@/shared/api/relayClient"; import { resetRateLimitGate } from "@/shared/api/relayRateLimitGate"; import { - applyCommunity, autoConnectDefaultRelayEnabled, getDefaultRelayUrl, } from "@/shared/api/tauri"; +import { + applyCommunity, + claimWorkspaceTransition, +} from "@/shared/api/tauriWorkspace"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { clearTrayAgentActivity } from "@/shared/api/trayMenu"; import { getOverrides } from "@/shared/features"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { resetLinkPreviewMetadataCache } from "@/shared/lib/useResolvedLinkPreviews"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; +import { resetNavigationDeepLinkDrain } from "@/shared/deep-link"; import { clearAllDrafts, initDraftStore, @@ -47,11 +51,13 @@ import type { Community } from "./types"; * destroyed via effect cleanup and do not need entries here. * See AGENTS.md "Community Switching" for the full contract. */ -function resetCommunityState({ +async function resetCommunityState({ resetAvatarState, + transitionGeneration, }: { resetAvatarState: boolean; -}): void { + transitionGeneration: number; +}): Promise { relayClient.disconnect(); resetRateLimitGate(); clearAllDrafts(); @@ -73,6 +79,7 @@ function resetCommunityState({ resetBackgroundMediaUploads(); clearSearchHitEventCache(); clearMarkdownNodeCache(); + await resetNavigationDeepLinkDrain(transitionGeneration); } type CommunityInitResult = @@ -116,19 +123,30 @@ export function useCommunityInit( // same-relay reconnect during onboarding must not cancel that work, while an // actual relay boundary must clear both the queue and its presentation probe. const appliedRelayUrlRef = useRef(null); + // Rust issues process-lifetime monotonic ownership tokens. React mounts and + // webview reloads do not share a lifetime with the native authority. // biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token/reposDir) — depending on the whole object would trigger resets on name-only changes useEffect(() => { let cancelled = false; async function init() { + // Acquire ownership from the process-lifetime Rust authority before any + // teardown await. A webview reload therefore cannot restart at token 1. + const transitionGeneration = await claimWorkspaceTransition(); + if (cancelled) return; + if (!activeCommunity) { if (hasInitializedRef.current) { if (prevCommunityIdRef.current) { saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current); prevCommunityIdRef.current = null; } - resetCommunityState({ resetAvatarState: true }); + await resetCommunityState({ + resetAvatarState: true, + transitionGeneration, + }); + if (cancelled) return; appliedRelayUrlRef.current = null; hasInitializedRef.current = false; } @@ -207,10 +225,15 @@ export function useCommunityInit( // store under the outgoing community ID and delete its snapshot. prevCommunityIdRef.current = null; } - resetCommunityState({ + await resetCommunityState({ resetAvatarState: appliedRelayUrlRef.current !== activeCommunity.relayUrl, + transitionGeneration, }); + // The native queue clear is asynchronous. A newer community can + // supersede this effect while it is pending; never let the stale run + // claim shared refs or apply its backend configuration afterward. + if (cancelled) return; } hasInitializedRef.current = true; appliedRelayUrlRef.current = activeCommunity.relayUrl; @@ -231,6 +254,7 @@ export function useCommunityInit( activeCommunity.token, activeCommunity.reposDir, getOverrides().agentManagedProfiles === true, + transitionGeneration, ); } catch (error) { // A bad `repos_dir` no longer reaches here — `apply_workspace` treats diff --git a/desktop/src/features/messages/lib/channelLink.test.mjs b/desktop/src/features/messages/lib/channelLink.test.mjs new file mode 100644 index 00000000000..7f51a39cefa --- /dev/null +++ b/desktop/src/features/messages/lib/channelLink.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { isChannelLink, parseChannelLink } from "./channelLink.ts"; + +test("parseChannelLink accepts the canonical channel path", () => { + assert.deepEqual( + parseChannelLink("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32"), + { + ok: true, + value: { channelId: "580ca78b-9dae-46f3-8854-bd671853ba32" }, + }, + ); +}); + +test("parseChannelLink accepts v7 and canonicalizes uppercase UUIDs", () => { + assert.deepEqual( + parseChannelLink("buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9"), + { + ok: true, + value: { channelId: "018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9" }, + }, + ); + assert.deepEqual( + parseChannelLink("buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32"), + { + ok: true, + value: { channelId: "580ca78b-9dae-46f3-8854-bd671853ba32" }, + }, + ); +}); + +test("parseChannelLink rejects malformed channel links", () => { + for (const href of [ + "buzz://channel", + "buzz://channel/", + "buzz://channel/one/two", + "buzz://channel/one?extra=true", + "buzz://channel/one#fragment", + "https://channel/one", + "buzz://channel/not-a-uuid", + "buzz://channel/%", + "buzz://channel/%ZZ", + "buzz://channel/%2F", + "buzz://channel/%00", + ]) { + assert.equal(parseChannelLink(href).ok, false, href); + } +}); + +test("isChannelLink recognizes only a valid canonical link", () => { + assert.equal( + isChannelLink("buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32"), + true, + ); + assert.equal( + isChannelLink("buzz://message?channel=channel-1&id=message-1"), + false, + ); +}); diff --git a/desktop/src/features/messages/lib/channelLink.ts b/desktop/src/features/messages/lib/channelLink.ts new file mode 100644 index 00000000000..42cecadb4d3 --- /dev/null +++ b/desktop/src/features/messages/lib/channelLink.ts @@ -0,0 +1,48 @@ +/** `buzz://channel/` link encoding and parsing. */ + +const CHANNEL_LINK_SCHEME = "buzz:"; +const CHANNEL_LINK_HOST = "channel"; +const CHANNEL_UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +export type ParsedChannelLink = { channelId: string }; + +export type ChannelLinkParseResult = + | { ok: true; value: ParsedChannelLink } + | { ok: false; reason: string }; + +export function parseChannelLink(url: string): ChannelLinkParseResult { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, reason: "invalid-url" }; + } + if (parsed.protocol !== CHANNEL_LINK_SCHEME) { + return { ok: false, reason: "wrong-scheme" }; + } + if (parsed.hostname !== CHANNEL_LINK_HOST) { + return { ok: false, reason: "wrong-host" }; + } + if (parsed.search || parsed.hash || parsed.username || parsed.password) { + return { ok: false, reason: "unexpected-components" }; + } + const segments = parsed.pathname.split("/").filter(Boolean); + if (segments.length !== 1) { + return { ok: false, reason: "missing-or-extra-channel" }; + } + let channelId: string; + try { + channelId = decodeURIComponent(segments[0]); + } catch { + return { ok: false, reason: "invalid-channel-encoding" }; + } + if (!CHANNEL_UUID_PATTERN.test(channelId)) { + return { ok: false, reason: "invalid-channel-uuid" }; + } + return { ok: true, value: { channelId: channelId.toLowerCase() } }; +} + +export function isChannelLink(href: string | undefined | null): boolean { + return href ? parseChannelLink(href).ok : false; +} diff --git a/desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs b/desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs new file mode 100644 index 00000000000..ce45d2fa549 --- /dev/null +++ b/desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import remarkChannelDeepLinks from "./remarkChannelDeepLinks.ts"; + +function run(value) { + const tree = { + type: "root", + children: [{ type: "paragraph", children: [{ type: "text", value }] }], + }; + remarkChannelDeepLinks()(tree); + return tree.children[0].children; +} + +test("turns a bare channel deep link into a custom node", () => { + const children = run( + "Open buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32 now", + ); + assert.equal(children[1].type, "channel-deep-link"); + assert.equal( + children[1].value, + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32", + ); +}); + +test("peels trailing sentence punctuation", () => { + const children = run( + "Open buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32.", + ); + assert.equal( + children[1].value, + "buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32", + ); + assert.equal(children[2].value, "."); +}); diff --git a/desktop/src/features/messages/lib/remarkChannelDeepLinks.ts b/desktop/src/features/messages/lib/remarkChannelDeepLinks.ts new file mode 100644 index 00000000000..efafec770e3 --- /dev/null +++ b/desktop/src/features/messages/lib/remarkChannelDeepLinks.ts @@ -0,0 +1,22 @@ +/** Detect bare `buzz://channel/` URLs in markdown text nodes. */ +import { createRemarkPrefixPlugin } from "../../../shared/lib/createRemarkPrefixPlugin.ts"; + +const CHANNEL_URL_PATTERN = /buzz:\/\/channel\/[^\s<>"')\]]+/g; +const TRAILING_PUNCTUATION_PATTERN = /[.,;:!?]+$/; + +export default function remarkChannelDeepLinks() { + return createRemarkPrefixPlugin(CHANNEL_URL_PATTERN, (matchText) => { + const value = matchText.replace(TRAILING_PUNCTUATION_PATTERN, ""); + return { + node: { + type: "channel-deep-link", + value, + data: { + hName: "channel-deep-link", + hChildren: [{ type: "text", value }], + }, + }, + trailing: matchText.slice(value.length), + }; + }); +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 8eb626a81dd..7fba52a0e63 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1118,22 +1118,6 @@ export async function cancelPairing(): Promise { await invokeTauri("cancel_pairing"); } -export async function applyCommunity( - relayUrl: string, - nsec?: string, - token?: string, - reposDir?: string, - agentManagedProfiles?: boolean, -): Promise { - await invokeTauri("apply_workspace", { - relayUrl, - nsec: nsec ?? null, - token: token ?? null, - reposDir: reposDir ?? null, - agentManagedProfiles: agentManagedProfiles ?? false, - }); -} - // Validate a candidate repos dir without mutating the filesystem. Rejects // with a human-readable reason; resolves for a valid or empty path. export async function validateReposDir(dir: string): Promise { diff --git a/desktop/src/shared/api/tauriWorkspace.ts b/desktop/src/shared/api/tauriWorkspace.ts new file mode 100644 index 00000000000..8a845d26d75 --- /dev/null +++ b/desktop/src/shared/api/tauriWorkspace.ts @@ -0,0 +1,23 @@ +import { invokeTauri } from "./tauri"; + +export async function claimWorkspaceTransition(): Promise { + return await invokeTauri("claim_workspace_transition"); +} + +export async function applyCommunity( + relayUrl: string, + nsec: string | undefined, + token: string | undefined, + reposDir: string | undefined, + agentManagedProfiles: boolean | undefined, + transitionGeneration: number, +): Promise { + await invokeTauri("apply_workspace", { + relayUrl, + nsec: nsec ?? null, + token: token ?? null, + reposDir: reposDir ?? null, + agentManagedProfiles: agentManagedProfiles ?? false, + transitionGeneration, + }); +} diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs new file mode 100644 index 00000000000..4b59a897af4 --- /dev/null +++ b/desktop/src/shared/deep-link.test.mjs @@ -0,0 +1,439 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +const ipcHandlers = new Map(); +let nextCallbackId = 1; +const callbacks = new Map(); + +const tauriInternals = { + invoke: (cmd, args) => { + const handler = ipcHandlers.get(cmd); + if (handler) return Promise.resolve(handler(args)); + return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`)); + }, + transformCallback: (callback) => { + const id = nextCallbackId++; + callbacks.set(id, callback); + return id; + }, +}; +globalThis.window = { + __TAURI_INTERNALS__: tauriInternals, + __TAURI_EVENT_PLUGIN_INTERNALS__: { unregisterListener: () => {} }, +}; +globalThis.__TAURI_INTERNALS__ = tauriInternals; + +const { listenForNavigationDeepLinks, resetNavigationDeepLinkDrain } = + await import("@/shared/deep-link.ts"); + +function deferred() { + let resolve; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +afterEach(() => { + ipcHandlers.clear(); + callbacks.clear(); +}); + +test("listener teardown leaves an unaccepted FIFO item for the next mount", async () => { + const queue = [ + { + id: "first", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }, + { + id: "second", + kind: "message", + channelId: "channel-2", + messageId: "message-2", + threadRootId: "root-2", + }, + ]; + const firstAcknowledge = deferred(); + const acknowledged = []; + let unlistenCount = 0; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => { + unlistenCount += 1; + }); + ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null); + ipcHandlers.set( + "acknowledge_pending_navigation_deep_link", + async ({ id }) => { + if (id === "first") await firstAcknowledge.promise; + assert.equal(queue[0]?.id, id); + acknowledged.push(id); + queue.shift(); + return true; + }, + ); + + let firstMountActive = true; + const firstOpened = []; + const firstUnlisten = await listenForNavigationDeepLinks( + (payload) => { + if (!firstMountActive) return false; + firstOpened.push(payload.channelId); + return true; + }, + (payload) => { + if (!firstMountActive) return false; + firstOpened.push(payload.messageId); + return true; + }, + ); + await settle(); + assert.deepEqual(firstOpened, ["channel-1"]); + + firstMountActive = false; + firstUnlisten(); + firstAcknowledge.resolve(); + await settle(); + + assert.deepEqual(acknowledged, ["first"]); + assert.equal(queue[0]?.id, "second"); + + const secondOpened = []; + const secondUnlisten = await listenForNavigationDeepLinks( + (payload) => { + secondOpened.push(payload.channelId); + return true; + }, + (payload) => { + secondOpened.push(payload.messageId); + return true; + }, + ); + await settle(); + + assert.deepEqual(secondOpened, ["message-2"]); + assert.deepEqual(acknowledged, ["first", "second"]); + assert.equal(queue.length, 0); + secondUnlisten(); + assert.equal(unlistenCount, 4); +}); + +test("concurrent listener remount does not take or acknowledge the in-flight head twice", async () => { + const queue = [ + { + id: "in-flight", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }, + ]; + const acknowledgeGate = deferred(); + const opened = []; + const acknowledged = []; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("take_pending_navigation_deep_link", () => queue[0] ?? null); + ipcHandlers.set( + "acknowledge_pending_navigation_deep_link", + async ({ id }) => { + acknowledged.push(id); + await acknowledgeGate.promise; + assert.equal(queue[0]?.id, id); + queue.shift(); + return true; + }, + ); + + const firstUnlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(`first:${payload.channelId}`); + return true; + }, + () => true, + ); + await settle(); + assert.deepEqual(opened, ["first:channel-1"]); + assert.deepEqual(acknowledged, ["in-flight"]); + + firstUnlisten(); + const secondUnlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(`second:${payload.channelId}`); + return true; + }, + () => true, + ); + await settle(); + + assert.deepEqual(opened, ["first:channel-1"]); + assert.deepEqual(acknowledged, ["in-flight"]); + + acknowledgeGate.resolve(); + await settle(); + await settle(); + + assert.deepEqual(opened, ["first:channel-1"]); + assert.deepEqual(acknowledged, ["in-flight"]); + assert.equal(queue.length, 0); + secondUnlisten(); +}); + +test("community reset prevents an in-flight route from acknowledging", async () => { + const pending = { + id: "old-community", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }; + const routeGate = deferred(); + let acknowledgeCount = 0; + let clearCount = 0; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("clear_pending_navigation_deep_links", () => { + clearCount += 1; + }); + ipcHandlers.set("take_pending_navigation_deep_link", () => pending); + ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => { + acknowledgeCount += 1; + return true; + }); + + const unlisten = await listenForNavigationDeepLinks( + async () => { + await routeGate.promise; + return true; + }, + () => true, + ); + await settle(); + + await resetNavigationDeepLinkDrain(2); + routeGate.resolve(); + await settle(); + + assert.equal(clearCount, 1); + assert.equal(acknowledgeCount, 0); + unlisten(); +}); + +test("community reset after take does not route the stale item", async () => { + const takeGate = deferred(); + const opened = []; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("clear_pending_navigation_deep_links", () => {}); + ipcHandlers.set("take_pending_navigation_deep_link", async () => { + await takeGate.promise; + return { + id: "old-community", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }; + }); + ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => true); + + const unlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(payload.channelId); + return true; + }, + () => true, + ); + await settle(); + + await resetNavigationDeepLinkDrain(2); + takeGate.resolve(); + await settle(); + + assert.deepEqual(opened, []); + unlisten(); +}); + +test("community reset stops the stale drain before taking another item", async () => { + const queue = [ + { + id: "first", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }, + { + id: "second", + kind: "channel", + channelId: "channel-2", + messageId: null, + threadRootId: null, + }, + ]; + const opened = []; + let takeCount = 0; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("clear_pending_navigation_deep_links", () => { + queue.length = 0; + }); + ipcHandlers.set("take_pending_navigation_deep_link", () => { + takeCount += 1; + return queue[0] ?? null; + }); + ipcHandlers.set( + "acknowledge_pending_navigation_deep_link", + async ({ id }) => { + assert.equal(queue[0]?.id, id); + queue.shift(); + await resetNavigationDeepLinkDrain(2); + return true; + }, + ); + + const unlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(payload.channelId); + return true; + }, + () => true, + ); + await settle(); + await settle(); + + assert.deepEqual(opened, ["channel-1"]); + assert.equal(takeCount, 1); + unlisten(); +}); + +test("community reset detaches a new drain from a pending stale route", async () => { + const oldPending = { + id: "old-community", + kind: "channel", + channelId: "channel-old", + messageId: null, + threadRootId: null, + }; + const newPending = { + id: "new-community", + kind: "channel", + channelId: "channel-new", + messageId: null, + threadRootId: null, + }; + const staleRouteGate = deferred(); + let activePending = oldPending; + const opened = []; + const acknowledged = []; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("clear_pending_navigation_deep_links", () => { + activePending = newPending; + }); + ipcHandlers.set("take_pending_navigation_deep_link", () => activePending); + ipcHandlers.set("acknowledge_pending_navigation_deep_link", ({ id }) => { + acknowledged.push(id); + if (activePending?.id === id) activePending = null; + return true; + }); + + const oldUnlisten = await listenForNavigationDeepLinks( + async (payload) => { + opened.push(`old:${payload.channelId}`); + await staleRouteGate.promise; + return true; + }, + () => true, + ); + await settle(); + assert.deepEqual(opened, ["old:channel-old"]); + + await resetNavigationDeepLinkDrain(2); + oldUnlisten(); + const newUnlisten = await listenForNavigationDeepLinks( + (payload) => { + opened.push(`new:${payload.channelId}`); + return true; + }, + () => true, + ); + await settle(); + await settle(); + + assert.deepEqual(opened, ["old:channel-old", "new:channel-new"]); + assert.deepEqual(acknowledged, ["new-community"]); + + staleRouteGate.resolve(); + await settle(); + assert.deepEqual(acknowledged, ["new-community"]); + newUnlisten(); +}); + +test("community reset tolerates native queue clear rejection", async () => { + const warnings = []; + const originalWarn = console.warn; + ipcHandlers.set("clear_pending_navigation_deep_links", () => { + throw new Error("clear failed"); + }); + console.warn = (...args) => warnings.push(args); + + try { + await resetNavigationDeepLinkDrain(2); + assert.equal(warnings.length, 1); + assert.match(String(warnings[0][1]), /clear failed/); + } finally { + console.warn = originalWarn; + } +}); + +test("rejected navigation remains queued and is not acknowledged", async () => { + const pending = { + id: "retry-me", + kind: "channel", + channelId: "channel-1", + messageId: null, + threadRootId: null, + }; + let acknowledgeCount = 0; + const warnings = []; + const originalWarn = console.warn; + + ipcHandlers.set("plugin:event|listen", () => nextCallbackId); + ipcHandlers.set("plugin:event|unlisten", () => {}); + ipcHandlers.set("take_pending_navigation_deep_link", () => pending); + ipcHandlers.set("acknowledge_pending_navigation_deep_link", () => { + acknowledgeCount += 1; + return true; + }); + console.warn = (...args) => warnings.push(args); + + try { + const unlisten = await listenForNavigationDeepLinks( + async () => { + throw new Error("route failed"); + }, + async () => true, + ); + await settle(); + + assert.equal(acknowledgeCount, 0); + assert.equal(warnings.length, 1); + assert.match(String(warnings[0][1]), /route failed/); + unlisten(); + } finally { + console.warn = originalWarn; + } +}); diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index c62a8bec3ba..df2d762d9c4 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -15,6 +15,8 @@ export interface DeepLinkDeps { onAddCommunityAvailable: (listener: () => void) => () => void; } +export type ChannelDeepLinkPayload = { channelId: string }; + /** * Payload emitted by the Rust deep-link handler for `buzz://message?…`. * Field names match the JSON shape produced in `desktop/src-tauri/src/lib.rs`. @@ -25,6 +27,15 @@ export type MessageDeepLinkPayload = { threadRootId: string | null; }; +type PendingNavigationDeepLink = { + id: string; + kind: "channel" | "message"; + channelId: string; + messageId: string | null; + threadRootId: string | null; + workspaceGeneration: number; +}; + export type NostrBindDeepLinkPayload = { challengeId: string; nonce: string; @@ -152,17 +163,113 @@ export async function listenForDeepLinks( }; } +let navigationDrainTail: Promise = Promise.resolve(); +let navigationDrainGeneration = 0; + +export async function resetNavigationDeepLinkDrain( + workspaceGeneration: number, +): Promise { + navigationDrainGeneration += 1; + // Drains from the previous community may be waiting indefinitely for an old + // router transition. Detach the new generation from that serialization tail; + // generation checks keep the superseded drain from acknowledging afterward. + navigationDrainTail = Promise.resolve(); + try { + await invoke("clear_pending_navigation_deep_links", { + workspaceGeneration, + }); + } catch (error: unknown) { + // A community switch must not strand the app behind its loading gate if + // the best-effort native queue cleanup is unavailable. The generation + // bump above still prevents in-flight JavaScript drains from acknowledging. + console.warn("Failed to clear pending navigation deep links", error); + } +} + +function serializeNavigationDrain(task: () => Promise): Promise { + const drain = navigationDrainTail.then(task, task); + // Keep the shared tail fulfilled so one route failure cannot poison future + // listener mounts. The caller still receives `drain` and reports the error. + navigationDrainTail = drain.catch(() => {}); + return drain; +} + +async function drainPendingNavigationDeepLinks( + onOpenChannel: ( + payload: ChannelDeepLinkPayload, + ) => boolean | Promise, + onOpenMessage: ( + payload: MessageDeepLinkPayload, + ) => boolean | Promise, +) { + const generation = navigationDrainGeneration; + while (generation === navigationDrainGeneration) { + const pending = await invoke( + "take_pending_navigation_deep_link", + ); + if (!pending || generation !== navigationDrainGeneration) return; + const accepted = await (pending.kind === "channel" + ? onOpenChannel({ channelId: pending.channelId }) + : pending.messageId + ? onOpenMessage({ + channelId: pending.channelId, + messageId: pending.messageId, + threadRootId: pending.threadRootId, + }) + : false); + if (!accepted || generation !== navigationDrainGeneration) return; + const acknowledged = await invoke( + "acknowledge_pending_navigation_deep_link", + { id: pending.id }, + ); + if (!acknowledged) return; + } +} + /** - * Register a listener for `deep-link-message` events. Must be called from - * inside the router tree (e.g. AppShell) because the navigation callback - * uses TanStack Router state. + * Register listeners for queued channel/message navigation emitted by Rust. + * A consumer must explicitly accept each item before it is acknowledged, so + * effect teardown leaves an in-flight queue head available for the next mount. */ -export function listenForMessageDeepLinks( - onOpen: (payload: MessageDeepLinkPayload) => void, +export async function listenForNavigationDeepLinks( + onOpenChannel: ( + payload: ChannelDeepLinkPayload, + ) => boolean | Promise, + onOpenMessage: ( + payload: MessageDeepLinkPayload, + ) => boolean | Promise, ): Promise { - return listen("deep-link-message", (event) => { - onOpen(event.payload); - }); + let drainRunning = false; + let drainRequested = false; + const drain = () => { + drainRequested = true; + if (drainRunning) return; + drainRunning = true; + void (async () => { + try { + while (drainRequested) { + drainRequested = false; + await serializeNavigationDrain(() => + drainPendingNavigationDeepLinks(onOpenChannel, onOpenMessage), + ); + } + } catch (error: unknown) { + console.warn("Failed to drain pending navigation deep links", error); + } finally { + drainRunning = false; + if (drainRequested) drain(); + } + })(); + }; + + const unlistens = await Promise.all([ + listen("deep-link-channel", drain), + listen("deep-link-message", drain), + ]); + drain(); + return () => { + for (const unlisten of unlistens) unlisten(); + }; } export function listenForNostrBindDeepLinks( diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 08168c10514..a939910e304 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -534,6 +534,7 @@ import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; +import { isChannelLink } from "../../features/messages/lib/channelLink.ts"; import { isMessageLink } from "../../features/messages/lib/messageLink.ts"; import { parseEntityLink } from "../lib/entityLink.ts"; import remarkSpoilers from "../lib/remarkSpoilers.ts"; @@ -545,7 +546,7 @@ const EVENT_HEX = function buzzDeepLinkUrlTransform(value, key) { if (key !== "href") return defaultUrlTransform(value); - if (isMessageLink(value)) return value; + if (isMessageLink(value) || isChannelLink(value)) return value; if (parseEntityLink(value).ok) return value; return defaultUrlTransform(value); } @@ -580,6 +581,23 @@ test("messageLinkUrlTransform: preserves buzz://message href with thread", () => assert.match(html, /href="buzz:\/\/message\?[^"]*thread=t1"/); }); +test("messageLinkUrlTransform: preserves buzz://channel href", () => { + const html = renderMarkdown( + "Click [here](buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32)", + ); + assert.match( + html, + /href="buzz:\/\/channel\/580ca78b-9dae-46f3-8854-bd671853ba32"/, + ); +}); + +test("messageLinkUrlTransform: rejects malformed buzz://channel href", () => { + const html = renderMarkdown( + "Click [here](buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32?extra=true)", + ); + assert.match(html, /href=""/); +}); + test("messageLinkUrlTransform: still strips javascript: scheme", () => { const html = renderMarkdown("[xss](javascript:alert(1))"); // defaultUrlTransform replaces unsafe schemes with the empty string. diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 433a8ce6e1c..7c694e4fcc8 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -13,6 +13,7 @@ import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { requestOpenSnapshotImport } from "@/features/agents/openSnapshotImportFromUrlEvent"; +import { parseChannelLink } from "@/features/messages/lib/channelLink"; import { parseMessageLink, resolveMessageLinkRenderTarget, @@ -61,6 +62,11 @@ import { } from "./markdown/entityLinks"; import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor"; import { FileCard } from "./markdown/FileCard"; +import { + ChannelDeepLinkAnchor, + MarkdownChannelDeepLink, + MarkdownChannelReference, +} from "./markdown/ChannelDeepLink"; import { InlineEmojiPopover } from "./markdown/InlineEmojiPopover"; import { createLinkPreviewImageLightbox } from "./markdown/LinkPreviewImageLightbox"; import { MarkdownInput } from "./markdown/MarkdownInput"; @@ -1348,10 +1354,16 @@ function createMarkdownComponents( ); } - // Intercept `buzz://message?channel=…&id=…` links so a click navigates - // in-app instead of opening the URL in the OS browser. http(s) links - // continue to use the existing target="_blank" behavior. + // Intercept `buzz://channel/` and `buzz://message?...` links so + // clicks navigate in-app instead of opening the URL in the OS browser. if (href) { + if (parseChannelLink(href).ok) { + return ( + + {children} + + ); + } const messageLinkTarget = resolveMessageLinkRenderTarget({ href, label, @@ -1661,46 +1673,16 @@ function createMarkdownComponents( } return ; }, - "channel-link": function MarkdownChannelLink({ - children, - }: { - children?: React.ReactNode; - }) { - const { channels, onOpenChannel } = useMarkdownRuntime(); - const text = String(children ?? ""); - const channelName = text.startsWith("#") ? text.slice(1) : text; - const channel = channels.find( - (c) => - c.channelType !== "dm" && - c.name.toLowerCase() === channelName.toLowerCase(), - ); - - if (channel && interactive) { - return ( - - ); - } - - return ( - - {children} - - ); - }, + "channel-deep-link": ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), + "channel-link": ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ), "message-link": function MarkdownMessageLink({ children, }: { diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx new file mode 100644 index 00000000000..6d11bf604e4 --- /dev/null +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx @@ -0,0 +1,94 @@ +import type * as React from "react"; + +import { parseChannelLink } from "@/features/messages/lib/channelLink"; + +import { useMarkdownRuntime } from "./runtimeContext"; + +const CHANNEL_LINK_CLASSES = + "font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80 cursor-pointer"; + +export function ChannelDeepLinkAnchor({ + children, + href, + ...props +}: React.ComponentPropsWithoutRef<"a">) { + const { onOpenChannel } = useMarkdownRuntime(); + if (!href) return <>{children}; + const parsed = parseChannelLink(href); + if (!parsed.ok) return <>{children}; + return ( + { + event.preventDefault(); + onOpenChannel(parsed.value.channelId); + }} + > + {children} + + ); +} + +export function MarkdownChannelDeepLink({ + children, + interactive, +}: { + children?: React.ReactNode; + interactive: boolean; +}) { + const { onOpenChannel } = useMarkdownRuntime(); + const href = String(children ?? ""); + const parsed = parseChannelLink(href); + if (!parsed.ok || !interactive) { + return {href}; + } + return ( + + ); +} + +export function MarkdownChannelReference({ + children, + interactive, +}: { + children?: React.ReactNode; + interactive: boolean; +}) { + const { channels, onOpenChannel } = useMarkdownRuntime(); + const text = String(children ?? ""); + const channelName = text.startsWith("#") ? text.slice(1) : text; + const channel = channels.find( + (candidate) => + candidate.channelType !== "dm" && + candidate.name.toLowerCase() === channelName.toLowerCase(), + ); + const baseClasses = + "inline-flex items-center rounded-md bg-primary/10 px-1 py-0.5 font-medium text-primary"; + if (!channel || !interactive) { + return ( + + {children} + + ); + } + return ( + + ); +} diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index 5853f8943e9..df953693737 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -3,6 +3,7 @@ import ReactMarkdown, { type Components } from "react-markdown"; import remarkBreaks from "remark-breaks"; import remarkGfm from "remark-gfm"; +import remarkChannelDeepLinks from "@/features/messages/lib/remarkChannelDeepLinks"; import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks"; import rehypeImageGallery from "@/shared/lib/rehypeImageGallery"; import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight"; @@ -98,6 +99,7 @@ function buildMarkdownElement(input: MarkdownParseInputs): React.ReactElement { remarkGfm, remarkBreaks, remarkSpoilers, + remarkChannelDeepLinks, remarkMessageLinks, [remarkMentions, { mentionNames: input.mentionNames }], [remarkChannelLinks, { channelNames: input.channelNames }], diff --git a/desktop/src/shared/ui/markdown/utils.ts b/desktop/src/shared/ui/markdown/utils.ts index a35e60cadc3..7487909cfb8 100644 --- a/desktop/src/shared/ui/markdown/utils.ts +++ b/desktop/src/shared/ui/markdown/utils.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { defaultUrlTransform } from "react-markdown"; +import { isChannelLink } from "@/features/messages/lib/channelLink"; import { isMessageLink } from "@/features/messages/lib/messageLink"; import { parseEntityLink } from "@/shared/lib/entityLink"; @@ -167,22 +168,20 @@ export function isInsideHiddenSpoiler(element: Element): boolean { } /** - * `urlTransform` for `` that preserves `buzz://` deep links - * used by Buzz — both `buzz://message?…` links and `buzz://pr|issue|repo?…` - * entity links. The default transform strips unknown schemes (returns `""`) - * before the `a` component override can see them, which would break copy → - * paste → click end-to-end. + * `urlTransform` for `` that preserves valid `buzz://` deep + * links used by Buzz: message links, channel links, and + * `buzz://pr|issue|repo?…` entity links. The default transform strips unknown + * schemes (returns `""`) before the `a` component override can see them. * * Policy: - * - `buzz://message` hrefs — preserved unconditionally (handled by the - * message-link pill renderer). - * - `buzz://pr|issue|repo` hrefs — preserved only when `parseEntityLink` - * succeeds, keeping the sanitizer active against arbitrary `buzz://` URIs. - * - Everything else delegates to `defaultUrlTransform`. + * - valid `buzz://message` and `buzz://channel` hrefs are preserved; + * - `buzz://pr|issue|repo` hrefs are preserved only when `parseEntityLink` + * succeeds, keeping the sanitizer active against arbitrary `buzz://` URIs; + * - everything else delegates to `defaultUrlTransform`. */ export function buzzDeepLinkUrlTransform(value: string, key: string): string { if (key !== "href") return defaultUrlTransform(value); - if (isMessageLink(value)) return value; + if (isMessageLink(value) || isChannelLink(value)) return value; if (parseEntityLink(value).ok) return value; return defaultUrlTransform(value); } diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index d4478a44226..fbbe4b9f67a 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -1,7 +1,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { listenForMessageDeepLinks } from "@/shared/deep-link"; +import { listenForNavigationDeepLinks } from "@/shared/deep-link"; /** * Subscribe to `buzz://message` deep links emitted by the Tauri backend @@ -24,16 +24,24 @@ export function useMessageDeepLinks(enabled = true) { if (!enabled) return; let cancelled = false; - const unlistenPromise = listenForMessageDeepLinks((payload) => { - if (cancelled) return; - void goChannel(payload.channelId, { - messageId: payload.messageId, - threadRootId: payload.threadRootId, - }); - }); + const unlistenPromise = listenForNavigationDeepLinks( + async (payload) => { + if (cancelled) return false; + await goChannel(payload.channelId); + return true; + }, + async (payload) => { + if (cancelled) return false; + await goChannel(payload.channelId, { + messageId: payload.messageId, + threadRootId: payload.threadRootId, + }); + return true; + }, + ); return () => { cancelled = true; - void unlistenPromise.then((fn) => fn()); + void unlistenPromise.then((unlisten) => unlisten()); }; }, [enabled, goChannel]); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4188408a5d8..f011c581d7d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -324,6 +324,13 @@ type E2eConfig = { /** Delay (ms) for `apply_workspace` so e2e tests can observe the * community-switch gate. 0/undefined = instant. */ applyCommunityDelayMs?: number; + /** Only delay apply calls targeting this relay URL when set. */ + applyCommunityDelayRelayUrl?: string; + /** Apply this relay as the next generation while delayed apply is in flight. */ + applyCommunitySupersedeRelayUrl?: string; + /** Delay (ms) for `clear_pending_navigation_deep_links` so e2e tests can + * exercise a switch superseded while native queue cleanup is pending. */ + clearPendingNavigationDeepLinksDelayMs?: number; openDmDelayMs?: number; sendMessageDelayMs?: number; /** Hold the media proxy at port 0 until the E2E release seam is invoked. */ @@ -474,6 +481,14 @@ type E2eConfig = { code?: string | null; name?: string | null; }>; + pendingNavigationDeepLinks?: Array<{ + id: string; + kind: "channel" | "message"; + channelId: string; + messageId?: string | null; + threadRootId?: string | null; + workspaceGeneration?: number; + }>; // When true, `get_identity` returns `lost: true` until `persist_current_identity` // or `import_identity` is called. Drives the identity-lost recovery UX in tests. identityLost?: boolean; @@ -1113,6 +1128,10 @@ declare global { }>; /** Release a mock media proxy held at port 0 and return its ready port. */ __BUZZ_E2E_RELEASE_MEDIA_PROXY__?: () => number; + __BUZZ_E2E_APPLIED_WORKSPACES__?: Array<{ + relayUrl?: string; + transitionGeneration?: number; + }>; /** Release mock send events that were stored but withheld from live subscribers. */ __BUZZ_E2E_RELEASE_SEND_MESSAGE_LIVE_ECHO__?: () => number; __BUZZ_E2E_EMIT_MEDIA_UPLOAD_PHASE__?: (input: { @@ -4356,6 +4375,26 @@ function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) { })); } +let mockPendingNavigationDeepLinks: Array<{ + id: string; + kind: "channel" | "message"; + channelId: string; + messageId: string | null; + threadRootId: string | null; + workspaceGeneration: number; +}> = []; + +function resetMockPendingNavigationDeepLinks(config: E2eConfig | null) { + mockPendingNavigationDeepLinks = ( + config?.mock?.pendingNavigationDeepLinks ?? [] + ).map((pending) => ({ + ...pending, + messageId: pending.messageId ?? null, + threadRootId: pending.threadRootId ?? null, + workspaceGeneration: pending.workspaceGeneration ?? 0, + })); +} + function recordMockUserStatus(event: RelayEvent) { const dTag = event.tags.find((tag) => tag[0] === "d")?.[1]; if (dTag) { @@ -10157,6 +10196,7 @@ export function maybeInstallE2eTauriMocks() { resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); + resetMockPendingNavigationDeepLinks(config); initializeMockHuddle(config.mock?.huddle, config); mockWebsocketSendMutexWedged = false; if (config.mock?.windowLabel) { @@ -10175,6 +10215,7 @@ export function maybeInstallE2eTauriMocks() { ensureRelayOriginFetch(); return mockMediaProxyPort; }; + window.__BUZZ_E2E_APPLIED_WORKSPACES__ = []; window.__BUZZ_E2E_EMIT_MOCK_HUDDLE_TTS_SPEAKER__ = (payload) => emit("huddle-tts-speaker-level", payload); window.__BUZZ_E2E_SIGNED_EVENTS__ = []; @@ -10522,6 +10563,24 @@ export function maybeInstallE2eTauriMocks() { deviceName: state === "running" ? "Mock desktop" : null, }; }; + const persistedWorkspaceTransitionGeneration = Number.parseInt( + window.sessionStorage.getItem("buzz-e2e-workspace-transition-generation") ?? + "0", + 10, + ); + let claimedWorkspaceTransitionGeneration = Number.isFinite( + persistedWorkspaceTransitionGeneration, + ) + ? persistedWorkspaceTransitionGeneration + : 0; + const claimNextWorkspaceTransitionGeneration = () => { + claimedWorkspaceTransitionGeneration += 1; + window.sessionStorage.setItem( + "buzz-e2e-workspace-transition-generation", + String(claimedWorkspaceTransitionGeneration), + ); + return claimedWorkspaceTransitionGeneration; + }; let mockImportedVoices: Array<{ key: string; displayName: string; @@ -11345,13 +11404,40 @@ export function maybeInstallE2eTauriMocks() { } return activeConfig?.mock?.linkPreviewMetadata ?? null; } + case "claim_workspace_transition": + return claimNextWorkspaceTransitionGeneration(); case "apply_workspace": { const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0; - if (applyDelayMs > 0) { - return new Promise((resolve) => + const delayRelayUrl = activeConfig?.mock?.applyCommunityDelayRelayUrl; + const relayUrl = (payload as { relayUrl?: string }).relayUrl; + if ( + applyDelayMs > 0 && + (delayRelayUrl === undefined || delayRelayUrl === relayUrl) + ) { + const supersedeRelayUrl = + activeConfig?.mock?.applyCommunitySupersedeRelayUrl; + if (supersedeRelayUrl) { + const supersedeGeneration = + claimNextWorkspaceTransitionGeneration(); + window.__BUZZ_E2E_APPLIED_WORKSPACES__?.push({ + relayUrl: supersedeRelayUrl, + transitionGeneration: supersedeGeneration, + }); + } + await new Promise((resolve) => window.setTimeout(resolve, applyDelayMs), ); } + const transitionGeneration = ( + payload as { transitionGeneration?: number } + ).transitionGeneration; + if (transitionGeneration !== claimedWorkspaceTransitionGeneration) { + return; + } + window.__BUZZ_E2E_APPLIED_WORKSPACES__?.push({ + relayUrl, + transitionGeneration, + }); return; } case "update_tray_agent_activity": @@ -11907,6 +11993,30 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } + case "clear_pending_navigation_deep_links": { + const { workspaceGeneration } = payload as { + workspaceGeneration: number; + }; + const clearDelayMs = + activeConfig?.mock?.clearPendingNavigationDeepLinksDelayMs ?? 0; + if (clearDelayMs > 0) { + await new Promise((resolve) => + window.setTimeout(resolve, clearDelayMs), + ); + } + mockPendingNavigationDeepLinks = mockPendingNavigationDeepLinks.filter( + (pending) => pending.workspaceGeneration >= workspaceGeneration, + ); + return; + } + case "take_pending_navigation_deep_link": + return mockPendingNavigationDeepLinks[0] ?? null; + case "acknowledge_pending_navigation_deep_link": { + const { id } = payload as { id: string }; + if (mockPendingNavigationDeepLinks[0]?.id !== id) return false; + mockPendingNavigationDeepLinks.shift(); + return true; + } case "get_relay_http_url": return getRelayHttpUrl(activeConfig); case "relay_requires_membership": diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 51b4867bf46..cc50045f375 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -22,6 +22,12 @@ const COMMUNITY_B = { relayUrl: "ws://localhost:3001", addedAt: "2026-01-02T00:00:00.000Z", }; +const COMMUNITY_C = { + id: "ws-c", + name: "Charlie", + relayUrl: "ws://localhost:3002", + addedAt: "2026-01-03T00:00:00.000Z", +}; async function seedCommunities( page: import("@playwright/test").Page, @@ -834,6 +840,128 @@ test.describe("community rail", () => { // The app settles into the new community once apply completes. await expect(buttonB).toHaveAttribute("aria-current", "true"); + await expect + .poll(() => + page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "clear_pending_navigation_deep_links", + ).length ?? 0, + ), + ) + .toBe(1); + }); + + test("does not apply a switch superseded during native queue cleanup", async ({ + page, + }) => { + await installMockBridge( + page, + { clearPendingNavigationDeepLinksDelayMs: 800 }, + { skipCommunitySeed: true }, + ); + await seedCommunities( + page, + [COMMUNITY_A, COMMUNITY_B, COMMUNITY_C], + COMMUNITY_A.id, + ); + await page.goto("/"); + + const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`); + const buttonC = page.getByTestId(`community-rail-button-${COMMUNITY_C.id}`); + await expect(buttonB).toBeVisible(); + await expect(buttonC).toBeVisible(); + + await page.evaluate( + ({ buttonBId, buttonCId }) => { + const buttonB = document.querySelector( + `[data-testid="${buttonBId}"]`, + ); + const buttonC = document.querySelector( + `[data-testid="${buttonCId}"]`, + ); + if (!buttonB || !buttonC) throw new Error("missing community buttons"); + buttonB.click(); + buttonC.click(); + }, + { + buttonBId: `community-rail-button-${COMMUNITY_B.id}`, + buttonCId: `community-rail-button-${COMMUNITY_C.id}`, + }, + ); + + await expect(buttonC).toHaveAttribute("aria-current", "true"); + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) + .filter(({ command }) => command === "apply_workspace") + .map(({ payload }) => (payload as { relayUrl?: string }).relayUrl), + ), + ) + .toEqual([COMMUNITY_A.relayUrl, COMMUNITY_C.relayUrl]); + }); + + test("superseding an in-flight workspace apply gives the newest switch ownership", async ({ + page, + }) => { + await installMockBridge( + page, + { + applyCommunityDelayMs: 800, + applyCommunityDelayRelayUrl: COMMUNITY_B.relayUrl, + applyCommunitySupersedeRelayUrl: COMMUNITY_C.relayUrl, + }, + { skipCommunitySeed: true }, + ); + await seedCommunities( + page, + [COMMUNITY_A, COMMUNITY_B, COMMUNITY_C], + COMMUNITY_A.id, + ); + await page.goto("/"); + + const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`); + await expect(buttonB).toBeVisible(); + await buttonB.click(); + await expect + .poll(() => + page.evaluate( + (relayUrl) => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).some( + ({ command, payload }) => + command === "apply_workspace" && + (payload as { relayUrl?: string }).relayUrl === relayUrl, + ), + COMMUNITY_B.relayUrl, + ), + ) + .toBe(true); + + await expect + .poll(() => page.evaluate(() => window.__BUZZ_E2E_APPLIED_WORKSPACES__)) + .toEqual([ + { relayUrl: COMMUNITY_A.relayUrl, transitionGeneration: 1 }, + { relayUrl: COMMUNITY_C.relayUrl, transitionGeneration: 3 }, + ]); + }); + + test("a webview reload acquires a newer native workspace generation", async ({ + page, + }) => { + await installMockBridge(page, undefined, { skipCommunitySeed: true }); + await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); + await page.goto("/"); + + await expect + .poll(() => page.evaluate(() => window.__BUZZ_E2E_APPLIED_WORKSPACES__)) + .toEqual([{ relayUrl: COMMUNITY_A.relayUrl, transitionGeneration: 1 }]); + + await page.reload(); + + await expect + .poll(() => page.evaluate(() => window.__BUZZ_E2E_APPLIED_WORKSPACES__)) + .toEqual([{ relayUrl: COMMUNITY_A.relayUrl, transitionGeneration: 2 }]); }); test("leaving the final community returns to setup without resetting identity", async ({ diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index eb76ef3a4f9..729dbb8c166 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -454,3 +454,63 @@ test("message deep links survive reload", async ({ page }) => { "Engineering shipped the desktop build.", ); }); + +// Cold-start OS links are queued natively until AppShell mounts its router listener. + +test("cold-start channel deep link drains after the router mounts", async ({ + page, +}) => { + await installMockBridge(page, { + pendingNavigationDeepLinks: [ + { + id: "navigation-channel-1", + kind: "channel", + channelId: ENGINEERING_CHANNEL_ID, + }, + ], + }); + + await page.goto("/"); + + await expect(page.getByTestId("chat-title")).toHaveText("engineering"); + await expect(page).toHaveURL( + new RegExp(`#/channels/${ENGINEERING_CHANNEL_ID}$`), + ); + await expect + .poll(() => + page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( + (entry) => + entry.command === "acknowledge_pending_navigation_deep_link", + ), + ), + ) + .toEqual([ + { + command: "acknowledge_pending_navigation_deep_link", + payload: { id: "navigation-channel-1" }, + }, + ]); +}); + +test("cold-start message deep link preserves its thread target", async ({ + page, +}) => { + await installMockBridge(page, { + pendingNavigationDeepLinks: [ + { + id: "navigation-message-1", + kind: "message", + channelId: WATERCOLOR_CHANNEL_ID, + messageId: "mock-forum-release-reply", + threadRootId: "mock-forum-release-thread", + }, + ], + }); + + await page.goto("/"); + + await expect(page.getByTestId("chat-title")).toHaveText("watercooler"); + await expect(page).toHaveURL(/messageId=mock-forum-release-reply/); + await expect(page).toHaveURL(/threadRootId=mock-forum-release-thread/); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 31af66ab0ac..33317cb4b67 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -278,6 +278,12 @@ type MockBridgeOptions = { canvasReadError?: string; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ applyCommunityDelayMs?: number; + /** Only delay apply calls targeting this relay URL when set. */ + applyCommunityDelayRelayUrl?: string; + /** Apply this relay as the next generation while delayed apply is in flight. */ + applyCommunitySupersedeRelayUrl?: string; + /** Delay (ms) for `clear_pending_navigation_deep_links`. */ + clearPendingNavigationDeepLinksDelayMs?: number; openDmDelayMs?: number; sendMessageDelayMs?: number; /** Hold the media proxy at port 0 until the E2E release seam is invoked. */ @@ -461,6 +467,15 @@ type MockBridgeOptions = { code?: string | null; name?: string | null; }>; + /** Pending channel/message links that arrived before AppShell mounted. */ + pendingNavigationDeepLinks?: Array<{ + id: string; + kind: "channel" | "message"; + channelId: string; + messageId?: string | null; + threadRootId?: string | null; + workspaceGeneration?: number; + }>; /** * Global agent config returned by `get_global_agent_config`. Defaults to * an empty config (no provider, model, or env vars) if not specified. diff --git a/mobile/lib/features/channels/deep_link_dispatcher.dart b/mobile/lib/features/channels/deep_link_dispatcher.dart index b264b31b69e..84eb1cfc0d3 100644 --- a/mobile/lib/features/channels/deep_link_dispatcher.dart +++ b/mobile/lib/features/channels/deep_link_dispatcher.dart @@ -17,7 +17,7 @@ import 'channels_provider.dart'; /// held (not dropped) while channels are still loading, so cold-start links /// dispatch as soon as the first channel fetch completes. typedef DeepLinkDestinationBuilder = - Widget Function(Channel channel, MessageDeepLink link); + Widget Function(Channel channel, BuzzDeepLink link); class DeepLinkDispatcher extends ConsumerStatefulWidget { final Widget child; @@ -68,8 +68,16 @@ class _DeepLinkDispatcherState extends ConsumerState { _maybeDispatchInvite(link); return; } - if (link is! MessageDeepLink || !widget.dispatchMessageLinks) return; + if ((link is! MessageDeepLink && link is! ChannelDeepLink) || + !widget.dispatchMessageLinks) { + return; + } + final channelId = switch (link) { + MessageDeepLink(:final channelId) => channelId, + ChannelDeepLink(:final channelId) => channelId, + _ => throw StateError('unsupported navigable deep link: $link'), + }; final channels = ref.read(channelsProvider).asData?.value; // Channels not loaded yet — keep the link parked; the channelsProvider // listener re-attempts once data arrives. @@ -78,13 +86,12 @@ class _DeepLinkDispatcherState extends ConsumerState { ref.read(pendingDeepLinkProvider.notifier).consume(); final channel = channels - .where((c) => c.id == link.channelId) + .where((c) => c.id == channelId) .cast() .firstOrNull; if (channel == null) { debugPrint( - 'deep-link: channel ${link.channelId} not found in workspace; ' - 'dropping link', + 'deep-link: channel $channelId not found in workspace; dropping link', ); ScaffoldMessenger.maybeOf(context)?.showSnackBar( const SnackBar(content: Text('Channel not found in this workspace')), @@ -99,8 +106,10 @@ class _DeepLinkDispatcherState extends ConsumerState { widget.destinationBuilder?.call(channel, link) ?? ChannelDetailPage( channel: channel, - initialMessageId: link.messageId, - initialThreadRootId: link.threadRootId, + initialMessageId: link is MessageDeepLink ? link.messageId : null, + initialThreadRootId: link is MessageDeepLink + ? link.threadRootId + : null, ), ), ); diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index e2c86b2fb06..7aa5c80682b 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -15,6 +15,8 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; import '../../shared/clipboard_utils.dart'; +import '../../shared/deeplink/deep_link.dart'; +import '../../shared/deeplink/pending_deep_link_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/syntax_highlight.dart'; import '../../shared/theme/theme.dart'; @@ -23,7 +25,9 @@ import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_only.dart'; +import 'channels_provider.dart'; import 'media_viewer_page.dart'; +import 'message_content/link_normalizer.dart'; import 'message_media.dart'; part 'message_content/media_carousel.dart'; @@ -156,6 +160,26 @@ class MessageContent extends HookConsumerWidget { final resolvedAgentMentionPubkeys = { ...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()), }; + final resolvedChannelNames = channelNames.isNotEmpty + ? channelNames + : { + for (final channel + in ref.watch(channelsProvider).asData?.value ?? const []) + channel.name.toLowerCase(): channel.id, + }; + final resolvedChannelTap = + onChannelTap ?? + (String channelId) { + ref + .read(pendingDeepLinkProvider.notifier) + .open(Uri(scheme: 'buzz', host: 'channel', path: channelId)); + }; + final channelPresentationKey = [ + for (final entry + in (resolvedChannelNames.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)))) + '${entry.key}\u0000${entry.value}', + ].join('\u0001'); final imetaByUrl = parseImetaTags(tags); final trailingGallery = maxLines == null ? _extractTrailingImageGallery(content, imetaByUrl) @@ -193,45 +217,17 @@ class MessageContent extends HookConsumerWidget { ? kEmojiOnlyCustomEmojiSize : kCustomEmojiInlineSize; - final finalContent = useMemoized(() { - // Convert autolinks and bare URLs to standard markdown links, - // but skip content inside backticks (inline code / fenced blocks). - final buffer = StringBuffer(); - final parts = markdownContent.split('`'); - for (var i = 0; i < parts.length; i++) { - if (i.isOdd) { - // Inside backticks — preserve as-is. - buffer.write('`${parts[i]}`'); - } else { - // 1. Angle-bracket autolinks: - var segment = parts[i].replaceAllMapped( - RegExp(r'<(https?://[^>]+)>'), - (m) => '[${m[1]}](${m[1]})', - ); - // 2. Bare URLs not already inside markdown link/image syntax. - // Negative lookbehind avoids matching URLs preceded by ]( or = - // which are already part of markdown links or imeta tags. - segment = segment.replaceAllMapped( - RegExp(r'(?\]]+'), - (m) { - final url = m[0]!; - // Skip if this URL is already a markdown link label that equals - // the URL (produced by step 1 or authored as [url](url)). - final start = m.start; - if (start >= 1 && segment[start - 1] == '[') return url; - return '[$url]($url)'; - }, - ); - buffer.write(segment); - } - } - final processed = buffer.toString(); + final linkNormalizedContent = useMemoized( + () => normalizeBareLinks(markdownContent), + [markdownContent], + ); + final finalContent = useMemoized(() { // Replace spaces with non-breaking spaces inside known mention names // so the gpt_markdown combined regex can match multi-word names // even when caseSensitive is not preserved. // Skip content inside backticks to avoid altering inline code. - final mentionParts = processed.split('`'); + final mentionParts = linkNormalizedContent.split('`'); final mentionBuf = StringBuffer(); for (var i = 0; i < mentionParts.length; i++) { if (i.isOdd) { @@ -250,27 +246,35 @@ class MessageContent extends HookConsumerWidget { mentionBuf.write(segment); } } - final mentionProcessed = mentionBuf.toString(); + var result = mentionBuf.toString(); // Ensure channel links at the very start of content don't get // swallowed by markdown processing. - var result = mentionProcessed; if (RegExp(r'^#[A-Za-z0-9_]').hasMatch(result)) { result = '\u200B$result'; } return result; - }, [markdownContent, resolvedMentionNames]); + }, [linkNormalizedContent, resolvedMentionNames]); final markdown = KeyedSubtree( - key: ValueKey('$finalContent\u0000$mentionPresentationKey'), + key: ValueKey( + '$finalContent\u0000$mentionPresentationKey\u0000$channelPresentationKey', + ), child: GptMarkdown( finalContent, style: style, followLinkColor: false, codeBuilder: (context, name, code, closed) => _MessageCodeBlock(name: name, code: code), - linkBuilder: (context, linkText, url, linkStyle) => - _buildLink(context, ref, linkText, url, linkStyle, style), + linkBuilder: (context, linkText, url, linkStyle) => _buildLink( + context, + ref, + linkText, + url, + linkStyle, + style, + resolvedChannelTap, + ), imageBuilder: (context, imageUrl) => _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), textAlign: textAlign, @@ -283,8 +287,8 @@ class MessageContent extends HookConsumerWidget { ), CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), _ChannelLinkMd( - channelNames: channelNames, - onChannelTap: onChannelTap, + channelNames: resolvedChannelNames, + onChannelTap: resolvedChannelTap, ), ...MarkdownComponent.inlineComponents, ], @@ -336,6 +340,7 @@ class MessageContent extends HookConsumerWidget { String url, TextStyle linkStyle, TextStyle? fallbackStyle, + void Function(String channelId) resolvedChannelTap, ) { String text = ''; linkText.visitChildren((span) { @@ -350,9 +355,22 @@ class MessageContent extends HookConsumerWidget { return GestureDetector( onTap: () async { final uri = Uri.tryParse(url); - if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) { + if (uri == null) return; + + // Rendered channel URLs must use the same callback as `#channel` + // references so detail-page callers can suppress self-navigation. + // Message and join links still need the top-level authenticated + // dispatcher. + if (uri.scheme == 'buzz') { + final deepLink = parseBuzzDeepLink(uri); + if (deepLink case ChannelDeepLink(:final channelId)) { + resolvedChannelTap(channelId); + } else if (deepLink != null) { + ref.read(pendingDeepLinkProvider.notifier).open(uri); + } return; } + if (uri.scheme != 'http' && uri.scheme != 'https') return; final auth = ref.read(mediaGetAuthServiceProvider); if (!auth.isRelayMediaUrl(url)) { diff --git a/mobile/lib/features/channels/message_content/link_normalizer.dart b/mobile/lib/features/channels/message_content/link_normalizer.dart new file mode 100644 index 00000000000..7b36fb5cbfa --- /dev/null +++ b/mobile/lib/features/channels/message_content/link_normalizer.dart @@ -0,0 +1,220 @@ +const _markdownDelimiters = ['***', '___', '**', '__', '~~', '*', '_']; + +final _autolinkPattern = RegExp( + r'<((?:https?://|buzz://(?:message\?|join\?|channel/))[^>]+)>', +); +final _bareLinkPattern = RegExp( + r'(?:https?://|buzz://(?:message\?|join\?|channel/))[^\s)>\]]+', +); +// Bare Buzz URLs stop at whitespace and Markdown's structural closers in the +// scanner above. Peel Unicode closing punctuation, final quotes, and terminal +// punctuation that can be adjacent in prose. ASCII apostrophe and quotation +// mark are not Unicode closing punctuation, so include them explicitly. This +// is intentionally Buzz-only so existing HTTP(S) normalization is unchanged. +final _trailingProseDelimiterPattern = RegExp( + r'''(?:['"]|[\p{Pe}\p{Pf}]|\p{Terminal_Punctuation})+$''', + unicode: true, +); +final _backtickRunPattern = RegExp(r'`+'); + +/// Converts supported Buzz and HTTP(S) autolinks and bare links into Markdown +/// links while leaving inline and fenced code untouched. Punctuation peeling +/// is limited to Buzz URLs so existing HTTP(S) destinations stay unchanged. +String normalizeBareLinks(String content) { + final buffer = StringBuffer(); + var offset = 0; + var proseStart = 0; + var codeStart = 0; + var inlineDelimiterLength = 0; + var fenceDelimiterLength = 0; + + while (offset < content.length) { + final run = _backtickRunPattern.matchAsPrefix(content, offset); + if (run == null) { + offset++; + continue; + } + + final runLength = run.end - run.start; + if (fenceDelimiterLength > 0) { + if (_isClosingFence(content, run.start, run.end, fenceDelimiterLength)) { + buffer.write(content.substring(codeStart, run.end)); + fenceDelimiterLength = 0; + proseStart = run.end; + } + } else if (inlineDelimiterLength > 0) { + if (runLength == inlineDelimiterLength) { + buffer.write(content.substring(codeStart, run.end)); + inlineDelimiterLength = 0; + proseStart = run.end; + } + } else if (_hasInlineCloserOnLine(content, run.end, runLength) || + (!_isOpeningFence(content, run.start, runLength) && + _hasInlineCloser(content, run.end, runLength))) { + buffer.write( + _normalizeLinkSegment(content.substring(proseStart, run.start)), + ); + codeStart = run.start; + inlineDelimiterLength = runLength; + } else if (_isOpeningFence(content, run.start, runLength)) { + buffer.write( + _normalizeLinkSegment(content.substring(proseStart, run.start)), + ); + codeStart = run.start; + fenceDelimiterLength = runLength; + } + + offset = run.end; + } + + if (inlineDelimiterLength > 0 || fenceDelimiterLength > 0) { + buffer.write(content.substring(codeStart)); + } else { + buffer.write(_normalizeLinkSegment(content.substring(proseStart))); + } + return buffer.toString(); +} + +bool _isOpeningFence(String content, int runStart, int runLength) { + if (runLength < 3) return false; + final lineStart = runStart == 0 + ? 0 + : content.lastIndexOf('\n', runStart - 1) + 1; + final indentation = content.substring(lineStart, runStart); + return indentation.length <= 3 && indentation.trim().isEmpty; +} + +bool _isClosingFence( + String content, + int runStart, + int runEnd, + int openerLength, +) { + if (runEnd - runStart < openerLength) return false; + final lineStart = runStart == 0 + ? 0 + : content.lastIndexOf('\n', runStart - 1) + 1; + final indentation = content.substring(lineStart, runStart); + if (indentation.length > 3 || indentation.trim().isNotEmpty) return false; + final newline = content.indexOf('\n', runEnd); + final lineEnd = newline < 0 ? content.length : newline; + return content.substring(runEnd, lineEnd).trim().isEmpty; +} + +bool _hasInlineCloserOnLine(String content, int start, int delimiterLength) { + final newline = content.indexOf('\n', start); + final lineEnd = newline < 0 ? content.length : newline; + for (final run in _backtickRunPattern.allMatches(content, start)) { + if (run.start >= lineEnd) return false; + if (run.end - run.start == delimiterLength) return true; + } + return false; +} + +bool _hasInlineCloser(String content, int start, int delimiterLength) { + for (final run in _backtickRunPattern.allMatches(content, start)) { + if (run.end - run.start == delimiterLength) return true; + } + return false; +} + +String _normalizeLinkSegment(String segment) { + var normalized = segment.replaceAllMapped(_autolinkPattern, (match) { + final url = match[1]!; + // An angle-bracket URL immediately after a Markdown label is that link's + // destination, not an autolink. Buzz schemes entered this pass in this + // feature; leave existing HTTP(S) behavior unchanged. + if (url.startsWith('buzz://') && + match.start >= 2 && + segment.substring(match.start - 2, match.start) == '](') { + return url; + } + return '[$url]($url)'; + }); + normalized = normalized.replaceAllMapped( + _bareLinkPattern, + (match) => _normalizeBareLink(normalized, match), + ); + return normalized; +} + +String _normalizeBareLink(String segment, Match match) { + final matched = match[0]!; + var url = matched; + var trailing = ''; + final isBuzzUrl = matched.startsWith('buzz://'); + final start = match.start; + + // Existing Markdown destinations and imeta attributes already own the URL. + // Check the preceding character explicitly instead of using RegExp + // lookbehind so this scanner remains portable to older runtimes. + if (start > 0) { + final previous = segment[start - 1]; + if (previous == '(' || + previous == '\\' || + previous == ']' || + previous == '=' || + (previous == '<' && + start >= 3 && + segment.substring(start - 3, start) == '](<')) { + return matched; + } + } + + if (isBuzzUrl) { + final outsideDelimiters = _trailingProseDelimiterPattern.firstMatch(url); + if (outsideDelimiters != null) { + url = url.substring(0, outsideDelimiters.start); + trailing = outsideDelimiters[0]!; + } + } + + var strippedDelimiter = true; + while (strippedDelimiter) { + strippedDelimiter = false; + for (final delimiter in _markdownDelimiters) { + if (url.endsWith(delimiter) && + _hasUnclosedMarkdownDelimiter( + segment.substring(0, start), + delimiter, + )) { + url = url.substring(0, url.length - delimiter.length); + trailing = '$delimiter$trailing'; + strippedDelimiter = true; + break; + } + } + } + + if (isBuzzUrl) { + final delimiters = _trailingProseDelimiterPattern.firstMatch(url); + if (delimiters != null) { + url = url.substring(0, delimiters.start); + trailing = '${delimiters[0]}$trailing'; + } + } + + // Preserve a URL already used as its own Markdown label. This covers both + // converted autolinks and authored `[url](url)` links. + if (start >= 1 && segment[start - 1] == '[') return matched; + return '[$url]($url)$trailing'; +} + +bool _hasUnclosedMarkdownDelimiter(String prefix, String delimiter) { + var open = false; + var offset = 0; + while (true) { + final index = prefix.indexOf(delimiter, offset); + if (index < 0) return open; + final before = index == 0 ? null : prefix[index - 1]; + final afterIndex = index + delimiter.length; + final after = afterIndex == prefix.length ? null : prefix[afterIndex]; + final canOpen = + (after == null || after.trim().isNotEmpty) && + (before == null || + before.trim().isEmpty || + RegExp(r'[^\w]').hasMatch(before)); + if (open || canOpen) open = !open; + offset = afterIndex; + } +} diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart index 0ef7b8e596d..8f0e399f1ce 100644 --- a/mobile/lib/shared/deeplink/deep_link.dart +++ b/mobile/lib/shared/deeplink/deep_link.dart @@ -50,6 +50,26 @@ class InviteDeepLink extends BuzzDeepLink { 'InviteDeepLink(relay: $relayUrl, code: $code, policyReceipt: $policyReceipt)'; } +/// A parsed channel-only deep link. +/// +/// Canonical form: `buzz://channel/`. +class ChannelDeepLink extends BuzzDeepLink { + /// Channel UUID from the sole path segment. + final String channelId; + + const ChannelDeepLink({required this.channelId}); + + @override + bool operator ==(Object other) => + other is ChannelDeepLink && other.channelId == channelId; + + @override + int get hashCode => channelId.hashCode; + + @override + String toString() => 'ChannelDeepLink(channel: $channelId)'; +} + /// A parsed `buzz://message` deep link. class MessageDeepLink extends BuzzDeepLink { /// Channel UUID from the `channel` query param. @@ -115,6 +135,27 @@ String buildMessageLink({ ).toString(); } +/// Parse a canonical `buzz://channel/` URI. +/// +/// The channel ID must be the URI's sole non-empty path segment. Query +/// parameters and fragments are rejected so malformed or ambiguous links never +/// become navigation targets. +ChannelDeepLink? parseChannelDeepLink(Uri uri) { + if (uri.scheme != 'buzz' || uri.host != 'channel') return null; + if (uri.hasQuery || uri.hasFragment || uri.userInfo.isNotEmpty) return null; + if (uri.pathSegments.length != 1 || uri.pathSegments.single.isEmpty) { + return null; + } + final channelId = uri.pathSegments.single; + if (!RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + caseSensitive: false, + ).hasMatch(channelId)) { + return null; + } + return ChannelDeepLink(channelId: channelId.toLowerCase()); +} + /// Parse a `buzz://message?…` URI into a [MessageDeepLink]. /// /// Returns `null` for non-`buzz` schemes, non-`message` hosts (e.g. @@ -218,4 +259,6 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) { /// Parse any supported Buzz deep link. BuzzDeepLink? parseBuzzDeepLink(Uri uri) => - parseInviteDeepLink(uri) ?? parseMessageDeepLink(uri); + parseInviteDeepLink(uri) ?? + parseChannelDeepLink(uri) ?? + parseMessageDeepLink(uri); diff --git a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart index 8dc46d9f105..4fd2a67e88a 100644 --- a/mobile/lib/shared/deeplink/pending_deep_link_provider.dart +++ b/mobile/lib/shared/deeplink/pending_deep_link_provider.dart @@ -23,7 +23,7 @@ class PendingDeepLinkNotifier extends Notifier { @override BuzzDeepLink? build() { final stream = debugUriStreamOverride ?? AppLinks().uriLinkStream; - _subscription = stream.listen(handleUri); + _subscription = stream.listen(open); ref.onDispose(() { _subscription?.cancel(); _subscription = null; @@ -32,8 +32,7 @@ class PendingDeepLinkNotifier extends Notifier { } /// Parse and park an incoming URI. Unsupported links are ignored loudly. - @visibleForTesting - void handleUri(Uri uri) { + void open(Uri uri) { final link = parseBuzzDeepLink(uri); if (link == null) { debugPrint('deep-link: ignoring unsupported link: $uri'); diff --git a/mobile/test/features/channels/deep_link_dispatcher_test.dart b/mobile/test/features/channels/deep_link_dispatcher_test.dart index 0771a7bb38e..f616c6a46ad 100644 --- a/mobile/test/features/channels/deep_link_dispatcher_test.dart +++ b/mobile/test/features/channels/deep_link_dispatcher_test.dart @@ -46,9 +46,44 @@ void main() { final destination = tester.widget<_CapturedDestination>( find.byType(_CapturedDestination), ); + final messageLink = destination.link as MessageDeepLink; expect(destination.channel.id, 'channel-1'); - expect(destination.link.messageId, 'message-2'); - expect(destination.link.threadRootId, 'message-1'); + expect(messageLink.messageId, 'message-2'); + expect(messageLink.threadRootId, 'message-1'); + }); + + testWidgets('dispatches a channel-only link to the channel root', ( + tester, + ) async { + const link = ChannelDeepLink(channelId: 'channel-1'); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + pendingDeepLinkProvider.overrideWith( + () => _FakePendingDeepLinkNotifier(link), + ), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier(Future.value([_channel])), + ), + ], + child: MaterialApp( + home: DeepLinkDispatcher( + destinationBuilder: (channel, link) => + _CapturedDestination(channel: channel, link: link), + child: const Scaffold(body: SizedBox()), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + final destination = tester.widget<_CapturedDestination>( + find.byType(_CapturedDestination), + ); + expect(destination.channel.id, 'channel-1'); + expect(destination.link, same(link)); }); testWidgets('retains invite and surfaces prepare failure', (tester) async { @@ -259,7 +294,7 @@ class _CapturedDestination extends StatelessWidget { const _CapturedDestination({required this.channel, required this.link}); final Channel channel; - final MessageDeepLink link; + final BuzzDeepLink link; @override Widget build(BuildContext context) => const SizedBox(); diff --git a/mobile/test/features/channels/message_content/link_normalizer_test.dart b/mobile/test/features/channels/message_content/link_normalizer_test.dart new file mode 100644 index 00000000000..da1f6cb6509 --- /dev/null +++ b/mobile/test/features/channels/message_content/link_normalizer_test.dart @@ -0,0 +1,154 @@ +import 'package:buzz/features/channels/message_content/link_normalizer.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const url = 'buzz://message?channel=channel-1&id=message-1'; + + test('normalizes supported bare and autolinked Buzz URLs', () { + expect( + normalizeBareLinks('See $url and <$url>'), + 'See [$url]($url) and [$url]($url)', + ); + }); + + test('preserves angle-bracket Buzz Markdown destinations', () { + const channelUrl = 'buzz://channel/550e8400-e29b-41d4-a716-446655440000'; + const messageUrl = 'buzz://message?channel=channel-1&id=message-1'; + expect( + normalizeBareLinks( + '[channel](<$channelUrl>) [message](<$messageUrl>) ' + '<$channelUrl> <$messageUrl>', + ), + '[channel]($channelUrl) [message]($messageUrl) ' + '[$channelUrl]($channelUrl) [$messageUrl]($messageUrl)', + ); + }); + + test('keeps prose closing delimiters outside bare Buzz URLs', () { + const channelUrl = 'buzz://channel/550e8400-e29b-41d4-a716-446655440000'; + const messageUrl = 'buzz://message?channel=channel-1&id=message-1'; + final cases = <(String, String)>[ + ('"', '"'), + ("'", "'"), + ('“', '”'), + ('‘', '’'), + ('«', '»'), + ('‹', '›'), + ('《', '》'), + ('〈', '〉'), + ('「', '」'), + ('『', '』'), + ('【', '】'), + ('〔', '〕'), + ('〖', '〗'), + ('〘', '〙'), + ('〚', '〛'), + ]; + + for (final (opening, closing) in cases) { + expect( + normalizeBareLinks('$opening$channelUrl$closing'), + '$opening[$channelUrl]($channelUrl)$closing', + ); + expect( + normalizeBareLinks('$opening$messageUrl$closing.'), + '$opening[$messageUrl]($messageUrl)$closing.', + ); + } + expect( + normalizeBareLinks( + 'See $channelUrl。 Then $messageUrl! Also $channelUrl.', + ), + 'See [$channelUrl]($channelUrl)。 Then [$messageUrl]($messageUrl)! ' + 'Also [$channelUrl]($channelUrl).', + ); + }); + + test('keeps punctuation and open Markdown delimiters outside links', () { + expect( + normalizeBareLinks('**open $url**. and **_${url}_**!'), + '**open [$url]($url)**. and **_[$url]($url)_**!', + ); + }); + + test('preserves URL suffix characters without a matching opener', () { + expect( + normalizeBareLinks( + 'See $url' + '_ and $url~~', + ), + 'See [$url' + '_]($url' + '_) and [$url~~]($url~~)', + ); + }); + + test('does not relink URLs owned by existing Markdown or attributes', () { + const httpUrl = 'https://example.com/file.png'; + expect( + normalizeBareLinks( + '[label]($httpUrl) ![image]($httpUrl) ' + 'imeta=url=$httpUrl escaped \\$httpUrl', + ), + '[label]($httpUrl) ![image]($httpUrl) ' + 'imeta=url=$httpUrl escaped \\$httpUrl', + ); + }); + + group('code boundaries', () { + final cases = <({String name, String input, String expected})>[ + ( + name: 'single-backtick inline span', + input: '`$url` then $url', + expected: '`$url` then [$url]($url)', + ), + ( + name: 'matching multi-backtick inline span', + input: '``$url`` then $url', + expected: '``$url`` then [$url]($url)', + ), + ( + name: 'literal shorter backtick run in inline span', + input: '``inside ` $url`` then $url', + expected: '``inside ` $url`` then [$url]($url)', + ), + ( + name: 'inline closer must have equal length', + input: '``$url``` still code`` then $url', + expected: '``$url``` still code`` then [$url]($url)', + ), + ( + name: 'fence accepts a longer line-start closer', + input: '```\n$url\n````\n$url', + expected: '```\n$url\n````\n[$url]($url)', + ), + ( + name: 'fence ignores an inline-looking backtick run', + input: '```\n$url ``` still code\n```\n$url', + expected: '```\n$url ``` still code\n```\n[$url]($url)', + ), + ( + name: 'unclosed backticks remain prose', + input: '$url then `$url', + expected: '[$url]($url) then `[$url]($url)', + ), + ]; + + for (final testCase in cases) { + test(testCase.name, () { + expect(normalizeBareLinks(testCase.input), testCase.expected); + }); + } + }); + + test( + 'preserves HTTP(S) destinations while retaining bare-link rendering', + () { + const httpUrl = 'https://example.com/search?q=why?'; + expect( + normalizeBareLinks('See $httpUrl and <$httpUrl>'), + 'See [$httpUrl]($httpUrl) and [$httpUrl]($httpUrl)', + ); + }, + ); +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 0d904960c23..d8dc5aa4afb 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -5,8 +5,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/misc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/shared/deeplink/deep_link.dart'; +import 'package:buzz/shared/deeplink/pending_deep_link_provider.dart'; import 'package:buzz/shared/emoji/emoji_only.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -154,6 +158,15 @@ bool _spanHasStyle( return found; } +class _TestChannelsNotifier extends ChannelsNotifier { + _TestChannelsNotifier(this.channels); + + final Future> channels; + + @override + Future> build() => channels; +} + void main() { group('MessageContent', () { testWidgets('forwards text alignment to markdown rendering', ( @@ -431,6 +444,340 @@ void main() { expect(allText, isNot(contains('(https://example.com)'))); }); + testWidgets('renders and routes a buzz message link', (tester) async { + const url = + 'buzz://message?channel=channel-1&id=message-2&thread=root-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '[Open message]($url)')), + ); + + expect(find.text('Open message'), findsOneWidget); + await tester.tap(find.text('Open message')); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: 'channel-1', + messageId: 'message-2', + threadRootId: 'root-1', + ), + ); + }); + + testWidgets('renders and routes bare Buzz message links', (tester) async { + const url = 'buzz://message?channel=channel-1&id=message-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'See $url now')), + ); + + expect(find.text(url), findsOneWidget); + await tester.tap(find.text(url)); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink(channelId: 'channel-1', messageId: 'message-1'), + ); + }); + + testWidgets('keeps Markdown delimiters outside bare Buzz links', ( + tester, + ) async { + const url = 'buzz://message?channel=channel-1&id=message-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '**$url**. and _${url}_')), + ); + + expect(find.text(url), findsNWidgets(2)); + + await tester.tap(find.text(url).first); + await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink(channelId: 'channel-1', messageId: 'message-1'), + ); + }); + + testWidgets('keeps non-adjacent Markdown delimiters outside links', ( + tester, + ) async { + const url = 'buzz://message?channel=channel-1&id=message-1'; + + await tester.pumpWidget( + _testable( + const MessageContent( + content: + '*join $url* and **open $url** and ' + '~~visit $url~~ and **_${url}_**.', + ), + ), + ); + + expect(find.text(url), findsNWidgets(4)); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + for (final link in find.text(url).evaluate()) { + await tester.tap(find.byWidget(link.widget)); + await tester.pump(); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: 'channel-1', + messageId: 'message-1', + ), + ); + container.read(pendingDeepLinkProvider.notifier).state = null; + } + }); + + testWidgets('excludes sentence punctuation from bare Buzz links', ( + tester, + ) async { + const messageUrl = 'buzz://message?channel=channel-1&id=message-1'; + const joinUrl = + 'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=invite-1'; + + await tester.pumpWidget( + _testable( + const MessageContent(content: 'See $messageUrl. Then $joinUrl!'), + ), + ); + + expect(find.text(messageUrl), findsOneWidget); + expect(find.text(joinUrl), findsOneWidget); + expect(_allRichText(tester), contains('See \u{FFFC}. Then \u{FFFC}!')); + + await tester.tap(find.text(messageUrl)); + await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink(channelId: 'channel-1', messageId: 'message-1'), + ); + + await tester.tap(find.text(joinUrl)); + await tester.pump(); + expect( + container.read(pendingDeepLinkProvider), + const InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-1', + ), + ); + }); + + testWidgets('renders and routes autolinked Buzz thread links', ( + tester, + ) async { + const url = 'buzz://message?channel=channel-1&id=reply-1&thread=root-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '<$url>')), + ); + + expect(find.text(url), findsOneWidget); + await tester.tap(find.text(url)); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink( + channelId: 'channel-1', + messageId: 'reply-1', + threadRootId: 'root-1', + ), + ); + }); + + testWidgets('renders and routes bare Buzz join links', (tester) async { + const url = + 'buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=invite-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'Join with $url')), + ); + + expect(find.text(url), findsOneWidget); + await tester.tap(find.text(url)); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const InviteDeepLink( + relayUrl: 'wss://relay.example.com', + code: 'invite-1', + ), + ); + }); + + testWidgets('renders and routes bare Buzz channel links', (tester) async { + const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'See $url now')), + ); + + expect(find.text(url), findsOneWidget); + await tester.tap(find.text(url)); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + testWidgets('renders and routes labeled Buzz channel links', ( + tester, + ) async { + const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '[Open channel]($url)')), + ); + + await tester.tap(find.text('Open channel')); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + testWidgets('routes angle-bracket Buzz Markdown destinations', ( + tester, + ) async { + const messageUrl = 'buzz://message?channel=channel-1&id=message-1'; + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + const channelUrl = 'buzz://channel/$channelId'; + String? tappedChannelId; + + await tester.pumpWidget( + _testable( + MessageContent( + content: + '[Open message](<$messageUrl>) [Open channel](<$channelUrl>)', + onChannelTap: (id) => tappedChannelId = id, + ), + ), + ); + + await tester.tap(find.text('Open message')); + await tester.pump(); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const MessageDeepLink(channelId: 'channel-1', messageId: 'message-1'), + ); + + container.read(pendingDeepLinkProvider.notifier).state = null; + await tester.tap(find.text('Open channel')); + await tester.pump(); + expect(tappedChannelId, channelId); + expect(container.read(pendingDeepLinkProvider), isNull); + }); + + testWidgets('routes rendered Buzz channel links through callback', ( + tester, + ) async { + const channelId = '580ca78b-9dae-46f3-8854-bd671853ba32'; + const url = 'buzz://channel/$channelId'; + String? tappedChannelId; + + await tester.pumpWidget( + _testable( + MessageContent( + content: '[Open channel]($url)', + onChannelTap: (id) => tappedChannelId = id, + ), + ), + ); + + await tester.tap(find.text('Open channel')); + await tester.pump(); + + expect(tappedChannelId, channelId); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect(container.read(pendingDeepLinkProvider), isNull); + }); + + testWidgets('renders and routes autolinked Buzz channel links', ( + tester, + ) async { + const url = 'buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'; + + await tester.pumpWidget( + _testable(const MessageContent(content: '<$url>')), + ); + + await tester.tap(find.text(url)); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + testWidgets('leaves malformed Buzz channel forms as plain text', ( + tester, + ) async { + const url = 'buzz://channel?channel=channel-1'; + + await tester.pumpWidget( + _testable(const MessageContent(content: 'See $url now')), + ); + + expect(find.text(url), findsNothing); + expect(_allRichText(tester), contains(url)); + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect(container.read(pendingDeepLinkProvider), isNull); + }); + testWidgets('renders bare URL as link', (tester) async { await tester.pumpWidget( _testable( @@ -1525,6 +1872,48 @@ Photos expect(tappedId, 'ch-id-1'); }); + testWidgets('resolved #channel defaults to in-app navigation', ( + tester, + ) async { + final channels = Future.value([ + Channel( + id: '580ca78b-9dae-46f3-8854-bd671853ba32', + name: 'general', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'creator', + createdAt: DateTime(2026), + memberCount: 1, + isMember: true, + ), + ]); + await tester.pumpWidget( + _testable( + const MessageContent(content: 'See #general'), + overrides: [ + channelsProvider.overrideWith( + () => _TestChannelsNotifier(channels), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('#general')); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageContent)), + ); + expect( + container.read(pendingDeepLinkProvider), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + testWidgets('unknown channel renders without tap', (tester) async { await tester.pumpWidget( _testable( diff --git a/mobile/test/shared/deeplink/deep_link_test.dart b/mobile/test/shared/deeplink/deep_link_test.dart index 70e20663082..52b7032643b 100644 --- a/mobile/test/shared/deeplink/deep_link_test.dart +++ b/mobile/test/shared/deeplink/deep_link_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() { _inviteTests(); + _channelTests(); _buildMessageLinkTests(); group('parseMessageDeepLink', () { @@ -65,6 +66,67 @@ void main() { }); } +void _channelTests() { + group('parseChannelDeepLink', () { + test('parses canonical channel path', () { + expect( + parseChannelDeepLink( + Uri.parse('buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'), + ), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + test('accepts v7 and canonicalizes uppercase UUIDs', () { + expect( + parseChannelDeepLink( + Uri.parse('buzz://channel/018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9'), + ), + const ChannelDeepLink( + channelId: '018fdb5d-3a64-7c35-b5f9-4a23e1f9d2d9', + ), + ); + expect( + parseChannelDeepLink( + Uri.parse('buzz://channel/580CA78B-9DAE-46F3-8854-BD671853BA32'), + ), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + + test('rejects missing, extra, query, and fragment forms', () { + for (final url in [ + 'buzz://channel', + 'buzz://channel/', + 'buzz://channel/one/two', + 'buzz://channel/one?extra=true', + 'buzz://channel/one#fragment', + 'https://channel/one', + 'buzz://channel/not-a-uuid', + 'buzz://channel/%2F', + 'buzz://channel/%00', + ]) { + expect(parseChannelDeepLink(Uri.parse(url)), isNull, reason: url); + } + }); + + test('is included in the top-level parser', () { + expect( + parseBuzzDeepLink( + Uri.parse('buzz://channel/580ca78b-9dae-46f3-8854-bd671853ba32'), + ), + const ChannelDeepLink( + channelId: '580ca78b-9dae-46f3-8854-bd671853ba32', + ), + ); + }); + }); +} + void _inviteTests() { group('parseInviteDeepLink', () { test('parses canonical HTTPS invite URL', () {