diff --git a/Makefile b/Makefile
index e70473d9..dad099ee 100644
--- a/Makefile
+++ b/Makefile
@@ -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
diff --git a/deploy/env/frontend.defaults b/deploy/env/frontend.defaults
index 84f01358..aa8c324b 100644
--- a/deploy/env/frontend.defaults
+++ b/deploy/env/frontend.defaults
@@ -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
diff --git a/docs/env.md b/docs/env.md
index daffe0e3..c556f254 100644
--- a/docs/env.md
+++ b/docs/env.md
@@ -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)
diff --git a/src/frontend/android/.gitignore b/src/frontend/android/.gitignore
index 48354a3d..ee69b065 100644
--- a/src/frontend/android/.gitignore
+++ b/src/frontend/android/.gitignore
@@ -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
diff --git a/src/frontend/android/app/build.gradle b/src/frontend/android/app/build.gradle
index a96b4182..7970287b 100644
--- a/src/frontend/android/app/build.gradle
+++ b/src/frontend/android/app/build.gradle
@@ -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'.")
+ }
}
}
}
diff --git a/src/frontend/android/app/src/main/AndroidManifest.xml b/src/frontend/android/app/src/main/AndroidManifest.xml
index def7d7b6..4dd16c00 100644
--- a/src/frontend/android/app/src/main/AndroidManifest.xml
+++ b/src/frontend/android/app/src/main/AndroidManifest.xml
@@ -21,15 +21,18 @@
-
+
diff --git a/src/frontend/android/app/src/main/res/values/strings.xml b/src/frontend/android/app/src/main/res/values/strings.xml
index 4f30de18..7b68cc58 100644
--- a/src/frontend/android/app/src/main/res/values/strings.xml
+++ b/src/frontend/android/app/src/main/res/values/strings.xml
@@ -1,7 +1,7 @@
- Messages
- Messages
+
local.suitenumerique.messages
local.suitenumerique.messages
diff --git a/src/frontend/capacitor.config.ts b/src/frontend/capacitor.config.ts
index 63f80126..8aba4eb2 100644
--- a/src/frontend/capacitor.config.ts
+++ b/src/frontend/capacitor.config.ts
@@ -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
diff --git a/src/frontend/ios/.gitignore b/src/frontend/ios/.gitignore
index f4702997..cc7cf10a 100644
--- a/src/frontend/ios/.gitignore
+++ b/src/frontend/ios/.gitignore
@@ -11,3 +11,4 @@ capacitor-cordova-ios-plugins
# Generated Config files
App/App/capacitor.config.json
App/App/config.xml
+App/generated.xcconfig
diff --git a/src/frontend/ios/App/App.xcodeproj/project.pbxproj b/src/frontend/ios/App/App.xcodeproj/project.pbxproj
index 84866a3c..6b01ab06 100644
--- a/src/frontend/ios/App/App.xcodeproj/project.pbxproj
+++ b/src/frontend/ios/App/App.xcodeproj/project.pbxproj
@@ -36,6 +36,7 @@
7A11AA0100000000000000E1 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; };
7A11AA0100000000000000E2 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; };
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;
diff --git a/src/frontend/ios/App/App/Info.plist b/src/frontend/ios/App/App/Info.plist
index 95873087..d4c47871 100644
--- a/src/frontend/ios/App/App/Info.plist
+++ b/src/frontend/ios/App/App/Info.plist
@@ -7,7 +7,7 @@
CFBundleDevelopmentRegion
en
CFBundleDisplayName
- $(PRODUCT_DISPLAY_NAME:default=Messages)
+ $(PRODUCT_DISPLAY_NAME:default=ST Messages)
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIdentifier
@@ -36,7 +36,7 @@
$(PRODUCT_BUNDLE_IDENTIFIER).auth
CFBundleURLSchemes
- stmessages
+ $(AUTH_CALLBACK_SCHEME:default=stmessages)
diff --git a/src/frontend/package.json b/src/frontend/package.json
index 2797c8fb..f1239dc2 100644
--- a/src/frontend/package.json
+++ b/src/frontend/package.json
@@ -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",
diff --git a/src/frontend/scripts/generate-ios-xcconfig.mjs b/src/frontend/scripts/generate-ios-xcconfig.mjs
new file mode 100644
index 00000000..2f4257ce
--- /dev/null
+++ b/src/frontend/scripts/generate-ios-xcconfig.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`);
diff --git a/src/frontend/src/vite-env.d.ts b/src/frontend/src/vite-env.d.ts
index 829c2b6f..126f8491 100644
--- a/src/frontend/src/vite-env.d.ts
+++ b/src/frontend/src/vite-env.d.ts
@@ -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 {
diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts
index 7bfc8782..6e9082b1 100644
--- a/src/frontend/vite.config.ts
+++ b/src/frontend/vite.config.ts
@@ -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',