fix: switch to HashRouter for Capacitor Android compatibility

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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-18 21:56:23 +07:00
parent 8c25110f44
commit c3ef0247fc
4 changed files with 133 additions and 33 deletions

View file

@ -1,6 +1,7 @@
import { lazy, Suspense } from "react"; 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 { ScrollToTop } from "./components/ScrollToTop";
import { DeepLinkHandler } from "./components/DeepLinkHandler";
// Eagerly load only the most-visited pages // Eagerly load only the most-visited pages
import Index from "./pages/Index"; import Index from "./pages/Index";
@ -61,7 +62,8 @@ function PageLoader() {
export function AppRouter() { export function AppRouter() {
return ( return (
<BrowserRouter> <HashRouter>
<DeepLinkHandler />
<ScrollToTop /> <ScrollToTop />
<Suspense fallback={<PageLoader />}> <Suspense fallback={<PageLoader />}>
<Routes> <Routes>
@ -122,7 +124,7 @@ export function AppRouter() {
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
</Suspense> </Suspense>
</BrowserRouter> </HashRouter>
); );
} }
export default AppRouter; export default AppRouter;

View file

@ -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=<data>
* → navigates to /amber-callback?event=<data>
*/
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;
}

View file

@ -178,12 +178,34 @@ export function clearAmberLogin(): void {
localStorage.removeItem(AMBER_LOGIN_KEY); 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. * Check if the current page was opened as an Amber callback.
* Also checks pre-captured data from main.tsx for PWA resilience. * Also checks pre-captured data from main.tsx for PWA resilience.
*/ */
export function isAmberCallback(): boolean { export function isAmberCallback(): boolean {
const params = new URLSearchParams(window.location.search); const params = getSearchParams();
if (params.has('event')) return true; if (params.has('event')) return true;
// Check pre-captured data (PWAs might lose URL params during hydration) // 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. * Falls back to pre-captured data from main.tsx.
*/ */
export function getAmberCallbackResult(): string | null { export function getAmberCallbackResult(): string | null {
const params = new URLSearchParams(window.location.search); const params = getSearchParams();
const fromUrl = params.get('event'); const fromUrl = params.get('event');
if (fromUrl) { if (fromUrl) {
@ -225,10 +247,17 @@ export function getAmberCallbackResult(): string | null {
/** /**
* Clean the URL after processing Amber callback. * Clean the URL after processing Amber callback.
* With HashRouter we replace the hash to remove the query params.
*/ */
export function cleanAmberCallbackUrl(): void { export function cleanAmberCallbackUrl(): void {
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; const cleanUrl = window.location.origin + window.location.pathname;
window.history.replaceState({}, '', cleanUrl); window.history.replaceState({}, '', cleanUrl);
}
} }
/** /**
@ -240,9 +269,22 @@ function generateOperationId(): string {
/** /**
* Build the dedicated Amber callback URL. * 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 { 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=`; return `${window.location.origin}/amber-callback?event=`;
} }

View file

@ -33,42 +33,36 @@ import '@fontsource/barlow-condensed/700.css';
* PWAs can sometimes lose URL parameters during React hydration. * PWAs can sometimes lose URL parameters during React hydration.
* This runs BEFORE React renders to capture Amber callback data. * This runs BEFORE React renders to capture Amber callback data.
* We store it in sessionStorage so the AmberCallbackHandler can retrieve it. * 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() { (function captureAmberCallback() {
const params = new URLSearchParams(window.location.search); // Try normal query string first
const event = params.get('event'); let eventParam = new URLSearchParams(window.location.search).get('event');
if (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] 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 { 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_url', window.location.href);
sessionStorage.setItem('amber_callback_timestamp', Date.now().toString()); sessionStorage.setItem('amber_callback_timestamp', Date.now().toString());
console.log('[Amber Pre-Check] Stored callback data in sessionStorage');
} catch (e) { } catch (e) {
console.error('[Amber Pre-Check] Failed to store callback:', 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( createRoot(document.getElementById("root")!).render(
<ErrorBoundary> <ErrorBoundary>