From fea61e1be2d334f8e31325482c3a61b5c7388fd8 Mon Sep 17 00:00:00 2001 From: jbpenrath Date: Wed, 12 Aug 2026 08:55:59 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7(mobile)=20make=20the=20app=20ident?= =?UTF-8?q?ity=20and=20auth=20scheme=20env-driven?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- Makefile | 20 +++++++- deploy/env/frontend.defaults | 1 + docs/env.md | 6 ++- src/frontend/android/.gitignore | 11 +++-- src/frontend/android/app/build.gradle | 47 ++++++++++++++++++- .../android/app/src/main/AndroidManifest.xml | 7 ++- .../app/src/main/res/values/strings.xml | 4 +- src/frontend/capacitor.config.ts | 15 +++--- src/frontend/ios/.gitignore | 1 + .../ios/App/App.xcodeproj/project.pbxproj | 34 ++++++++++++-- src/frontend/ios/App/App/Info.plist | 4 +- src/frontend/package.json | 2 +- .../scripts/generate-ios-xcconfig.mjs | 46 ++++++++++++++++++ src/frontend/src/vite-env.d.ts | 4 ++ src/frontend/vite.config.ts | 3 ++ 15 files changed, 178 insertions(+), 27 deletions(-) create mode 100644 src/frontend/scripts/generate-ios-xcconfig.mjs 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',