From c3ef0247fc16ee6fbc3a899bed38147b2763a61c Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 18 Jun 2026 21:56:23 +0700 Subject: [PATCH] fix: switch to HashRouter for Capacitor Android compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The primary cause of the white screen in the APK was BrowserRouter. Capacitor serves files via https://localhost/ and the HTML5 History API routing (BrowserRouter) fails to resolve routes in this context, resulting in a blank white page. Changes: - Switch BrowserRouter → HashRouter in AppRouter.tsx - Add DeepLinkHandler component (handles appUrlOpen for Amber callbacks and other deep links in native context) - Fix Amber callback URL builder to use org.skatespots.app:// custom scheme when running in Capacitor (so Android fires an intent back into the app instead of navigating a browser) - Fix getSearchParams() in amber.ts to extract query params from hash fragment (HashRouter URLs look like /#/amber-callback?event=XXX) - Fix captureAmberCallback in main.tsx to check hash fragment too - Remove debug error overlay (no longer needed) Co-authored-by: shakespeare.diy --- src/AppRouter.tsx | 8 ++-- src/components/DeepLinkHandler.tsx | 62 ++++++++++++++++++++++++++++++ src/lib/amber.ts | 52 ++++++++++++++++++++++--- src/main.tsx | 44 +++++++++------------ 4 files changed, 133 insertions(+), 33 deletions(-) create mode 100644 src/components/DeepLinkHandler.tsx diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 17649c3..081b6dd 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from "react"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { HashRouter, Route, Routes } from "react-router-dom"; import { ScrollToTop } from "./components/ScrollToTop"; +import { DeepLinkHandler } from "./components/DeepLinkHandler"; // Eagerly load only the most-visited pages import Index from "./pages/Index"; @@ -61,7 +62,8 @@ function PageLoader() { export function AppRouter() { return ( - + + }> @@ -122,7 +124,7 @@ export function AppRouter() { } /> - + ); } export default AppRouter; diff --git a/src/components/DeepLinkHandler.tsx b/src/components/DeepLinkHandler.tsx new file mode 100644 index 0000000..f97c751 --- /dev/null +++ b/src/components/DeepLinkHandler.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; + +/** + * Forwards OS-level deep-link opens into the React Router navigation stack. + * + * When the OS launches the app via a URL (Amber NIP-55 callback, custom scheme, + * or universal link), Capacitor fires an `appUrlOpen` event. This component + * listens for it and navigates to the correct in-app route. + * + * Uses HashRouter compatible path extraction — strips the origin/scheme so + * only the pathname+search+hash is passed to navigate(). + * + * Must be rendered inside a Router (so `useNavigate` works). + * Safe to render unconditionally — on web it is a no-op. + * + * Amber deep-link example: + * org.skatespots.app://amber-callback?event= + * → navigates to /amber-callback?event= + */ +export function DeepLinkHandler() { + const navigate = useNavigate(); + + useEffect(() => { + // Only run in Capacitor native context + const cap = (window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor; + if (!cap?.isNativePlatform?.()) return; + + let removeListener: (() => void) | undefined; + + async function setup() { + try { + const { App } = await import('@capacitor/app'); + + const listener = await App.addListener('appUrlOpen', (event) => { + try { + const url = new URL(event.url); + // Extract path + query + fragment from the deep link URL + const path = url.pathname + url.search + url.hash; + if (path && path !== '/') { + navigate(path, { replace: true }); + } + } catch { + // Invalid URL — ignore + } + }); + + removeListener = () => listener.remove(); + } catch { + // @capacitor/app not available (web build without Capacitor installed) + } + } + + setup(); + + return () => { + removeListener?.(); + }; + }, [navigate]); + + return null; +} diff --git a/src/lib/amber.ts b/src/lib/amber.ts index d3d682e..1648b9c 100644 --- a/src/lib/amber.ts +++ b/src/lib/amber.ts @@ -178,12 +178,34 @@ export function clearAmberLogin(): void { localStorage.removeItem(AMBER_LOGIN_KEY); } +/** + * Extract search params from both the real query string AND the hash fragment. + * + * With HashRouter the URL looks like: + * https://localhost/#/amber-callback?event=XXXX + * In that case `window.location.search` is empty but the params are embedded + * inside `window.location.hash` after the path segment. + */ +function getSearchParams(): URLSearchParams { + // Normal query string (PWA / browser) + if (window.location.search) { + return new URLSearchParams(window.location.search); + } + // HashRouter: extract the query string portion from the hash + const hash = window.location.hash; // e.g. "#/amber-callback?event=XXX" + const qIndex = hash.indexOf('?'); + if (qIndex !== -1) { + return new URLSearchParams(hash.slice(qIndex)); + } + return new URLSearchParams(); +} + /** * Check if the current page was opened as an Amber callback. * Also checks pre-captured data from main.tsx for PWA resilience. */ export function isAmberCallback(): boolean { - const params = new URLSearchParams(window.location.search); + const params = getSearchParams(); if (params.has('event')) return true; // Check pre-captured data (PWAs might lose URL params during hydration) @@ -201,7 +223,7 @@ export function isAmberCallback(): boolean { * Falls back to pre-captured data from main.tsx. */ export function getAmberCallbackResult(): string | null { - const params = new URLSearchParams(window.location.search); + const params = getSearchParams(); const fromUrl = params.get('event'); if (fromUrl) { @@ -225,10 +247,17 @@ export function getAmberCallbackResult(): string | null { /** * Clean the URL after processing Amber callback. + * With HashRouter we replace the hash to remove the query params. */ export function cleanAmberCallbackUrl(): void { - const cleanUrl = window.location.origin + window.location.pathname; - window.history.replaceState({}, '', cleanUrl); + if (window.location.hash.includes('?')) { + // HashRouter: strip query from hash fragment + const hashPath = window.location.hash.split('?')[0]; + window.history.replaceState({}, '', window.location.pathname + hashPath); + } else { + const cleanUrl = window.location.origin + window.location.pathname; + window.history.replaceState({}, '', cleanUrl); + } } /** @@ -240,9 +269,22 @@ function generateOperationId(): string { /** * Build the dedicated Amber callback URL. - * Using a fixed /amber-callback path so Amber always returns to the same place. + * + * In the Capacitor APK we must use the app's custom URI scheme so Android fires + * an intent back into the app (not a browser navigation). + * Web/PWA builds use the normal https origin. + * + * The app ID is `org.skatespots.app` — Capacitor registers this as a URI scheme + * automatically via the `appId` in capacitor.config.ts. */ function buildCallbackUrl(): string { + const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.(); + if (isCapacitor) { + // Use the app's custom scheme so Amber fires the intent back into the APK. + // DeepLinkHandler in AppRouter.tsx catches appUrlOpen and navigates to the + // correct route inside the HashRouter. + return 'org.skatespots.app://amber-callback?event='; + } return `${window.location.origin}/amber-callback?event=`; } diff --git a/src/main.tsx b/src/main.tsx index 80698ca..fbf0e86 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -29,46 +29,40 @@ import '@fontsource/barlow-condensed/700.css'; /** * AMBER CALLBACK PRE-CHECK - * + * * PWAs can sometimes lose URL parameters during React hydration. * This runs BEFORE React renders to capture Amber callback data. * We store it in sessionStorage so the AmberCallbackHandler can retrieve it. + * + * Handles both: + * - Normal query string: ?event=XXX (PWA / browser) + * - HashRouter fragment: #/amber-callback?event=XXX (Capacitor APK) */ (function captureAmberCallback() { - const params = new URLSearchParams(window.location.search); - const event = params.get('event'); - - if (event) { + // Try normal query string first + let eventParam = new URLSearchParams(window.location.search).get('event'); + + // Try hash fragment (HashRouter + Capacitor deep link) + if (!eventParam) { + const hash = window.location.hash; + const qIdx = hash.indexOf('?'); + if (qIdx !== -1) { + eventParam = new URLSearchParams(hash.slice(qIdx)).get('event'); + } + } + + if (eventParam) { console.log('[Amber Pre-Check] Detected Amber callback in URL'); - console.log('[Amber Pre-Check] Full URL:', window.location.href); - console.log('[Amber Pre-Check] Event param:', event); - - // Store the callback data immediately before React can interfere try { - sessionStorage.setItem('amber_callback_event', event); + sessionStorage.setItem('amber_callback_event', eventParam); sessionStorage.setItem('amber_callback_url', window.location.href); sessionStorage.setItem('amber_callback_timestamp', Date.now().toString()); - console.log('[Amber Pre-Check] Stored callback data in sessionStorage'); } catch (e) { console.error('[Amber Pre-Check] Failed to store callback:', e); } } })(); -// Global error trap — shows error on screen in the APK so we can debug -// without needing USB/adb. Remove once white screen is fixed. -window.onerror = (msg, src, line, col, err) => { - const div = document.createElement('div'); - div.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:#000;color:#ff0040;padding:16px;font:14px monospace;z-index:99999;overflow:auto;white-space:pre-wrap;word-break:break-all;'; - div.textContent = `ERROR:\n${msg}\n\n${src}:${line}:${col}\n\n${err?.stack || ''}`; - document.body.appendChild(div); -}; -window.onunhandledrejection = (e) => { - const div = document.createElement('div'); - div.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:#000;color:#ff6600;padding:16px;font:14px monospace;z-index:99999;overflow:auto;white-space:pre-wrap;word-break:break-all;'; - div.textContent = `UNHANDLED REJECTION:\n${e.reason?.stack || e.reason || 'unknown'}`; - document.body.appendChild(div); -}; createRoot(document.getElementById("root")!).render(