Skip to content

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.

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.

apps/mobile/capacitor.config.ts
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.

VariableWhereEffect
CAPACITOR_SERVER_URLapps/mobile at cap sync timeWebView origin; http:// enables cleartext and mixed content
PUBLIC_SOCKET_URLapps/web/.env.localNeeded on a LAN device so multiplayer does not dial localhost

apps/mobile/package.json and apps/web/package.json both declare the Capacitor plugins. Only some have callers.

PluginAdapterCalled from app code
@capacitor/coreplatform.ts (isNativeApp, getNativePlatform)Yes: bootstrap, PWA install gate
@capacitor/status-barchrome.ts (applyNativeChrome)Yes: bootstrap
@capacitor/appdeep-links.ts (setupNativeDeepLinks)Yes: bootstrap
@capacitor/browserbrowser.tsOnly closeExternalBrowser, from deep-link handlers
@capacitor/hapticshaptics.ts (triggerNativeHaptic)No
@capacitor/shareshare.ts (shareNative)No
@capacitor/networknetwork.tsNo
@capacitor/preferencesreminders.tsNo; requestDailyReminderAfterCompletion is a documented no-op
@capacitor/splash-screennoneConfig only (launchAutoHide)
@revenuecat/purchases-capacitornone (purchases.ts deleted)No; mobile manifest only

Every adapter follows the same guard:

apps/web/src/lib/mobile/haptics.ts
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.

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.

signInWithOAuth asks Convex Auth for a provider redirect, stores the PKCE verifier in localStorage, and navigates the current page:

apps/web/src/lib/runtime/auth-client.ts
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.

CommitDate (UTC−4)Change
6f5d22632026-08-11 04:29Park 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.
f3a70d692026-08-11 11:54Refactor 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.

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:

apps/web/src/convex/supporterWaitlist.ts
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.

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.

For an actor key kk and stored timestamps TkT_k, a call at time tt is allowed when

∣{ τ∈Tk:τ>t−W }∣<L,L=5, W=3,600,000 ms\big|\{\, \tau \in T_k : \tau > t - W \,\}\big| < L, \qquad L = 5,\ W = 3{,}600{,}000\ \text{ms}

and on success tt 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.games serves. 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.html placeholder indefinitely. The service worker is also off in production (see Edge asset pipeline), so there is no cached shell to fall back to.
  • allowNavigation is a host list. *.convex.site and *.convex.cloud allow any Convex deployment, not only this project’s. isAllowedOAuthRedirect pins OAuth redirects to the configured convexSiteUrl, but other navigations to those wildcards stay inside the WebView.
  • OAuth code arriving through a deep link. If Universal Links are fixed, appUrlOpen calls goto(path). handleOAuthCodeFromUrl only runs in AppNavigation’s onMount, 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-capacitor in apps/mobile/package.json will be linked into native projects on cap sync even though no JavaScript calls it.
ChoiceAlternativeWhy
Remote server.urlBundle 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/mobilePlugins imported in feature codeFeature code stays browser-safe; each adapter no-ops outside the shell.
Waitlist email captureRevenueCat IAPRemoved the same day it landed. Collecting interest needs no store products, receipts, or webhook secrets.
CapacitorReact Native / FlutterListed 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.