From 4e1ddafd61e123957901158433109f2a2147a706 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Wed, 12 Aug 2026 18:15:38 -0400 Subject: [PATCH] projects: preserve entity links across cold starts Queue validated entity intents until the frontend acknowledges them, and consume launch URLs so opening a share link from a stopped app reliably navigates after React mounts. Signed-off-by: Thomas Petersen --- desktop/src-tauri/src/deep_link.rs | 114 +++++++++++++++++- desktop/src-tauri/src/lib.rs | 18 ++- desktop/src/shared/deep-link.ts | 51 +++++++- desktop/src/shared/useEntityDeepLinks.ts | 6 +- desktop/src/testing/e2eBridge.ts | 25 ++++ .../e2e/entity-link-recipient-cards.spec.ts | 25 ++++ desktop/tests/helpers/bridge.ts | 5 + 7 files changed, 226 insertions(+), 18 deletions(-) diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ecc16e36b..84a322c80 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -54,6 +54,49 @@ impl PendingCommunityDeepLinks { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingEntityDeepLink { + id: String, + href: String, +} + +#[derive(Default)] +pub(crate) struct PendingEntityDeepLinks(Mutex>); + +impl PendingEntityDeepLinks { + fn enqueue(&self, href: String) -> PendingEntityDeepLink { + let mut queue = self.0.lock().expect("pending deep-link queue poisoned"); + if let Some(existing) = queue.iter().find(|item| item.href == href) { + return existing.clone(); + } + let pending = PendingEntityDeepLink { + id: uuid::Uuid::new_v4().to_string(), + href, + }; + queue.push_back(pending.clone()); + pending + } + + fn first(&self) -> Option { + self.0 + .lock() + .expect("pending deep-link queue poisoned") + .front() + .cloned() + } + + fn acknowledge(&self, id: &str) -> bool { + let mut queue = self.0.lock().expect("pending 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_community_deep_link( pending: State<'_, PendingCommunityDeepLinks>, @@ -69,6 +112,21 @@ pub(crate) fn acknowledge_pending_community_deep_link( pending.acknowledge(&id) } +#[tauri::command] +pub(crate) fn take_pending_entity_deep_link( + pending: State<'_, PendingEntityDeepLinks>, +) -> Option { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_entity_deep_link( + id: String, + pending: State<'_, PendingEntityDeepLinks>, +) -> bool { + pending.acknowledge(&id) +} + fn queue_community_deep_link( app: &tauri::AppHandle, kind: &str, @@ -88,6 +146,10 @@ fn queue_community_deep_link( }); } +fn queue_entity_deep_link(app: &tauri::AppHandle, href: String) -> PendingEntityDeepLink { + app.state::().enqueue(href) +} + fn activate_main_window(app: &tauri::AppHandle) { let Some(window) = app.get_webview_window("main") else { return; @@ -104,6 +166,29 @@ fn activate_main_window(app: &tauri::AppHandle) { } } +#[cfg(desktop)] +pub(crate) fn install_deep_link_handlers(app: &mut tauri::App) { + use tauri_plugin_deep_link::DeepLinkExt; + + let dl_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + for url in event.urls() { + handle_deep_link_url(&dl_handle, url.as_str()); + } + }); + + #[cfg(any(target_os = "windows", target_os = "linux"))] + match app.deep_link().get_current() { + Ok(Some(urls)) => { + for url in urls { + handle_deep_link_url(app.handle(), url.as_str()); + } + } + Ok(None) => {} + Err(error) => eprintln!("buzz-desktop: failed to read launch deep link: {error}"), + } +} + /// 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 @@ -462,7 +547,8 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { return; } activate_main_window(app); - let _ = app.emit("deep-link-entity", url_str.to_owned()); + let pending = queue_entity_deep_link(app, url_str.to_owned()); + let _ = app.emit("deep-link-entity", pending); } Some("nostr-bind") => match parse_nostr_bind_deep_link(&url) { Ok(payload) => { @@ -489,7 +575,7 @@ mod tests { use super::{ parse_add_community_deep_link, parse_entity_deep_link, parse_join_deep_link, parse_message_deep_link, parse_nostr_bind_deep_link, PendingCommunityDeepLink, - PendingCommunityDeepLinks, ENTITY_LINK_TABS, + PendingCommunityDeepLinks, PendingEntityDeepLinks, ENTITY_LINK_TABS, }; fn entity_link_golden() -> serde_json::Value { @@ -602,6 +688,30 @@ mod tests { assert!(queue.first().is_none()); } + #[test] + fn pending_entity_links_survive_until_acknowledged_in_order() { + let queue = PendingEntityDeepLinks::default(); + let first = queue.enqueue("buzz://project?owner=aa&d=first".to_owned()); + let second = queue.enqueue("buzz://project?owner=aa&d=second".to_owned()); + + assert_eq!(queue.first(), Some(first.clone())); + assert!(!queue.acknowledge(&second.id)); + assert!(queue.acknowledge(&first.id)); + assert_eq!(queue.first(), Some(second)); + } + + #[test] + fn pending_entity_links_dedupe_launch_and_open_callbacks() { + let queue = PendingEntityDeepLinks::default(); + let href = "buzz://project?owner=aa&d=buzz".to_owned(); + let first = queue.enqueue(href.clone()); + let duplicate = queue.enqueue(href); + + assert_eq!(duplicate.id, first.id); + assert!(queue.acknowledge(&first.id)); + 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", diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e5c7cd3fd..f08820d82 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_entity_deep_link, + handle_deep_link_url, take_pending_community_deep_link, take_pending_entity_deep_link, + PendingCommunityDeepLinks, PendingEntityDeepLinks, }; use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, @@ -303,6 +304,7 @@ pub fn run() { .manage(build_app_state()) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) + .manage(PendingEntityDeepLinks::default()) .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) @@ -514,15 +516,7 @@ pub fn run() { // and on cold start. The single-instance plugin handles forwarding // from duplicate launches on Windows/Linux. #[cfg(desktop)] - { - use tauri_plugin_deep_link::DeepLinkExt; - let dl_handle = app.handle().clone(); - app.deep_link().on_open_url(move |event| { - for url in event.urls() { - handle_deep_link_url(&dl_handle, url.as_str()); - } - }); - } + deep_link::install_deep_link_handlers(app); // Defer launch-time agent restoration until `apply_workspace` has // installed the active workspace relay and identity. Starting here @@ -615,6 +609,8 @@ pub fn run() { terminal_runtime::terminal_focus, take_pending_community_deep_link, acknowledge_pending_community_deep_link, + take_pending_entity_deep_link, + acknowledge_pending_entity_deep_link, start_builderlab_login, cancel_builderlab_login, get_builderlab_auth, diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 2a1801ec5..2418cda44 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -58,6 +58,11 @@ type PendingCommunityDeepLink = { policyReceipt: string | null; }; +type PendingEntityDeepLink = { + id: string; + href: string; +}; + function acceptPendingCommunityDeepLink( pending: PendingCommunityDeepLink, deps: DeepLinkDeps, @@ -171,10 +176,50 @@ export function listenForMessageDeepLinks( * the raw URL; callers parse it with `parseEntityLink` before navigating. */ export function listenForEntityDeepLinks( - onOpen: (href: string) => void, + onOpen: (href: string) => boolean, ): Promise { - return listen("deep-link-entity", (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_entity_deep_link", + ); + if (!pending) break; + if (!onOpen(pending.href)) return; + const acknowledged = await invoke( + "acknowledge_pending_entity_deep_link", + { id: pending.id }, + ); + if (!acknowledged) break; + } + } + } catch (error: unknown) { + console.warn("Failed to drain pending entity deep links", error); + } finally { + drainRunning = false; + if (drainRequested) drain(); + } + })(); + }; + + return listen("deep-link-entity", (event) => { + // String payloads are retained for older backends and E2E bridge calls. + if (typeof event.payload === "string") { + onOpen(event.payload); + } else { + drain(); + } + }).then((unlisten) => { + drain(); + return unlisten; }); } diff --git a/desktop/src/shared/useEntityDeepLinks.ts b/desktop/src/shared/useEntityDeepLinks.ts index 4e71ef283..3b4f59dfc 100644 --- a/desktop/src/shared/useEntityDeepLinks.ts +++ b/desktop/src/shared/useEntityDeepLinks.ts @@ -21,9 +21,11 @@ export function useEntityDeepLinks(enabled = true) { let cancelled = false; const unlistenPromise = listenForEntityDeepLinks((href) => { - if (cancelled) return; + if (cancelled) return false; const parsed = parseEntityLink(href); - if (parsed.ok) openEntityLink(parsed.value); + if (!parsed.ok) return false; + openEntityLink(parsed.value); + return true; }); return () => { cancelled = true; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index a3de3a5ea..2b6a58042 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -474,6 +474,12 @@ type E2eConfig = { code?: string | null; name?: string | null; }>; + // Entity links captured before React mounts. The app drains and acknowledges + // this mocked Rust-side queue after installing its event listener. + pendingEntityDeepLinks?: Array<{ + id: string; + href: string; + }>; // 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 +4362,14 @@ function resetMockPendingCommunityDeepLinks(config: E2eConfig | null) { })); } +let mockPendingEntityDeepLinks: Array<{ id: string; href: string }> = []; + +function resetMockPendingEntityDeepLinks(config: E2eConfig | null) { + mockPendingEntityDeepLinks = (config?.mock?.pendingEntityDeepLinks ?? []).map( + (pending) => ({ ...pending }), + ); +} + function recordMockUserStatus(event: RelayEvent) { const dTag = event.tags.find((tag) => tag[0] === "d")?.[1]; if (dTag) { @@ -10151,6 +10165,7 @@ export function maybeInstallE2eTauriMocks() { resetMockPersonaCatalogEvents(config); resetMockSaveSubscriptions(config); resetMockPendingCommunityDeepLinks(config); + resetMockPendingEntityDeepLinks(config); initializeMockHuddle(config.mock?.huddle, config); mockWebsocketSendMutexWedged = false; if (config.mock?.windowLabel) { @@ -11982,6 +11997,16 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } + case "take_pending_entity_deep_link": + return mockPendingEntityDeepLinks[0] ?? null; + case "acknowledge_pending_entity_deep_link": { + const { id } = payload as { id: string }; + if (mockPendingEntityDeepLinks[0]?.id !== id) { + return false; + } + mockPendingEntityDeepLinks.shift(); + return true; + } case "get_relay_http_url": return getRelayHttpUrl(activeConfig); case "relay_requires_membership": diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index 7bc5f0aec..75afe47f3 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -276,3 +276,28 @@ test("reopening the same entity link reapplies its workspace state", async ({ await emitEntityLink(issueLink); await expect(issueHeading).toBeVisible(); }); + +test("cold-start entity links drain after the React listener mounts", async ({ + page, +}) => { + const href = `buzz://repo?owner=${DEFAULT_MOCK_PUBKEY}&d=buzz&tab=prs`; + await installMockBridge(page, { + pendingEntityDeepLinks: [{ id: "cold-start-project", href }], + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + + await expect( + page.getByRole("tab", { name: "Pull Request", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect + .poll(() => + page.evaluate(() => + window.__TAURI_INTERNALS__?.invoke?.( + "take_pending_entity_deep_link", + {}, + ), + ), + ) + .toBeNull(); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 31af66ab0..74696eae1 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -461,6 +461,11 @@ type MockBridgeOptions = { code?: string | null; name?: string | null; }>; + /** Entity links captured by Rust before the React listener mounts. */ + pendingEntityDeepLinks?: Array<{ + id: string; + href: string; + }>; /** * Global agent config returned by `get_global_agent_config`. Defaults to * an empty config (no provider, model, or env vars) if not specified.