feat(mobile): bootstrap mobile app (PWA + Capacitor + backend auth/CORS)
Implements the §12 bootstrap from docs/mobile-app-plan.md. Backend (§6): - auth: optional returnToken flag on login returns the JWT in the body (data.authToken) for native Keychain/Keystore + Bearer; web cookie flow unchanged. - main.ts: explicit CORS allowlist (APP_URL + CORS_ALLOWED_ORIGINS env + Capacitor WebView origins), credentials enabled, replaces open enableCors(). - optional OpenAPI/Swagger at /api/docs behind SWAGGER_ENABLED. - env: CORS_ALLOWED_ORIGINS, SWAGGER_ENABLED, CAP_SERVER_URL. PWA: - manifest metadata, hand-rolled service worker (network-first nav, SWR assets, never intercepts /api,/socket.io,/collab), prod-only registration, apple-touch-icon. Capacitor: - capacitor.config.ts (webDir apps/client/dist; iOS via CAP_SERVER_URL to avoid bundling the AGPL client in the .ipa, see plan §9), cap:* scripts, deps, .gitignore for native dirs. - docs/mobile-bootstrap.md documenting what is done and the remaining manual steps (cap add ios/android, APNs/FCM, stores). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/app-icon-192x192.png" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-touch-fullscreen" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Gitmost" />
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Gitmost",
|
||||
"short_name": "Gitmost",
|
||||
"description": "Gitmost - open-source collaborative documentation and knowledge base.",
|
||||
"lang": "en",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0E1117",
|
||||
"theme_color": "#0E1117",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/favicon-16x16.png",
|
||||
"type": "image/png",
|
||||
"sizes": "16x16"
|
||||
},
|
||||
{
|
||||
"src": "icons/favicon-32x32.png",
|
||||
"type": "image/png",
|
||||
"sizes": "32x32"
|
||||
},
|
||||
{
|
||||
"src": "icons/app-icon-192x192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "180x180 192x192"
|
||||
},
|
||||
{
|
||||
"src": "icons/app-icon-512x512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
{ "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" },
|
||||
{ "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" },
|
||||
{ "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
|
||||
{ "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }
|
||||
]
|
||||
}
|
||||
|
||||
82
apps/client/public/sw.js
Normal file
82
apps/client/public/sw.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// Gitmost PWA service worker.
|
||||
// Conservative strategy:
|
||||
// - Never intercept API, websocket or collaboration traffic (always network).
|
||||
// - Navigations: network-first, fall back to the cached app shell offline.
|
||||
// - Other same-origin GET assets: stale-while-revalidate.
|
||||
// Bump CACHE_VERSION to invalidate stale assets on deploy.
|
||||
const CACHE_VERSION = "gitmost-v1";
|
||||
const APP_SHELL_URL = "/";
|
||||
|
||||
// Path prefixes that must always hit the network (auth/state/realtime).
|
||||
const NETWORK_ONLY_PREFIXES = ["/api", "/socket.io", "/collab"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
// Activate this worker immediately without waiting for old tabs to close.
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches
|
||||
.open(CACHE_VERSION)
|
||||
.then((cache) => cache.add(APP_SHELL_URL))
|
||||
.catch(() => {}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const keys = await caches.keys();
|
||||
await Promise.all(
|
||||
keys
|
||||
.filter((key) => key !== CACHE_VERSION)
|
||||
.map((key) => caches.delete(key)),
|
||||
);
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
|
||||
// Only handle same-origin GET requests; everything else goes to the network.
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (NETWORK_ONLY_PREFIXES.some((prefix) => url.pathname.startsWith(prefix)))
|
||||
return;
|
||||
|
||||
// Navigations: network-first with an offline fallback to the cached shell.
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
return await fetch(request);
|
||||
} catch {
|
||||
const cache = await caches.open(CACHE_VERSION);
|
||||
const cached = await cache.match(APP_SHELL_URL);
|
||||
return cached || Response.error();
|
||||
}
|
||||
})(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static assets: stale-while-revalidate.
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(CACHE_VERSION);
|
||||
const cached = await cache.match(request);
|
||||
const network = fetch(request)
|
||||
.then((response) => {
|
||||
// Only cache successful, same-origin (basic) responses.
|
||||
if (response && response.status === 200 && response.type === "basic") {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return cached || (await network) || Response.error();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -62,3 +62,13 @@ root.render(
|
||||
</MantineProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// Register the service worker for PWA installability and an offline app shell.
|
||||
// Production only: in dev the Vite server and HMR must not be intercepted.
|
||||
if (import.meta.env.PROD && "serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch((err) => {
|
||||
console.error("Service worker registration failed:", err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user