Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"platforms": ["macOS", "windows", "linux"],
"permissions": [
"core:default",
"deep-link:default",
"dialog:default",
"fs:default",
{
Expand Down
126 changes: 126 additions & 0 deletions apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ mock.module("@tauri-apps/api/event", () => ({
}
}));

// URLs the deep-link plugin reports as having launched the app.
let launchUrls: () => Promise<string[] | null> = async () => null;
mock.module("@tauri-apps/plugin-deep-link", () => ({
getCurrent: () => launchUrls()
}));

type PendingNativeOAuthAttempt = import("@/services/nativeOAuthAttempt").PendingNativeOAuthAttempt;
const { beginNativeOAuthAttempt, readPendingNativeOAuthAttempt } =
await import("@/services/nativeOAuthAttempt");
Expand Down Expand Up @@ -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<void> {
await act(async () => {
renderer = create(
<NotificationProvider>
<DeepLinkHandler tauri appVariant={appVariant} />
</NotificationProvider>
);
// 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");
});
});
79 changes: 73 additions & 6 deletions apps/maple-research/frontend/src/components/DeepLinkHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string>();

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(),
Expand Down Expand Up @@ -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<string>("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");

Expand Down Expand Up @@ -212,13 +261,31 @@ 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<string>("deep-link-received", async (event) => {
if (disposed) return;
rememberHandledDeepLink(event.payload);
await handleDeepLink(event.payload);
});

if (disposed) {
unlisten();
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) {
Expand Down