feat: Android back/app links and web app-shell cache

This commit is contained in:
SkateSpots 2026-10-28 16:39:07 +07:00
parent 21be1ed5d6
commit d741823039
9 changed files with 274 additions and 59 deletions

View file

@ -2077,8 +2077,22 @@ session with **no other memory of this project**. So:
used by **both** maps (`SpotMap`, `RouteDialog`). Deliberately NOT a service
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.
Still open: offline **app shell** (SW) — unnecessary for native, deferred for
web.
**Web app shell (2026-10-28):** `public/push-sw.js` is also the app-shell
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
hosts (`blossom.primal.net`, `blossom.band`) instead of three. ⚠️
`BlossomUploader.upload` signs ONE auth event then does

10
package-lock.json generated
View file

@ -11,6 +11,7 @@
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/android": "^8.4.2",
"@capacitor/app": "^8.1.2",
"@capacitor/camera": "^8.2.1",
"@capacitor/core": "^8.4.2",
"@capacitor/geolocation": "^8.2.0",
@ -324,6 +325,15 @@
"@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": {
"version": "8.2.4",
"resolved": "https://registry.npmjs.org/@capacitor/camera/-/camera-8.2.4.tgz",

View file

@ -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!'",
"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: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: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",
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts",
"translation:matrix": "node scripts/translation-matrix.mjs"
@ -33,6 +33,7 @@
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",
"@capacitor/android": "^8.4.2",
"@capacitor/app": "^8.1.2",
"@capacitor/camera": "^8.2.1",
"@capacitor/core": "^8.4.2",
"@capacitor/geolocation": "^8.2.0",

View 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"]
}
}
]

View file

@ -6,8 +6,76 @@
* and shows an OS notification. Tapping it focuses/opens the app, where the real
* 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) => {
let data = {};
try {

44
scripts/inject-deep-links.mjs Executable file
View 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.');

View file

@ -1,76 +1,116 @@
import { useEffect } from 'react';
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
* @capacitor/app (which is installed on the native build machine but not in
* 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>
* The `@capacitor/app` import is dynamic so the web bundle does not need the
* native plugin at runtime.
*/
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
interface CapacitorGlobal {
isNativePlatform?: () => boolean;
Plugins?: {
App?: {
addListener: (
event: string,
handler: (data: { url: string }) => void
) => Promise<{ remove: () => void }>;
};
};
// Custom scheme: org.skatespots.app://amber-callback?event=... — the host is
// the route; a normal URL's path is the route.
if (url.protocol !== 'https:' && url.protocol !== 'http:' && url.host) {
return `/${url.host}${url.pathname}${url.search}`;
}
if (url.pathname && url.pathname !== '/') return `${url.pathname}${url.search}`;
return '/';
}
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() {
const navigate = useNavigate();
useEffect(() => {
const cap = (window as unknown as { Capacitor?: CapacitorGlobal }).Capacitor;
if (!cap?.isNativePlatform?.()) return;
if (!Capacitor.isNativePlatform()) return;
const AppPlugin = cap?.Plugins?.App;
if (!AppPlugin) return;
let disposed = false;
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 {
sessionStorage.setItem('amber_callback_event', eventParam);
sessionStorage.setItem('amber_callback_url', event.url);
sessionStorage.setItem('amber_callback_timestamp', Date.now().toString());
} catch { /* ignore storage errors */ }
}
// 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
// storage unavailable — the callback page can still read the URL
}
}
const route = routeFromUrl(url);
if (route && route !== '/') navigate(route, { replace: true });
} catch {
// invalid URL — ignore
}
}).then((listener) => {
removeListener = () => listener.remove();
}).catch(() => {
// Plugin not available — ignore
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 () => {
removeListener?.();
disposed = true;
for (const remove of removers) remove();
};
}, [navigate]);

View 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 });
}

View file

@ -46,6 +46,7 @@ import '@fontsource-variable/inter';
import 'leaflet/dist/leaflet.css';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
import { registerAppServiceWorker } from '@/lib/appServiceWorker';
/**
* AMBER CALLBACK PRE-CHECK + REDIRECT
@ -130,6 +131,8 @@ import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
})();
registerAppServiceWorker();
createRoot(document.getElementById("root")!).render(
<ErrorBoundary>
<App />