Mobile Capacitor shell
1. Foundational mental model
Section titled “1. Foundational mental model”apps/mobile holds no UI code. It is a Capacitor config whose WebView opens the production site. Every screen, route, and game rule is the same SvelteKit app a browser gets from https://flags.games. The native layer adds a status bar style, a splash screen, deep-link handling, and a set of optional plugins that the web code calls through thin adapters in apps/web/src/lib/mobile/.
Shipping a web change updates the app with no store review. The flip side: the app needs the network to show anything beyond www/index.html, which is a one-line “Loading flags.games…” placeholder.
2. Implementation status & known gaps
Section titled “2. Implementation status & known gaps”docs/plans/product/native-mobile-app.md still lists RevenueCat IAP as a locked v1 decision and maps billing to apps/web/src/convex/billing.ts. That file no longer exists. Treat the plan as history for the billing parts.
3. Concrete implementation
Section titled “3. Concrete implementation”Capacitor config
Section titled “Capacitor config”const serverUrl = process.env.CAPACITOR_SERVER_URL?.trim() || "https://flags.games";const useCleartext = serverUrl.startsWith("http://");
const allowNavigation = [ "flags.games", "*.flags.games", "*.convex.site", "*.convex.cloud", ...(lanHost && lanHost !== "flags.games" ? [lanHost] : []),];The rest of the config sets webDir: "www", the flagsgames iOS URL scheme, SplashScreen with launchAutoHide, StatusBar style DARK, and allowMixedContent on Android only when the server URL is plain HTTP.
| Variable | Where | Effect |
|---|---|---|
CAPACITOR_SERVER_URL | apps/mobile at cap sync time | WebView origin; http:// enables cleartext and mixed content |
PUBLIC_SOCKET_URL | apps/web/.env.local | Needed on a LAN device so multiplayer does not dial localhost |
Plugins: present vs wired
Section titled “Plugins: present vs wired”apps/mobile/package.json and apps/web/package.json both declare the Capacitor plugins. Only some have callers.
| Plugin | Adapter | Called from app code |
|---|---|---|
@capacitor/core | platform.ts (isNativeApp, getNativePlatform) | Yes: bootstrap, PWA install gate |
@capacitor/status-bar | chrome.ts (applyNativeChrome) | Yes: bootstrap |
@capacitor/app | deep-links.ts (setupNativeDeepLinks) | Yes: bootstrap |
@capacitor/browser | browser.ts | Only closeExternalBrowser, from deep-link handlers |
@capacitor/haptics | haptics.ts (triggerNativeHaptic) | No |
@capacitor/share | share.ts (shareNative) | No |
@capacitor/network | network.ts | No |
@capacitor/preferences | reminders.ts | No; requestDailyReminderAfterCompletion is a documented no-op |
@capacitor/splash-screen | none | Config only (launchAutoHide) |
@revenuecat/purchases-capacitor | none (purchases.ts deleted) | No; mobile manifest only |
Every adapter follows the same guard:
export async function triggerNativeHaptic(kind: HapticFeedbackKind): Promise<void> { if (!isNativeApp()) { return; }
try { const { Haptics, ImpactStyle, NotificationType } = await import("@capacitor/haptics"); if (kind === "light") { await Haptics.impact({ style: ImpactStyle.Light }); return; } // ... medium, success, error, warning ... } catch { // ignore missing plugin }}Plugins load with dynamic import() so the browser bundle does not pay for them, and a missing native plugin degrades to a no-op.
Deep links
Section titled “Deep links”setupNativeDeepLinks reads the launch URL, listens for appUrlOpen, and closes the in-app browser on appStateChange to active. extractPathFromAppUrl accepts flagsgames: URLs and any flags.games or *.flags.games host, and the bootstrap passes the path to SvelteKit’s goto.
OAuth inside the shell
Section titled “OAuth inside the shell”signInWithOAuth asks Convex Auth for a provider redirect, stores the PKCE verifier in localStorage, and navigates the current page:
if (result.redirect && result.verifier) { if (!isAllowedOAuthRedirect(result.redirect, publicEnv.convexSiteUrl)) { throw new Error("OAuth redirect blocked."); } this.#setStorageItem(VERIFIER_STORAGE_KEY, result.verifier); window.location.href = result.redirect; return;}In a browser that round trip lands back on flags.games?code=… in the same tab, and handleOAuthCodeFromUrl (run from AppNavigation’s onMount) trades the code plus verifier for tokens. In the shell, the steps below break it:
openExternalUrl and closeExternalBrowser exist for a system-browser flow, but nothing calls openExternalUrl, and Universal Links cannot bring the code back until the .well-known placeholders are filled. Email and password sign-in has no redirect and works in the WebView.
RevenueCat: added, then removed
Section titled “RevenueCat: added, then removed”| Commit | Date (UTC−4) | Change |
|---|---|---|
6f5d2263 | 2026-08-11 04:29 | Park RevenueCat billing and paywall WIP for separate ship track. Adds apps/mobile, $lib/mobile/* including purchases.ts, Convex billing.ts, lib/billing/revenuecatWebhook.ts, the billingCustomers and billingEvents tables, a /revenuecat-webhook HTTP route, PUBLIC_REVENUECAT_APPLE_API_KEY / PUBLIC_REVENUECAT_GOOGLE_API_KEY, and SupporterPaywall.svelte. |
f3a70d69 | 2026-08-11 11:54 | Refactor billing and entitlement management by removing RevenueCat integration. Deletes billing.ts, the webhook, purchases.ts, the paywall and entitlement client; replaces billingCustomers with supporterWaitlist; adds SupporterUpsell.svelte. |
Supporter waitlist
Section titled “Supporter waitlist”The live replacement collects emails. joinSupporterWaitlist is a public mutation with validated source (settings, learn, house_ad, demo, results) and offer (supporter, lifetime, founder). It normalises the email, throttles, upserts by emailLower, and schedules a confirmation email only on first insert:
const allowed = await tryConsumeRollingWindowSlot( ctx, `supporter-waitlist:${emailLower}`, WAITLIST_THROTTLE_LIMIT, WAITLIST_THROTTLE_WINDOW_MS, now);if (!allowed) { return { status: "rate_limited" as const };}WAITLIST_THROTTLE_LIMIT is 5 and the window is one hour. Each row gets a crypto.randomUUID() unsubscribe token; unsubscribeSupporterWaitlist looks it up through the by_unsubscribe_token index. Signed-in callers get userId attached; anonymous callers do not need an account.
4. Internal mechanics & mathematics
Section titled “4. Internal mechanics & mathematics”Platform detection
Section titled “Platform detection”isNativeApp() is Capacitor.isNativePlatform(). Capacitor injects its bridge into pages loaded from server.url, so the same deployed bundle answers true inside the shell and false in Safari or Chrome. There is no user-agent sniffing and no build flag.
Waitlist throttle
Section titled “Waitlist throttle”For an actor key and stored timestamps , a call at time is allowed when
and on success is appended. The key is the normalised email, so the budget is per address. One address can trigger at most one confirmation email ever (only the insert path schedules it) and at most 5 row updates per hour.
5. Threat model, failure modes & edge cases
Section titled “5. Threat model, failure modes & edge cases”- Remote code in a store app. The binary runs whatever
flags.gamesserves. A compromised Vercel deploy is a compromised app with native bridge access. The plugin set is small (no filesystem, no contacts), which caps what the bridge exposes. - Offline launch. With no network the WebView shows the
www/index.htmlplaceholder indefinitely. The service worker is also off in production (see Edge asset pipeline), so there is no cached shell to fall back to. allowNavigationis a host list.*.convex.siteand*.convex.cloudallow any Convex deployment, not only this project’s.isAllowedOAuthRedirectpins OAuth redirects to the configuredconvexSiteUrl, but other navigations to those wildcards stay inside the WebView.- OAuth code arriving through a deep link. If Universal Links are fixed,
appUrlOpencallsgoto(path).handleOAuthCodeFromUrlonly runs inAppNavigation’sonMount, which does not re-run on client navigation, so the code would sit in the URL until a reload. - Embedded WebView policy. Based on general knowledge of Google’s policy, Google blocks OAuth in embedded WebViews. Any fix has to use the system browser (
openExternalUrl) and return through a verified link. - Waitlist abuse. The throttle is per email, not per IP. A script can submit many distinct addresses and each new one triggers a confirmation email to that address. There is no Turnstile check on this mutation.
- Stale dependency.
@revenuecat/purchases-capacitorinapps/mobile/package.jsonwill be linked into native projects oncap synceven though no JavaScript calls it.
6. Architectural tradeoffs & non-goals
Section titled “6. Architectural tradeoffs & non-goals”| Choice | Alternative | Why |
|---|---|---|
Remote server.url | Bundle the built SPA into www/ | One UI, updated on every Vercel deploy, no store review for web changes. Costs offline launch and ties app uptime to the site. |
Adapters in apps/web/src/lib/mobile | Plugins imported in feature code | Feature code stays browser-safe; each adapter no-ops outside the shell. |
| Waitlist email capture | RevenueCat IAP | Removed the same day it landed. Collecting interest needs no store products, receipts, or webhook secrets. |
| Capacitor | React Native / Flutter | Listed as a non-goal in the product plan: no second UI codebase. |
Non-goals (from docs/plans/product/native-mobile-app.md): a bundled offline-first build, a native UI rewrite, paid gameplay boosts, and mid-round ads.