Adoption du cycle de vie UIScene : sans lui, l'app ne démarre plus

Le build échouait au lancement sur _UIApplicationEvaluateRuntimeIssueForNo
SceneLifecycleAdoption. Apple est explicite : « Beginning in iOS 27 […] apps
built with the latest SDK must adopt the scene-based life cycle or they fail to
launch » (Transitioning to the UIKit scene-based life cycle). Ce n'est plus un
avertissement comme en iOS 26, c'est un assert. Aucune dérogation n'est
documentée : ni clé Info.plist, ni entitlement, ni date de tolérance — le seul
report possible serait de ne pas construire avec le dernier SDK, ce qui ne fait
que déplacer l'échéance sous contrainte de release.

Capacitor a adopté UIScene en 8.5.0 (PR #8536, « required in iOS 27 »), et
8.5.2 corrige une régression qui faisait échouer chaque démarrage à froid sur
une JS Eval error (#8595). On passe donc de 8.3.1 à 8.5.2, pas à 8.5.0.

⚠️ npx cap migrate N'A PAS été utilisé, et c'est volontaire : le guide officiel
exclut les projets à gestion d'URL personnalisée dans l'AppDelegate — « Projects
with hand-rolled scene delegates or custom AppDelegate URL handling should
follow the manual steps instead ». Notre branche GoogleSignIn en est la
définition. Le SceneDelegate suit le template livré dans le paquet 8.5.2, avec
deux écarts nécessaires décrits ci-dessous.

⚠️ CE QUI CASSE EN SILENCE, et qui est repris dans SceneDelegate.swift. Une
fois le manifeste posé, UIKit cesse d'appeler trois méthodes de l'AppDelegate
« even if you keep them implemented » (Apple) :
- application(_:open:options:) -> le retour OAuth Google partirait vers Safari
  sans jamais revenir. Déprécié iOS 26, remplacé par scene(_:openURLContexts:).
  La signature change : une URL devient un Set, donc on filtre ce que Google
  consomme avant de passer le reste à Capacitor.
- applicationDidBecomeActive -> les saisies du widget (eau, café) resteraient
  bloquées dans l'App Group. Reprise dans sceneDidBecomeActive.
- application(_:continue:restorationHandler:) -> les Universal Links des
  widgets et complications ouvriraient Safari, où le cookie d'auth n'existe pas.
Aucune des trois ne produit d'erreur de compilation. Elles ne sont pas laissées
en doublon dans l'AppDelegate : une méthode morte qui a l'air vivante fait
chercher la panne au mauvais endroit.

✅ CE QUI NE BOUGE PAS, vérifié dans la doc avant d'écrire une ligne :
didFinishLaunchingWithOptions reste appelé, et Apple documente la séquence
(launch -> création des scènes -> scene(_:willConnectTo:)). L'injection du
cookie garde donc son ordre : elle s'exécute avant que la WKWebView charge. La
règle non négociable n°2 du CLAUDE.md est tenue. Les callbacks de notification
distante restent aussi sur l'AppDelegate — UISceneDelegate n'a aucun équivalent
et ils ne sont pas dépréciés.

Écart au template Capacitor : il pose un CAPBridgeViewController() nu comme
rootViewController. L'appliquer tel quel remplacerait MainViewController, qui
porte l'enregistrement des 9 plugins natifs et la config WKWebView — sans
erreur visible. Le manifeste gardant UISceneStoryboardFile = Main, UIKit charge
déjà le storyboard : on ne crée la fenêtre que s'il ne l'a pas fait, et avec
notre sous-classe.

npm audit : 0 vulnérabilité en production. 13 dans l'outillage de build, qui ne
part pas dans l'app. ⛔ Ne PAS lancer npm audit fix --force : il propose de
redescendre @capacitor/cli en 7.6.9, ce qui supprimerait le support UIScene.

⚠️ RESTE À FAIRE SUR LE MAC, et rien ne compilera sans : ajouter
SceneDelegate.swift au target App dans Xcode. Le dossier App/ utilise des
références explicites dans le pbxproj (les groupes synchronisés ne couvrent que
les targets Watch/Widgets), et je n'édite pas un pbxproj à l'aveugle.

Vérifié ici : plist valide et relu par plistlib, syntaxe des deux fichiers
Swift validée par swiftc -parse. Aucune compilation possible sur Linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-09-22 08:04:14 +00:00
parent d5330b8f1a
commit 3755cafa1f
5 changed files with 152 additions and 47 deletions

View File

@@ -1,12 +1,11 @@
import UIKit
import Capacitor
#if canImport(GoogleSignIn)
import GoogleSignIn
#endif
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
// Conservée comme dans le template Capacitor 8.5. En cycle par scènes,
// c'est le SceneDelegate qui possède la fenêtre affichée.
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
@@ -47,28 +46,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
HTTPCookieStorage.shared.setCookie(cookie)
}
func applicationWillResignActive(_ application: UIApplication) {}
func applicationDidEnterBackground(_ application: UIApplication) {}
func applicationWillEnterForeground(_ application: UIApplication) {}
func applicationDidBecomeActive(_ application: UIApplication) {
// Les saisies faites depuis le widget (eau, café) attendent dans l'App
// Group : une extension n'a ni session ni droit de tenir une requête.
// On les transmet ici, à chaque retour au premier plan.
Task { await CoachQuickSync.flush() }
// ─── Cycle de vie par scènes (obligatoire iOS 27) ───────────────────
//
// ⚠️ Depuis l'adoption du `UIApplicationSceneManifest`, UIKit n'appelle
// PLUS `applicationDidBecomeActive`, `application(_:open:options:)` ni
// `application(_:continue:restorationHandler:)` — « even if you keep them
// implemented » (Apple, *Transitioning to the UIKit scene-based life
// cycle*). Les trois ont déménagé dans `SceneDelegate.swift` :
//
// applicationDidBecomeActive -> sceneDidBecomeActive (CoachQuickSync)
// application(_:open:options:) -> scene(_:openURLContexts:) (OAuth Google)
// application(_:continue:…) -> scene(_:continue:) (Universal Links)
//
// Elles ne sont pas conservées ici en doublon : une méthode morte qui a
// l'air vivante est exactement ce qui fait chercher la panne au mauvais
// endroit. Les callbacks de notification distante, eux, restent sur
// l'AppDelegate — `UISceneDelegate` n'en a aucun équivalent.
func application(_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
options: UIScene.ConnectionOptions) -> UISceneConfiguration {
let config = UISceneConfiguration(name: "Default Configuration",
sessionRole: connectingSceneSession.role)
config.delegateClass = SceneDelegate.self
return config
}
func applicationWillTerminate(_ application: UIApplication) {}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
#if canImport(GoogleSignIn)
// Retour du flux OAuth Google (reversed client id URL scheme).
if GIDSignIn.sharedInstance.handle(url) {
return true
}
#endif
return ApplicationDelegateProxy.shared.application(app, open: url, options: options)
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
}

View File

@@ -50,6 +50,25 @@
<string>location</string>
<string>fetch</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>

View File

@@ -0,0 +1,81 @@
import UIKit
import Capacitor
#if canImport(GoogleSignIn)
import GoogleSignIn
#endif
/// Cycle de vie par scènes, **obligatoire** depuis iOS 27 : « apps built with
/// the latest SDK must adopt the scene-based life cycle or they fail to launch »
/// (Apple, *Transitioning to the UIKit scene-based life cycle*). Sans ce
/// fichier et le `UIApplicationSceneManifest` de l'Info.plist, l'app ne démarre
/// pas du tout — c'est un assert au lancement, pas un avertissement.
///
/// ⚠️ **Ce qui arrive ici arrive en SILENCE.** Une fois le manifeste en place,
/// UIKit cesse d'appeler `application(_:open:options:)`,
/// `application(_:continue:restorationHandler:)` et `applicationDidBecomeActive`
/// sur l'AppDelegate — « even if you keep them implemented » (Apple). Rien ne
/// casse à la compilation : le retour OAuth Google part vers Safari et ne
/// revient jamais, et les saisies du widget restent bloquées dans l'App Group.
/// Les trois sont donc repris ci-dessous.
///
/// Ce que la migration NE change PAS, et c'est ce qui la rendait acceptable :
/// `application(_:didFinishLaunchingWithOptions:)` reste appelé, et toujours
/// **avant** que la scène ne se connecte — donc avant que la WKWebView charge.
/// L'injection du cookie d'auth garde son ordre (Apple documente la séquence :
/// launch → création des scènes → `scene(_:willConnectTo:)`). Les callbacks de
/// notification distante ne bougent pas non plus : `UISceneDelegate` n'a aucun
/// équivalent, et ils ne sont pas dépréciés.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
// Le manifeste déclare `UISceneStoryboardFile = Main` : UIKit a donc
// déjà chargé Main.storyboard et instancié `MainViewController`, qui
// porte l'enregistrement des plugins natifs et la config WKWebView.
//
// Le template de Capacitor pose ici un `CAPBridgeViewController()` nu.
// L'appliquer tel quel remplacerait notre sous-classe et ferait perdre
// cette configuration **sans erreur visible**. On ne crée donc la
// fenêtre que si le storyboard ne l'a pas fait, et avec notre
// contrôleur.
if window == nil {
let fenetre = UIWindow(windowScene: windowScene)
fenetre.rootViewController = MainViewController()
window = fenetre
fenetre.makeKeyAndVisible()
}
SceneDelegateProxy.shared.scene(scene, willConnectTo: session, options: connectionOptions)
}
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
// Retour du flux OAuth Google. `handle(url)` prend UNE url alors que la
// scène en livre un ensemble : on laisse à Capacitor ce que Google n'a
// pas consommé, plutôt que de tout lui passer ou de tout lui cacher.
var restants = URLContexts
#if canImport(GoogleSignIn)
restants = URLContexts.filter { !GIDSignIn.sharedInstance.handle($0.url) }
#endif
guard !restants.isEmpty else { return }
SceneDelegateProxy.shared.scene(scene, openURLContexts: restants)
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
// Universal Links : les .widgetURL des widgets, de la Live Activity et
// des complications Watch passent par là. Sans ce relais, ils
// ouvriraient Safari, où le cookie injecté dans la WKWebView n'existe
// pas.
SceneDelegateProxy.shared.scene(scene, continue: userActivity)
}
func sceneDidBecomeActive(_ scene: UIScene) {
// Reprend `applicationDidBecomeActive`, qu'UIKit n'appelle plus. Les
// saisies faites depuis le widget (eau, café) attendent dans l'App
// Group : une extension n'a ni session ni droit de tenir une requête.
Task { await CoachQuickSync.flush() }
}
}