commit a91c42f66866e0997e1df13fbfac0d7118a683d5 parent c50e8107a66e7e6e1c015ace81f485d15b0c5ea3 Author: Florian Dold <dold@taler.net> Date: Wed, 26 Aug 2026 23:14:50 +0200 wallet-webui: honor Taler link tab targets Diffstat:
13 files changed, 359 insertions(+), 57 deletions(-)
diff --git a/packages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-integration.ts b/packages/taler-harness/src/integrationtests/test-wallet-web-ui-extension-integration.ts @@ -92,6 +92,8 @@ function fixtureHtml(url: URL): string { ? `<meta name="taler-uri" content="https://example.com/not-taler"> <meta name="taler-uri" content="${TALER_URI}">` : ""; + const linkTarget = + url.searchParams.get("linkTarget") === "blank" ? ` target="_blank"` : ""; return `<!doctype html> <html><head> <meta charset="utf-8"> @@ -108,7 +110,7 @@ ${support ? `<meta name="taler-support" content="${support}">` : ""} ${metadataTags} </head><body> <h1>Integration fixture</h1> -<a id="taler-link" href="${TALER_URI}" onclick="window.fallbackCount += 1; event.preventDefault()">Taler action</a> +<a id="taler-link" href="${TALER_URI}"${linkTarget} onclick="window.fallbackCount += 1; event.preventDefault()">Taler action</a> </body></html>`; } @@ -371,14 +373,28 @@ async function exerciseChromeIntegration( const link = await context.newPage(); await link.goto(`${baseUrl}/?support=uri`); - const beforeLink = new Set(context.pages()); + const linkPageCount = context.pages().length; await link.locator("#taler-link").click(); + await link.waitForURL(`chrome-extension://${extensionId}/wallet.html**`); + assert.equal( + context.pages().length, + linkPageCount, + "an unannotated Taler link must reuse its source tab", + ); + + const blankLink = await context.newPage(); + await blankLink.goto(`${baseUrl}/?support=uri&linkTarget=blank`); + const beforeBlankLink = new Set(context.pages()); + await blankLink.locator("#taler-link").click(); const linkWallet = await waitForChromeWalletPage( context, extensionId, - beforeLink, + beforeBlankLink, + ); + assert.equal( + await blankLink.evaluate(() => (globalThis as any).fallbackCount), + 0, ); - assert.equal(await link.evaluate(() => (globalThis as any).fallbackCount), 0); await linkWallet.close(); await clearChromeActionInbox(admin); @@ -801,15 +817,29 @@ export async function runWalletWebUiFirefoxExtensionIntegrationTest( const beforeLink = await driver.getAllWindowHandles(); await driver.findElement(By.id("taler-link")).click(); await driver.wait( + until.urlContains(`moz-extension://${EXTENSION_UUID}/wallet.html`), + 15_000, + "Firefox URI link did not replace its source tab", + ); + assert.equal( + (await driver.getAllWindowHandles()).length, + beforeLink.length, + "Firefox unannotated Taler link must not create a tab", + ); + + await driver.get(`${fixture.baseUrl}/?support=uri&linkTarget=blank`); + const beforeBlankLink = await driver.getAllWindowHandles(); + await driver.findElement(By.id("taler-link")).click(); + await driver.wait( async () => - (await driver.getAllWindowHandles()).length > beforeLink.length, + (await driver.getAllWindowHandles()).length > beforeBlankLink.length, 15_000, - "Firefox URI link was not hijacked", + "Firefox target=_blank URI link did not open a wallet tab", ); assert.equal(await firefoxValue(driver, "return window.fallbackCount"), 0); const sourceHandle = await driver.getWindowHandle(); const actionHandle = (await driver.getAllWindowHandles()).find( - (handle) => !beforeLink.includes(handle), + (handle) => !beforeBlankLink.includes(handle), ); if (actionHandle) { await driver.switchTo().window(actionHandle); diff --git a/packages/wallet-webui/src/api/contracts.ts b/packages/wallet-webui/src/api/contracts.ts @@ -15,6 +15,8 @@ export interface WalletConnection { export type WalletTarget = "pwa" | "chrome" | "firefox"; +export type ExternalNavigationTarget = "current-tab" | "new-tab"; + export interface BrowserIntegrationSettings { autoOpen: boolean; injectApi: boolean; @@ -42,13 +44,19 @@ export interface ActionEntry { expiresAt: number; source: "link" | "metadata" | "paste" | "qr" | "protocol"; autoOpen: boolean; + fulfillmentTarget: ExternalNavigationTarget; +} + +export interface ActionPutOptions { + autoOpen?: boolean; + fulfillmentTarget?: ExternalNavigationTarget; } export interface ActionInbox { put( uri: string, source: ActionEntry["source"], - autoOpen?: boolean, + options?: ActionPutOptions, ): Promise<ActionEntry>; get(id: string): Promise<ActionEntry | undefined>; list(): Promise<ActionEntry[]>; @@ -59,7 +67,7 @@ export interface ActionInbox { export interface PlatformAdapter { readonly target: WalletTarget; readonly actionInbox: ActionInbox; - openExternal(url: string): Promise<void>; + openExternal(url: string, target?: ExternalNavigationTarget): Promise<void>; getPersistenceCapability(): Promise<PersistenceCapability>; requestPersistentStorage(): Promise<"granted" | "denied" | "unavailable">; requestPwaInstallation(): Promise<"accepted" | "dismissed" | "unavailable">; diff --git a/packages/wallet-webui/src/platform/action-inbox.ts b/packages/wallet-webui/src/platform/action-inbox.ts @@ -1,5 +1,9 @@ import { Paytos, Result, TalerUris } from "@gnu-taler/taler-util"; -import type { ActionEntry, ActionInbox } from "../api/contracts.js"; +import type { + ActionEntry, + ActionInbox, + ActionPutOptions, +} from "../api/contracts.js"; const ACTION_TTL_MS = 30 * 60 * 1000; @@ -94,7 +98,7 @@ export class StoredActionInbox implements ActionInbox { async put( uri: string, source: ActionEntry["source"], - autoOpen = false, + options: ActionPutOptions = {}, ): Promise<ActionEntry> { validateActionUri(uri); const now = Date.now(); @@ -102,7 +106,8 @@ export class StoredActionInbox implements ActionInbox { id: randomActionId(), uri, source, - autoOpen: autoOpen === true, + autoOpen: options.autoOpen === true, + fulfillmentTarget: options.fulfillmentTarget ?? "new-tab", createdAt: now, expiresAt: now + ACTION_TTL_MS, }; @@ -118,7 +123,10 @@ export class StoredActionInbox implements ActionInbox { return this.mutate((entries) => { const changed = removeExpired(entries, Date.now()); return { - result: this.completedIds.has(id) ? undefined : entries[id], + result: + this.completedIds.has(id) || !entries[id] + ? undefined + : normalizeEntry(entries[id]), changed, }; }); @@ -130,6 +138,7 @@ export class StoredActionInbox implements ActionInbox { return { result: Object.values(entries) .filter((entry) => !this.completedIds.has(entry.id)) + .map(normalizeEntry) .sort((a, b) => b.createdAt - a.createdAt), changed, }; @@ -153,6 +162,14 @@ export class StoredActionInbox implements ActionInbox { } } +function normalizeEntry(entry: ActionEntry): ActionEntry { + return { + ...entry, + fulfillmentTarget: + entry.fulfillmentTarget === "current-tab" ? "current-tab" : "new-tab", + }; +} + function removeExpired( entries: Record<string, ActionEntry>, now: number, diff --git a/packages/wallet-webui/src/platform/adapters.ts b/packages/wallet-webui/src/platform/adapters.ts @@ -47,11 +47,15 @@ export function makePwaPlatform(): PlatformAdapter { return { target: "pwa", actionInbox, - async openExternal(url) { + async openExternal(url, target = "new-tab") { if (!isSafeExternalUrl(url)) { throw new TypeError("refusing to open an unsafe external URL"); } - window.open(url, "_blank", "noopener,noreferrer"); + window.open( + url, + target === "current-tab" ? "_self" : "_blank", + "noopener,noreferrer", + ); }, async getPersistenceCapability() { const storage = navigator.storage; @@ -138,7 +142,9 @@ export function makePwaPlatform(): PlatformAdapter { const search = new URLSearchParams(location.search); const rawUri = search.get("talerUri"); if (!rawUri) return undefined; - const entry = await actionInbox.put(rawUri, "protocol", true); + const entry = await actionInbox.put(rawUri, "protocol", { + autoOpen: true, + }); search.delete("talerUri"); const cleanSearch = search.toString(); history.replaceState( @@ -164,11 +170,15 @@ export function makeExtensionPlatform( return { target, actionInbox, - async openExternal(url) { + async openExternal(url, target = "new-tab") { if (!isSafeExternalUrl(url)) { throw new TypeError("refusing to open an unsafe external URL"); } - await chrome.tabs.create({ url }); + if (target === "current-tab") { + await chrome.tabs.update({ url }); + } else { + await chrome.tabs.create({ url }); + } }, async getPersistenceCapability() { return { @@ -220,8 +230,13 @@ export function makeExtensionPlatform( const search = new URLSearchParams(location.search); const rawUri = search.get("talerUri"); if (!rawUri) return undefined; - const entry = await actionInbox.put(rawUri, "protocol", true); + const browserAction = search.get("talerActionSource") === "browser"; + const entry = await actionInbox.put(rawUri, "protocol", { + autoOpen: true, + fulfillmentTarget: browserAction ? "current-tab" : "new-tab", + }); search.delete("talerUri"); + search.delete("talerActionSource"); const cleanSearch = search.toString(); history.replaceState( history.state, diff --git a/packages/wallet-webui/src/platform/content-script.ts b/packages/wallet-webui/src/platform/content-script.ts @@ -4,6 +4,8 @@ import { INTEGRATION_SETTINGS_KEY, readBrowserIntegrationSettings, } from "./integration-settings.js"; +import { linkNavigationTarget } from "./link-navigation.js"; +import type { ExternalNavigationTarget } from "../api/contracts.js"; type SupportFeature = "api" | "callback" | "uri"; @@ -21,12 +23,17 @@ function validTalerUri(uri: string): boolean { ); } -function submit(uri: string, source: "link" | "metadata"): void { +function submit( + uri: string, + source: "link" | "metadata", + navigationTarget?: ExternalNavigationTarget, +): void { if (!validTalerUri(uri)) return; void chrome.runtime.sendMessage({ kind: "wallet-action", uri, source, + ...(navigationTarget ? { navigationTarget } : {}), }); } @@ -57,24 +64,23 @@ function currentMetadataUri(): string | undefined { function maybeInstallClickHandler(): void { if (clickInstalled || !settings?.hijackLinks || !support.has("uri")) return; clickInstalled = true; - document.addEventListener( - "click", - (event) => { - if (!settings?.hijackLinks || !support.has("uri")) return; - if (event.defaultPrevented || event.button !== 0) return; - const target = event.target; - if (!(target instanceof Element)) return; - const anchor = target.closest("a[href]"); - if (!(anchor instanceof HTMLAnchorElement)) return; - const uri = anchor.getAttribute("href") ?? ""; - if (!validTalerUri(uri)) return; - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); - submit(uri, "link"); - }, - { capture: true }, - ); + const handleLink = (event: MouseEvent): void => { + if (!settings?.hijackLinks || !support.has("uri")) return; + if (event.defaultPrevented || (event.button !== 0 && event.button !== 1)) + return; + const target = event.target; + if (!(target instanceof Element)) return; + const anchor = target.closest("a[href]"); + if (!(anchor instanceof HTMLAnchorElement)) return; + const uri = anchor.getAttribute("href") ?? ""; + if (!validTalerUri(uri)) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + submit(uri, "link", linkNavigationTarget(anchor, event)); + }; + document.addEventListener("click", handleLink, { capture: true }); + document.addEventListener("auxclick", handleLink, { capture: true }); } function maybeSubmitMetadata(): void { diff --git a/packages/wallet-webui/src/platform/extension-background.ts b/packages/wallet-webui/src/platform/extension-background.ts @@ -110,6 +110,7 @@ type ActionMessage = { kind: "wallet-action"; uri: string; source: "link" | "metadata"; + navigationTarget?: "current-tab" | "new-tab"; }; type PageIntegrationMessage = { @@ -188,7 +189,9 @@ chrome.runtime.onMessage.addListener( sendResponse({ opened: false }); return; } - const entry = await inbox.put(uri, "metadata", false); + const entry = await inbox.put(uri, "metadata", { + fulfillmentTarget: "current-tab", + }); await chrome.tabs.create({ url: chrome.runtime.getURL(`wallet.html#/action/${entry.id}`), }); @@ -213,11 +216,22 @@ chrome.runtime.onMessage.addListener( sendResponse({ ok: true, opened: false }); return; } - const entry = await inbox.put(message.uri, message.source, autoOpen); + const entry = await inbox.put(message.uri, message.source, { + autoOpen, + fulfillmentTarget: "current-tab", + }); if (entry.autoOpen) { - await chrome.tabs.create({ - url: chrome.runtime.getURL(`wallet.html#/action/${entry.id}`), - }); + const url = chrome.runtime.getURL( + `wallet.html#/action/${entry.id}`, + ); + if ( + message.source === "link" && + message.navigationTarget === "current-tab" + ) { + await chrome.tabs.update(sender.tab!.id!, { url }); + } else { + await chrome.tabs.create({ url }); + } } sendResponse({ ok: true, actionId: entry.id }); } catch { diff --git a/packages/wallet-webui/src/platform/link-navigation.ts b/packages/wallet-webui/src/platform/link-navigation.ts @@ -0,0 +1,27 @@ +import type { ExternalNavigationTarget } from "../api/contracts.js"; + +type LinkGesture = Pick< + MouseEvent, + "button" | "ctrlKey" | "metaKey" | "shiftKey" +>; + +/** + * Classify a Taler link like an ordinary top-level HTML link. The extension + * content script only runs in the top frame, so _parent and _top are the same + * browsing context as _self. + */ +export function linkNavigationTarget( + anchor: HTMLAnchorElement, + event: LinkGesture, +): ExternalNavigationTarget { + if (event.button === 1 || event.ctrlKey || event.metaKey || event.shiftKey) { + return "new-tab"; + } + const target = (anchor.getAttribute("target") ?? "").trim().toLowerCase(); + return !target || + target === "_self" || + target === "_parent" || + target === "_top" + ? "current-tab" + : "new-tab"; +} diff --git a/packages/wallet-webui/src/platform/page-integration.ts b/packages/wallet-webui/src/platform/page-integration.ts @@ -40,8 +40,24 @@ export function installTalerPageIntegration( if (!validUri(uri)) return undefined; const target = new URL(config.walletUrl); target.searchParams.set("talerUri", uri); + target.searchParams.set("talerActionSource", "browser"); return target.href; }; + const navigationTarget = ( + anchor: HTMLAnchorElement, + event: MouseEvent, + ): string => { + if ( + event.button === 1 || + event.ctrlKey || + event.metaKey || + event.shiftKey + ) { + return "_blank"; + } + const target = (anchor.getAttribute("target") ?? "").trim(); + return target || "_self"; + }; const anchorOnClick = (event: MouseEvent): false | undefined => { if (!state?.allowLinkHijack) return; const current = event.currentTarget; @@ -50,7 +66,7 @@ export function installTalerPageIntegration( if (!raw) return; const target = convertURIToWebExtensionPath(raw); if (!target) return; - window.open(target, current.target || "_self", "noopener"); + window.open(target, navigationTarget(current, event), "noopener"); event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); @@ -62,7 +78,7 @@ export function installTalerPageIntegration( if (!raw) return; const target = convertURIToWebExtensionPath(raw); if (!target) return; - window.open(target, anchor.target || "_self", "noopener"); + window.open(target, navigationTarget(anchor, event), "noopener"); event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); @@ -70,6 +86,8 @@ export function installTalerPageIntegration( const registerProtocolHandler = (): void => { if (!state?.allowLinkHijack || state.clickListener) return; state.clickListener = (event: MouseEvent) => { + if (event.defaultPrevented || (event.button !== 0 && event.button !== 1)) + return; const target = event.target; if (!(target instanceof Element)) return; const anchor = target.closest("a[href]"); @@ -78,6 +96,7 @@ export function installTalerPageIntegration( openAnchor(anchor, event); }; document.addEventListener("click", state.clickListener, true); + document.addEventListener("auxclick", state.clickListener, true); }; if (config.api && !("taler" in pageWindow)) { diff --git a/packages/wallet-webui/src/routes/App.tsx b/packages/wallet-webui/src/routes/App.tsx @@ -43,6 +43,7 @@ import { import { useHashLocation } from "wouter-preact/use-hash-location"; import { useSWRConfig } from "swr"; import { isWalletKey } from "../api/canonical.js"; +import type { ExternalNavigationTarget } from "../api/contracts.js"; import type { AppServices } from "./context.js"; import { ServicesContext, useServices } from "./context.js"; import { operationsForNotification } from "../api/notifications.js"; @@ -3734,6 +3735,23 @@ function PaymentRoute() { const [, navigate] = useLocation(); const transactionId = params?.transactionId ?? ""; const actionId = params?.actionId ?? ""; + const [fulfillmentTarget, setFulfillmentTarget] = + useState<ExternalNavigationTarget>(); + useEffect(() => { + let active = true; + setFulfillmentTarget(undefined); + void platform.actionInbox.get(actionId).then( + (entry) => { + if (active) setFulfillmentTarget(entry?.fulfillmentTarget ?? "new-tab"); + }, + () => { + if (active) setFulfillmentTarget("new-tab"); + }, + ); + return () => { + active = false; + }; + }, [actionId, platform]); const query = useWalletQuery( connection, WalletApiOperation.GetTransactionById, @@ -3759,9 +3777,9 @@ function PaymentRoute() { repurchaseTransactionId !== undefined && replayPayment?.txState.major === TransactionMajorState.Done; useEffect(() => { - if (!replayDone) return; + if (!replayDone || fulfillmentTarget === undefined) return; void completeActionBestEffort(platform.actionInbox, actionId); - }, [actionId, platform, replayDone]); + }, [actionId, fulfillmentTarget, platform, replayDone]); const waitingForContract = payment?.txState.minor === TransactionMinorState.ClaimProposal && payment.txState.working === true && @@ -3788,7 +3806,7 @@ function PaymentRoute() { void completeActionBestEffort(platform.actionInbox, actionId); navigate("/"); }; - if (query.isLoading || waitingForContract) + if (query.isLoading || waitingForContract || fulfillmentTarget === undefined) return ( <PaymentScreen state="loading" @@ -3831,7 +3849,13 @@ function PaymentRoute() { /> ); if (transaction.txState.major === TransactionMajorState.Dialog) - return <PaymentDialog transaction={transaction} actionId={actionId} />; + return ( + <PaymentDialog + transaction={transaction} + actionId={actionId} + fulfillmentTarget={fulfillmentTarget} + /> + ); const resultState = paymentResultState(transaction, replayPayment); const statusTransaction = repurchaseTransactionId ? (replayPayment ?? transaction) @@ -3870,7 +3894,7 @@ function PaymentRoute() { onCancel={completeAndReturn} onOpenFulfillment={() => { if (safeWebUrl(terms.fulfillment_url)) - void platform.openExternal(terms.fulfillment_url); + void platform.openExternal(terms.fulfillment_url, fulfillmentTarget); }} onCopyPosConfirmation={(code) => void navigator.clipboard.writeText(code)} /> @@ -3880,6 +3904,7 @@ function PaymentRoute() { function PaymentDialog(props: { transaction: TransactionPayment; actionId: string; + fulfillmentTarget: ExternalNavigationTarget; }) { const { connection, platform } = useServices(); const { language } = useLanguage(); @@ -4089,7 +4114,8 @@ function PaymentDialog(props: { : () => void cancel() } onOpenFulfillment={() => { - if (fulfillmentUrl) void platform.openExternal(fulfillmentUrl); + if (fulfillmentUrl) + void platform.openExternal(fulfillmentUrl, props.fulfillmentTarget); }} /> ); diff --git a/packages/wallet-webui/src/testing/demo-wallet.ts b/packages/wallet-webui/src/testing/demo-wallet.ts @@ -29,6 +29,7 @@ import { import type { ActionEntry, ActionInbox, + ActionPutOptions, ConnectionStatus, PersistenceCapability, PlatformAdapter, @@ -367,7 +368,7 @@ class MemoryActionInbox implements ActionInbox { async put( uri: string, source: ActionEntry["source"], - autoOpen = false, + options: ActionPutOptions = {}, ): Promise<ActionEntry> { validateActionUri(uri); if (!Object.values(demoActionUris).includes(uri)) { @@ -378,7 +379,8 @@ class MemoryActionInbox implements ActionInbox { id: `demo-action-${++this.sequence}`, uri, source, - autoOpen, + autoOpen: options.autoOpen === true, + fulfillmentTarget: options.fulfillmentTarget ?? "new-tab", createdAt, expiresAt: createdAt + 30 * 60 * 1000, }; diff --git a/packages/wallet-webui/test/action-inbox.test.ts b/packages/wallet-webui/test/action-inbox.test.ts @@ -30,14 +30,39 @@ test("action inbox validates, hides the URI behind an id and expires entries", a const entry = await inbox.put( "taler://withdraw/bank.example.com/12345", "qr", - true, + { autoOpen: true }, ); assert.match(entry.id, /^[0-9a-f]{36}$/); assert.equal((await inbox.get(entry.id))?.source, "qr"); + assert.equal(entry.fulfillmentTarget, "new-tab"); await inbox.cleanup(entry.expiresAt); assert.equal(await inbox.get(entry.id), undefined); }); +test("action inbox preserves browser fulfillment and normalizes legacy entries", async () => { + const store = new MemoryStore(); + const inbox = new StoredActionInbox(store); + const browserEntry = await inbox.put( + "taler://pay/merchant.example/order/session", + "link", + { fulfillmentTarget: "current-tab" }, + ); + assert.equal( + (await inbox.get(browserEntry.id))?.fulfillmentTarget, + "current-tab", + ); + const legacyId = "legacy"; + store.entries[legacyId] = { + id: legacyId, + uri: "taler://refund/merchant.example/order/session", + source: "protocol", + autoOpen: true, + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + } as ActionEntry; + assert.equal((await inbox.get(legacyId))?.fulfillmentTarget, "new-tab"); +}); + test("action inbox accepts a direct payto URI without exposing it in the route", async () => { const store = new MemoryStore(); const inbox = new StoredActionInbox(store); diff --git a/packages/wallet-webui/test/adapters.test.ts b/packages/wallet-webui/test/adapters.test.ts @@ -121,13 +121,16 @@ test("PWA capability discovery reports advisory quota and contains API failures" test("platform adapters reject unsafe external navigation", async () => { const window = installBrowser(); const opened: string[] = []; - window.open = ((url: string | URL) => { + const openTargets: string[] = []; + window.open = ((url: string | URL, target?: string) => { opened.push(String(url)); + openTargets.push(target ?? ""); return null; }) as typeof window.open; const pwa = makePwaPlatform(); await pwa.openExternal("https://bank.example/transfer/1"); await pwa.openExternal("http://127.0.0.1:8080/transfer/1"); + await pwa.openExternal("https://merchant.example/fulfillment", "current-tab"); for (const value of [ "javascript:alert(1)", "data:text/html,phishing", @@ -139,13 +142,19 @@ test("platform adapters reject unsafe external navigation", async () => { assert.deepEqual(opened, [ "https://bank.example/transfer/1", "http://127.0.0.1:8080/transfer/1", + "https://merchant.example/fulfillment", ]); + assert.deepEqual(openTargets, ["_blank", "_blank", "_self"]); const tabs: string[] = []; + const updates: string[] = []; Object.defineProperty(globalThis, "chrome", { configurable: true, value: { - tabs: { create: async ({ url }: { url: string }) => tabs.push(url) }, + tabs: { + create: async ({ url }: { url: string }) => tabs.push(url), + update: async ({ url }: { url: string }) => updates.push(url), + }, storage: { session: { get: async () => ({}), set: async () => {} } }, runtime: { sendMessage: async () => ({}), @@ -155,11 +164,16 @@ test("platform adapters reject unsafe external navigation", async () => { }); const extension = makeExtensionPlatform("chrome"); await extension.openExternal("https://bank.example/transfer/2"); + await extension.openExternal( + "https://merchant.example/fulfillment/2", + "current-tab", + ); await assert.rejects( () => extension.openExternal("file:///tmp/transfer"), /unsafe external URL/, ); assert.deepEqual(tabs, ["https://bank.example/transfer/2"]); + assert.deepEqual(updates, ["https://merchant.example/fulfillment/2"]); await window.happyDOM.abort(); }); @@ -201,3 +215,34 @@ test("protocol capture scrubs its source only after durable persistence", async assert.equal(new URLSearchParams(location.search).get("embed"), "yes"); await window.happyDOM.abort(); }); + +test("extension protocol capture retains browser-action fulfillment context", async () => { + const uri = "taler://pay/merchant.example/-/-/order"; + const window = installBrowser( + `https://wallet.example/wallet.html?talerUri=${encodeURIComponent(uri)}&talerActionSource=browser&embed=yes#/balance`, + ); + let sessionValues: Record<string, unknown> = {}; + Object.defineProperty(globalThis, "chrome", { + configurable: true, + value: { + tabs: {}, + storage: { + session: { + get: async (key: string) => ({ [key]: sessionValues[key] }), + set: async (values: Record<string, unknown>) => { + sessionValues = { ...sessionValues, ...values }; + }, + }, + }, + runtime: { + sendMessage: async () => ({}), + getURL: (path: string) => path, + }, + }, + }); + const entry = await makeExtensionPlatform("chrome").captureProtocolInput(); + assert.equal(entry?.uri, uri); + assert.equal(entry?.fulfillmentTarget, "current-tab"); + assert.equal(location.search, "?embed=yes"); + await window.happyDOM.abort(); +}); diff --git a/packages/wallet-webui/test/browser-integration.test.ts b/packages/wallet-webui/test/browser-integration.test.ts @@ -9,6 +9,7 @@ import { setBrowserIntegrationSetting, } from "../src/platform/integration-settings.js"; import { installTalerPageIntegration } from "../src/platform/page-integration.js"; +import { linkNavigationTarget } from "../src/platform/link-navigation.js"; class MemoryStorage { values: Record<string, unknown> = {}; @@ -57,6 +58,45 @@ test("integration settings are privacy-preserving and migrate legacy choices", a assert.equal(updated.injectApi, false); }); +test("Taler links follow normal top-level target and gesture semantics", () => { + const dom = installDom(); + const anchor = dom.document.createElement( + "a", + ) as unknown as HTMLAnchorElement; + const gesture = { + button: 0, + ctrlKey: false, + metaKey: false, + shiftKey: false, + }; + assert.equal(linkNavigationTarget(anchor, gesture), "current-tab"); + for (const target of ["_self", "_parent", "_top"]) { + anchor.setAttribute("target", target); + assert.equal(linkNavigationTarget(anchor, gesture), "current-tab"); + } + for (const target of ["_blank", "checkout"]) { + anchor.setAttribute("target", target); + assert.equal(linkNavigationTarget(anchor, gesture), "new-tab"); + } + anchor.removeAttribute("target"); + assert.equal( + linkNavigationTarget(anchor, { ...gesture, ctrlKey: true }), + "new-tab", + ); + assert.equal( + linkNavigationTarget(anchor, { ...gesture, metaKey: true }), + "new-tab", + ); + assert.equal( + linkNavigationTarget(anchor, { ...gesture, shiftKey: true }), + "new-tab", + ); + assert.equal( + linkNavigationTarget(anchor, { ...gesture, button: 1 }), + "new-tab", + ); +}); + function installDom() { const dom = new Window({ url: "https://merchant.example/checkout" }); for (const [key, value] of Object.entries({ @@ -79,13 +119,15 @@ test("main-world compatibility API is frozen, minimal and opens validated action const dom = installDom(); let callbackValue: unknown; let opened: string | undefined; + let openedTarget: string | undefined; (dom as unknown as Record<string, unknown>).talerCallback = ( value: unknown, ) => { callbackValue = value; }; - dom.open = ((url: string | URL) => { + dom.open = ((url: string | URL, target?: string) => { opened = String(url); + openedTarget = target; return null; }) as typeof dom.open; installTalerPageIntegration({ @@ -123,6 +165,10 @@ test("main-world compatibility API is frozen, minimal and opens validated action new URL(actionUrl).searchParams.get("talerUri"), "taler://pay/merchant.example/-/-/order", ); + assert.equal( + new URL(actionUrl).searchParams.get("talerActionSource"), + "browser", + ); const anchor = dom.document.createElement("a"); anchor.href = "taler://pay/merchant.example/-/-/order"; @@ -134,6 +180,28 @@ test("main-world compatibility API is frozen, minimal and opens validated action stopImmediatePropagation() {}, }); assert.equal(new URL(opened!).searchParams.get("talerUri"), anchor.href); + assert.equal(openedTarget, "_self"); + + anchor.target = "_blank"; + taler.__internal.anchorOnClick({ + currentTarget: anchor, + button: 0, + preventDefault() {}, + stopPropagation() {}, + stopImmediatePropagation() {}, + }); + assert.equal(openedTarget, "_blank"); + + anchor.removeAttribute("target"); + taler.__internal.anchorOnClick({ + currentTarget: anchor, + button: 0, + ctrlKey: true, + preventDefault() {}, + stopPropagation() {}, + stopImmediatePropagation() {}, + }); + assert.equal(openedTarget, "_blank"); opened = undefined; taler.__internal.registerProtocolHandler();