From 2cca29177375db6e8dfacf176efad322e61430e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sat, 18 Jul 2026 19:37:04 +0200 Subject: [PATCH] fix(pwa): keep mobile OAuth consent from being swallowed by the installed app (#701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem On mobile, starting an OAuth connection from ChatGPT (MCP connector) opens the **installed Whisper Money PWA** and lands on the **dashboard** instead of the OAuth consent screen — so the connection can never be approved. ## Cause The web app manifest declares `scope: "/"`, so the installed PWA claims the entire origin, including `/oauth/authorize`. On Android the OAuth link gets captured into the app, and the default launch behavior just **focuses the already-open window (the dashboard) and drops the incoming URL** — the consent page never loads. Narrowing `scope` to exclude `/oauth` isn't viable: the app's routes are flat (`/dashboard`, `/transactions`, `/accounts`, …) and share no prefix, so a narrower scope would push half the app out to the browser. ## Fix - `site.webmanifest`: add `launch_handler.client_mode: "focus-existing"` so the launcher hands the app the captured target URL via the launch queue. - `app.tsx`: a `launchQueue` consumer that navigates to the target URL when it's an `/oauth/` path. Every other launch is a no-op (keeps its place). Low blast radius — additive manifest field + a guard scoped to `/oauth/` paths; existing (desktop) OAuth is unaffected. ## Verification Cannot be tested off-device. After deploy, on the phone: 1. **Reinstall the PWA** (remove from home screen, re-add) so Android re-fetches the manifest. 2. Retry the ChatGPT OAuth connect → should land on the "Connect ChatGPT to Whisper Money" consent screen. Depends on `launch_handler`/`launchQueue` (Chrome/Edge 102+, covers nearly all Android). --- public/favicon/site.webmanifest | 3 +++ resources/js/app.tsx | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/public/favicon/site.webmanifest b/public/favicon/site.webmanifest index ae6b41c0..1bb66769 100644 --- a/public/favicon/site.webmanifest +++ b/public/favicon/site.webmanifest @@ -6,6 +6,9 @@ "scope": "/", "id": "/", "display": "standalone", + "launch_handler": { + "client_mode": "focus-existing" + }, "orientation": "portrait-primary", "theme_color": "#1b1b18", "background_color": "#ffffff", diff --git a/resources/js/app.tsx b/resources/js/app.tsx index adb1fe31..add6ca0b 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -34,6 +34,38 @@ import { __, setTranslations } from './utils/i18n'; installChunkLoadRecovery(); +// The installed PWA claims the whole origin (manifest scope "/"), so on mobile an +// OAuth link (e.g. from ChatGPT connecting over MCP) gets captured into the app. +// Paired with launch_handler "focus-existing", the launcher hands us the target +// URL instead of dropping it and showing the dashboard — navigate there so the +// OAuth consent screen actually loads. Non-OAuth launches keep their place. +type LaunchParams = { targetURL?: string }; + +const launchQueue = ( + window as Window & { + launchQueue?: { + setConsumer(consumer: (params: LaunchParams) => void): void; + }; + } +).launchQueue; + +if (launchQueue) { + launchQueue.setConsumer(({ targetURL }) => { + if (!targetURL) { + return; + } + + const target = new URL(targetURL); + + if ( + target.pathname.startsWith('/oauth/') && + target.href !== window.location.href + ) { + window.location.href = targetURL; + } + }); +} + Sentry.init({ dsn: import.meta.env.SENTRY_LARAVEL_DSN, environment: import.meta.env.MODE,