Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 247 additions & 2 deletions desktop/src-tauri/src/deep_link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,79 @@ pub(crate) struct PendingCommunityDeepLink {
#[derive(Default)]
pub(crate) struct PendingCommunityDeepLinks(Mutex<VecDeque<PendingCommunityDeepLink>>);

#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PendingNavigationDeepLink {
id: String,
kind: String,
channel_id: String,
message_id: Option<String>,
thread_root_id: Option<String>,
}

#[derive(Default)]
pub(crate) struct PendingNavigationDeepLinks(Mutex<VecDeque<PendingNavigationDeepLink>>);

impl PendingNavigationDeepLinks {
fn lock(&self) -> std::sync::MutexGuard<'_, VecDeque<PendingNavigationDeepLink>> {
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
}) {
return;
}
queue.push_back(pending);
}

fn clear(&self) {
self.lock().clear();
}

fn first(&self) -> Option<PendingNavigationDeepLink> {
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(pending: State<'_, PendingNavigationDeepLinks>) {
pending.clear();
}

#[tauri::command]
pub(crate) fn take_pending_navigation_deep_link(
pending: State<'_, PendingNavigationDeepLinks>,
) -> Option<PendingNavigationDeepLink> {
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");
Expand Down Expand Up @@ -88,6 +161,20 @@ fn queue_community_deep_link(
});
}

fn queue_navigation_deep_link(app: &tauri::AppHandle, kind: &str, payload: &serde_json::Value) {
let Some(channel_id) = payload["channelId"].as_str() else {
return;
};
app.state::<PendingNavigationDeepLinks>()
.enqueue(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),
});
}

fn activate_main_window(app: &tauri::AppHandle) {
let Some(window) = app.get_webview_window("main") else {
return;
Expand All @@ -104,6 +191,19 @@ fn activate_main_window(app: &tauri::AppHandle) {
}
}

fn parse_channel_deep_link(url: &Url) -> Option<serde_json::Value> {
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
Expand Down Expand Up @@ -350,6 +450,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);
Comment thread
loganj marked this conversation as resolved.
}
Some("message") => {
// `buzz://message?channel=<uuid>&id=<eventId>[&thread=<rootId>]`
//
Expand All @@ -364,6 +473,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) {
Expand All @@ -389,8 +499,9 @@ 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,
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 {
Expand All @@ -404,6 +515,100 @@ mod tests {
}
}

fn pending_navigation(
id: &str,
kind: &str,
channel_id: &str,
message_id: Option<&str>,
thread_root_id: Option<&str>,
) -> 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),
}
}

#[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() {
let queue = PendingNavigationDeepLinks::default();
queue.enqueue(pending_navigation(
"first",
"channel",
"channel-1",
None,
None,
));
queue.enqueue(pending_navigation(
"second",
"message",
"channel-1",
Some("message-1"),
None,
));

queue.clear();
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"));
Expand Down Expand Up @@ -477,6 +682,46 @@ mod tests {
}
}

#[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();
Expand Down
10 changes: 7 additions & 3 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,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,
Expand Down Expand Up @@ -290,7 +291,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();
Expand All @@ -302,6 +302,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())
Expand Down Expand Up @@ -611,6 +612,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,
Expand Down
14 changes: 11 additions & 3 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { clearTrayAgentActivity } from "@/shared/api/trayMenu";
import { getOverrides } from "@/shared/features";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import { resetNavigationDeepLinkDrain } from "@/shared/deep-link";
import {
clearAllDrafts,
initDraftStore,
Expand Down Expand Up @@ -46,12 +47,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,
}: {
resetAvatarState: boolean;
}): void {
}): Promise<void> {
relayClient.disconnect();
await resetNavigationDeepLinkDrain();
resetRateLimitGate();
clearAllDrafts();
resetAgentObserverStore();
Expand Down Expand Up @@ -120,6 +122,12 @@ export function useCommunityInit(

async function init() {
if (!activeCommunity) {
if (hasInitializedRef.current) {
await resetCommunityState({ resetAvatarState: true });
hasInitializedRef.current = false;
prevCommunityIdRef.current = null;
appliedRelayUrlRef.current = null;
}
try {
const defaultRelayUrl = await getDefaultRelayUrl();
const autoConnectDefaultRelay =
Expand Down Expand Up @@ -194,7 +202,7 @@ export function useCommunityInit(
// store under the outgoing community ID and delete its snapshot.
prevCommunityIdRef.current = null;
}
resetCommunityState({
await resetCommunityState({
resetAvatarState:
appliedRelayUrlRef.current !== activeCommunity.relayUrl,
});
Expand Down
Loading
Loading