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:
parent
8c25110f44
commit
c3ef0247fc
4 changed files with 133 additions and 33 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
62
src/components/DeepLinkHandler.tsx
Normal file
62
src/components/DeepLinkHandler.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
const cleanUrl = window.location.origin + window.location.pathname;
|
if (window.location.hash.includes('?')) {
|
||||||
window.history.replaceState({}, '', cleanUrl);
|
// 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.
|
* 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=`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
40
src/main.tsx
40
src/main.tsx
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue