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 <thomasp@squareup.com>
This commit is contained in:
Thomas Petersen
2026-08-12 18:15:38 -04:00
parent 05f4ec0c4d
commit 4e1ddafd61
7 changed files with 226 additions and 18 deletions
+112 -2
View File
@@ -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<VecDeque<PendingEntityDeepLink>>);
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<PendingEntityDeepLink> {
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<PendingEntityDeepLink> {
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::<PendingEntityDeepLinks>().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",
+7 -11
View File
@@ -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,
+48 -3
View File
@@ -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<UnlistenFn> {
return listen<string>("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<PendingEntityDeepLink | null>(
"take_pending_entity_deep_link",
);
if (!pending) break;
if (!onOpen(pending.href)) return;
const acknowledged = await invoke<boolean>(
"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<PendingEntityDeepLink | string>("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;
});
}
+4 -2
View File
@@ -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;
+25
View File
@@ -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":
@@ -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();
});
+5
View File
@@ -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.