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
218 changes: 216 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,70 @@ 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 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 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 +152,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 +182,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 +441,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 +464,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 +490,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 +506,78 @@ 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_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 +651,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
9 changes: 6 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,
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 @@ -289,7 +290,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 @@ -301,6 +301,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 @@ -610,6 +611,8 @@ 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,
start_builderlab_login,
cancel_builderlab_login,
get_builderlab_auth,
Expand Down
60 changes: 60 additions & 0 deletions desktop/src/features/messages/lib/channelLink.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
});
Loading
Loading