🔧(mobile) make the app identity and auth scheme env-driven

A staging and a production build must be installable side by side on one
device, and two apps claiming the same OIDC deep-link scheme would make
Android ask the user which one receives the login callback, mid-flow. The
app id, the displayed name and the callback scheme therefore become
per-environment (MOBILE_APP_ID / MOBILE_APP_NAME / MOBILE_AUTH_SCHEME).
This commit is contained in:
jbpenrath
2026-08-13 12:08:40 +02:00
parent 72b624e310
commit fea61e1be2
15 changed files with 178 additions and 27 deletions
+19 -1
View File
@@ -758,8 +758,26 @@ mobile-android-reverse: ## (host) map device ports to the dev stack via adb reve
@$(foreach port,$(ANDROID_REVERSE_PORTS),adb reverse tcp:$(port) tcp:$(port);)
.PHONY: mobile-android-reverse
# Read a MOBILE_* value from the frontend env files the way the container does
# (frontend.local overriding frontend.defaults, last definition wins). Gradle
# runs on the host and would otherwise read a *different* MOBILE_APP_ID than the
# `cap sync` that produced the bundle — a divergence a store upload freezes
# forever. A gradle guard cross-checks the two (android/app/build.gradle).
# Surrounding double quotes are stripped the way compose's dotenv parser does,
# so a quoted value does not reach gradle with its quotes and trip the appId
# cross-check with an unreadable message.
mobile_env = $(shell sed -n 's/^$(1)=//p' deploy/env/frontend.defaults deploy/env/frontend.local 2>/dev/null | tail -n1 | sed 's/^"//;s/"$$//')
# The same env the container used for `cap sync` must reach the host gradle
# build, debug included: a MOBILE_AUTH_SCHEME set only on the JS side would ship
# a manifest declaring the old scheme, and the OIDC callback would never come
# back — the login opens, and nothing returns.
mobile-android-run: mobile-build ## (host) build+install the debug APK on a device then adb reverse
@cd src/frontend/android && ./gradlew assembleDebug
@cd src/frontend/android && \
MOBILE_APP_ID="$(call mobile_env,MOBILE_APP_ID)" \
MOBILE_APP_NAME="$(call mobile_env,MOBILE_APP_NAME)" \
MOBILE_AUTH_SCHEME="$(call mobile_env,MOBILE_AUTH_SCHEME)" \
./gradlew assembleDebug
@adb install -r $(ANDROID_DEBUG_APK)
@$(MAKE) mobile-android-reverse
.PHONY: mobile-android-run
+1
View File
@@ -20,6 +20,7 @@ NEXT_PUBLIC_SENTRY_ENVIRONMENT=
## An organisation publishing to the stores overrides it — in BOTH contexts — with
## its own signed bundle id, which never lands in this open-source repo.
MOBILE_APP_ID=local.suitenumerique.messages
MOBILE_APP_NAME=
## Mobile hot reload (Capacitor). Baked as the WebView's server.url at
## `cap sync` (capacitor.config.ts): the app loads the JS straight from the
+4 -2
View File
@@ -274,11 +274,13 @@ _Those settings are deprecated and will be removed in the future._
| Variable | Default | Description | Required |
|----------|---------|-------------|----------|
| `MOBILE_APP_ID` | `local.suitenumerique.messages` | Store/OS bundle identifier of the native app. The repo ships a neutral placeholder; an organisation publishing to the App Store / Play Store overrides it with its own signed id. Read by `cap sync` (container) **and** the native builds gradle `applicationId`, iOS `PRODUCT_BUNDLE_IDENTIFIER` — so it must be exported in **both** the container env and the host/CI env. Independent of the auth callback scheme (`stmessages`). | Optional |
| `MOBILE_APP_ID` | `local.suitenumerique.messages` | Store/OS bundle identifier of the native app. The repo ships a neutral placeholder; an organisation publishing to the App Store / Play Store overrides it with its own signed id. Read by `cap sync` (container) **and** the native builds: gradle `applicationId` reads the host/CI env (`make mobile-android-run` exports it for you), while Xcode reads `ios/App/generated.xcconfig`, written by `make mobile-build` from the container env — no export can reach an IDE build. Independent of the auth callback scheme (`MOBILE_AUTH_SCHEME`). | Optional |
| `MOBILE_APP_NAME` | `ST Messages` | The application name displayed on the device. The repo ships a neutral placeholder; an organisation publishing to the App Store / Play Store overrides it with its own name. Read by `cap sync` (container) **and** the native builds, through the same two channels as `MOBILE_APP_ID`: gradle `resValue app_name` (host/CI env, exported by `make mobile-android-run`) and the iOS `PRODUCT_DISPLAY_NAME` setting in `ios/App/generated.xcconfig` (written by `make mobile-build`). | Optional |
| `MOBILE_AUTH_SCHEME` | `stmessages` | Deep-link scheme ending the mobile OIDC flow. Give each environment its own value so a staging and a production build can be installed side by side — sharing it makes Android ask the user which app should receive the login callback, mid-flow. Read by Vite for the JS side **and** by the native builds: Android `manifestPlaceholders` reads the host/CI env (`make mobile-android-run` exports it for you), iOS reads the `AUTH_CALLBACK_SCHEME` setting from `ios/App/generated.xcconfig` (written by `make mobile-build`). Must be listed in the backend's `MOBILE_AUTH_CALLBACK_SCHEMES`. | Optional |
| `MOBILE_DEV_SERVER_URL` | `http://localhost:8900` (dev env, `frontend.defaults`) | **Dev only.** URL of the Vite dev server baked as Capacitor `server.url` at `cap sync` (`capacitor.config.ts`): the WebView then loads the app from Vite with hot reload instead of the embedded bundle. To disable (embedded bundle / OTA testing), set it **empty** in `frontend.local` and rerun `make mobile-build`. Must never be set for a release build — a gradle guard fails Android release builds carrying it. See [mobile.md](./mobile.md#hot-reload-on-by-default-in-dev) | Optional |
| `MOBILE_ALLOW_CLEARTEXT_FOR_DEV` | `1` (dev env, `frontend.defaults`) | **Dev only.** Baked as Capacitor `server.cleartext` at `cap sync` (`capacitor.config.ts`), i.e. `android:usesCleartextTraffic` in the Android manifest: allows plain HTTP for the whole app — the WebView reaching the Vite dev server and the native fetch/OTA layer reaching the `http://localhost:8901` backend and the RustFS OTA bucket (needed even with hot reload disabled). Must never be set for a release build — the manifest then stays cleartext-free. iOS equivalent: `NSAllowsLocalNetworking` (`Info.plist`, manual). | Optional |
> **Note**: overriding `MOBILE_APP_ID` only changes the app identity; it does **not** touch the OIDC deep-link scheme (`stmessages`), which is fixed and declared in the iOS `Info.plist` (`CFBundleURLTypes`) and the Android manifest.
> **Note**: overriding `MOBILE_APP_ID` only changes the app identity; the OIDC deep-link scheme is a separate knob (`MOBILE_AUTH_SCHEME`), declared in the iOS `Info.plist` (`CFBundleURLTypes`) and the Android manifest. Changing one without the other is valid — but two builds installed together need **both** to differ.
### Mobile App Authentication (Capacitor)
+7 -4
View File
@@ -53,16 +53,19 @@ captures/
.idea/navEditor.xml
# Keystore files
# Uncomment the following lines if you do not want to check your keystore files in.
#*.jks
#*.keystore
# The Play upload key and its passwords are per-instance secrets: like
# MOBILE_APP_ID and google-services.json, the publishing organisation supplies
# its own and none of it ever belongs in this open-source repo.
*.jks
*.keystore
keystore.properties
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
google-services.json*
# Freeline
freeline.py
+45 -2
View File
@@ -13,10 +13,25 @@ android {
// via MOBILE_APP_ID (kept out of this open-source repo). It may differ
// from the namespace above.
applicationId System.getenv("MOBILE_APP_ID") ?: "local.suitenumerique.messages"
// Displayed app name, same per-instance override pattern as the
// applicationId above. Defined as resValues (not values/strings.xml,
// which would clash with them) so MOBILE_APP_NAME can drive them at
// build time; the manifest keeps pointing at @string/app_name.
def appDisplayName = System.getenv("MOBILE_APP_NAME") ?: "ST Messages"
resValue "string", "app_name", appDisplayName
resValue "string", "title_activity_main", appDisplayName
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
// OIDC callback scheme declared by the manifest's intent-filter. Per
// environment (like applicationId) so a staging and a production build
// can coexist on one device: sharing it would make Android prompt the
// user to pick an app mid-login. The default must match auth.ts and the
// iOS AUTH_CALLBACK_SCHEME build setting (sso-invariants.test.ts).
manifestPlaceholders = [
authCallbackScheme: System.getenv("MOBILE_AUTH_SCHEME") ?: "stmessages"
]
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -47,9 +62,10 @@ tasks.configureEach { task ->
if (task.name == 'preReleaseBuild') {
task.doFirst {
def capConfig = file('src/main/assets/capacitor.config.json')
def server = capConfig.exists()
? new groovy.json.JsonSlurper().parse(capConfig)?.server
def parsed = capConfig.exists()
? new groovy.json.JsonSlurper().parse(capConfig)
: null
def server = parsed?.server
if (server?.url) {
throw new GradleException(
"capacitor.config.json contains a dev server.url (hot reload). " +
@@ -61,6 +77,33 @@ tasks.configureEach { task ->
"(MOBILE_ALLOW_CLEARTEXT_FOR_DEV). " +
"Rerun a bare 'make mobile-build' before building a release.")
}
// MOBILE_APP_ID is read twice from two different environments: by
// `cap sync` in the container (the appId written to the config read
// here) and by gradle on the host (applicationId above). Forgetting
// the host half is silent — and a store upload freezes the id for
// the lifetime of the app.
def syncedAppId = parsed?.appId
def builtAppId = android.defaultConfig.applicationId
if (syncedAppId && syncedAppId != builtAppId) {
throw new GradleException(
"applicationId '${builtAppId}' does not match the appId " +
"'${syncedAppId}' synced into capacitor.config.json. " +
"Export the same MOBILE_APP_ID for the host gradle build " +
"as for 'make mobile-build'.")
}
// Same double-read as MOBILE_APP_ID: the displayed name (resValue
// above) comes from the host env, the synced appName from the
// container env. A divergence is recoverable (unlike the appId) but
// ships a wrong name silently, so fail it the same way.
def syncedAppName = parsed?.appName
def builtAppName = System.getenv("MOBILE_APP_NAME") ?: "ST Messages"
if (syncedAppName && syncedAppName != builtAppName) {
throw new GradleException(
"app name '${builtAppName}' does not match the appName " +
"'${syncedAppName}' synced into capacitor.config.json. " +
"Export the same MOBILE_APP_NAME for the host gradle build " +
"as for 'make mobile-build'.")
}
}
}
}
@@ -21,15 +21,18 @@
<!--
Deep-link scheme ending the mobile OIDC flow: routes the
stmessages://auth?token=… redirect from the Custom Tab back to
<scheme>://auth?token=… redirect from the Custom Tab back to
the app (caught by App.addListener("appUrlOpen")).
Substituted from MOBILE_AUTH_SCHEME by the manifestPlaceholder
in build.gradle, so a staging build declares its own scheme and
does not fight the production app for the callback.
Source of truth: src/features/native/auth.ts (AUTH_CALLBACK_SCHEME).
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="stmessages" />
<data android:scheme="${authCallbackScheme}" />
</intent-filter>
</activity>
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Messages</string>
<string name="title_activity_main">Messages</string>
<!-- app_name / title_activity_main are resValues driven by MOBILE_APP_NAME
(see app/build.gradle); defining them here too would clash. -->
<string name="package_name">local.suitenumerique.messages</string>
<string name="custom_url_scheme">local.suitenumerique.messages</string>
+9 -6
View File
@@ -46,12 +46,15 @@ const otaBuildId = process.env.MOBILE_OTA_BUILD_ID;
const devServerUrl = process.env.MOBILE_DEV_SERVER_URL;
const config: CapacitorConfig = {
// Build-time app identity. The repo ships a neutral placeholder; an
// organisation publishing to the stores overrides it with its own signed
// bundle id via the MOBILE_APP_ID env var (read here by `cap sync`, and by
// the native builds — see android/app/build.gradle and the iOS pbxproj).
appId: process.env.MOBILE_APP_ID ?? "local.suitenumerique.messages",
appName: "Messages",
// Build-time app identity. The repo ships neutral placeholders; an
// organisation publishing to the stores overrides them via the MOBILE_APP_ID
// (signed bundle id) and MOBILE_APP_NAME (displayed name) env vars — read
// here by `cap sync`, and by the native builds: gradle resValue/applicationId
// on Android (android/app/build.gradle), the generated xcconfig on iOS
// (scripts/generate-ios-xcconfig.mjs). `||` not `??`: the env files ship the
// vars empty, and an empty string must fall back like an unset one.
appId: process.env.MOBILE_APP_ID || "local.suitenumerique.messages",
appName: process.env.MOBILE_APP_NAME || "ST Messages",
webDir: "dist",
server: {
// Dev only: `cap sync` turns this into android:usesCleartextTraffic in the
+1
View File
@@ -11,3 +11,4 @@ capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
App/generated.xcconfig
@@ -36,6 +36,7 @@
7A11AA0100000000000000E1 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
7A11AA0100000000000000E2 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
7A11AA0100000000000000F1 /* generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = generated.xcconfig; sourceTree = SOURCE_ROOT; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -54,6 +55,7 @@
isa = PBXGroup;
children = (
958DCC722DB07C7200EA8C5F /* debug.xcconfig */,
7A11AA0100000000000000F1 /* generated.xcconfig */,
504EC3061FED79650016851F /* App */,
504EC3051FED79650016851F /* Products */,
);
@@ -97,6 +99,7 @@
504EC3011FED79650016851F /* Frameworks */,
504EC3021FED79650016851F /* Resources */,
7A11AA0100000000000000C1 /* Strip dev ATS exception */,
7A11AA0100000000000000C2 /* Check synced Capacitor identity */,
);
buildRules = (
);
@@ -187,6 +190,28 @@
shellScript = "# NSAllowsLocalNetworking in App/Info.plist exists only so dev builds can\n# reach the plain-HTTP backend on localhost. Delete the whole ATS dict from\n# the built product in every non-Debug configuration so the exception can\n# never ship (Archive always builds Release).\nif [ \"${CONFIGURATION}\" != \"Debug\" ]; then\n PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n if /usr/libexec/PlistBuddy -c \"Print :NSAppTransportSecurity\" \"${PLIST}\" >/dev/null 2>&1; then\n /usr/libexec/PlistBuddy -c \"Delete :NSAppTransportSecurity\" \"${PLIST}\"\n fi\nfi\n";
showEnvVarsInLog = 0;
};
7A11AA0100000000000000C2 /* Check synced Capacitor identity */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)",
"$(SRCROOT)/App/capacitor.config.json",
);
name = "Check synced Capacitor identity";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# `make mobile-build` writes generated.xcconfig and capacitor.config.json in\n# one run from one env; the identity Xcode compiled must match what was\n# synced. A stale or hand-edited value would otherwise ship silently — and\n# the first store upload freezes the bundle id forever. Debug builds skip the\n# check (Android equivalent: the preReleaseBuild guard in app/build.gradle).\nif [ \"${CONFIGURATION}\" != \"Debug\" ]; then\n CONFIG_JSON=\"${SRCROOT}/App/capacitor.config.json\"\n PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\n SYNCED_ID=$(/usr/bin/plutil -extract appId raw -o - \"${CONFIG_JSON}\")\n SYNCED_NAME=$(/usr/bin/plutil -extract appName raw -o - \"${CONFIG_JSON}\")\n BUILT_ID=$(/usr/libexec/PlistBuddy -c \"Print :CFBundleIdentifier\" \"${PLIST}\")\n BUILT_NAME=$(/usr/libexec/PlistBuddy -c \"Print :CFBundleDisplayName\" \"${PLIST}\")\n if [ \"${SYNCED_ID}\" != \"${BUILT_ID}\" ] || [ \"${SYNCED_NAME}\" != \"${BUILT_NAME}\" ]; then\n echo \"error: built identity '${BUILT_ID}' / '${BUILT_NAME}' does not match the synced capacitor.config.json '${SYNCED_ID}' / '${SYNCED_NAME}'. Rerun 'make mobile-build' so generated.xcconfig and the synced config agree.\" >&2\n exit 1\n fi\nfi\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -233,7 +258,7 @@
/* Begin XCBuildConfiguration section */
504EC3141FED79650016851F /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
baseConfigurationReference = 7A11AA0100000000000000F1 /* generated.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
@@ -291,6 +316,7 @@
};
504EC3151FED79650016851F /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7A11AA0100000000000000F1 /* generated.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
@@ -356,8 +382,7 @@
);
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
MOBILE_APP_ID = "local.suitenumerique.messages";
PRODUCT_BUNDLE_IDENTIFIER = "$(MOBILE_APP_ID)";
PRODUCT_BUNDLE_IDENTIFIER = "$(MOBILE_APP_ID:default=local.suitenumerique.messages)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -379,8 +404,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
MOBILE_APP_ID = "local.suitenumerique.messages";
PRODUCT_BUNDLE_IDENTIFIER = "$(MOBILE_APP_ID)";
PRODUCT_BUNDLE_IDENTIFIER = "$(MOBILE_APP_ID:default=local.suitenumerique.messages)";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+2 -2
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>$(PRODUCT_DISPLAY_NAME:default=Messages)</string>
<string>$(PRODUCT_DISPLAY_NAME:default=ST Messages)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -36,7 +36,7 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER).auth</string>
<key>CFBundleURLSchemes</key>
<array>
<string>stmessages</string>
<string>$(AUTH_CALLBACK_SCHEME:default=stmessages)</string>
</array>
</dict>
</array>
+1 -1
View File
@@ -24,7 +24,7 @@
"ts:check": "tsc --noEmit",
"i18n:extract": "i18next-cli extract",
"analyze": "ANALYZE=1 vite build && node ./scripts/print-bundle-stats.mjs",
"mobile:build": "npm run build && npx cap sync",
"mobile:build": "npm run build && npx cap sync && node scripts/generate-ios-xcconfig.mjs",
"mobile:ota:bucket": "node scripts/create-ota-bucket.mjs",
"mobile:ota:keygen": "node scripts/generate-ota-keys.mjs",
"mobile:ota:publish": "node scripts/publish-ota.mjs",
@@ -0,0 +1,46 @@
// Write ios/App/generated.xcconfig from the MOBILE_* env of the container
// build, alongside `cap sync` (npm run mobile:build). Xcode builds run from the
// IDE on the host, where an exported shell variable never lands — this file is
// how the same env that produced capacitor.config.json reaches the iOS build
// settings (bundle id, display name, auth scheme). Android needs no such file:
// gradle reads the env directly (see android/app/build.gradle and the
// mobile-android-* Makefile targets).
//
// The file is gitignored (per-instance identity, like the synced config); the
// fallbacks below must match the ones at each point of use — gradle, auth.ts
// and the Info.plist `:default=` operators (pinned by sso-invariants.test.ts).
//
// Usage: node scripts/generate-ios-xcconfig.mjs
import { writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const settings = {
MOBILE_APP_ID: process.env.MOBILE_APP_ID || "local.suitenumerique.messages",
PRODUCT_DISPLAY_NAME: process.env.MOBILE_APP_NAME || "ST Messages",
AUTH_CALLBACK_SCHEME: process.env.MOBILE_AUTH_SCHEME || "stmessages",
};
// A newline or a "//" would truncate the setting (xcconfig comment/line
// syntax) and silently ship the placeholder identity instead of failing.
for (const [key, value] of Object.entries(settings)) {
if (/[\n\r]|\/\//.test(value)) {
throw new Error(`${key} value ${JSON.stringify(value)} is not a valid single-line xcconfig value`);
}
}
const target = resolve(
dirname(fileURLToPath(import.meta.url)),
"../ios/App/generated.xcconfig",
);
writeFileSync(
target,
[
"// Generated by scripts/generate-ios-xcconfig.mjs (make mobile-build) — do not edit.",
...Object.entries(settings).map(([key, value]) => `${key} = ${value}`),
"",
].join("\n"),
);
process.stderr.write(`Wrote ${target}\n`);
+4
View File
@@ -39,6 +39,10 @@ interface ImportMetaEnv {
// (capacitor.config.ts). Exposed so ota.ts can refuse a server-provided
// manifest URL on a build that embeds no key.
readonly MOBILE_OTA_SIGNING_PUBLIC_KEY_B64?: string;
// OIDC deep-link scheme, per environment so two builds can coexist on a
// device. Also declared natively (Android manifestPlaceholder, iOS build
// setting) from the same variable — see auth.ts.
readonly MOBILE_AUTH_SCHEME?: string;
}
interface ImportMeta {
+3
View File
@@ -89,10 +89,13 @@ export default defineConfig({
// hot reload session (see capacitor.config.ts) and skip the OTA check there.
// MOBILE_OTA_SIGNING_PUBLIC_KEY_B64 (public key, safe to inline) lets ota.ts
// refuse a server-provided manifest URL on a build that can't verify bundles.
// MOBILE_AUTH_SCHEME must match the scheme the native projects declare, so
// auth.ts builds its callback URL with the one the OS will actually route.
envPrefix: [
'NEXT_PUBLIC_',
'MOBILE_DEV_SERVER_URL',
'MOBILE_OTA_SIGNING_PUBLIC_KEY_B64',
'MOBILE_AUTH_SCHEME',
],
build: {
outDir: 'dist',