From e36aee6cd4811f841e49c6e6f5470322e0eb3b55 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 06:50:46 -0400 Subject: [PATCH 01/32] fix(mobile): route rendered buzz message links Signed-off-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu Co-authored-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu Signed-off-by: Logan Johnson --- .../features/channels/message_content.dart | 14 +++++++++- .../channels/message_content_test.dart | 27 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index e2c86b2fb06..12f4b369fb6 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'; @@ -350,9 +352,19 @@ 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; + + // `buzz://message` and `buzz://join` are app-owned links. Park the + // parsed target so the top-level dispatcher can route it once the + // authenticated/channel UI is ready; do not hand it to an OS browser, + // which is what made rendered mobile deep links inert. + if (uri.scheme == 'buzz') { + if (parseBuzzDeepLink(uri) != null) { + ref.read(pendingDeepLinkProvider.notifier).handleUri(uri); + } return; } + if (uri.scheme != 'http' && uri.scheme != 'https') return; final auth = ref.read(mediaGetAuthServiceProvider); if (!auth.isRelayMediaUrl(url)) { diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 0d904960c23..9d4135f9493 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -7,6 +7,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; 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'; @@ -431,6 +433,31 @@ 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 bare URL as link', (tester) async { await tester.pumpWidget( _testable( From 57b45a7e4275cd6daf66d8c081771f11e8943476 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 07:08:45 -0400 Subject: [PATCH 02/32] fix(mobile): cover rendered Buzz link forms Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../features/channels/message_content.dart | 17 ++-- .../deeplink/pending_deep_link_provider.dart | 5 +- .../channels/message_content_test.dart | 87 +++++++++++++++++++ 3 files changed, 100 insertions(+), 9 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 12f4b369fb6..2bc68f94fe1 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -205,16 +205,21 @@ class MessageContent extends HookConsumerWidget { // Inside backticks — preserve as-is. buffer.write('`${parts[i]}`'); } else { - // 1. Angle-bracket autolinks: + // 1. Angle-bracket autolinks: and supported Buzz + // links. gpt_markdown does not auto-link custom schemes. var segment = parts[i].replaceAllMapped( - RegExp(r'<(https?://[^>]+)>'), + RegExp(r'<((?:https?://|buzz://(?:message\?|join\?))[^>]+)>'), (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. + // Keep this in sync with the app-owned links accepted by + // parseBuzzDeepLink. Negative lookbehind avoids matching URLs + // preceded by ]( or =, which are already part of markdown links + // or imeta tags. segment = segment.replaceAllMapped( - RegExp(r'(?\]]+'), + RegExp( + r'(?\]]+', + ), (m) { final url = m[0]!; // Skip if this URL is already a markdown link label that equals @@ -360,7 +365,7 @@ class MessageContent extends HookConsumerWidget { // which is what made rendered mobile deep links inert. if (uri.scheme == 'buzz') { if (parseBuzzDeepLink(uri) != null) { - ref.read(pendingDeepLinkProvider.notifier).handleUri(uri); + ref.read(pendingDeepLinkProvider.notifier).open(uri); } return; } 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/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 9d4135f9493..71508ad68cb 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -458,6 +458,93 @@ void main() { ); }); + 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('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('leaves unsupported Buzz 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( From 00da6d6179e1a097e7490d67fd5fad64782c361d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 08:50:21 -0400 Subject: [PATCH 03/32] fix: route channel references across clients Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/deep_link.rs | 198 +++++++++++++++++- desktop/src-tauri/src/lib.rs | 9 +- .../messages/lib/channelLink.test.mjs | 60 ++++++ .../src/features/messages/lib/channelLink.ts | 48 +++++ .../lib/remarkChannelDeepLinks.test.mjs | 35 ++++ .../messages/lib/remarkChannelDeepLinks.ts | 22 ++ desktop/src/shared/deep-link.ts | 66 +++++- desktop/src/shared/ui/markdown.test.mjs | 20 +- desktop/src/shared/ui/markdown.tsx | 68 +++--- .../shared/ui/markdown/ChannelDeepLink.tsx | 94 +++++++++ desktop/src/shared/ui/markdown/nodeCache.ts | 2 + desktop/src/shared/ui/markdown/utils.ts | 21 +- desktop/src/shared/useMessageDeepLinks.ts | 24 ++- desktop/src/testing/e2eBridge.ts | 34 +++ desktop/tests/e2e/navigation.spec.ts | 60 ++++++ desktop/tests/helpers/bridge.ts | 8 + .../channels/deep_link_dispatcher.dart | 23 +- .../features/channels/message_content.dart | 43 +++- mobile/lib/shared/deeplink/deep_link.dart | 45 +++- .../channels/deep_link_dispatcher_test.dart | 41 +++- .../channels/message_content_test.dart | 161 +++++++++++++- .../test/shared/deeplink/deep_link_test.dart | 62 ++++++ 22 files changed, 1050 insertions(+), 94 deletions(-) create mode 100644 desktop/src/features/messages/lib/channelLink.test.mjs create mode 100644 desktop/src/features/messages/lib/channelLink.ts create mode 100644 desktop/src/features/messages/lib/remarkChannelDeepLinks.test.mjs create mode 100644 desktop/src/features/messages/lib/remarkChannelDeepLinks.ts create mode 100644 desktop/src/shared/ui/markdown/ChannelDeepLink.tsx diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc367..bc1a3fd74fc 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -20,6 +20,73 @@ 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, +} + +#[derive(Default)] +pub(crate) struct PendingNavigationDeepLinks(Mutex>); + +impl PendingNavigationDeepLinks { + fn enqueue(&self, pending: PendingNavigationDeepLink) { + let mut queue = self + .0 + .lock() + .expect("pending navigation deep-link queue poisoned"); + 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 { + self.0 + .lock() + .expect("pending navigation deep-link queue poisoned") + .front() + .cloned() + } + + fn acknowledge(&self, id: &str) -> bool { + let mut queue = self + .0 + .lock() + .expect("pending navigation deep-link queue poisoned"); + 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 { + 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 +155,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::() + .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; @@ -104,6 +185,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 +444,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 +467,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) { @@ -389,8 +493,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 { @@ -404,6 +509,55 @@ 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_join_serializes_policy_receipt_for_cold_launch_recovery() { let mut link = pending("join", "wss://relay.example", Some("invite")); @@ -477,6 +631,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(); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7aa954ce8e6..6982569509a 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, + 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,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, 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/deep-link.ts b/desktop/src/shared/deep-link.ts index c62a8bec3ba..c1fd4d4be07 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,14 @@ export type MessageDeepLinkPayload = { threadRootId: string | null; }; +type PendingNavigationDeepLink = { + id: string; + kind: "channel" | "message"; + channelId: string; + messageId: string | null; + threadRootId: string | null; +}; + export type NostrBindDeepLinkPayload = { challengeId: string; nonce: string; @@ -157,12 +167,58 @@ export async function listenForDeepLinks( * inside the router tree (e.g. AppShell) because the navigation callback * uses TanStack Router state. */ -export function listenForMessageDeepLinks( - onOpen: (payload: MessageDeepLinkPayload) => void, +export async function listenForNavigationDeepLinks( + onOpenChannel: (payload: ChannelDeepLinkPayload) => void, + onOpenMessage: (payload: MessageDeepLinkPayload) => void, ): 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; + while (true) { + const pending = await invoke( + "take_pending_navigation_deep_link", + ); + if (!pending) break; + if (pending.kind === "channel") { + onOpenChannel({ channelId: pending.channelId }); + } else if (pending.messageId) { + onOpenMessage({ + channelId: pending.channelId, + messageId: pending.messageId, + threadRootId: pending.threadRootId, + }); + } + const acknowledged = await invoke( + "acknowledge_pending_navigation_deep_link", + { id: pending.id }, + ); + if (!acknowledged) break; + } + } + } 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..977773c46c5 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,22 @@ 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( + (payload) => { + if (cancelled) return; + void goChannel(payload.channelId); + }, + (payload) => { + if (cancelled) return; + void goChannel(payload.channelId, { + messageId: payload.messageId, + threadRootId: payload.threadRootId, + }); + }, + ); 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..ca560a5337e 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -474,6 +474,13 @@ type E2eConfig = { code?: string | null; name?: string | null; }>; + pendingNavigationDeepLinks?: Array<{ + id: string; + kind: "channel" | "message"; + channelId: string; + messageId?: string | null; + threadRootId?: string | null; + }>; // 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; @@ -4356,6 +4363,24 @@ function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) { })); } +let mockPendingNavigationDeepLinks: Array<{ + id: string; + kind: "channel" | "message"; + channelId: string; + messageId: string | null; + threadRootId: string | null; +}> = []; + +function resetMockPendingNavigationDeepLinks(config: E2eConfig | null) { + mockPendingNavigationDeepLinks = ( + config?.mock?.pendingNavigationDeepLinks ?? [] + ).map((pending) => ({ + ...pending, + messageId: pending.messageId ?? null, + threadRootId: pending.threadRootId ?? null, + })); +} + function recordMockUserStatus(event: RelayEvent) { const dTag = event.tags.find((tag) => tag[0] === "d")?.[1]; if (dTag) { @@ -10157,6 +10182,7 @@ export function maybeInstallE2eTauriMocks() { resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); + resetMockPendingNavigationDeepLinks(config); initializeMockHuddle(config.mock?.huddle, config); mockWebsocketSendMutexWedged = false; if (config.mock?.windowLabel) { @@ -11907,6 +11933,14 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } + 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/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..ff2f66ec613 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -461,6 +461,14 @@ 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; + }>; /** * 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 2bc68f94fe1..8556adbbb21 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -25,6 +25,7 @@ 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_media.dart'; @@ -158,6 +159,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) @@ -208,7 +229,9 @@ class MessageContent extends HookConsumerWidget { // 1. Angle-bracket autolinks: and supported Buzz // links. gpt_markdown does not auto-link custom schemes. var segment = parts[i].replaceAllMapped( - RegExp(r'<((?:https?://|buzz://(?:message\?|join\?))[^>]+)>'), + RegExp( + r'<((?:https?://|buzz://(?:message\?|join\?|channel/))[^>]+)>', + ), (m) => '[${m[1]}](${m[1]})', ); // 2. Bare URLs not already inside markdown link/image syntax. @@ -218,15 +241,17 @@ class MessageContent extends HookConsumerWidget { // or imeta tags. segment = segment.replaceAllMapped( RegExp( - r'(?\]]+', + r'(?\]]+', ), (m) { - final url = m[0]!; + final matched = m[0]!; + final url = matched.replaceFirst(RegExp(r'[.,!?:;]+$'), ''); + final trailingPunctuation = matched.substring(url.length); // 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)'; + if (start >= 1 && segment[start - 1] == '[') return matched; + return '[$url]($url)$trailingPunctuation'; }, ); buffer.write(segment); @@ -269,7 +294,9 @@ class MessageContent extends HookConsumerWidget { }, [markdownContent, resolvedMentionNames]); final markdown = KeyedSubtree( - key: ValueKey('$finalContent\u0000$mentionPresentationKey'), + key: ValueKey( + '$finalContent\u0000$mentionPresentationKey\u0000$channelPresentationKey', + ), child: GptMarkdown( finalContent, style: style, @@ -290,8 +317,8 @@ class MessageContent extends HookConsumerWidget { ), CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), _ChannelLinkMd( - channelNames: channelNames, - onChannelTap: onChannelTap, + channelNames: resolvedChannelNames, + onChannelTap: resolvedChannelTap, ), ...MarkdownComponent.inlineComponents, ], 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/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_test.dart b/mobile/test/features/channels/message_content_test.dart index 71508ad68cb..62244b8f09c 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -5,6 +5,8 @@ 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'; @@ -156,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', ( @@ -478,6 +489,44 @@ void main() { ); }); + 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 { @@ -528,7 +577,75 @@ void main() { ); }); - testWidgets('leaves unsupported Buzz forms as plain text', ( + 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('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'; @@ -1639,6 +1756,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', () { From 60c53b79de85458a917a39134984cd4de8130706 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 10:14:18 -0400 Subject: [PATCH 04/32] fix(desktop): preserve queued links across teardown Only acknowledge pending navigation after the active listener accepts it, leaving raced FIFO items for the next mount. Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/shared/deep-link.test.mjs | 125 ++++++++++++++++++++++ desktop/src/shared/deep-link.ts | 59 +++++----- desktop/src/shared/useMessageDeepLinks.ts | 6 +- 3 files changed, 163 insertions(+), 27 deletions(-) create mode 100644 desktop/src/shared/deep-link.test.mjs diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs new file mode 100644 index 00000000000..e2eff466ba0 --- /dev/null +++ b/desktop/src/shared/deep-link.test.mjs @@ -0,0 +1,125 @@ +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 } = 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); +}); diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index c1fd4d4be07..5901e47c1b7 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -162,14 +162,42 @@ export async function listenForDeepLinks( }; } +async function drainPendingNavigationDeepLinks( + onOpenChannel: (payload: ChannelDeepLinkPayload) => boolean, + onOpenMessage: (payload: MessageDeepLinkPayload) => boolean, +) { + while (true) { + const pending = await invoke( + "take_pending_navigation_deep_link", + ); + if (!pending) return; + const accepted = + pending.kind === "channel" + ? onOpenChannel({ channelId: pending.channelId }) + : pending.messageId + ? onOpenMessage({ + channelId: pending.channelId, + messageId: pending.messageId, + threadRootId: pending.threadRootId, + }) + : false; + if (!accepted) 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 async function listenForNavigationDeepLinks( - onOpenChannel: (payload: ChannelDeepLinkPayload) => void, - onOpenMessage: (payload: MessageDeepLinkPayload) => void, + onOpenChannel: (payload: ChannelDeepLinkPayload) => boolean, + onOpenMessage: (payload: MessageDeepLinkPayload) => boolean, ): Promise { let drainRunning = false; let drainRequested = false; @@ -181,26 +209,7 @@ export async function listenForNavigationDeepLinks( try { while (drainRequested) { drainRequested = false; - while (true) { - const pending = await invoke( - "take_pending_navigation_deep_link", - ); - if (!pending) break; - if (pending.kind === "channel") { - onOpenChannel({ channelId: pending.channelId }); - } else if (pending.messageId) { - onOpenMessage({ - channelId: pending.channelId, - messageId: pending.messageId, - threadRootId: pending.threadRootId, - }); - } - const acknowledged = await invoke( - "acknowledge_pending_navigation_deep_link", - { id: pending.id }, - ); - if (!acknowledged) break; - } + await drainPendingNavigationDeepLinks(onOpenChannel, onOpenMessage); } } catch (error: unknown) { console.warn("Failed to drain pending navigation deep links", error); diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index 977773c46c5..f9c54795eea 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -26,15 +26,17 @@ export function useMessageDeepLinks(enabled = true) { let cancelled = false; const unlistenPromise = listenForNavigationDeepLinks( (payload) => { - if (cancelled) return; + if (cancelled) return false; void goChannel(payload.channelId); + return true; }, (payload) => { - if (cancelled) return; + if (cancelled) return false; void goChannel(payload.channelId, { messageId: payload.messageId, threadRootId: payload.threadRootId, }); + return true; }, ); return () => { From e0e191ac73ba7d7c3d36045f538a3457dacf7cdf Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 10:24:33 -0400 Subject: [PATCH 05/32] chore: retrigger pull request checks Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson From bd6dc2f54f1b1f0d485c8f9745649fb00b57493f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 11:02:02 -0400 Subject: [PATCH 06/32] fix(desktop): acknowledge links after navigation Recover poisoned navigation queues and keep failed routes pending until a later listener can retry them. Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/deep_link.rs | 46 ++++++++++++++++------- desktop/src/shared/deep-link.test.mjs | 39 +++++++++++++++++++ desktop/src/shared/deep-link.ts | 35 ++++++++++------- desktop/src/shared/useMessageDeepLinks.ts | 8 ++-- 4 files changed, 97 insertions(+), 31 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index bc1a3fd74fc..8f0f2ea3302 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -34,11 +34,15 @@ pub(crate) struct PendingNavigationDeepLink { 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 - .0 - .lock() - .expect("pending navigation deep-link queue poisoned"); + let mut queue = self.lock(); if queue.iter().any(|item| { item.kind == pending.kind && item.channel_id == pending.channel_id @@ -51,18 +55,11 @@ impl PendingNavigationDeepLinks { } fn first(&self) -> Option { - self.0 - .lock() - .expect("pending navigation deep-link queue poisoned") - .front() - .cloned() + self.lock().front().cloned() } fn acknowledge(&self, id: &str) -> bool { - let mut queue = self - .0 - .lock() - .expect("pending navigation deep-link queue poisoned"); + let mut queue = self.lock(); if queue.front().is_some_and(|item| item.id == id) { queue.pop_front(); true @@ -558,6 +555,29 @@ mod tests { 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")); diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index e2eff466ba0..6845cd52a24 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -123,3 +123,42 @@ test("listener teardown leaves an unaccepted FIFO item for the next mount", asyn secondUnlisten(); assert.equal(unlistenCount, 4); }); + +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 5901e47c1b7..941f371e50c 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -163,24 +163,27 @@ export async function listenForDeepLinks( } async function drainPendingNavigationDeepLinks( - onOpenChannel: (payload: ChannelDeepLinkPayload) => boolean, - onOpenMessage: (payload: MessageDeepLinkPayload) => boolean, + onOpenChannel: ( + payload: ChannelDeepLinkPayload, + ) => boolean | Promise, + onOpenMessage: ( + payload: MessageDeepLinkPayload, + ) => boolean | Promise, ) { while (true) { const pending = await invoke( "take_pending_navigation_deep_link", ); if (!pending) return; - const accepted = - pending.kind === "channel" - ? onOpenChannel({ channelId: pending.channelId }) - : pending.messageId - ? onOpenMessage({ - channelId: pending.channelId, - messageId: pending.messageId, - threadRootId: pending.threadRootId, - }) - : false; + 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) return; const acknowledged = await invoke( "acknowledge_pending_navigation_deep_link", @@ -196,8 +199,12 @@ async function drainPendingNavigationDeepLinks( * effect teardown leaves an in-flight queue head available for the next mount. */ export async function listenForNavigationDeepLinks( - onOpenChannel: (payload: ChannelDeepLinkPayload) => boolean, - onOpenMessage: (payload: MessageDeepLinkPayload) => boolean, + onOpenChannel: ( + payload: ChannelDeepLinkPayload, + ) => boolean | Promise, + onOpenMessage: ( + payload: MessageDeepLinkPayload, + ) => boolean | Promise, ): Promise { let drainRunning = false; let drainRequested = false; diff --git a/desktop/src/shared/useMessageDeepLinks.ts b/desktop/src/shared/useMessageDeepLinks.ts index f9c54795eea..fbbe4b9f67a 100644 --- a/desktop/src/shared/useMessageDeepLinks.ts +++ b/desktop/src/shared/useMessageDeepLinks.ts @@ -25,14 +25,14 @@ export function useMessageDeepLinks(enabled = true) { let cancelled = false; const unlistenPromise = listenForNavigationDeepLinks( - (payload) => { + async (payload) => { if (cancelled) return false; - void goChannel(payload.channelId); + await goChannel(payload.channelId); return true; }, - (payload) => { + async (payload) => { if (cancelled) return false; - void goChannel(payload.channelId, { + await goChannel(payload.channelId, { messageId: payload.messageId, threadRootId: payload.threadRootId, }); From b5d1a632c0b01717f0bb28f19d19dc0e732e8076 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 11:29:10 -0400 Subject: [PATCH 07/32] Serialize desktop deep-link drains across remounts Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/shared/deep-link.test.mjs | 62 +++++++++++++++++++++++++++ desktop/src/shared/deep-link.ts | 14 +++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index 6845cd52a24..c137df308e4 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -124,6 +124,68 @@ test("listener teardown leaves an unaccepted FIFO item for the next mount", asyn 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("rejected navigation remains queued and is not acknowledged", async () => { const pending = { id: "retry-me", diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 941f371e50c..7416dabb892 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -162,6 +162,16 @@ export async function listenForDeepLinks( }; } +let navigationDrainTail: Promise = Promise.resolve(); + +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, @@ -216,7 +226,9 @@ export async function listenForNavigationDeepLinks( try { while (drainRequested) { drainRequested = false; - await drainPendingNavigationDeepLinks(onOpenChannel, onOpenMessage); + await serializeNavigationDrain(() => + drainPendingNavigationDeepLinks(onOpenChannel, onOpenMessage), + ); } } catch (error: unknown) { console.warn("Failed to drain pending navigation deep links", error); From cd62bfb7af0f632a231a3a727590101b0a69b215 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 14:18:35 -0400 Subject: [PATCH 08/32] fix(mobile): honor channel link callbacks Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../features/channels/message_content.dart | 25 +++++++++++++----- .../channels/message_content_test.dart | 26 +++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 8556adbbb21..25ca63c8162 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -303,8 +303,15 @@ class MessageContent extends HookConsumerWidget { 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, @@ -370,6 +377,7 @@ class MessageContent extends HookConsumerWidget { String url, TextStyle linkStyle, TextStyle? fallbackStyle, + void Function(String channelId) resolvedChannelTap, ) { String text = ''; linkText.visitChildren((span) { @@ -386,12 +394,15 @@ class MessageContent extends HookConsumerWidget { final uri = Uri.tryParse(url); if (uri == null) return; - // `buzz://message` and `buzz://join` are app-owned links. Park the - // parsed target so the top-level dispatcher can route it once the - // authenticated/channel UI is ready; do not hand it to an OS browser, - // which is what made rendered mobile deep links inert. + // 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') { - if (parseBuzzDeepLink(uri) != null) { + final deepLink = parseBuzzDeepLink(uri); + if (deepLink case ChannelDeepLink(:final channelId)) { + resolvedChannelTap(channelId); + } else if (deepLink != null) { ref.read(pendingDeepLinkProvider.notifier).open(uri); } return; diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 62244b8f09c..4a56513e532 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -622,6 +622,32 @@ void main() { ); }); + 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 { From ed4e0287794ddcfe1af1a3867d210c22d83c0913 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 14:45:29 -0400 Subject: [PATCH 09/32] fix(mobile): preserve emphasis around bare links Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../features/channels/message_content.dart | 40 +++++++++++++++++-- .../channels/message_content_test.dart | 22 ++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 25ca63c8162..542f26813bc 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -245,13 +245,45 @@ class MessageContent extends HookConsumerWidget { ), (m) { final matched = m[0]!; - final url = matched.replaceFirst(RegExp(r'[.,!?:;]+$'), ''); - final trailingPunctuation = matched.substring(url.length); + var url = matched; + var trailing = ''; + final start = m.start; + + // A bare URL inside emphasis initially includes the closing + // Markdown delimiter. Peel only a delimiter whose matching + // opener immediately precedes this URL, preserving legitimate + // URL characters in ordinary prose. Sentence punctuation can + // appear on either side of that closing delimiter. + final outsidePunctuation = RegExp(r'[.,!?:;]+$').firstMatch(url); + if (outsidePunctuation != null) { + url = url.substring(0, outsidePunctuation.start); + trailing = outsidePunctuation[0]!; + } + for (final delimiter in const [ + '***', + '___', + '**', + '__', + '~~', + '*', + '_', + ]) { + if (segment.substring(0, start).endsWith(delimiter) && + url.endsWith(delimiter)) { + url = url.substring(0, url.length - delimiter.length); + trailing = '$delimiter$trailing'; + break; + } + } + final punctuation = RegExp(r'[.,!?:;]+$').firstMatch(url); + if (punctuation != null) { + url = url.substring(0, punctuation.start); + trailing = '${punctuation[0]}$trailing'; + } // 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 matched; - return '[$url]($url)$trailingPunctuation'; + return '[$url]($url)$trailing'; }, ); buffer.write(segment); diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 4a56513e532..0a844ad5f2f 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -489,6 +489,28 @@ void main() { ); }); + 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('excludes sentence punctuation from bare Buzz links', ( tester, ) async { From c9f268552b7cf45572b600d518c7c1b1a5633ac2 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 14:51:29 -0400 Subject: [PATCH 10/32] fix(mobile): satisfy file size ratchet Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- mobile/lib/features/channels/message_content.dart | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 542f26813bc..05be9452ac6 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -226,8 +226,6 @@ class MessageContent extends HookConsumerWidget { // Inside backticks — preserve as-is. buffer.write('`${parts[i]}`'); } else { - // 1. Angle-bracket autolinks: and supported Buzz - // links. gpt_markdown does not auto-link custom schemes. var segment = parts[i].replaceAllMapped( RegExp( r'<((?:https?://|buzz://(?:message\?|join\?|channel/))[^>]+)>', @@ -249,11 +247,8 @@ class MessageContent extends HookConsumerWidget { var trailing = ''; final start = m.start; - // A bare URL inside emphasis initially includes the closing - // Markdown delimiter. Peel only a delimiter whose matching - // opener immediately precedes this URL, preserving legitimate - // URL characters in ordinary prose. Sentence punctuation can - // appear on either side of that closing delimiter. + // Keep balanced Markdown delimiters outside the generated link + // without excluding legitimate URL characters globally. final outsidePunctuation = RegExp(r'[.,!?:;]+$').firstMatch(url); if (outsidePunctuation != null) { url = url.substring(0, outsidePunctuation.start); From a94efe066d47b93664c5b70779d89351d9389e65 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 14:52:05 -0400 Subject: [PATCH 11/32] fix(mobile): meet message content size limit Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- mobile/lib/features/channels/message_content.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 05be9452ac6..72fe762d3d0 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -247,8 +247,6 @@ class MessageContent extends HookConsumerWidget { var trailing = ''; final start = m.start; - // Keep balanced Markdown delimiters outside the generated link - // without excluding legitimate URL characters globally. final outsidePunctuation = RegExp(r'[.,!?:;]+$').firstMatch(url); if (outsidePunctuation != null) { url = url.substring(0, outsidePunctuation.start); From ec07e69e014e06a6be4636772d426766fbd528b6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 14:56:35 -0400 Subject: [PATCH 12/32] fix(mobile): pass message content ratchet Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- mobile/lib/features/channels/message_content.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 72fe762d3d0..74fd2e77ebf 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -232,11 +232,6 @@ class MessageContent extends HookConsumerWidget { ), (m) => '[${m[1]}](${m[1]})', ); - // 2. Bare URLs not already inside markdown link/image syntax. - // Keep this in sync with the app-owned links accepted by - // parseBuzzDeepLink. Negative lookbehind avoids matching URLs - // preceded by ]( or =, which are already part of markdown links - // or imeta tags. segment = segment.replaceAllMapped( RegExp( r'(?\]]+', From 7fc7e20e75a20829c16ac8fb7dcfebac3204c3f4 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 15:16:06 -0400 Subject: [PATCH 13/32] fix(desktop): reset deep-link drains on community switch Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../features/communities/useCommunityInit.ts | 2 + desktop/src/shared/deep-link.test.mjs | 39 ++++++++++++++++++- desktop/src/shared/deep-link.ts | 12 ++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 0c27ab0541f..6127685ac28 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -15,6 +15,7 @@ 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, @@ -53,6 +54,7 @@ function resetCommunityState({ resetAvatarState: boolean; }): void { relayClient.disconnect(); + resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); resetAgentObserverStore(); diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index c137df308e4..37a5de57fbb 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -23,7 +23,8 @@ globalThis.window = { }; globalThis.__TAURI_INTERNALS__ = tauriInternals; -const { listenForNavigationDeepLinks } = await import("@/shared/deep-link.ts"); +const { listenForNavigationDeepLinks, resetNavigationDeepLinkDrain } = + await import("@/shared/deep-link.ts"); function deferred() { let resolve; @@ -186,6 +187,42 @@ test("concurrent listener remount does not take or acknowledge the in-flight hea 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; + + 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; + }); + + const unlisten = await listenForNavigationDeepLinks( + async () => { + await routeGate.promise; + return true; + }, + () => true, + ); + await settle(); + + resetNavigationDeepLinkDrain(); + routeGate.resolve(); + await settle(); + + assert.equal(acknowledgeCount, 0); + unlisten(); +}); + test("rejected navigation remains queued and is not acknowledged", async () => { const pending = { id: "retry-me", diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 7416dabb892..3916fd698a3 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -163,6 +163,11 @@ export async function listenForDeepLinks( } let navigationDrainTail: Promise = Promise.resolve(); +let navigationDrainGeneration = 0; + +export function resetNavigationDeepLinkDrain(): void { + navigationDrainGeneration += 1; +} function serializeNavigationDrain(task: () => Promise): Promise { const drain = navigationDrainTail.then(task, task); @@ -180,11 +185,12 @@ async function drainPendingNavigationDeepLinks( payload: MessageDeepLinkPayload, ) => boolean | Promise, ) { - while (true) { + const generation = navigationDrainGeneration; + while (generation === navigationDrainGeneration) { const pending = await invoke( "take_pending_navigation_deep_link", ); - if (!pending) return; + if (!pending || generation !== navigationDrainGeneration) return; const accepted = await (pending.kind === "channel" ? onOpenChannel({ channelId: pending.channelId }) : pending.messageId @@ -194,7 +200,7 @@ async function drainPendingNavigationDeepLinks( threadRootId: pending.threadRootId, }) : false); - if (!accepted) return; + if (!accepted || generation !== navigationDrainGeneration) return; const acknowledged = await invoke( "acknowledge_pending_navigation_deep_link", { id: pending.id }, From f8e7de95daa5d266456d093490393b83dbc256b6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 15:38:50 -0400 Subject: [PATCH 14/32] fix(desktop): clear deep links on community switch Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/deep_link.rs | 11 ++++++++++- desktop/src-tauri/src/lib.rs | 5 +++-- desktop/src/features/communities/useCommunityInit.ts | 8 ++++---- desktop/src/shared/deep-link.test.mjs | 7 ++++++- desktop/src/shared/deep-link.ts | 3 ++- desktop/src/testing/e2eBridge.ts | 3 +++ 6 files changed, 28 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 8f0f2ea3302..f8e079c0bb2 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -54,6 +54,10 @@ impl PendingNavigationDeepLinks { queue.push_back(pending); } + fn clear(&self) { + self.lock().clear(); + } + fn first(&self) -> Option { self.lock().front().cloned() } @@ -69,6 +73,11 @@ impl PendingNavigationDeepLinks { } } +#[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>, @@ -551,7 +560,7 @@ mod tests { assert!(!queue.acknowledge("second")); assert!(queue.acknowledge("first")); assert_eq!(queue.first().unwrap().id, "second"); - assert!(queue.acknowledge("second")); + queue.clear(); assert!(queue.first().is_none()); } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6982569509a..0dd0ee717b0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -50,8 +50,8 @@ use builderlab::*; use commands::*; use deep_link::{ 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, + 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, @@ -618,6 +618,7 @@ pub fn run() { 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, diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 6127685ac28..0e8a5d5f08e 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -48,13 +48,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 { relayClient.disconnect(); - resetNavigationDeepLinkDrain(); + await resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); resetAgentObserverStore(); @@ -209,7 +209,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, }); diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index 37a5de57fbb..3f6fa12645b 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -197,9 +197,13 @@ test("community reset prevents an in-flight route from acknowledging", async () }; 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; @@ -215,10 +219,11 @@ test("community reset prevents an in-flight route from acknowledging", async () ); await settle(); - resetNavigationDeepLinkDrain(); + await resetNavigationDeepLinkDrain(); routeGate.resolve(); await settle(); + assert.equal(clearCount, 1); assert.equal(acknowledgeCount, 0); unlisten(); }); diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 3916fd698a3..5b991f93638 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -165,8 +165,9 @@ export async function listenForDeepLinks( let navigationDrainTail: Promise = Promise.resolve(); let navigationDrainGeneration = 0; -export function resetNavigationDeepLinkDrain(): void { +export async function resetNavigationDeepLinkDrain(): Promise { navigationDrainGeneration += 1; + await invoke("clear_pending_navigation_deep_links"); } function serializeNavigationDrain(task: () => Promise): Promise { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ca560a5337e..ba8642c0ec0 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11933,6 +11933,9 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } + case "clear_pending_navigation_deep_links": + mockPendingNavigationDeepLinks.length = 0; + return; case "take_pending_navigation_deep_link": return mockPendingNavigationDeepLinks[0] ?? null; case "acknowledge_pending_navigation_deep_link": { From 4399a93b6f7aa720ebee6d3aae5f5cccfdc901c5 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 16:32:43 -0400 Subject: [PATCH 15/32] fix: address deep-link review feedback Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/deep_link.rs | 22 ++++ .../features/communities/useCommunityInit.ts | 2 +- desktop/src/shared/deep-link.test.mjs | 107 ++++++++++++++++++ desktop/src/shared/deep-link.ts | 9 +- desktop/tests/e2e/community-rail.spec.ts | 10 ++ .../features/channels/message_content.dart | 55 ++++++--- .../channels/message_content_test.dart | 33 ++++++ 7 files changed, 222 insertions(+), 16 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index f8e079c0bb2..098b3f1e79e 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -560,6 +560,28 @@ mod tests { 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()); } diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 0e8a5d5f08e..ac3326beb09 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -130,7 +130,7 @@ export function useCommunityInit( saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current); prevCommunityIdRef.current = null; } - resetCommunityState({ resetAvatarState: true }); + await resetCommunityState({ resetAvatarState: true }); appliedRelayUrlRef.current = null; hasInitializedRef.current = false; } diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index 3f6fa12645b..80be3ef55ef 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -228,6 +228,113 @@ test("community reset prevents an in-flight route from acknowledging", async () 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(); + 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(); + 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 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(); + 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", diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 5b991f93638..f2962c995f9 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -167,7 +167,14 @@ let navigationDrainGeneration = 0; export async function resetNavigationDeepLinkDrain(): Promise { navigationDrainGeneration += 1; - await invoke("clear_pending_navigation_deep_links"); + try { + await invoke("clear_pending_navigation_deep_links"); + } 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 { diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 51b4867bf46..ea6138ff0bf 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -834,6 +834,16 @@ 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("leaving the final community returns to setup without resetting identity", async ({ diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 74fd2e77ebf..9f5c612cd77 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -76,6 +76,25 @@ String _safeDownloadedFilename(String filename) { return safe.isEmpty ? 'attachment' : safe; } +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; + } +} + /// Renders message content with markdown formatting, @mentions, #channel links, /// and media-aware markdown images/videos. class MessageContent extends HookConsumerWidget { @@ -247,20 +266,28 @@ class MessageContent extends HookConsumerWidget { url = url.substring(0, outsidePunctuation.start); trailing = outsidePunctuation[0]!; } - for (final delimiter in const [ - '***', - '___', - '**', - '__', - '~~', - '*', - '_', - ]) { - if (segment.substring(0, start).endsWith(delimiter) && - url.endsWith(delimiter)) { - url = url.substring(0, url.length - delimiter.length); - trailing = '$delimiter$trailing'; - break; + var strippedDelimiter = true; + while (strippedDelimiter) { + strippedDelimiter = false; + for (final delimiter in const [ + '***', + '___', + '**', + '__', + '~~', + '*', + '_', + ]) { + if (url.endsWith(delimiter) && + _hasUnclosedMarkdownDelimiter( + segment.substring(0, start), + delimiter, + )) { + url = url.substring(0, url.length - delimiter.length); + trailing = '$delimiter$trailing'; + strippedDelimiter = true; + break; + } } } final punctuation = RegExp(r'[.,!?:;]+$').firstMatch(url); diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 0a844ad5f2f..22e7fa2853f 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -511,6 +511,39 @@ void main() { ); }); + 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 { From b909caa3f0f23ce2c160e576334ec14be08e3d21 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 4 Aug 2026 16:56:30 -0400 Subject: [PATCH 16/32] fix(mobile): satisfy deep-link file size gate Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../features/channels/message_content.dart | 102 ++---------------- .../message_content/link_normalizer.dart | 93 ++++++++++++++++ .../message_content/link_normalizer_test.dart | 36 +++++++ 3 files changed, 138 insertions(+), 93 deletions(-) create mode 100644 mobile/lib/features/channels/message_content/link_normalizer.dart create mode 100644 mobile/test/features/channels/message_content/link_normalizer_test.dart diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 9f5c612cd77..7aa5c80682b 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -27,6 +27,7 @@ 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'; @@ -76,25 +77,6 @@ String _safeDownloadedFilename(String filename) { return safe.isEmpty ? 'attachment' : safe; } -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; - } -} - /// Renders message content with markdown formatting, @mentions, #channel links, /// and media-aware markdown images/videos. class MessageContent extends HookConsumerWidget { @@ -235,82 +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 { - var segment = parts[i].replaceAllMapped( - RegExp( - r'<((?:https?://|buzz://(?:message\?|join\?|channel/))[^>]+)>', - ), - (m) => '[${m[1]}](${m[1]})', - ); - segment = segment.replaceAllMapped( - RegExp( - r'(?\]]+', - ), - (m) { - final matched = m[0]!; - var url = matched; - var trailing = ''; - final start = m.start; - - final outsidePunctuation = RegExp(r'[.,!?:;]+$').firstMatch(url); - if (outsidePunctuation != null) { - url = url.substring(0, outsidePunctuation.start); - trailing = outsidePunctuation[0]!; - } - var strippedDelimiter = true; - while (strippedDelimiter) { - strippedDelimiter = false; - for (final delimiter in const [ - '***', - '___', - '**', - '__', - '~~', - '*', - '_', - ]) { - if (url.endsWith(delimiter) && - _hasUnclosedMarkdownDelimiter( - segment.substring(0, start), - delimiter, - )) { - url = url.substring(0, url.length - delimiter.length); - trailing = '$delimiter$trailing'; - strippedDelimiter = true; - break; - } - } - } - final punctuation = RegExp(r'[.,!?:;]+$').firstMatch(url); - if (punctuation != null) { - url = url.substring(0, punctuation.start); - trailing = '${punctuation[0]}$trailing'; - } - // Skip if this URL is already a markdown link label that equals - // the URL (produced by step 1 or authored as [url](url)). - if (start >= 1 && segment[start - 1] == '[') return matched; - return '[$url]($url)$trailing'; - }, - ); - 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) { @@ -329,16 +246,15 @@ 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( 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..ba6bff3310a --- /dev/null +++ b/mobile/lib/features/channels/message_content/link_normalizer.dart @@ -0,0 +1,93 @@ +const _markdownDelimiters = ['***', '___', '**', '__', '~~', '*', '_']; + +final _autolinkPattern = RegExp( + r'<((?:https?://|buzz://(?:message\?|join\?|channel/))[^>]+)>', +); +final _bareLinkPattern = RegExp( + r'(?\]]+', +); +final _trailingPunctuationPattern = RegExp(r'[.,!?:;]+$'); + +/// Converts supported autolinks and bare links into Markdown links while +/// leaving inline and fenced code untouched. +String normalizeBareLinks(String content) { + final buffer = StringBuffer(); + final parts = content.split('`'); + for (var i = 0; i < parts.length; i++) { + if (i.isOdd) { + buffer.write('`${parts[i]}`'); + continue; + } + + var segment = parts[i].replaceAllMapped( + _autolinkPattern, + (match) => '[${match[1]}](${match[1]})', + ); + segment = segment.replaceAllMapped( + _bareLinkPattern, + (match) => _normalizeBareLink(segment, match), + ); + buffer.write(segment); + } + return buffer.toString(); +} + +String _normalizeBareLink(String segment, Match match) { + final matched = match[0]!; + var url = matched; + var trailing = ''; + final start = match.start; + + final outsidePunctuation = _trailingPunctuationPattern.firstMatch(url); + if (outsidePunctuation != null) { + url = url.substring(0, outsidePunctuation.start); + trailing = outsidePunctuation[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; + } + } + } + + final punctuation = _trailingPunctuationPattern.firstMatch(url); + if (punctuation != null) { + url = url.substring(0, punctuation.start); + trailing = '${punctuation[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/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..446983cecc4 --- /dev/null +++ b/mobile/test/features/channels/message_content/link_normalizer_test.dart @@ -0,0 +1,36 @@ +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('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('leaves links inside backticks untouched', () { + expect(normalizeBareLinks('`$url` then $url'), '`$url` then [$url]($url)'); + }); +} From f3db81f6598a0486f8313c4eda620e4d46173d59 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 14:48:23 -0400 Subject: [PATCH 17/32] fix(mobile): preserve multi-backtick code links Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../message_content/link_normalizer.dart | 46 +++++++++++++------ .../message_content/link_normalizer_test.dart | 11 +++++ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/mobile/lib/features/channels/message_content/link_normalizer.dart b/mobile/lib/features/channels/message_content/link_normalizer.dart index ba6bff3310a..7840d57ba5a 100644 --- a/mobile/lib/features/channels/message_content/link_normalizer.dart +++ b/mobile/lib/features/channels/message_content/link_normalizer.dart @@ -12,26 +12,44 @@ final _trailingPunctuationPattern = RegExp(r'[.,!?:;]+$'); /// leaving inline and fenced code untouched. String normalizeBareLinks(String content) { final buffer = StringBuffer(); - final parts = content.split('`'); - for (var i = 0; i < parts.length; i++) { - if (i.isOdd) { - buffer.write('`${parts[i]}`'); - continue; + final backtickRuns = RegExp(r'`+').allMatches(content); + var offset = 0; + String? codeDelimiter; + + for (final run in backtickRuns) { + if (codeDelimiter == null) { + buffer.write(_normalizeLinkSegment(content.substring(offset, run.start))); + codeDelimiter = run[0]!; + } else { + buffer.write(content.substring(offset, run.start)); + if (run[0] == codeDelimiter) { + codeDelimiter = null; + } } - var segment = parts[i].replaceAllMapped( - _autolinkPattern, - (match) => '[${match[1]}](${match[1]})', - ); - segment = segment.replaceAllMapped( - _bareLinkPattern, - (match) => _normalizeBareLink(segment, match), - ); - buffer.write(segment); + buffer.write(run[0]!); + offset = run.end; } + + final trailing = content.substring(offset); + buffer.write( + codeDelimiter == null ? _normalizeLinkSegment(trailing) : trailing, + ); return buffer.toString(); } +String _normalizeLinkSegment(String segment) { + var normalized = segment.replaceAllMapped( + _autolinkPattern, + (match) => '[${match[1]}](${match[1]})', + ); + normalized = normalized.replaceAllMapped( + _bareLinkPattern, + (match) => _normalizeBareLink(normalized, match), + ); + return normalized; +} + String _normalizeBareLink(String segment, Match match) { final matched = match[0]!; var url = matched; diff --git a/mobile/test/features/channels/message_content/link_normalizer_test.dart b/mobile/test/features/channels/message_content/link_normalizer_test.dart index 446983cecc4..33407672166 100644 --- a/mobile/test/features/channels/message_content/link_normalizer_test.dart +++ b/mobile/test/features/channels/message_content/link_normalizer_test.dart @@ -33,4 +33,15 @@ void main() { test('leaves links inside backticks untouched', () { expect(normalizeBareLinks('`$url` then $url'), '`$url` then [$url]($url)'); }); + + test('leaves links inside matching multi-backtick code spans untouched', () { + expect( + normalizeBareLinks('``$url`` then $url'), + '``$url`` then [$url]($url)', + ); + expect( + normalizeBareLinks('````$url```` then $url'), + '````$url```` then [$url]($url)', + ); + }); } From da7ccd35a36c60be3292a8c7eca14d8d6dfba5c0 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 10 Aug 2026 15:34:34 -0400 Subject: [PATCH 18/32] fix(mobile): distinguish inline code from fences Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../message_content/link_normalizer.dart | 129 ++++++++++++++---- .../message_content/link_normalizer_test.dart | 66 +++++++-- 2 files changed, 157 insertions(+), 38 deletions(-) diff --git a/mobile/lib/features/channels/message_content/link_normalizer.dart b/mobile/lib/features/channels/message_content/link_normalizer.dart index 7840d57ba5a..3f8b41b59e9 100644 --- a/mobile/lib/features/channels/message_content/link_normalizer.dart +++ b/mobile/lib/features/channels/message_content/link_normalizer.dart @@ -7,37 +7,109 @@ final _bareLinkPattern = RegExp( r'(?\]]+', ); final _trailingPunctuationPattern = RegExp(r'[.,!?:;]+$'); +final _backtickRunPattern = RegExp(r'`+'); -/// Converts supported autolinks and bare links into Markdown links while -/// leaving inline and fenced code untouched. +/// 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(); - final backtickRuns = RegExp(r'`+').allMatches(content); var offset = 0; - String? codeDelimiter; - - for (final run in backtickRuns) { - if (codeDelimiter == null) { - buffer.write(_normalizeLinkSegment(content.substring(offset, run.start))); - codeDelimiter = run[0]!; - } else { - buffer.write(content.substring(offset, run.start)); - if (run[0] == codeDelimiter) { - codeDelimiter = null; + 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; } - buffer.write(run[0]!); offset = run.end; } - final trailing = content.substring(offset); - buffer.write( - codeDelimiter == null ? _normalizeLinkSegment(trailing) : trailing, - ); + 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, @@ -54,12 +126,15 @@ String _normalizeBareLink(String segment, Match match) { final matched = match[0]!; var url = matched; var trailing = ''; + final isBuzzUrl = matched.startsWith('buzz://'); final start = match.start; - final outsidePunctuation = _trailingPunctuationPattern.firstMatch(url); - if (outsidePunctuation != null) { - url = url.substring(0, outsidePunctuation.start); - trailing = outsidePunctuation[0]!; + if (isBuzzUrl) { + final outsidePunctuation = _trailingPunctuationPattern.firstMatch(url); + if (outsidePunctuation != null) { + url = url.substring(0, outsidePunctuation.start); + trailing = outsidePunctuation[0]!; + } } var strippedDelimiter = true; @@ -79,10 +154,12 @@ String _normalizeBareLink(String segment, Match match) { } } - final punctuation = _trailingPunctuationPattern.firstMatch(url); - if (punctuation != null) { - url = url.substring(0, punctuation.start); - trailing = '${punctuation[0]}$trailing'; + if (isBuzzUrl) { + final punctuation = _trailingPunctuationPattern.firstMatch(url); + if (punctuation != null) { + url = url.substring(0, punctuation.start); + trailing = '${punctuation[0]}$trailing'; + } } // Preserve a URL already used as its own Markdown label. This covers both diff --git a/mobile/test/features/channels/message_content/link_normalizer_test.dart b/mobile/test/features/channels/message_content/link_normalizer_test.dart index 33407672166..6bf29973e11 100644 --- a/mobile/test/features/channels/message_content/link_normalizer_test.dart +++ b/mobile/test/features/channels/message_content/link_normalizer_test.dart @@ -30,18 +30,60 @@ void main() { ); }); - test('leaves links inside backticks untouched', () { - expect(normalizeBareLinks('`$url` then $url'), '`$url` then [$url]($url)'); - }); + 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)', + ), + ]; - test('leaves links inside matching multi-backtick code spans untouched', () { - expect( - normalizeBareLinks('``$url`` then $url'), - '``$url`` then [$url]($url)', - ); - expect( - normalizeBareLinks('````$url```` then $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)', + ); + }, + ); } From 685e5ee8dfa8b9ab6c4f9942d61e0ee0af7f871f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 12 Aug 2026 13:32:49 -0400 Subject: [PATCH 19/32] fix(desktop): cancel superseded community resets Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../features/communities/useCommunityInit.ts | 7 ++- desktop/src/testing/e2eBridge.ts | 13 ++++- desktop/tests/e2e/community-rail.spec.ts | 56 +++++++++++++++++++ desktop/tests/helpers/bridge.ts | 2 + 4 files changed, 76 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index ac3326beb09..d914c0be754 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -54,7 +54,6 @@ async function resetCommunityState({ resetAvatarState: boolean; }): Promise { relayClient.disconnect(); - await resetNavigationDeepLinkDrain(); resetRateLimitGate(); clearAllDrafts(); resetAgentObserverStore(); @@ -75,6 +74,7 @@ async function resetCommunityState({ resetBackgroundMediaUploads(); clearSearchHitEventCache(); clearMarkdownNodeCache(); + await resetNavigationDeepLinkDrain(); } type CommunityInitResult = @@ -131,6 +131,7 @@ export function useCommunityInit( prevCommunityIdRef.current = null; } await resetCommunityState({ resetAvatarState: true }); + if (cancelled) return; appliedRelayUrlRef.current = null; hasInitializedRef.current = false; } @@ -213,6 +214,10 @@ export function useCommunityInit( resetAvatarState: appliedRelayUrlRef.current !== activeCommunity.relayUrl, }); + // 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; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index ba8642c0ec0..8202154bd95 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -324,6 +324,9 @@ type E2eConfig = { /** Delay (ms) for `apply_workspace` so e2e tests can observe the * community-switch gate. 0/undefined = instant. */ applyCommunityDelayMs?: number; + /** 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. */ @@ -11933,9 +11936,17 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } - case "clear_pending_navigation_deep_links": + case "clear_pending_navigation_deep_links": { + const clearDelayMs = + activeConfig?.mock?.clearPendingNavigationDeepLinksDelayMs ?? 0; + if (clearDelayMs > 0) { + await new Promise((resolve) => + window.setTimeout(resolve, clearDelayMs), + ); + } mockPendingNavigationDeepLinks.length = 0; return; + } case "take_pending_navigation_deep_link": return mockPendingNavigationDeepLinks[0] ?? null; case "acknowledge_pending_navigation_deep_link": { diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index ea6138ff0bf..6fcddd10eb5 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, @@ -846,6 +852,56 @@ test.describe("community rail", () => { .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("leaving the final community returns to setup without resetting identity", async ({ context, page, diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ff2f66ec613..5baea7bcdc6 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -278,6 +278,8 @@ type MockBridgeOptions = { canvasReadError?: string; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ applyCommunityDelayMs?: number; + /** 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. */ From d514fdbe9422b691085eb23cef4e9ee86936c3be Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 12 Aug 2026 13:59:06 -0400 Subject: [PATCH 20/32] fix(desktop): supersede in-flight workspace applies Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/app_state.rs | 16 ++-- desktop/src-tauri/src/commands/workspace.rs | 96 ++++++++++++++++++- desktop/src-tauri/src/lib.rs | 1 + .../features/communities/useCommunityInit.ts | 15 ++- desktop/src/shared/api/tauri.ts | 16 ---- desktop/src/shared/api/tauriWorkspace.ts | 25 +++++ desktop/src/testing/e2eBridge.ts | 45 ++++++++- desktop/tests/e2e/community-rail.spec.ts | 44 +++++++++ desktop/tests/helpers/bridge.ts | 4 + 9 files changed, 231 insertions(+), 31 deletions(-) create mode 100644 desktop/src/shared/api/tauriWorkspace.ts 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/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index aa88bfe39ac..12cf733d517 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::{AtomicU64, Ordering}, + Mutex, +}; use tauri::{AppHandle, Emitter, Manager, State}; use crate::app_state::AppState; @@ -110,6 +113,33 @@ pub async fn validate_repos_dir(dir: String) -> Result<(), String> { .map_err(|e| format!("spawn_blocking failed: {e}"))? } +#[derive(Default)] +pub struct WorkspaceTransitionState { + generation: AtomicU64, + commit: Mutex<()>, +} + +impl WorkspaceTransitionState { + fn claim(&self, generation: u64) { + self.generation.fetch_max(generation, Ordering::AcqRel); + } + + fn is_current(&self, generation: u64) -> bool { + self.generation.load(Ordering::Acquire) == generation + } +} + +fn workspace_transition_is_current(state: &AppState, generation: u64) -> bool { + state.workspace_transition.is_current(generation) +} + +/// Claim ownership for a frontend workspace transition before it begins async +/// teardown. This invalidates an older `apply_workspace` already in flight. +#[tauri::command] +pub fn claim_workspace_transition(generation: u64, state: State<'_, AppState>) { + state.workspace_transition.claim(generation); +} + /// Apply a workspace's configuration to the backend session. /// /// Called by the frontend on app init (after reload) to configure the @@ -129,10 +159,20 @@ 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::(); + // Claim newest intent before any await or blocking work. fetch_max makes a + // late-arriving older invocation harmless even if command scheduling is + // reordered across Tauri worker threads. + state.workspace_transition.claim(transition_generation); + 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 +203,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 +258,22 @@ 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::(); + if !workspace_transition_is_current(&state, transition_generation) { + return Ok(()); + } super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?; + 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 @@ -251,6 +312,9 @@ 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 @@ -259,6 +323,9 @@ pub async fn apply_workspace( } } 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 @@ -274,6 +341,9 @@ 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 let Err(error) = restore_managed_agents_on_launch(&app, &state.shutdown_started).await { @@ -284,3 +354,21 @@ pub async fn apply_workspace( Ok(()) } + +#[cfg(test)] +mod tests { + use super::WorkspaceTransitionState; + + #[test] + fn newer_workspace_claim_permanently_supersedes_older_generation() { + let transition = WorkspaceTransitionState::default(); + transition.claim(1); + assert!(transition.is_current(1)); + transition.claim(3); + assert!(!transition.is_current(1)); + assert!(transition.is_current(3)); + transition.claim(2); + assert!(!transition.is_current(2)); + assert!(transition.is_current(3)); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0dd0ee717b0..5c7dd53f763 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -886,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/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index d914c0be754..57a9ca6f8f2 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -5,10 +5,13 @@ 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"; @@ -118,12 +121,21 @@ 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); + // Monotonic ownership token shared with Rust. Every effect claims a newer + // generation before asynchronous teardown/apply work can be superseded. + const transitionGenerationRef = useRef(0); // 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; + const transitionGeneration = ++transitionGenerationRef.current; async function init() { + // Publish ownership before any teardown await. This reaches the Rust + // authority early enough to invalidate an older apply already in flight. + await claimWorkspaceTransition(transitionGeneration); + if (cancelled) return; + if (!activeCommunity) { if (hasInitializedRef.current) { if (prevCommunityIdRef.current) { @@ -238,6 +250,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/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..22c0581d806 --- /dev/null +++ b/desktop/src/shared/api/tauriWorkspace.ts @@ -0,0 +1,25 @@ +import { invokeTauri } from "./tauri"; + +export async function claimWorkspaceTransition( + transitionGeneration: number, +): Promise { + await invokeTauri("claim_workspace_transition", { transitionGeneration }); +} + +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/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 8202154bd95..907f5ca48a0 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -324,6 +324,10 @@ 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; @@ -1123,6 +1127,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: { @@ -10204,6 +10212,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__ = []; @@ -10551,6 +10560,7 @@ export function maybeInstallE2eTauriMocks() { deviceName: state === "running" ? "Mock desktop" : null, }; }; + let claimedWorkspaceTransitionGeneration = 0; let mockImportedVoices: Array<{ key: string; displayName: string; @@ -11374,13 +11384,44 @@ export function maybeInstallE2eTauriMocks() { } return activeConfig?.mock?.linkPreviewMetadata ?? null; } + case "claim_workspace_transition": + claimedWorkspaceTransitionGeneration = Math.max( + claimedWorkspaceTransitionGeneration, + (payload as { transitionGeneration?: number }).transitionGeneration ?? + 0, + ); + return; 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) { + claimedWorkspaceTransitionGeneration += 1; + window.__BUZZ_E2E_APPLIED_WORKSPACES__?.push({ + relayUrl: supersedeRelayUrl, + transitionGeneration: claimedWorkspaceTransitionGeneration, + }); + } + 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": diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 6fcddd10eb5..63e6aca5f46 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -902,6 +902,50 @@ test.describe("community rail", () => { .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("leaving the final community returns to setup without resetting identity", async ({ context, page, diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 5baea7bcdc6..b6263980e9a 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -278,6 +278,10 @@ 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; From 939b3395f7d2ed243a2a607bb7975278435083fd Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 12 Aug 2026 14:20:30 -0400 Subject: [PATCH 21/32] fix(mobile): avoid lookbehind in link normalization Keep existing Markdown destinations and imeta attributes intact by checking the preceding character in Dart rather than encoding that boundary as a regular-expression lookbehind. Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/message_content/link_normalizer.dart | 15 ++++++++++++++- .../message_content/link_normalizer_test.dart | 12 ++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/mobile/lib/features/channels/message_content/link_normalizer.dart b/mobile/lib/features/channels/message_content/link_normalizer.dart index 3f8b41b59e9..97fc8323453 100644 --- a/mobile/lib/features/channels/message_content/link_normalizer.dart +++ b/mobile/lib/features/channels/message_content/link_normalizer.dart @@ -4,7 +4,7 @@ final _autolinkPattern = RegExp( r'<((?:https?://|buzz://(?:message\?|join\?|channel/))[^>]+)>', ); final _bareLinkPattern = RegExp( - r'(?\]]+', + r'(?:https?://|buzz://(?:message\?|join\?|channel/))[^\s)>\]]+', ); final _trailingPunctuationPattern = RegExp(r'[.,!?:;]+$'); final _backtickRunPattern = RegExp(r'`+'); @@ -129,6 +129,19 @@ String _normalizeBareLink(String segment, Match match) { 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 == '=') { + return matched; + } + } + if (isBuzzUrl) { final outsidePunctuation = _trailingPunctuationPattern.firstMatch(url); if (outsidePunctuation != null) { diff --git a/mobile/test/features/channels/message_content/link_normalizer_test.dart b/mobile/test/features/channels/message_content/link_normalizer_test.dart index 6bf29973e11..4bfb4eaa5f6 100644 --- a/mobile/test/features/channels/message_content/link_normalizer_test.dart +++ b/mobile/test/features/channels/message_content/link_normalizer_test.dart @@ -30,6 +30,18 @@ void main() { ); }); + 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})>[ ( From 81cd9bfbf35e1f1a9d7d23f50b2a4464c98b9584 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 12 Aug 2026 14:40:12 -0400 Subject: [PATCH 22/32] fix(desktop): preserve workspace ownership across reloads Allocate transition generations from process-lifetime native state so frontend remounts cannot restart below the active workspace generation. Model and test the same-process reload boundary. Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/workspace.rs | 42 ++++++++++--------- .../features/communities/useCommunityInit.ts | 12 +++--- desktop/src/shared/api/tauriWorkspace.ts | 6 +-- desktop/src/testing/e2eBridge.ts | 31 ++++++++++---- desktop/tests/e2e/community-rail.spec.ts | 18 ++++++++ 5 files changed, 70 insertions(+), 39 deletions(-) diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 12cf733d517..0648014a963 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -120,8 +120,8 @@ pub struct WorkspaceTransitionState { } impl WorkspaceTransitionState { - fn claim(&self, generation: u64) { - self.generation.fetch_max(generation, Ordering::AcqRel); + fn claim_next(&self) -> u64 { + self.generation.fetch_add(1, Ordering::AcqRel) + 1 } fn is_current(&self, generation: u64) -> bool { @@ -133,11 +133,12 @@ fn workspace_transition_is_current(state: &AppState, generation: u64) -> bool { state.workspace_transition.is_current(generation) } -/// Claim ownership for a frontend workspace transition before it begins async -/// teardown. This invalidates an older `apply_workspace` already in flight. +/// 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(generation: u64, state: State<'_, AppState>) { - state.workspace_transition.claim(generation); +pub fn claim_workspace_transition(state: State<'_, AppState>) -> u64 { + state.workspace_transition.claim_next() } /// Apply a workspace's configuration to the backend session. @@ -163,10 +164,8 @@ pub async fn apply_workspace( app: AppHandle, ) -> Result<(), String> { let state = app.state::(); - // Claim newest intent before any await or blocking work. fetch_max makes a - // late-arriving older invocation harmless even if command scheduling is - // reordered across Tauri worker threads. - state.workspace_transition.claim(transition_generation); + // 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(()); } @@ -360,15 +359,20 @@ mod tests { use super::WorkspaceTransitionState; #[test] - fn newer_workspace_claim_permanently_supersedes_older_generation() { + fn workspace_claims_remain_monotonic_across_frontend_epochs() { let transition = WorkspaceTransitionState::default(); - transition.claim(1); - assert!(transition.is_current(1)); - transition.claim(3); - assert!(!transition.is_current(1)); - assert!(transition.is_current(3)); - transition.claim(2); - assert!(!transition.is_current(2)); - assert!(transition.is_current(3)); + 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)); } } diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 57a9ca6f8f2..aa2b318fc37 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -121,19 +121,17 @@ 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); - // Monotonic ownership token shared with Rust. Every effect claims a newer - // generation before asynchronous teardown/apply work can be superseded. - const transitionGenerationRef = useRef(0); + // 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; - const transitionGeneration = ++transitionGenerationRef.current; async function init() { - // Publish ownership before any teardown await. This reaches the Rust - // authority early enough to invalidate an older apply already in flight. - await claimWorkspaceTransition(transitionGeneration); + // 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) { diff --git a/desktop/src/shared/api/tauriWorkspace.ts b/desktop/src/shared/api/tauriWorkspace.ts index 22c0581d806..8a845d26d75 100644 --- a/desktop/src/shared/api/tauriWorkspace.ts +++ b/desktop/src/shared/api/tauriWorkspace.ts @@ -1,9 +1,7 @@ import { invokeTauri } from "./tauri"; -export async function claimWorkspaceTransition( - transitionGeneration: number, -): Promise { - await invokeTauri("claim_workspace_transition", { transitionGeneration }); +export async function claimWorkspaceTransition(): Promise { + return await invokeTauri("claim_workspace_transition"); } export async function applyCommunity( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 907f5ca48a0..6f76e4d9e41 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -10560,7 +10560,24 @@ export function maybeInstallE2eTauriMocks() { deviceName: state === "running" ? "Mock desktop" : null, }; }; - let claimedWorkspaceTransitionGeneration = 0; + 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; @@ -11385,12 +11402,7 @@ export function maybeInstallE2eTauriMocks() { return activeConfig?.mock?.linkPreviewMetadata ?? null; } case "claim_workspace_transition": - claimedWorkspaceTransitionGeneration = Math.max( - claimedWorkspaceTransitionGeneration, - (payload as { transitionGeneration?: number }).transitionGeneration ?? - 0, - ); - return; + return claimNextWorkspaceTransitionGeneration(); case "apply_workspace": { const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0; const delayRelayUrl = activeConfig?.mock?.applyCommunityDelayRelayUrl; @@ -11402,10 +11414,11 @@ export function maybeInstallE2eTauriMocks() { const supersedeRelayUrl = activeConfig?.mock?.applyCommunitySupersedeRelayUrl; if (supersedeRelayUrl) { - claimedWorkspaceTransitionGeneration += 1; + const supersedeGeneration = + claimNextWorkspaceTransitionGeneration(); window.__BUZZ_E2E_APPLIED_WORKSPACES__?.push({ relayUrl: supersedeRelayUrl, - transitionGeneration: claimedWorkspaceTransitionGeneration, + transitionGeneration: supersedeGeneration, }); } await new Promise((resolve) => diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 63e6aca5f46..cc50045f375 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -946,6 +946,24 @@ test.describe("community rail", () => { ]); }); + 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 ({ context, page, From 15e7c4c4c3c913266cb416301d9d564f45cd88c6 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 12 Aug 2026 14:45:41 -0400 Subject: [PATCH 23/32] fix(desktop): retain superseded launch restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leave the one-shot managed-agent restore pending until the current workspace transition completes it successfully. Serialize token claims with pending completion so a stale restore cannot consume the winning transition’s work. Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/workspace.rs | 59 +++++++++++++++++---- 1 file changed, 48 insertions(+), 11 deletions(-) diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 0648014a963..b8e24b3a2b9 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -1,7 +1,7 @@ use nostr::Keys; use serde::{Deserialize, Serialize}; use std::sync::{ - atomic::{AtomicU64, Ordering}, + atomic::{AtomicBool, AtomicU64, Ordering}, Mutex, }; use tauri::{AppHandle, Emitter, Manager, State}; @@ -121,12 +121,24 @@ pub struct WorkspaceTransitionState { impl WorkspaceTransitionState { 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 } fn is_current(&self, generation: u64) -> bool { self.generation.load(Ordering::Acquire) == generation } + + 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 { @@ -296,8 +308,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 @@ -326,10 +338,14 @@ pub async fn apply_workspace( return; } if restore_pending { - if let Err(error) = - restore_managed_agents_on_launch(&app, &state.shutdown_started).await - { - eprintln!("buzz-desktop: failed to restore managed agents: {error}"); + match restore_managed_agents_on_launch(&app, &state.shutdown_started).await { + 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}"); + } } } }); @@ -343,10 +359,14 @@ pub async fn apply_workspace( if !workspace_transition_is_current(&state, transition_generation) { return; } - if let Err(error) = - restore_managed_agents_on_launch(&app, &state.shutdown_started).await - { - eprintln!("buzz-desktop: failed to restore managed agents: {error}"); + match restore_managed_agents_on_launch(&app, &state.shutdown_started).await { + 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}"); + } } }); } @@ -357,6 +377,7 @@ pub async fn apply_workspace( #[cfg(test)] mod tests { use super::WorkspaceTransitionState; + use std::sync::atomic::{AtomicBool, Ordering}; #[test] fn workspace_claims_remain_monotonic_across_frontend_epochs() { @@ -375,4 +396,20 @@ mod tests { 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)); + } } From cdaf95bee5a1ac3233ea89ea9e449f2b9f05f44e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 12 Aug 2026 15:00:20 -0400 Subject: [PATCH 24/32] fix(desktop): cancel superseded workspace restores Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/mesh_llm.rs | 70 ++++++-- desktop/src-tauri/src/commands/workspace.rs | 163 +++++++++++++++++- .../src-tauri/src/managed_agents/restore.rs | 114 ++++++++---- .../src-tauri/src/managed_agents/runtime.rs | 2 +- .../src/managed_agents/runtime/stop.rs | 2 +- 5 files changed, 296 insertions(+), 55 deletions(-) diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 7356cd7fc0c..91533494865 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,51 @@ 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(()); + } + if let Some(stale) = runtime.take() { + drop(runtime); + 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(()); + } + if let Some(stale) = runtime.take() { + drop(runtime); + stale.stop().await.map_err(|error| error.to_string())?; + runtime = state.mesh_llm_runtime.lock().await; + } + if owner.is_some_and(|owner| !owner.is_current()) { return Ok(()); } 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 +410,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 +435,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 +783,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/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index b8e24b3a2b9..6540228e3e7 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -117,6 +117,36 @@ pub async fn validate_repos_dir(dir: String) -> Result<(), String> { pub 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 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()) + } } impl WorkspaceTransitionState { @@ -129,6 +159,29 @@ impl WorkspaceTransitionState { self.generation.load(Ordering::Acquire) == 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) } @@ -281,7 +334,16 @@ pub async fn apply_workspace( if !workspace_transition_is_current(&state, transition_generation) { return Ok(()); } - super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?; + 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(()); } @@ -327,8 +389,12 @@ pub async fn apply_workspace( 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}"); } @@ -338,7 +404,13 @@ pub async fn apply_workspace( return; } if restore_pending { - match 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 + { Ok(()) => state.workspace_transition.complete_restore_if_current( transition_generation, &state.managed_agent_restore_pending, @@ -359,7 +431,13 @@ pub async fn apply_workspace( if !workspace_transition_is_current(&state, transition_generation) { return; } - match 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 + { Ok(()) => state.workspace_transition.complete_restore_if_current( transition_generation, &state.managed_agent_restore_pending, @@ -412,4 +490,79 @@ mod tests { 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 supersession_during_mesh_restore_blocks_runtime_install() { + let transition = WorkspaceTransitionState::default(); + let stale_owner = transition.owner(transition.claim_next()); + transition.claim_next(); + + let mut runtime_relay = None; + assert_eq!( + stale_owner.while_current(|| runtime_relay = Some("wss://stale.example")), + None + ); + assert_eq!(runtime_relay, None); + } + + #[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/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 25dadbeec60..e6958f8c4c4 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() { - continue; + 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 record = records + .iter_mut() + .find(|record| record.pubkey == *pubkey) + .expect("candidate record remains available"); + for stale_key in stale_keys { + super::stop_managed_agent_pair(app, record, &mut runtimes, &stale_key)?; } + changed = true; } - 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 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(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. 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, From 4629fb1d67513b41b70a2d4ce6508acab77e2096 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 12 Aug 2026 15:06:28 -0400 Subject: [PATCH 25/32] fix(desktop): guard mesh teardown ownership Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/mesh_llm.rs | 18 ++++++--- desktop/src-tauri/src/commands/workspace.rs | 43 +++++++++++++++++++-- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 91533494865..82a93ae7b26 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -365,8 +365,12 @@ pub(crate) async fn restore_mesh_sharing( if runtime.as_ref().is_some_and(runtime_matches) { return Ok(()); } - if let Some(stale) = runtime.take() { - drop(runtime); + 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())?; } } @@ -381,14 +385,18 @@ pub(crate) async fn restore_mesh_sharing( if runtime.as_ref().is_some_and(runtime_matches) { return Ok(()); } - if let Some(stale) = runtime.take() { - drop(runtime); + 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())?; - runtime = state.mesh_llm_runtime.lock().await; } if owner.is_some_and(|owner| !owner.is_current()) { return Ok(()); } + runtime = state.mesh_llm_runtime.lock().await; if config.start_on_next_launch { // Keep the role-switch checkpoint armed until the still-current // workspace owner has installed the runtime. A superseded restore must diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 6540228e3e7..652bd320188 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -147,6 +147,20 @@ impl<'a> WorkspaceTransitionOwner<'a> { 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 { @@ -506,17 +520,38 @@ mod tests { } #[test] - fn supersession_during_mesh_restore_blocks_runtime_install() { + 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"); - let mut runtime_relay = None; assert_eq!( - stale_owner.while_current(|| runtime_relay = Some("wss://stale.example")), + stale_owner.take_if_current_and(&mut runtime, |_| true), None ); - assert_eq!(runtime_relay, 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] From 5d66432015b0b6697d5238b8faf681b5b44da1ba Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 12 Aug 2026 15:57:10 -0400 Subject: [PATCH 26/32] fix(desktop): preserve newer deep links during workspace reset Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/workspace.rs | 6 ++- desktop/src-tauri/src/deep_link.rs | 51 ++++++++++++++----- .../src-tauri/src/managed_agents/restore.rs | 8 +-- .../features/communities/useCommunityInit.ts | 10 +++- desktop/src/shared/deep-link.test.mjs | 8 +-- desktop/src/shared/deep-link.ts | 9 +++- desktop/src/testing/e2eBridge.ts | 10 +++- desktop/tests/helpers/bridge.ts | 1 + 8 files changed, 77 insertions(+), 26 deletions(-) diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 652bd320188..1062afeef1d 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -114,7 +114,7 @@ pub async fn validate_repos_dir(dir: String) -> Result<(), String> { } #[derive(Default)] -pub struct WorkspaceTransitionState { +pub(crate) struct WorkspaceTransitionState { generation: AtomicU64, commit: Mutex<()>, /// Provider deployments are externally last-write-wins. Serialize the @@ -173,6 +173,10 @@ impl WorkspaceTransitionState { self.generation.load(Ordering::Acquire) == generation } + pub(crate) fn current_generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + fn owner(&self, generation: u64) -> WorkspaceTransitionOwner<'_> { WorkspaceTransitionOwner { transition: self, diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index 098b3f1e79e..ba205aad410 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -28,6 +28,7 @@ pub(crate) struct PendingNavigationDeepLink { channel_id: String, message_id: Option, thread_root_id: Option, + workspace_generation: u64, } #[derive(Default)] @@ -48,14 +49,16 @@ impl PendingNavigationDeepLinks { && 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(&self) { - self.lock().clear(); + fn clear_before(&self, workspace_generation: u64) { + self.lock() + .retain(|pending| pending.workspace_generation >= workspace_generation); } fn first(&self) -> Option { @@ -74,8 +77,11 @@ impl PendingNavigationDeepLinks { } #[tauri::command] -pub(crate) fn clear_pending_navigation_deep_links(pending: State<'_, PendingNavigationDeepLinks>) { - pending.clear(); +pub(crate) fn clear_pending_navigation_deep_links( + workspace_generation: u64, + pending: State<'_, PendingNavigationDeepLinks>, +) { + pending.clear_before(workspace_generation); } #[tauri::command] @@ -165,6 +171,10 @@ fn queue_navigation_deep_link(app: &tauri::AppHandle, kind: &str, payload: &serd let Some(channel_id) = payload["channelId"].as_str() else { return; }; + let workspace_generation = app + .state::() + .workspace_transition + .current_generation(); app.state::() .enqueue(PendingNavigationDeepLink { id: uuid::Uuid::new_v4().to_string(), @@ -172,6 +182,7 @@ fn queue_navigation_deep_link(app: &tauri::AppHandle, kind: &str, payload: &serd 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, }); } @@ -521,6 +532,17 @@ mod tests { 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(), @@ -528,6 +550,7 @@ mod tests { 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, } } @@ -565,24 +588,28 @@ mod tests { } #[test] - fn pending_navigation_links_can_be_cleared() { + fn pending_navigation_links_can_be_cleared_without_dropping_new_generation() { let queue = PendingNavigationDeepLinks::default(); - queue.enqueue(pending_navigation( - "first", + queue.enqueue(pending_navigation_at( + "stale", "channel", "channel-1", None, None, + 1, )); - queue.enqueue(pending_navigation( - "second", - "message", + queue.enqueue(pending_navigation_at( + "fresh", + "channel", "channel-1", - Some("message-1"), None, + None, + 2, )); - queue.clear(); + queue.clear_before(2); + assert_eq!(queue.first().unwrap().id, "fresh"); + assert!(queue.acknowledge("fresh")); assert!(queue.first().is_none()); } diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index e6958f8c4c4..661dab4f4e7 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -200,10 +200,10 @@ pub async fn restore_managed_agents_on_launch( .cloned() .collect(); if !stale_keys.is_empty() { - let record = records - .iter_mut() - .find(|record| record.pubkey == *pubkey) - .expect("candidate record remains available"); + 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)?; } diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index aa2b318fc37..b4d524bdb5b 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -53,8 +53,10 @@ import type { Community } from "./types"; */ async function resetCommunityState({ resetAvatarState, + transitionGeneration, }: { resetAvatarState: boolean; + transitionGeneration: number; }): Promise { relayClient.disconnect(); resetRateLimitGate(); @@ -77,7 +79,7 @@ async function resetCommunityState({ resetBackgroundMediaUploads(); clearSearchHitEventCache(); clearMarkdownNodeCache(); - await resetNavigationDeepLinkDrain(); + await resetNavigationDeepLinkDrain(transitionGeneration); } type CommunityInitResult = @@ -140,7 +142,10 @@ export function useCommunityInit( saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current); prevCommunityIdRef.current = null; } - await resetCommunityState({ resetAvatarState: true }); + await resetCommunityState({ + resetAvatarState: true, + transitionGeneration, + }); if (cancelled) return; appliedRelayUrlRef.current = null; hasInitializedRef.current = false; @@ -223,6 +228,7 @@ export function useCommunityInit( 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 diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index 80be3ef55ef..45c7e67769d 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -219,7 +219,7 @@ test("community reset prevents an in-flight route from acknowledging", async () ); await settle(); - await resetNavigationDeepLinkDrain(); + await resetNavigationDeepLinkDrain(2); routeGate.resolve(); await settle(); @@ -256,7 +256,7 @@ test("community reset after take does not route the stale item", async () => { ); await settle(); - await resetNavigationDeepLinkDrain(); + await resetNavigationDeepLinkDrain(2); takeGate.resolve(); await settle(); @@ -298,7 +298,7 @@ test("community reset stops the stale drain before taking another item", async ( async ({ id }) => { assert.equal(queue[0]?.id, id); queue.shift(); - await resetNavigationDeepLinkDrain(); + await resetNavigationDeepLinkDrain(2); return true; }, ); @@ -327,7 +327,7 @@ test("community reset tolerates native queue clear rejection", async () => { console.warn = (...args) => warnings.push(args); try { - await resetNavigationDeepLinkDrain(); + await resetNavigationDeepLinkDrain(2); assert.equal(warnings.length, 1); assert.match(String(warnings[0][1]), /clear failed/); } finally { diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index f2962c995f9..247de1c7f12 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -33,6 +33,7 @@ type PendingNavigationDeepLink = { channelId: string; messageId: string | null; threadRootId: string | null; + workspaceGeneration: number; }; export type NostrBindDeepLinkPayload = { @@ -165,10 +166,14 @@ export async function listenForDeepLinks( let navigationDrainTail: Promise = Promise.resolve(); let navigationDrainGeneration = 0; -export async function resetNavigationDeepLinkDrain(): Promise { +export async function resetNavigationDeepLinkDrain( + workspaceGeneration: number, +): Promise { navigationDrainGeneration += 1; try { - await invoke("clear_pending_navigation_deep_links"); + 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 diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6f76e4d9e41..f011c581d7d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -487,6 +487,7 @@ type E2eConfig = { 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. @@ -4380,6 +4381,7 @@ let mockPendingNavigationDeepLinks: Array<{ channelId: string; messageId: string | null; threadRootId: string | null; + workspaceGeneration: number; }> = []; function resetMockPendingNavigationDeepLinks(config: E2eConfig | null) { @@ -4389,6 +4391,7 @@ function resetMockPendingNavigationDeepLinks(config: E2eConfig | null) { ...pending, messageId: pending.messageId ?? null, threadRootId: pending.threadRootId ?? null, + workspaceGeneration: pending.workspaceGeneration ?? 0, })); } @@ -11991,6 +11994,9 @@ export function maybeInstallE2eTauriMocks() { return true; } case "clear_pending_navigation_deep_links": { + const { workspaceGeneration } = payload as { + workspaceGeneration: number; + }; const clearDelayMs = activeConfig?.mock?.clearPendingNavigationDeepLinksDelayMs ?? 0; if (clearDelayMs > 0) { @@ -11998,7 +12004,9 @@ export function maybeInstallE2eTauriMocks() { window.setTimeout(resolve, clearDelayMs), ); } - mockPendingNavigationDeepLinks.length = 0; + mockPendingNavigationDeepLinks = mockPendingNavigationDeepLinks.filter( + (pending) => pending.workspaceGeneration >= workspaceGeneration, + ); return; } case "take_pending_navigation_deep_link": diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index b6263980e9a..33317cb4b67 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -474,6 +474,7 @@ type MockBridgeOptions = { channelId: string; messageId?: string | null; threadRootId?: string | null; + workspaceGeneration?: number; }>; /** * Global agent config returned by `get_global_agent_config`. Defaults to From f2f122d80b0788b2788eb0a3e9a3fc68bfbd546f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 12 Aug 2026 16:28:31 -0400 Subject: [PATCH 27/32] fix(desktop): detach navigation drains after reset Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src/shared/deep-link.test.mjs | 64 +++++++++++++++++++++++++++ desktop/src/shared/deep-link.ts | 4 ++ 2 files changed, 68 insertions(+) diff --git a/desktop/src/shared/deep-link.test.mjs b/desktop/src/shared/deep-link.test.mjs index 45c7e67769d..4b59a897af4 100644 --- a/desktop/src/shared/deep-link.test.mjs +++ b/desktop/src/shared/deep-link.test.mjs @@ -318,6 +318,70 @@ test("community reset stops the stale drain before taking another item", async ( 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; diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 247de1c7f12..df2d762d9c4 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -170,6 +170,10 @@ 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, From 1f314a68dfe019b4481ef38c6d9aaef5843963e7 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 13 Aug 2026 14:17:02 -0400 Subject: [PATCH 28/32] fix(desktop): scope restored profile reconciliation Keep launch-restored profile work on its captured workspace relay and stop stale transitions before both relay query and publish. Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/agents.rs | 2 +- .../src-tauri/src/commands/agents_profile.rs | 43 +++++++++++----- desktop/src-tauri/src/commands/mod.rs | 2 +- desktop/src-tauri/src/commands/workspace.rs | 49 ++++++++++++++++++- .../src-tauri/src/managed_agents/restore.rs | 14 ++++-- 5 files changed, 92 insertions(+), 18 deletions(-) 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/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 1062afeef1d..c3c5b265f7a 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -134,6 +134,10 @@ impl<'a> WorkspaceTransitionOwner<'a> { 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 @@ -169,10 +173,14 @@ impl WorkspaceTransitionState { self.generation.fetch_add(1, Ordering::AcqRel) + 1 } - fn is_current(&self, generation: u64) -> bool { + 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) } @@ -523,6 +531,45 @@ mod tests { 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(); diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index 661dab4f4e7..767fdad21a6 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -500,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}"); } From 4ad8bdadadbee61366ab7f9e8a7e92b9516416ec Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 13 Aug 2026 14:57:27 -0400 Subject: [PATCH 29/32] fix(deep-links): preserve navigation ownership Keep authored angle-bracket Buzz destinations intact on mobile and make desktop generation capture atomic with queue insertion. Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/commands/workspace.rs | 10 ++- desktop/src-tauri/src/deep_link.rs | 72 +++++++++++++++---- .../message_content/link_normalizer.dart | 21 ++++-- .../message_content/link_normalizer_test.dart | 13 ++++ .../channels/message_content_test.dart | 35 +++++++++ 5 files changed, 132 insertions(+), 19 deletions(-) diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index c3c5b265f7a..665e39f937b 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -168,7 +168,7 @@ impl<'a> WorkspaceTransitionOwner<'a> { } impl WorkspaceTransitionState { - fn claim_next(&self) -> u64 { + 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 } @@ -185,6 +185,14 @@ impl WorkspaceTransitionState { 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, diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ba205aad410..caa8e2be437 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -167,23 +167,34 @@ 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 workspace_generation = app - .state::() - .workspace_transition - .current_generation(); - app.state::() - .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), - workspace_generation, - }); + 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) { @@ -613,6 +624,41 @@ mod tests { 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()); diff --git a/mobile/lib/features/channels/message_content/link_normalizer.dart b/mobile/lib/features/channels/message_content/link_normalizer.dart index 97fc8323453..c5d139a9d8f 100644 --- a/mobile/lib/features/channels/message_content/link_normalizer.dart +++ b/mobile/lib/features/channels/message_content/link_normalizer.dart @@ -111,10 +111,18 @@ bool _hasInlineCloser(String content, int start, int delimiterLength) { } String _normalizeLinkSegment(String segment) { - var normalized = segment.replaceAllMapped( - _autolinkPattern, - (match) => '[${match[1]}](${match[1]})', - ); + 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), @@ -137,7 +145,10 @@ String _normalizeBareLink(String segment, Match match) { if (previous == '(' || previous == '\\' || previous == ']' || - previous == '=') { + previous == '=' || + (previous == '<' && + start >= 3 && + segment.substring(start - 3, start) == '](<')) { return matched; } } diff --git a/mobile/test/features/channels/message_content/link_normalizer_test.dart b/mobile/test/features/channels/message_content/link_normalizer_test.dart index 4bfb4eaa5f6..cd25a2b839d 100644 --- a/mobile/test/features/channels/message_content/link_normalizer_test.dart +++ b/mobile/test/features/channels/message_content/link_normalizer_test.dart @@ -11,6 +11,19 @@ void main() { ); }); + 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 punctuation and open Markdown delimiters outside links', () { expect( normalizeBareLinks('**open $url**. and **_${url}_**!'), diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 22e7fa2853f..d8dc5aa4afb 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -677,6 +677,41 @@ void main() { ); }); + 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 { From b283471af3dfb101c72424813bbf92b061952abd Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 13 Aug 2026 15:32:02 -0400 Subject: [PATCH 30/32] refactor(desktop): split deep-link tests Keep the production deep-link module under the desktop file-size ratchet while preserving its test coverage unchanged. Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- desktop/src-tauri/src/deep_link.rs | 512 +---------------------- desktop/src-tauri/src/deep_link_tests.rs | 508 ++++++++++++++++++++++ 2 files changed, 510 insertions(+), 510 deletions(-) create mode 100644 desktop/src-tauri/src/deep_link_tests.rs diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index caa8e2be437..ad87912df4f 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -517,513 +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_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"); - } -} +#[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"); +} From bb9960835aa2c08d6314b20cb35d546311d43c87 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 13 Aug 2026 16:33:33 -0400 Subject: [PATCH 31/32] fix(mobile): exclude quotes from Buzz links Keep authored single and double quote delimiters outside normalized bare Buzz URLs so channel and message destinations remain valid. Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../channels/message_content/link_normalizer.dart | 6 ++++++ .../channels/message_content/link_normalizer_test.dart | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/mobile/lib/features/channels/message_content/link_normalizer.dart b/mobile/lib/features/channels/message_content/link_normalizer.dart index c5d139a9d8f..ef7c1b743f3 100644 --- a/mobile/lib/features/channels/message_content/link_normalizer.dart +++ b/mobile/lib/features/channels/message_content/link_normalizer.dart @@ -7,6 +7,7 @@ final _bareLinkPattern = RegExp( r'(?:https?://|buzz://(?:message\?|join\?|channel/))[^\s)>\]]+', ); final _trailingPunctuationPattern = RegExp(r'[.,!?:;]+$'); +final _trailingQuotePattern = RegExp(r'''['"]+$'''); final _backtickRunPattern = RegExp(r'`+'); /// Converts supported Buzz and HTTP(S) autolinks and bare links into Markdown @@ -159,6 +160,11 @@ String _normalizeBareLink(String segment, Match match) { url = url.substring(0, outsidePunctuation.start); trailing = outsidePunctuation[0]!; } + final outsideQuotes = _trailingQuotePattern.firstMatch(url); + if (outsideQuotes != null) { + url = url.substring(0, outsideQuotes.start); + trailing = '${outsideQuotes[0]}$trailing'; + } } var strippedDelimiter = true; diff --git a/mobile/test/features/channels/message_content/link_normalizer_test.dart b/mobile/test/features/channels/message_content/link_normalizer_test.dart index cd25a2b839d..5fdadaf4027 100644 --- a/mobile/test/features/channels/message_content/link_normalizer_test.dart +++ b/mobile/test/features/channels/message_content/link_normalizer_test.dart @@ -24,6 +24,16 @@ void main() { ); }); + test('keeps quotes outside bare Buzz URLs', () { + const channelUrl = 'buzz://channel/550e8400-e29b-41d4-a716-446655440000'; + const messageUrl = 'buzz://message?channel=channel-1&id=message-1'; + expect( + normalizeBareLinks('See "$channelUrl" and \'$messageUrl\'.'), + 'See "[$channelUrl]($channelUrl)" and ' + "'[$messageUrl]($messageUrl)'.", + ); + }); + test('keeps punctuation and open Markdown delimiters outside links', () { expect( normalizeBareLinks('**open $url**. and **_${url}_**!'), From 173aa03a8f989db006ffe99d398826098e5b6195 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 13 Aug 2026 17:03:22 -0400 Subject: [PATCH 32/32] fix(mobile): close Buzz prose delimiter boundary Normalize bare Buzz links against Unicode closing punctuation, final quotes, and terminal punctuation while preserving existing HTTP behavior. Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson --- .../message_content/link_normalizer.dart | 32 ++++++++-------- .../message_content/link_normalizer_test.dart | 38 +++++++++++++++++-- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/mobile/lib/features/channels/message_content/link_normalizer.dart b/mobile/lib/features/channels/message_content/link_normalizer.dart index ef7c1b743f3..7b36fb5cbfa 100644 --- a/mobile/lib/features/channels/message_content/link_normalizer.dart +++ b/mobile/lib/features/channels/message_content/link_normalizer.dart @@ -6,8 +6,15 @@ final _autolinkPattern = RegExp( final _bareLinkPattern = RegExp( r'(?:https?://|buzz://(?:message\?|join\?|channel/))[^\s)>\]]+', ); -final _trailingPunctuationPattern = RegExp(r'[.,!?:;]+$'); -final _trailingQuotePattern = RegExp(r'''['"]+$'''); +// 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 @@ -155,15 +162,10 @@ String _normalizeBareLink(String segment, Match match) { } if (isBuzzUrl) { - final outsidePunctuation = _trailingPunctuationPattern.firstMatch(url); - if (outsidePunctuation != null) { - url = url.substring(0, outsidePunctuation.start); - trailing = outsidePunctuation[0]!; - } - final outsideQuotes = _trailingQuotePattern.firstMatch(url); - if (outsideQuotes != null) { - url = url.substring(0, outsideQuotes.start); - trailing = '${outsideQuotes[0]}$trailing'; + final outsideDelimiters = _trailingProseDelimiterPattern.firstMatch(url); + if (outsideDelimiters != null) { + url = url.substring(0, outsideDelimiters.start); + trailing = outsideDelimiters[0]!; } } @@ -185,10 +187,10 @@ String _normalizeBareLink(String segment, Match match) { } if (isBuzzUrl) { - final punctuation = _trailingPunctuationPattern.firstMatch(url); - if (punctuation != null) { - url = url.substring(0, punctuation.start); - trailing = '${punctuation[0]}$trailing'; + final delimiters = _trailingProseDelimiterPattern.firstMatch(url); + if (delimiters != null) { + url = url.substring(0, delimiters.start); + trailing = '${delimiters[0]}$trailing'; } } diff --git a/mobile/test/features/channels/message_content/link_normalizer_test.dart b/mobile/test/features/channels/message_content/link_normalizer_test.dart index 5fdadaf4027..da1f6cb6509 100644 --- a/mobile/test/features/channels/message_content/link_normalizer_test.dart +++ b/mobile/test/features/channels/message_content/link_normalizer_test.dart @@ -24,13 +24,43 @@ void main() { ); }); - test('keeps quotes outside bare Buzz URLs', () { + 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" and \'$messageUrl\'.'), - 'See "[$channelUrl]($channelUrl)" and ' - "'[$messageUrl]($messageUrl)'.", + normalizeBareLinks( + 'See $channelUrl。 Then $messageUrl! Also $channelUrl.', + ), + 'See [$channelUrl]($channelUrl)。 Then [$messageUrl]($messageUrl)! ' + 'Also [$channelUrl]($channelUrl).', ); });