diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index ba44831..7f975f6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -49,22 +49,24 @@ jobs: - name: Install JS deps run: pnpm install --frozen-lockfile - - name: Resolve API + gateway URLs - # The mobile shell defaults apiFetch to /api which only works when - # the web bundle is hosted on the same origin as the API. Capacitor - # serves from https://localhost, so without baking VITE_API_BASE - # into the build, every login attempt 404s and `start.challenge` - # blows up. Allow the build to fall back to the values committed - # in apps/mobile/.env.production if the repo vars are not set. + - name: Resolve URLs + # The bundled web build needs the API + gateway URLs at compile time + # (Vite inlines them). The Capacitor shell additionally needs + # TEMPEST_WEB_URL set so the native app loads the live web origin - + # WebAuthn / passkeys refuse to run from https://localhost. All + # three values fall back to apps/mobile/.env.production if the + # repo vars are not configured. run: | : "${VITE_API_BASE:=${{ vars.VITE_API_BASE }}}" : "${VITE_GATEWAY_URL:=${{ vars.VITE_GATEWAY_URL }}}" - if [ -z "$VITE_API_BASE" ] && [ -f apps/mobile/.env.production ]; then + : "${TEMPEST_WEB_URL:=${{ vars.TEMPEST_WEB_URL }}}" + if [ -f apps/mobile/.env.production ]; then set -a; . apps/mobile/.env.production; set +a fi echo "VITE_API_BASE=$VITE_API_BASE" >> "$GITHUB_ENV" echo "VITE_GATEWAY_URL=$VITE_GATEWAY_URL" >> "$GITHUB_ENV" - echo "Building web bundle against API=$VITE_API_BASE GW=$VITE_GATEWAY_URL" + echo "TEMPEST_WEB_URL=$TEMPEST_WEB_URL" >> "$GITHUB_ENV" + echo "Building web bundle against API=$VITE_API_BASE GW=$VITE_GATEWAY_URL WEB=$TEMPEST_WEB_URL" - name: Build web bundle env: @@ -77,6 +79,8 @@ jobs: # project. We don't commit it; CI re-creates it every run so the # build is always reproducible from the current Capacitor config. working-directory: apps/mobile + env: + TEMPEST_WEB_URL: ${{ env.TEMPEST_WEB_URL }} run: | pnpm exec cap add android pnpm exec cap sync android diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 87c32da..4c95f54 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -35,16 +35,18 @@ jobs: gem install cocoapods --no-document pod --version - - name: Resolve API + gateway URLs + - name: Resolve URLs run: | : "${VITE_API_BASE:=${{ vars.VITE_API_BASE }}}" : "${VITE_GATEWAY_URL:=${{ vars.VITE_GATEWAY_URL }}}" - if [ -z "$VITE_API_BASE" ] && [ -f apps/mobile/.env.production ]; then + : "${TEMPEST_WEB_URL:=${{ vars.TEMPEST_WEB_URL }}}" + if [ -f apps/mobile/.env.production ]; then set -a; . apps/mobile/.env.production; set +a fi echo "VITE_API_BASE=$VITE_API_BASE" >> "$GITHUB_ENV" echo "VITE_GATEWAY_URL=$VITE_GATEWAY_URL" >> "$GITHUB_ENV" - echo "Building web bundle against API=$VITE_API_BASE GW=$VITE_GATEWAY_URL" + echo "TEMPEST_WEB_URL=$TEMPEST_WEB_URL" >> "$GITHUB_ENV" + echo "Building web bundle against API=$VITE_API_BASE GW=$VITE_GATEWAY_URL WEB=$TEMPEST_WEB_URL" - name: Build web bundle env: @@ -54,6 +56,8 @@ jobs: - name: Add iOS platform working-directory: apps/mobile + env: + TEMPEST_WEB_URL: ${{ env.TEMPEST_WEB_URL }} run: | pnpm exec cap add ios pnpm exec cap sync ios diff --git a/apps/mobile/.env.production b/apps/mobile/.env.production index 3449a7c..80a5463 100644 --- a/apps/mobile/.env.production +++ b/apps/mobile/.env.production @@ -7,6 +7,12 @@ # Example (replace with the URLs printed by `railway up`): # VITE_API_BASE=https://tempest-api-production-2899.up.railway.app # VITE_GATEWAY_URL=wss://tempest-gateway-production-1213ce.up.railway.app/gateway +# +# TEMPEST_WEB_URL controls whether Capacitor loads the live deployment as +# the initial page (recommended; passkeys / WebAuthn work over the real +# HTTPS origin) or falls back to the bundled web/dist (faster first paint, +# but passkeys refuse to run on https://localhost). VITE_API_BASE=https://tempest-api-production-2899.up.railway.app VITE_GATEWAY_URL=wss://tempest-gateway-production-1213ce.up.railway.app/gateway +TEMPEST_WEB_URL=https://tempest-web-production-1213ce.up.railway.app diff --git a/apps/mobile/capacitor.config.ts b/apps/mobile/capacitor.config.ts index ce566dd..96da267 100644 --- a/apps/mobile/capacitor.config.ts +++ b/apps/mobile/capacitor.config.ts @@ -1,9 +1,21 @@ import type { CapacitorConfig } from "@capacitor/cli"; // Capacitor wraps the existing apps/web Vite build into native iOS / Android -// shells. The web bundle is built into apps/web/dist and we point Capacitor's -// `webDir` at that path; `pnpm --filter tempest-mobile build` handles the -// build + sync round trip. +// shells. There are two supported modes: +// +// * server.url set (recommended for now): the native app loads the live +// web deployment as its initial page. WebAuthn / passkeys, Service +// Workers, and other origin-locked APIs use the real HTTPS origin and +// work the same as a browser visit. Set TEMPEST_WEB_URL in the build +// env (CI repo var or apps/mobile/.env.production) to enable this. +// +// * server.url unset (offline-first): the app uses the bundled +// apps/web/dist as the initial page, served from https://localhost. +// Faster first paint, but WebAuthn refuses to run on the localhost +// origin so passkey login does not work. A native passkey bridge is +// needed to use this mode for auth - tracked as future work. +const liveWebUrl = process.env.TEMPEST_WEB_URL?.trim(); + const config: CapacitorConfig = { appId: "chat.tempest.app", appName: "Tempest", @@ -21,12 +33,11 @@ const config: CapacitorConfig = { contentInset: "automatic", limitsNavigationsToAppBoundDomains: false, backgroundColor: "#0b0d12", - // Support iPad multitasking (split view, slide over). The web layout - // already adapts to the available width via the >=900px breakpoint. scheme: "Tempest", }, server: { androidScheme: "https", + ...(liveWebUrl ? { url: liveWebUrl, cleartext: false } : {}), }, plugins: { SplashScreen: { diff --git a/apps/web/src/auth/passkey.ts b/apps/web/src/auth/passkey.ts index 3bb5d58..eea5b03 100644 --- a/apps/web/src/auth/passkey.ts +++ b/apps/web/src/auth/passkey.ts @@ -41,13 +41,25 @@ function rewriteForGet(input: any): CredentialRequestOptions { return { publicKey: o }; } +function ensureSupported(): void { + if (typeof navigator === "undefined" || !navigator.credentials || typeof PublicKeyCredential === "undefined") { + throw new Error( + "Passkeys aren't available in this webview. Open Tempest in a browser " + + "or update your in-app webview to the latest version (Android System WebView " + + "/ Safari) and try again.", + ); + } +} + export async function createPasskey(challenge: any): Promise { + ensureSupported(); const cred = (await navigator.credentials.create(rewriteForCreate(challenge))) as PublicKeyCredential | null; if (!cred) throw new Error("passkey creation cancelled"); return serializeCredential(cred, "create"); } export async function getPasskey(challenge: any): Promise { + ensureSupported(); const cred = (await navigator.credentials.get(rewriteForGet(challenge))) as PublicKeyCredential | null; if (!cred) throw new Error("passkey assertion cancelled"); return serializeCredential(cred, "get"); diff --git a/docs/MOBILE.md b/docs/MOBILE.md index 42eadfd..c0ff667 100644 --- a/docs/MOBILE.md +++ b/docs/MOBILE.md @@ -66,6 +66,26 @@ The CI workflow patches the generated Xcode project to keep iPad support universal even if the Capacitor template ever regresses, and forces `UIRequiresFullScreen=NO` so iPad multitasking works. +## Passkeys / WebAuthn on mobile + +WebAuthn refuses to run on the synthetic `https://localhost` origin +Capacitor uses for the bundled webview. Two ways to handle this: + +1. **`TEMPEST_WEB_URL` set (default).** The Capacitor app loads the + live web deployment as its initial page. Passkeys then use the real + HTTPS origin and Just Work, identical to a browser visit. Set the + value as a repo variable in GitHub Actions or in + `apps/mobile/.env.production`. The app keeps its native chrome + (no browser bar, native splash, status-bar styling); it just sources + its HTML from the live URL. +2. **`TEMPEST_WEB_URL` unset.** The app uses the bundled + `apps/web/dist`. Faster cold start, works offline, but passkey + login throws "passkeys aren't available in this webview" because + `navigator.credentials` refuses to operate on `localhost`. A + native passkey bridge (Android `CredentialManager`, iOS + `ASAuthorizationController`) is the path to fully offline-first + auth - tracked as future work. + ## Signing ### Android release APK