feat: Android back/app links and web app-shell cache
This commit is contained in:
parent
21be1ed5d6
commit
d741823039
9 changed files with 274 additions and 59 deletions
18
AGENTS.md
18
AGENTS.md
|
|
@ -2077,8 +2077,22 @@ session with **no other memory of this project**. So:
|
||||||
used by **both** maps (`SpotMap`, `RouteDialog`). Deliberately NOT a service
|
used by **both** maps (`SpotMap`, `RouteDialog`). Deliberately NOT a service
|
||||||
worker: a SW only helps the web build, while the Android app runs inside
|
worker: a SW only helps the web build, while the Android app runs inside
|
||||||
Capacitor's WebView where a SW never sees tile requests. Works on both.
|
Capacitor's WebView where a SW never sees tile requests. Works on both.
|
||||||
Still open: offline **app shell** (SW) — unnecessary for native, deferred for
|
**Web app shell (2026-10-28):** `public/push-sw.js` is also the app-shell
|
||||||
web.
|
cache — hashed assets cache-first, navigations network-first with
|
||||||
|
`/index.html` fallback, old `skate-spots-shell-*` caches removed. It is
|
||||||
|
registered unconditionally on web by `lib/appServiceWorker.ts` (skipped on
|
||||||
|
native). The push handlers are unchanged; `/api/*` and cross-origin model
|
||||||
|
requests are never cached.
|
||||||
|
- **Android navigation + app links (2026-10-28):** `@capacitor/app` is now a
|
||||||
|
real dependency. `DeepLinkHandler` dynamically imports it and handles
|
||||||
|
`appUrlOpen` (HTTPS paths, `#/` HashRouter paths, and
|
||||||
|
`org.skatespots.app://` host routes) plus hardware `backButton` (close the top
|
||||||
|
Radix overlay → history back → minimise at the map). `scripts/inject-deep-links.mjs`
|
||||||
|
adds the verified `https://skate-spots.org` + custom-scheme intent filters to
|
||||||
|
the generated manifest; `public/.well-known/assetlinks.json` carries the
|
||||||
|
release cert SHA-256 `B7:52:F2:6A:…:12:0A` for package
|
||||||
|
`org.skatespots.app`. If the signing cert changes, update assetlinks and
|
||||||
|
redeploy; app-link verification happens at install.
|
||||||
- **Photo redundancy (2026-09-28):** `useUploadFile` now uses **two** Blossom
|
- **Photo redundancy (2026-09-28):** `useUploadFile` now uses **two** Blossom
|
||||||
hosts (`blossom.primal.net`, `blossom.band`) instead of three. ⚠️
|
hosts (`blossom.primal.net`, `blossom.band`) instead of three. ⚠️
|
||||||
`BlossomUploader.upload` signs ONE auth event then does
|
`BlossomUploader.upload` signs ONE auth event then does
|
||||||
|
|
|
||||||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -11,6 +11,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||||
"@capacitor/android": "^8.4.2",
|
"@capacitor/android": "^8.4.2",
|
||||||
|
"@capacitor/app": "^8.1.2",
|
||||||
"@capacitor/camera": "^8.2.1",
|
"@capacitor/camera": "^8.2.1",
|
||||||
"@capacitor/core": "^8.4.2",
|
"@capacitor/core": "^8.4.2",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
|
|
@ -324,6 +325,15 @@
|
||||||
"@capacitor/core": "^8.5.0"
|
"@capacitor/core": "^8.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@capacitor/app": {
|
||||||
|
"version": "8.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.2.tgz",
|
||||||
|
"integrity": "sha512-uKBlsrXH0IdvUn69e/KKYbZ32A06pcrUdG8LWuZ/6y33YMXZXLEEdNSwl+jKS/SrsTWqnePs8EqSWaiQM3mUGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@capacitor/core": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@capacitor/camera": {
|
"node_modules/@capacitor/camera": {
|
||||||
"version": "8.2.4",
|
"version": "8.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-8.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-8.2.4.tgz",
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,10 @@
|
||||||
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && node scripts/check-i18n.mjs && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
"test": "npm i --silent && tsc --noEmit && tsc -p cvm-server/tsconfig.json && tsc -p ai-proxy/tsconfig.json && tsc -p push-service/tsconfig.json && eslint && node scripts/check-i18n.mjs && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||||
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
|
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
|
||||||
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
|
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
|
||||||
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
|
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs",
|
||||||
"android:open": "npx cap open android",
|
"android:open": "npx cap open android",
|
||||||
"android:version": "node scripts/sync-android-version.mjs",
|
"android:version": "node scripts/sync-android-version.mjs",
|
||||||
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && cd android && ./gradlew assembleRelease",
|
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-deep-links.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && cd android && ./gradlew assembleRelease",
|
||||||
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html",
|
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html",
|
||||||
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
|
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
|
||||||
"translation:matrix": "node scripts/translation-matrix.mjs"
|
"translation:matrix": "node scripts/translation-matrix.mjs"
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||||
"@capacitor/android": "^8.4.2",
|
"@capacitor/android": "^8.4.2",
|
||||||
|
"@capacitor/app": "^8.1.2",
|
||||||
"@capacitor/camera": "^8.2.1",
|
"@capacitor/camera": "^8.2.1",
|
||||||
"@capacitor/core": "^8.4.2",
|
"@capacitor/core": "^8.4.2",
|
||||||
"@capacitor/geolocation": "^8.2.0",
|
"@capacitor/geolocation": "^8.2.0",
|
||||||
|
|
|
||||||
10
public/.well-known/assetlinks.json
Normal file
10
public/.well-known/assetlinks.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "org.skatespots.app",
|
||||||
|
"sha256_cert_fingerprints": ["B7:52:F2:6A:64:AE:E3:21:A5:98:CC:E4:65:41:57:3A:DF:E8:88:C1:BC:1F:8B:7D:EE:12:7D:3B:AE:83:12:0A"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -6,8 +6,76 @@
|
||||||
* and shows an OS notification. Tapping it focuses/opens the app, where the real
|
* and shows an OS notification. Tapping it focuses/opens the app, where the real
|
||||||
* message is fetched by wrapId and decrypted locally.
|
* message is fetched by wrapId and decrypted locally.
|
||||||
*
|
*
|
||||||
* Deliberately tiny: no caching, no offline logic.
|
* It also acts as the tiny app-shell cache: hashed assets are cache-first,
|
||||||
|
* navigations are network-first with an index fallback. No API, DM or model
|
||||||
|
* request is ever stored; only same-origin static bytes.
|
||||||
*/
|
*/
|
||||||
|
const SHELL_CACHE = 'skate-spots-shell-v2';
|
||||||
|
const SHELL_ASSETS = ['/', '/index.html'];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_ASSETS)).catch(() => undefined),
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key.startsWith('skate-spots-shell-') && key !== SHELL_CACHE)
|
||||||
|
.map((key) => caches.delete(key)),
|
||||||
|
),
|
||||||
|
).then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const request = event.request;
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/.netlify/')) return;
|
||||||
|
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const copy = response.clone();
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.put('/index.html', copy)).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match('/index.html').then((cached) => cached || Response.error())),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStatic =
|
||||||
|
url.pathname.startsWith('/assets/') ||
|
||||||
|
url.pathname.startsWith('/images/') ||
|
||||||
|
url.pathname.startsWith('/flags/') ||
|
||||||
|
url.pathname.startsWith('/ort/') ||
|
||||||
|
/\.(?:js|mjs|css|woff2?|ttf|wasm|png|jpe?g|webp|svg|ico)$/i.test(url.pathname);
|
||||||
|
if (!isStatic) return;
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(request).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const copy = response.clone();
|
||||||
|
caches.open(SHELL_CACHE).then((cache) => cache.put(request, copy)).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
let data = {};
|
let data = {};
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
44
scripts/inject-deep-links.mjs
Executable file
44
scripts/inject-deep-links.mjs
Executable file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Add Android App Links + the custom scheme to the generated manifest.
|
||||||
|
* Capacitor regenerates AndroidManifest.xml, so this must run after `cap sync`
|
||||||
|
* and before Gradle (see package.json android:build).
|
||||||
|
*
|
||||||
|
* HTTPS links are verified with https://skate-spots.org/.well-known/assetlinks.json
|
||||||
|
* (release signing certificate). The custom scheme is for Amber deep links and
|
||||||
|
* fallback launches where no verified domain is available.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
const root = join(new URL('..', import.meta.url).pathname);
|
||||||
|
const manifestPath = join(root, 'android/app/src/main/AndroidManifest.xml');
|
||||||
|
let manifest = readFileSync(manifestPath, 'utf8');
|
||||||
|
|
||||||
|
if (manifest.includes('android:host="skate-spots.org"')) {
|
||||||
|
console.log('[inject-deep-links] already present');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = `
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" android:host="skate-spots.org" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="org.skatespots.app" />
|
||||||
|
</intent-filter>`;
|
||||||
|
|
||||||
|
const marker = /(\s*<\/activity>)/i;
|
||||||
|
if (!marker.test(manifest)) {
|
||||||
|
console.warn('[inject-deep-links] <activity> close not found — skipped');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
manifest = manifest.replace(marker, `${filters}\n </activity>`);
|
||||||
|
writeFileSync(manifestPath, manifest);
|
||||||
|
console.log('[inject-deep-links] Android App Links + org.skatespots.app added.');
|
||||||
|
|
@ -1,76 +1,116 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forwards OS-level deep-link opens into the React Router navigation stack.
|
* Native-only bridge:
|
||||||
|
* - forwards app links (`https://skate-spots.org/...`) and the custom
|
||||||
|
* `org.skatespots.app://` scheme into the HashRouter;
|
||||||
|
* - handles Android's hardware Back: close an open Radix overlay, go back in
|
||||||
|
* history, or minimise at the map instead of killing the app;
|
||||||
|
* - keeps the Amber deep-link callback context available to the callback page.
|
||||||
*
|
*
|
||||||
* Uses the global window.Capacitor object to avoid a hard npm dependency on
|
* The `@capacitor/app` import is dynamic so the web bundle does not need the
|
||||||
* @capacitor/app (which is installed on the native build machine but not in
|
* native plugin at runtime.
|
||||||
* the web/CI build environment).
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*
|
|
||||||
* Amber deep-link example:
|
|
||||||
* org.skatespots.app://amber-callback?event=<data>
|
|
||||||
* → navigates to /amber-callback?event=<data>
|
|
||||||
*/
|
*/
|
||||||
|
function routeFromUrl(url: URL): string {
|
||||||
|
// HashRouter links: https://skate-spots.org/#/spot/<pk>/<d>
|
||||||
|
if (url.hash.startsWith('#/')) return url.hash.slice(1);
|
||||||
|
if (url.hash && url.hash !== '#') return url.hash.slice(1);
|
||||||
|
|
||||||
// Type for the global Capacitor object injected by the native runtime
|
// Custom scheme: org.skatespots.app://amber-callback?event=... — the host is
|
||||||
interface CapacitorGlobal {
|
// the route; a normal URL's path is the route.
|
||||||
isNativePlatform?: () => boolean;
|
if (url.protocol !== 'https:' && url.protocol !== 'http:' && url.host) {
|
||||||
Plugins?: {
|
return `/${url.host}${url.pathname}${url.search}`;
|
||||||
App?: {
|
}
|
||||||
addListener: (
|
if (url.pathname && url.pathname !== '/') return `${url.pathname}${url.search}`;
|
||||||
event: string,
|
return '/';
|
||||||
handler: (data: { url: string }) => void
|
}
|
||||||
) => Promise<{ remove: () => void }>;
|
|
||||||
};
|
function closeTopOverlay(): boolean {
|
||||||
};
|
const overlay = document.querySelector(
|
||||||
|
'[data-state="open"][role="dialog"], [data-state="open"][role="alertdialog"], [data-state="open"][data-radix-menu-content]',
|
||||||
|
);
|
||||||
|
if (!overlay) return false;
|
||||||
|
const active = document.activeElement instanceof HTMLElement ? document.activeElement : overlay;
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
key: 'Escape',
|
||||||
|
code: 'Escape',
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
active.dispatchEvent(event);
|
||||||
|
if (!event.defaultPrevented) document.dispatchEvent(event);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeepLinkHandler() {
|
export function DeepLinkHandler() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cap = (window as unknown as { Capacitor?: CapacitorGlobal }).Capacitor;
|
if (!Capacitor.isNativePlatform()) return;
|
||||||
if (!cap?.isNativePlatform?.()) return;
|
|
||||||
|
|
||||||
const AppPlugin = cap?.Plugins?.App;
|
let disposed = false;
|
||||||
if (!AppPlugin) return;
|
const removers: Array<() => void> = [];
|
||||||
|
|
||||||
let removeListener: (() => void) | undefined;
|
void import('@capacitor/app')
|
||||||
|
.then(({ App }) => {
|
||||||
|
if (disposed) return;
|
||||||
|
|
||||||
AppPlugin.addListener('appUrlOpen', (event) => {
|
App.addListener('appUrlOpen', (event) => {
|
||||||
try {
|
|
||||||
const url = new URL(event.url);
|
|
||||||
// Pre-capture Amber callback event param into sessionStorage so
|
|
||||||
// AmberCallbackPage can find it after React Router renders the route.
|
|
||||||
const eventParam = url.searchParams.get('event');
|
|
||||||
if (eventParam) {
|
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem('amber_callback_event', eventParam);
|
const url = new URL(event.url);
|
||||||
sessionStorage.setItem('amber_callback_url', event.url);
|
const eventParam = url.searchParams.get('event');
|
||||||
sessionStorage.setItem('amber_callback_timestamp', Date.now().toString());
|
if (eventParam) {
|
||||||
} catch { /* ignore storage errors */ }
|
try {
|
||||||
}
|
sessionStorage.setItem('amber_callback_event', eventParam);
|
||||||
// Extract path + query + fragment from the deep link URL
|
sessionStorage.setItem('amber_callback_url', event.url);
|
||||||
const path = url.pathname + url.search + url.hash;
|
sessionStorage.setItem('amber_callback_timestamp', Date.now().toString());
|
||||||
if (path && path !== '/') {
|
} catch {
|
||||||
navigate(path, { replace: true });
|
// storage unavailable — the callback page can still read the URL
|
||||||
}
|
}
|
||||||
} catch {
|
}
|
||||||
// Invalid URL — ignore
|
const route = routeFromUrl(url);
|
||||||
}
|
if (route && route !== '/') navigate(route, { replace: true });
|
||||||
}).then((listener) => {
|
} catch {
|
||||||
removeListener = () => listener.remove();
|
// invalid URL — ignore
|
||||||
}).catch(() => {
|
}
|
||||||
// Plugin not available — ignore
|
}).then((listener) => {
|
||||||
});
|
if (disposed) listener.remove();
|
||||||
|
else removers.push(() => listener.remove());
|
||||||
|
}).catch(() => undefined);
|
||||||
|
|
||||||
|
App.addListener('backButton', ({ canGoBack }) => {
|
||||||
|
// The most common native-back expectation: close the top sheet/menu
|
||||||
|
// first, never leave the app with a dialog still on screen.
|
||||||
|
if (closeTopOverlay()) return;
|
||||||
|
|
||||||
|
if (canGoBack) {
|
||||||
|
window.history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hash = window.location.hash || '#/';
|
||||||
|
if (hash !== '#/' && hash !== '') {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// At the map with no history: background the app rather than killing
|
||||||
|
// it. This matches the platform's usual home-button behaviour.
|
||||||
|
void App.minimizeApp();
|
||||||
|
}).then((listener) => {
|
||||||
|
if (disposed) listener.remove();
|
||||||
|
else removers.push(() => listener.remove());
|
||||||
|
}).catch(() => undefined);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Native plugin unavailable — web/PWA continues normally.
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
removeListener?.();
|
disposed = true;
|
||||||
|
for (const remove of removers) remove();
|
||||||
};
|
};
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
|
|
||||||
25
src/lib/appServiceWorker.ts
Normal file
25
src/lib/appServiceWorker.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Capacitor } from '@capacitor/core';
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the app-shell service worker on the web only.
|
||||||
|
*
|
||||||
|
* The same worker also handles Web Push, so registering it unconditionally does
|
||||||
|
* not conflict with the push opt-in path. Native Capacitor uses its local asset
|
||||||
|
* server instead — a service worker there would only add a second cache.
|
||||||
|
*/
|
||||||
|
export function registerAppServiceWorker(): void {
|
||||||
|
if (started || typeof window === 'undefined' || !('serviceWorker' in navigator)) return;
|
||||||
|
if (Capacitor.isNativePlatform()) return;
|
||||||
|
started = true;
|
||||||
|
|
||||||
|
const register = () => {
|
||||||
|
navigator.serviceWorker.register('/push-sw.js').catch(() => {
|
||||||
|
// Private mode / unsupported browser — the app works without it.
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'complete') register();
|
||||||
|
else window.addEventListener('load', register, { once: true });
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ import '@fontsource-variable/inter';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.css';
|
import 'leaflet.markercluster/dist/MarkerCluster.css';
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||||||
|
import { registerAppServiceWorker } from '@/lib/appServiceWorker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AMBER CALLBACK PRE-CHECK + REDIRECT
|
* AMBER CALLBACK PRE-CHECK + REDIRECT
|
||||||
|
|
@ -130,6 +131,8 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
registerAppServiceWorker();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<App />
|
<App />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue