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
|
||||
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
10
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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
|
||||
* 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
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 { 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]);
|
||||
|
||||
|
|
|
|||
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.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 />
|
||||
|
|
|
|||
Loading…
Reference in a new issue