Appearance
Build the Mobile App
services/mobile (@tt/mobile) is the web client packaged as a native iOS and Android app with Capacitor. There is no mobile-specific UI: the page is services/client, built with --mode native and copied into the native projects. What differs from the web is how the page reaches the API, and a handful of native touches (splash screen, status bar, Android back button).
How it differs from the web build
Web (pnpm build) | Native (pnpm build:native) | |
|---|---|---|
| Output | services/client/dist | services/client/dist-native |
| Page origin | https://tim.ovh | capacitor://localhost (iOS), https://localhost (Android) |
| API base URL | /api, same-origin | https://tim.ovh/api, absolute — VITE_API_ORIGIN, set from TIM_API_ORIGIN at build time |
| Session | first-party cookie | bearer token (below) |
| Service worker | Workbox PWA | none — the WebView is the app |
| Google sign-in | browser redirect through the API | native ID token, verified by the API (below) |
| Push notifications | Web Push (VAPID) | APNs / FCM device token (below) |
The mode is chosen in services/client/vite.config.ts; the page reads import.meta.env.VITE_API_ORIGIN through getApiBaseUrl() and Capacitor.isNativePlatform() through lib/native.ts.
Authentication in the native app
The page runs on its own origin, so the API's session cookie is never sent — browsers do not attach a SameSite=Lax cookie to a cross-site request. Instead:
- The API enables better-auth's bearer plugin (
services/api/src/auth/auth-options.ts). Sign-in responses carry the session token in aset-auth-tokenheader, which CORS exposes (services/api/src/main.ts). - The client keeps that token (
services/client/src/lib/sessionToken.ts): in memory, and persisted with Capacitor Preferences. It is loaded before the app mounts. - Every API request adds
Authorization: Bearer …— the generated SDK through a request interceptor inlib/client.ts, the better-auth client through itsauthoption inlib/auth.ts, the SSE stream inuseEventStream.ts, and every remaining raw call throughapiFetch(). - A sign-out response, or a 401, clears the token.
On the web there is never a token, so no header is added and the cookie session is exactly what it was. Do not add a raw fetch() to the API — use the generated SDK, or apiFetch() from lib/client.ts, so the call works in both.
The API must also list the app's origins in CORS_ORIGINS: capacitor://localhost and https://localhost. This is an environment change on the deployed API, not a code change.
Prerequisites
- The repo's usual toolchain;
pnpm installbrings in the Capacitor CLI. - Android: Android Studio (or the SDK + Java 21). Set
ANDROID_HOME. - iOS: Xcode on macOS. The iOS project uses Swift Package Manager, so no CocoaPods.
Build and run
bash
pnpm --filter @tt/mobile build:web # client, --mode native → dist-native
pnpm --filter @tt/mobile sync # copy into android/ and ios/, update plugins
pnpm --filter @tt/mobile open:android # Android Studio → run on a device/emulator
pnpm --filter @tt/mobile open:ios # Xcode → pick a team, run on a device/simulatorRepeat build:web + sync after every client change; the native projects only ever see the copied bundle (android/app/src/main/assets/public, ios/App/App/public, both git-ignored).
To point a build at another server, set the API origin when building the bundle:
bash
TIM_API_ORIGIN=https://beta.tim.ovh pnpm --filter @tt/mobile build:webAgainst a local API
The device must reach your API over the network — localhost on a phone is the phone. Use your machine's LAN address, and add the app origins to that API's CORS_ORIGINS:
bash
TIM_API_ORIGIN=http://192.168.1.20:33000 pnpm --filter @tt/mobile build:webAndroid blocks cleartext (http://) traffic by default; for a local API either serve it over HTTPS or add android:usesCleartextTraffic="true" to the <application> in android/app/src/main/AndroidManifest.xml for that debug build only.
Google sign-in in the app
A WebView cannot complete the web's redirect flow, so the app asks Google natively — the account picker on Android, the Google SDK on iOS, through @capgo/capacitor-social-login — for an ID token, and posts it to better-auth's signIn.social({ idToken }) (services/client/src/lib/nativeGoogle.ts). The API verifies the token against Google's keys with services/api/src/auth/google-id-token.ts, which accepts the web client ID and the iOS client ID as audience (an Android token is issued for the web client already). The response carries the session and the bearer token like an e-mail sign-in.
The client IDs come from /api/config (googleWebClientId, googleIosClientId), so the Google button appears on a platform only once its client is configured on the server. On iOS the build must also have been made for that client: the Google SDK kills the app when the client's reversed ID is not one of its URL schemes, so the build carries the iOS client ID (TIM_GOOGLE_IOS_CLIENT_ID) and the matching GOOGLE_IOS_URL_SCHEME, and the button shows only when the server's client is that one. The one-time Google Cloud, build-setting and SHA-1 setup is in Sign and Release.
Push notifications in the app
usePushNotifications (the profile page's toggle) branches on the platform: a browser subscribes through the service worker as before; the app asks the OS for a device token (services/client/src/lib/nativePush.ts) and registers it as a push subscription with platform: ios | android. The token is kept in Preferences, and a tapped notification opens the url the payload carries.
Who gets a device's push
A browser has one push subscription and an app install one device token, and the API binds each to one user (it upserts on the endpoint). On a shared device, and after a session that ended without a sign-out (expiry, revocation), the device cannot tell from its subscription whose it is. So the client records who turned push on, per device (services/client/src/lib/pushOptIn.ts — localStorage in a browser, Preferences in the app): the users who opted in there, and the user the device's push is currently registered for. At every session start — sign-in, launch with a session, a switch of account — usePushSessionBinding (App.vue) acts on it, the same way in a browser (bindWebPush) and in the app (syncNativePush):
| Session starts for… | Result | Switch |
|---|---|---|
| a user who opted in on this device | Registered for them again. The API upsert moves a row the previous session left behind, and re-creates one it pruned. In the app, registering with the OS again is how a rotated token is heard. With no subscription left (a sign-out released it), the browser subscribes again, and the app registers again, without a prompt, when the permission is still granted. | ON |
| anyone else | Released, never taken over. The browser unsubscribes; the app stays (or becomes) unregistered with the OS. The row is still the previous user's, and the API won't let anyone else delete it. The next send to it comes back gone (Web Push 404/410, FCM UNREGISTERED, APNs 410), and PushService prunes the row then. | OFF |
The switch reads ON only when the device's push is live and registered for the user now signed in. Turning it off releases the push and forgets that user's opt-in. Sign-out (signOut(), before the session goes) also releases it, but keeps the opt-in, so the same person signing back in gets push back. In the app, a session that dies without a sign-out suspends the OS registration and keeps the opt-in (suspendNativePush). A browser keeps its subscription until the next session start decides.
A device whose push predates the record (subscribed by an older client) is settled once, at its next session start. POST /api/push/subscriptions/lookup answers whether its endpoint is registered to the signed-in user, and never says anything about anyone else's rows. If it is theirs, they keep it. Otherwise it is released. If the server can't answer, nothing changes until the next session start.
An Android build without google-services.json is allowed and simply has no push. It must never reach PushNotifications.register() / unregister(): with no Firebase app those throw on Capacitor's plugin thread, and the bridge turns that into an app crash, not a rejected promise. So nativePushAvailable() asks first. It asks the app-local TimPushSupport plugin (android/app/src/main/java/ovh/tim/app/PushSupportPlugin.java, registered in MainActivity), which reports whether the build has the google_app_id string resource. The google-services Gradle plugin generates that resource from google-services.json, and Firebase starts from it, so the answer is fixed by the Gradle build itself. It does not matter whether the file was copied in before or after cap sync. On such a build the profile page says push is unavailable. Sign-out calls unregister() only on an install that registered.
On the API, the same push_subscriptions table holds every device; PushService delivers a row by its platform — Web Push, APNs (push/providers/apns.ts, HTTP/2 with an ES256 provider token) or FCM HTTP v1 (push/providers/fcm.ts, a service-account access token). Each channel is on only when its environment variables are set, and logs so at start-up. Credentials and the Xcode / Firebase steps are in Sign and Release.
Builds from CI
- Mobile app (Android, manual) — a debug APK, or with release ticked a signed Play Store bundle (
.aab) plus release APK. Linux runner. - Mobile app (iOS, manual) — a signed
.ipa, or with upload ticked a TestFlight upload. macOS runner (10× minutes).
Both are workflow_dispatch only and need the secrets listed in Sign and Release the Mobile App.
Icons and splash screens
services/mobile/assets/ holds the sources (icon.png 1024², splash.png and splash-dark.png 2732²), made from services/client/public/logo_hq.png. Regenerate every platform asset from them with:
bash
pnpm --filter @tt/mobile assetsVersion, identifiers
- Bundle / application id:
ovh.tim.app(capacitor.config.ts,android/app/build.gradle, the Xcode project). - Versions live in the native projects (
versionName/versionCodeinandroid/app/build.gradle,MARKETING_VERSION/CURRENT_PROJECT_VERSIONin Xcode). Bump them for each store submission.
Not yet done
- Sign in with Apple: App Store guideline 4.8 requires it alongside Google sign-in. Until it exists, leave
GOOGLE_IOS_CLIENT_IDunset so iOS offers e-mail sign-in only. - Push on the web inside the app: none — the app uses the OS channels above. A user's browser subscriptions and phone registrations coexist; each is toggled from the device it belongs to.