From 06a013496a6f6420cf49b906371c92f018a9511e 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 1/3] 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 | 152 +++++++++++++++++- .../src/components/DeepLinkHandler.tsx | 97 +++++++++-- 3 files changed, 232 insertions(+), 18 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 beef7ea6d..f6024bf94 100644 --- a/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx +++ b/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx @@ -110,7 +110,8 @@ mock.module("@tauri-apps/api/event", () => ({ })); let startupUrls: string[] | null = null; -const getCurrent = mock(async () => startupUrls); +let launchUrls: () => Promise = async () => startupUrls; +const getCurrent = mock(() => launchUrls()); mock.module("@tauri-apps/plugin-deep-link", () => ({ getCurrent })); type PendingNativeOAuthAttempt = import("@/services/nativeOAuthAttempt").PendingNativeOAuthAttempt; @@ -179,6 +180,7 @@ describe("DeepLinkHandler native auth callbacks", () => { beforeEach(async () => { deepLinkListener = undefined; startupUrls = null; + launchUrls = async () => startupUrls; getCurrent.mockClear(); renderer = null; storage = new MemoryStorage(); @@ -418,7 +420,7 @@ describe("DeepLinkHandler native auth callbacks", () => { ); await Promise.resolve(); }); - expect(getCurrent).toHaveBeenCalledTimes(1); + expect(getCurrent).toHaveBeenCalledTimes(2); expect(renderer?.root.findByType(NativeOAuthAccountConfirmation).props.account.userId).toBe( USER_ID ); @@ -428,8 +430,8 @@ describe("DeepLinkHandler native auth callbacks", () => { expect(sharedState().installCalls).toHaveLength(1); }); - test("Dev cold launch ignores other-app and unsolicited callbacks; production does not read startup URLs", async () => { - expect(getCurrent).not.toHaveBeenCalled(); + test("Dev cold launch ignores other-app and unsolicited callbacks", async () => { + expect(getCurrent).toHaveBeenCalledTimes(1); startupUrls = [ `cloud.opensecret.maple://auth?handoff_grant=${VALID_GRANT}`, `cloud.opensecret.maple.dev://auth?handoff_grant=${VALID_GRANT}` @@ -442,7 +444,7 @@ describe("DeepLinkHandler native auth callbacks", () => { ); await Promise.resolve(); }); - expect(getCurrent).toHaveBeenCalledTimes(1); + expect(getCurrent).toHaveBeenCalledTimes(2); expect(nativeCalls).toHaveLength(0); expect(sharedState().installCalls).toHaveLength(0); expect(location.href).toBe("tauri://localhost/"); @@ -622,3 +624,143 @@ 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).toBe(""); + expect(stored).not.toContain(VALID_GRANT); + }); + + test("never stores credentials from rejected callbacks", async () => { + await render(); + for (const url of [ + "cloud.opensecret.maple://auth?access_token=secret&refresh_token=secret", + `cloud.opensecret.maple://auth?handoff_grant=${VALID_GRANT}&cache_namespace_root=secret`, + "cloud.opensecret.maple://payment-success?access_token=secret", + "cloud.opensecret.maple://secret:secret@payment-success", + "cloud.opensecret.maple://payment-success#secret" + ]) { + await flushDeepLink(url); + } + expect(storage.getItem("maple.handledDeepLinks")).toBeNull(); + }); + + test("production builds ignore a Dev launch link", async () => { + launchUrls = async () => ["cloud.opensecret.maple.dev://payment-canceled?case=production"]; + await render(); + expect(location.href).toBe("tauri://localhost/"); + }); + + 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 7e5628c04..574afc874 100644 --- a/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx +++ b/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx @@ -18,9 +18,69 @@ 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 use their pending-attempt fence instead; never store credential-bearing URLs. +const HANDLED_DEEP_LINKS_KEY = "maple.handledDeepLinks"; +const MAX_HANDLED_DEEP_LINKS = 20; +const deepLinksHandledThisPage = new Set(); + +function deepLinkKey(url: string): string | null { + try { + const parsed = new URL(url); + const firstPathPart = parsed.pathname.split("/").filter(Boolean)[0] || parsed.hostname; + if ( + firstPathPart === "auth" || + parsed.username || + parsed.password || + parsed.hash || + ["handoff_grant", "access_token", "refresh_token", "cache_namespace_root"].some((name) => + parsed.searchParams.has(name) + ) + ) { + return null; + } + return parsed.toString(); + } catch { + return null; + } +} + +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); + if (key === null) return; + 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 key !== null && (deepLinksHandledThisPage.has(key) || readHandledDeepLinks().includes(key)); +} export function DeepLinkHandler({ tauri = isTauri(), @@ -73,7 +133,7 @@ export function DeepLinkHandler({ if (tauri) { console.log("[Deep Link] Setting up handler for Tauri app"); - const handleUrl = async (url: string) => { + const handleDeepLink = async (url: string) => { if (disposed) return; let isAuthenticationCallback = false; console.log("[Deep Link] Received callback"); @@ -84,6 +144,9 @@ export function DeepLinkHandler({ const callbackScheme = `${nativeCallbackScheme(appVariant)}:`; // The dev app owns only its dev scheme, including payment returns. if (appVariant === "dev" && urlObj.protocol !== callbackScheme) return; + if (appVariant === "production" && urlObj.protocol === `${nativeCallbackScheme("dev")}:`) { + return; + } // The URL path structure will be: cloud.opensecret.maple://path?params const pathParts = urlObj.pathname.split("/").filter(Boolean); const firstPathPart = pathParts[0] || urlObj.hostname; @@ -213,22 +276,30 @@ export function DeepLinkHandler({ } } }; - // Subscribe before reading the launch URL so callbacks during startup - // use the same account/attempt fence as already-running returns. - unlisten = await listen("deep-link-received", (event) => - handleUrl(event.payload) - ); + + // 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) { unlisten(); return; } - if (desktopDev && appVariant === "dev") { - for (const url of (await getCurrent()) ?? []) { - await handleUrl(url); - } + 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) { From 99a232c8d602bd74fbe30586cf23b27e1a8bcc44 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:33:57 +0000 Subject: [PATCH 2/3] fix: preserve deep-link safety when replay storage is unavailable --- .../src/components/DeepLinkHandler.test.tsx | 36 +++++++++++++++++++ .../src/components/DeepLinkHandler.tsx | 25 +++++++++---- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx b/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx index f6024bf94..501948310 100644 --- a/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx +++ b/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx @@ -716,6 +716,42 @@ describe("DeepLinkHandler launch links", () => { expect(location.href).toBe("tauri://localhost/"); }); + test("the same live link works again after navigating away", async () => { + const url = "cloud.opensecret.maple://payment-canceled?case=repeat"; + await render(); + await flushDeepLink(url); + expect(location.href).toBe("/pricing?canceled=true"); + location.href = "tauri://localhost/"; + await flushDeepLink(url); + expect(location.href).toBe("/pricing?canceled=true"); + }); + + test("does not reload its payment destination when replay storage is unavailable", async () => { + const url = "cloud.opensecret.maple://payment-canceled?case=storage-failure"; + storage.setItem = () => { + throw new Error("storage unavailable"); + }; + launchUrls = async () => [url]; + await render(); + expect(location.href).toBe("/pricing?canceled=true"); + act(() => renderer?.unmount()); + renderer = null; + const reloadedLocation = { + pathname: "/pricing", + search: "?canceled=true", + href: "tauri://localhost/pricing?canceled=true" + }; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { sessionStorage: storage, location: reloadedLocation }, + writable: true + }); + // An unremembered delivery represents a new page after a failed write. + launchUrls = async () => [url + "&delivery=reload"]; + await render(); + expect(reloadedLocation.href).toBe("tauri://localhost/pricing?canceled=true"); + }); + test("never stores an auth grant", async () => { await render(); await flushDeepLink(`cloud.opensecret.maple://auth?handoff_grant=${VALID_GRANT}`); diff --git a/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx b/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx index 574afc874..58404f25e 100644 --- a/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx +++ b/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx @@ -79,7 +79,17 @@ function rememberHandledDeepLink(url: string): void { function wasDeepLinkHandled(url: string): boolean { const key = deepLinkKey(url); - return key !== null && (deepLinksHandledThisPage.has(key) || readHandledDeepLinks().includes(key)); + return ( + key !== null && (deepLinksHandledThisPage.has(key) || readHandledDeepLinks().includes(key)) + ); +} + +function navigatePaymentReturn(destination: string): void { + // If replay storage is unavailable, the launch URL is read after every + // reload. Once at its destination, avoid starting another reload. + if (`${window.location.pathname}${window.location.search}` !== destination) { + window.location.href = destination; + } } export function DeepLinkHandler({ @@ -144,7 +154,10 @@ export function DeepLinkHandler({ const callbackScheme = `${nativeCallbackScheme(appVariant)}:`; // The dev app owns only its dev scheme, including payment returns. if (appVariant === "dev" && urlObj.protocol !== callbackScheme) return; - if (appVariant === "production" && urlObj.protocol === `${nativeCallbackScheme("dev")}:`) { + if ( + appVariant === "production" && + urlObj.protocol === `${nativeCallbackScheme("dev")}:` + ) { return; } // The URL path structure will be: cloud.opensecret.maple://path?params @@ -246,17 +259,17 @@ export function DeepLinkHandler({ if (isCreditSuccess) { // Keep the established root callback contract; the home route bridges it into // the dedicated API credits settings page. - window.location.href = "/?credits_success=true"; + navigatePaymentReturn("/?credits_success=true"); } else if (isSuccess) { // Navigate to the success page or show a success message - window.location.href = "/pricing?success=true"; + navigatePaymentReturn("/pricing?success=true"); } else if (isCanceled) { // Navigate to the canceled page or show a canceled message - window.location.href = "/pricing?canceled=true"; + navigatePaymentReturn("/pricing?canceled=true"); } else { // Handle unknown payment status console.warn("[Deep Link] Unknown payment status in callback"); - window.location.href = "/pricing"; + navigatePaymentReturn("/pricing"); } } else { console.warn("[Deep Link] Unknown deep link type:", firstPathPart); From 22bebb710c097188aaca346f729cb57da53968df Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:36:46 +0000 Subject: [PATCH 3/3] fix: settle launch callbacks after payment redirects --- .../src/components/DeepLinkHandler.test.tsx | 26 ++++++++++++++++ .../src/components/DeepLinkHandler.tsx | 31 ++++++++++++++----- 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx b/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx index 501948310..dacb8dc74 100644 --- a/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx +++ b/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx @@ -752,6 +752,31 @@ describe("DeepLinkHandler launch links", () => { expect(reloadedLocation.href).toBe("tauri://localhost/pricing?canceled=true"); }); + test("launch replay leaves settled credits and Team pages when storage is unavailable", async () => { + storage.setItem = () => { + throw new Error("storage unavailable"); + }; + for (const [url, pathname] of [ + ["cloud.opensecret.maple://payment-success-credits?case=settled", "/settings/api"], + ["cloud.opensecret.maple://payment-success?case=settled", "/settings/team"] + ]) { + const settledLocation = { pathname, search: "", href: `tauri://localhost${pathname}` }; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { sessionStorage: storage, location: settledLocation }, + writable: true + }); + launchUrls = async () => [url]; + await render(); + expect(settledLocation.href).toBe(`tauri://localhost${pathname}`); + // A fresh click remains actionable even from its settled page. + await flushDeepLink(url); + expect(settledLocation.href).not.toBe(`tauri://localhost${pathname}`); + act(() => renderer?.unmount()); + renderer = null; + } + }); + test("never stores an auth grant", async () => { await render(); await flushDeepLink(`cloud.opensecret.maple://auth?handoff_grant=${VALID_GRANT}`); @@ -773,6 +798,7 @@ describe("DeepLinkHandler launch links", () => { await flushDeepLink(url); } expect(storage.getItem("maple.handledDeepLinks")).toBeNull(); + expect(location.href).toBe("tauri://localhost/"); }); test("production builds ignore a Dev launch link", async () => { diff --git a/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx b/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx index 58404f25e..2ddead5ec 100644 --- a/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx +++ b/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx @@ -84,9 +84,17 @@ function wasDeepLinkHandled(url: string): boolean { ); } -function navigatePaymentReturn(destination: string): void { +function navigatePaymentReturn(destination: string, launch: boolean): void { // If replay storage is unavailable, the launch URL is read after every // reload. Once at its destination, avoid starting another reload. + // Credits and Team success routes replace their initial callback URL. + if ( + launch && + ((destination === "/?credits_success=true" && window.location.pathname === "/settings/api") || + (destination === "/pricing?success=true" && window.location.pathname === "/settings/team")) + ) { + return; + } if (`${window.location.pathname}${window.location.search}` !== destination) { window.location.href = destination; } @@ -143,7 +151,7 @@ export function DeepLinkHandler({ if (tauri) { console.log("[Deep Link] Setting up handler for Tauri app"); - const handleDeepLink = async (url: string) => { + const handleDeepLink = async (url: string, launch = false) => { if (disposed) return; let isAuthenticationCallback = false; console.log("[Deep Link] Received callback"); @@ -224,6 +232,15 @@ export function DeepLinkHandler({ resolveConfirmation(false); } } + } else if ( + urlObj.username || + urlObj.password || + urlObj.hash || + ["handoff_grant", "access_token", "refresh_token", "cache_namespace_root"].some( + (name) => urlObj.searchParams.has(name) + ) + ) { + return; } else if ( firstPathPart === "payment" || firstPathPart === "payment-success" || @@ -259,17 +276,17 @@ export function DeepLinkHandler({ if (isCreditSuccess) { // Keep the established root callback contract; the home route bridges it into // the dedicated API credits settings page. - navigatePaymentReturn("/?credits_success=true"); + navigatePaymentReturn("/?credits_success=true", launch); } else if (isSuccess) { // Navigate to the success page or show a success message - navigatePaymentReturn("/pricing?success=true"); + navigatePaymentReturn("/pricing?success=true", launch); } else if (isCanceled) { // Navigate to the canceled page or show a canceled message - navigatePaymentReturn("/pricing?canceled=true"); + navigatePaymentReturn("/pricing?canceled=true", launch); } else { // Handle unknown payment status console.warn("[Deep Link] Unknown payment status in callback"); - navigatePaymentReturn("/pricing"); + navigatePaymentReturn("/pricing", launch); } } else { console.warn("[Deep Link] Unknown deep link type:", firstPathPart); @@ -310,7 +327,7 @@ export function DeepLinkHandler({ if (disposed) return; if (wasDeepLinkHandled(url)) continue; rememberHandledDeepLink(url); - await handleDeepLink(url); + await handleDeepLink(url, true); } console.log("[Deep Link] Handler setup complete");