mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user