From 80fc4560f660161484ad24157faf08e236d966b7 Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:49:44 -0500 Subject: [PATCH] fix: handle deep links that launch the app Rust forwards each deep link to the webview as an event, but a link that launches the app arrives before the webview is listening, so it was dropped and the app opened on its default screen. The deep-link plugin keeps the launch URL, so read it with getCurrent() once the listener is attached. getCurrent() keeps returning that URL for the whole session and handling a link reloads the page, so handled links are remembered in sessionStorage (with any OAuth handoff grant blanked) to avoid handling the same link twice. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src-tauri/capabilities/default.json | 1 + .../src/components/DeepLinkHandler.test.tsx | 126 ++++++++++++++++++ .../src/components/DeepLinkHandler.tsx | 79 ++++++++++- 3 files changed, 200 insertions(+), 6 deletions(-) diff --git a/apps/maple-research/frontend/src-tauri/capabilities/default.json b/apps/maple-research/frontend/src-tauri/capabilities/default.json index f4084376f..715e27dbe 100644 --- a/apps/maple-research/frontend/src-tauri/capabilities/default.json +++ b/apps/maple-research/frontend/src-tauri/capabilities/default.json @@ -6,6 +6,7 @@ "platforms": ["macOS", "windows", "linux"], "permissions": [ "core:default", + "deep-link:default", "dialog:default", "fs:default", { diff --git a/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx b/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx index 5ecb2b0ff..135af0661 100644 --- a/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx +++ b/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx @@ -109,6 +109,12 @@ mock.module("@tauri-apps/api/event", () => ({ } })); +// URLs the deep-link plugin reports as having launched the app. +let launchUrls: () => Promise = async () => null; +mock.module("@tauri-apps/plugin-deep-link", () => ({ + getCurrent: () => launchUrls() +})); + type PendingNativeOAuthAttempt = import("@/services/nativeOAuthAttempt").PendingNativeOAuthAttempt; const { beginNativeOAuthAttempt, readPendingNativeOAuthAttempt } = await import("@/services/nativeOAuthAttempt"); @@ -575,3 +581,123 @@ describe("DeepLinkHandler payment callbacks", () => { expect(location.href).toBe("/pricing"); }); }); + +describe("DeepLinkHandler launch links", () => { + let location: { href: string }; + let renderer: ReactTestRenderer | null; + let storage: MemoryStorage; + let originalConsoleError: typeof console.error; + let originalConsoleLog: typeof console.log; + let originalConsoleWarn: typeof console.warn; + + async function render(appVariant: "production" | "dev" = "production"): Promise { + await act(async () => { + renderer = create( + + + + ); + // Let the listener attach and the async launch-link check finish. + for (let i = 0; i < 5; i++) await new Promise((resolve) => setTimeout(resolve, 0)); + }); + if (!deepLinkListener) throw new Error("Deep-link listener was not registered"); + } + + beforeEach(() => { + deepLinkListener = undefined; + sharedState().currentUser = undefined; + renderer = null; + storage = new MemoryStorage(); + location = { href: "tauri://localhost/" }; + launchUrls = async () => null; + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + value: storage, + writable: true + }); + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { sessionStorage: storage, location }, + writable: true + }); + originalConsoleError = console.error; + originalConsoleLog = console.log; + originalConsoleWarn = console.warn; + console.error = mock(() => {}); + console.log = mock(() => {}); + console.warn = mock(() => {}); + }); + + afterEach(() => { + if (renderer) act(() => renderer?.unmount()); + launchUrls = async () => null; + restoreGlobal("sessionStorage", originalGlobals.sessionStorage); + restoreGlobal("window", originalGlobals.window); + console.error = originalConsoleError; + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + }); + + test("handles the link that launched the app", async () => { + const url = "cloud.opensecret.maple://payment-canceled?case=launch"; + launchUrls = async () => [url]; + + await render(); + + expect(location.href).toBe("/pricing?canceled=true"); + expect(JSON.parse(storage.getItem("maple.handledDeepLinks") ?? "[]")).toEqual([url]); + }); + + test("does not handle the launch link again after the page reloads", async () => { + const url = "cloud.opensecret.maple://payment-success?case=reload"; + storage.setItem("maple.handledDeepLinks", JSON.stringify([url])); + launchUrls = async () => [url]; + + await render(); + + expect(location.href).toBe("tauri://localhost/"); + }); + + test("does not repeat a link that already arrived as an event", async () => { + const url = "cloud.opensecret.maple://payment-success?case=live"; + await render(); + await flushDeepLink(url); + expect(location.href).toBe("/pricing?success=true"); + + act(() => renderer?.unmount()); + renderer = null; + location.href = "tauri://localhost/"; + launchUrls = async () => [url]; + await render(); + + expect(location.href).toBe("tauri://localhost/"); + }); + + test("never stores an auth grant", async () => { + await render(); + await flushDeepLink(`cloud.opensecret.maple://auth?handoff_grant=${VALID_GRANT}`); + + const stored = storage.getItem("maple.handledDeepLinks") ?? ""; + expect(stored).toContain("handoff_grant=redacted"); + expect(stored).not.toContain(VALID_GRANT); + }); + + test("dev builds ignore a production launch link", async () => { + launchUrls = async () => ["cloud.opensecret.maple://payment-success?case=dev"]; + + await render("dev"); + + expect(location.href).toBe("tauri://localhost/"); + }); + + test("live links still work when the launch link can't be read", async () => { + launchUrls = async () => { + throw new Error("plugin unavailable"); + }; + + await render(); + await flushDeepLink("cloud.opensecret.maple://payment-canceled?case=fallback"); + + expect(location.href).toBe("/pricing?canceled=true"); + }); +}); diff --git a/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx b/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx index 1c0bcb700..e70aa3cd2 100644 --- a/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx +++ b/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useOpenSecret } from "@mapleai/sdk"; import { isTauri } from "@/utils/platform"; import { listen } from "@tauri-apps/api/event"; +import { getCurrent } from "@tauri-apps/plugin-deep-link"; import { getSafeInternalRedirect } from "@/utils/internalRedirect"; import { authorizeNativeOAuthCallback, @@ -17,9 +18,59 @@ import { type MapleAppVariant } from "@/config/mapleAppVariant"; -// For direct deep link handling, we'll listen to our custom event -// If we had the types installed, we would use: -// import { onOpenUrl } from '@tauri-apps/plugin-deep-link'; +// Rust forwards each deep link as a "deep-link-received" event. A link that +// launches the app arrives before this component is listening, so that event +// is dropped; the deep-link plugin keeps the launch URL, and we read it with +// getCurrent() once the listener is attached. +// +// getCurrent() keeps returning that URL for the whole app session, and +// handling a link reloads the page, so remember what's been handled. Auth +// links carry a one-time grant, which is blanked before anything is stored. +const HANDLED_DEEP_LINKS_KEY = "maple.handledDeepLinks"; +const MAX_HANDLED_DEEP_LINKS = 20; +const deepLinksHandledThisPage = new Set(); + +function deepLinkKey(url: string): string { + try { + const parsed = new URL(url); + if (parsed.searchParams.has("handoff_grant")) { + parsed.searchParams.set("handoff_grant", "redacted"); + } + return parsed.toString(); + } catch { + return url; + } +} + +function readHandledDeepLinks(): string[] { + try { + const parsed: unknown = JSON.parse(sessionStorage.getItem(HANDLED_DEEP_LINKS_KEY) ?? "[]"); + return Array.isArray(parsed) ? parsed.filter((k): k is string => typeof k === "string") : []; + } catch { + return []; + } +} + +function rememberHandledDeepLink(url: string): void { + const key = deepLinkKey(url); + deepLinksHandledThisPage.add(key); + try { + const handled = readHandledDeepLinks().filter((k) => k !== key); + handled.push(key); + sessionStorage.setItem( + HANDLED_DEEP_LINKS_KEY, + JSON.stringify(handled.slice(-MAX_HANDLED_DEEP_LINKS)) + ); + } catch { + // Without storage, a reload may see the launch link again. The handlers + // below still reject replays (auth needs a pending attempt). + } +} + +function wasDeepLinkHandled(url: string): boolean { + const key = deepLinkKey(url); + return deepLinksHandledThisPage.has(key) || readHandledDeepLinks().includes(key); +} export function DeepLinkHandler({ tauri = isTauri(), @@ -71,10 +122,8 @@ export function DeepLinkHandler({ if (tauri) { console.log("[Deep Link] Setting up handler for Tauri app"); - // Listen for the custom event we emit from Rust - unlisten = await listen("deep-link-received", async (event) => { + const handleDeepLink = async (url: string) => { if (disposed) return; - const url = event.payload; let isAuthenticationCallback = false; console.log("[Deep Link] Received callback"); @@ -212,6 +261,13 @@ export function DeepLinkHandler({ console.error("[Deep Link] Failed to process deep link", error); } } + }; + + // Listen for the custom event we emit from Rust + unlisten = await listen("deep-link-received", async (event) => { + if (disposed) return; + rememberHandledDeepLink(event.payload); + await handleDeepLink(event.payload); }); if (disposed) { @@ -219,6 +275,17 @@ export function DeepLinkHandler({ return; } + const launchUrls = await getCurrent().catch((error: unknown) => { + console.warn("[Deep Link] Could not read the launch link:", error); + return null; + }); + for (const url of launchUrls ?? []) { + if (disposed) return; + if (wasDeepLinkHandled(url)) continue; + rememberHandledDeepLink(url); + await handleDeepLink(url); + } + console.log("[Deep Link] Handler setup complete"); } } catch (error) {