diff --git a/CLAUDE.md b/CLAUDE.md index c7c6676..2f2be7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,8 @@ Instructions pour Claude Code sur ce projet. Lis ce fichier en début de session ## 🔒 Règles non négociables 1. **Le code web n'est PAS ici** — pour modifier l'UI/UX, c'est `coach_sportif/web/`. Ce repo ne touche QUE le natif (Swift, Kotlin, plugins Capacitor, config). -2. **Cookie inject AppDelegate** (iOS) : mécanisme critique pour l'auth WKWebView. Toute modif `AppDelegate.swift` doit préserver ce flux. +2. **Cookie inject AppDelegate** (iOS) : mécanisme critique pour l'auth WKWebView. Toute modif `AppDelegate.swift` doit préserver ce flux. Il vit dans `didFinishLaunchingWithOptions`, qui **reste appelé** en cycle de vie par scènes et **avant** la connexion de la scène — Apple documente la séquence, l'ordre cookie → WebView est donc garanti. +2bis. **⚠️ Cycle de vie UIScene depuis le 22/09/2026** (obligatoire iOS 27, Capacitor ≥ 8.5.2). Trois callbacks ont quitté `AppDelegate.swift` pour `SceneDelegate.swift`, et UIKit **ne les appelle plus sur l'AppDelegate même si on les y remet** : retour OAuth Google (`scene(_:openURLContexts:)`), Universal Links (`scene(_:continue:)`), flush des saisies widget (`sceneDidBecomeActive`). Aucun ne produit d'erreur de compilation s'il est mal placé — la panne est silencieuse. Le `SceneDelegate` instancie **`MainViewController`**, jamais un `CAPBridgeViewController` nu comme le template Capacitor : la sous-classe porte l'enregistrement des plugins et la config WKWebView. ⛔ Ne pas lancer `npx cap migrate` sur ce projet (le guide officiel exclut les AppDelegate à gestion d'URL personnalisée), ni `npm audit fix --force` (il redescend le CLI en v7 et supprime le support UIScene). 3. **Health Connect Android** : nécessite la privacy policy URL dans le manifest (validation Play Console). 4. **Pas d'extrapolation** : root cause vérifiable avant tout fix natif. iOS+Android = doubles surfaces de bug. 5. **Default yes** : décider et exécuter (sauf actions destructives). diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 67137a3..2c89f1e 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -71,6 +71,7 @@ E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */; }; F1ABCDEF0123456789AB0001 /* CoachHealthRoute.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0002 /* CoachHealthRoute.swift */; }; F1ABCDEF0123456789AB0003 /* CoachSleep.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1ABCDEF0123456789AB0004 /* CoachSleep.swift */; }; + 60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -195,6 +196,7 @@ F1ABCDEF0123456789AB0004 /* CoachSleep.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoachSleep.swift; sourceTree = ""; }; F6BEAEF6F6274D61CAF22636 /* CoachWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CoachWatchApp.swift; sourceTree = ""; }; FBFCA969A1B083CD3178D9B2 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -208,7 +210,18 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - 880A76D4302B601000F582D5 /* CoachWatchWidgets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (880A76E2302B601100F582D5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = CoachWatchWidgets; sourceTree = ""; }; + 880A76D4302B601000F582D5 /* CoachWatchWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 880A76E2302B601100F582D5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = CoachWatchWidgets; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -330,6 +343,7 @@ 6030BD533EBC2D1D26DB9479 /* LiveStore.swift */, 340B99D5EA4DB985D7DFDA10 /* CoachLiveView.swift */, B93DA6B121E94B11DF938C90 /* LiveActivityManager.swift */, + B82F240520FD4C6CB4A80522 /* SceneDelegate.swift */, ); path = App; sourceTree = ""; @@ -589,6 +603,7 @@ 548F8BFA06417B35474BD69E /* CoachLiveView.swift in Sources */, D1384CFA4960060CDBF05FC3 /* CoachLiveActivityAttributes.swift in Sources */, E547F675B2F61C3475D2A787 /* LiveActivityManager.swift in Sources */, + 60BB59C4096F4C8EB22DE183 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ios/App/App/AppDelegate.swift b/ios/App/App/AppDelegate.swift index 6575b45..54b8077 100644 --- a/ios/App/App/AppDelegate.swift +++ b/ios/App/App/AppDelegate.swift @@ -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) - } } diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index ab76977..452998b 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -50,6 +50,25 @@ location fetch + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/ios/App/App/SceneDelegate.swift b/ios/App/App/SceneDelegate.swift new file mode 100644 index 0000000..367209d --- /dev/null +++ b/ios/App/App/SceneDelegate.swift @@ -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) { + // 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() } + } +} diff --git a/package-lock.json b/package-lock.json index 752f665..5e06f5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,12 @@ "version": "0.1.0", "license": "UNLICENSED", "dependencies": { - "@capacitor/android": "^8.3.1", + "@capacitor/android": "^8.5.2", "@capacitor/app": "^8.1.0", - "@capacitor/core": "^8.3.1", + "@capacitor/core": "^8.5.2", "@capacitor/geolocation": "^8.2.0", "@capacitor/haptics": "^8.0.2", - "@capacitor/ios": "^8.3.1", + "@capacitor/ios": "^8.5.2", "@capacitor/local-notifications": "^8.0.2", "@capacitor/push-notifications": "^8.0.3", "@capacitor/splash-screen": "^8.0.1", @@ -24,7 +24,7 @@ }, "devDependencies": { "@capacitor/assets": "^3.0.5", - "@capacitor/cli": "^8.3.1" + "@capacitor/cli": "^8.5.2" } }, "node_modules/@babel/code-frame": { @@ -51,12 +51,12 @@ } }, "node_modules/@capacitor/android": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.3.1.tgz", - "integrity": "sha512-hjskIG8YcBEh3X4yaTXvE9gcqpdcxunTgFruSKnuPxtMxAUzEK4Oq25x0Z1g3cz+MQPc+lRG09R7Ovc+ydKsNw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@capacitor/android/-/android-8.5.2.tgz", + "integrity": "sha512-rtiaz78egCcS+vs29wkWCL3C+RhazKMSygXcjutwWP8NmO6r5UBxOvGf3Wjz5+ahobyQ723tn9gCePwRwcNDZQ==", "license": "MIT", "peerDependencies": { - "@capacitor/core": "^8.3.0" + "@capacitor/core": "^8.5.0" } }, "node_modules/@capacitor/app": { @@ -452,10 +452,11 @@ "dev": true }, "node_modules/@capacitor/cli": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.3.1.tgz", - "integrity": "sha512-1sPGW4THTDfR6YjXwZ0jM7oAfAtciPOHN00qs/3sNAQx1kKrrEYSfDPwCm1/xlAgi0OeL69SiRfw314Ans+1sw==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.5.2.tgz", + "integrity": "sha512-O2kpA5ZPJUAmliDCcmpMf8xnJwMezxlFPUrlyOAjbL3U9MFNY3l4ZpPI/UXwvyPmR8ETcVF9pFr+VdWq61rqTQ==", "dev": true, + "license": "MIT", "dependencies": { "@ionic/cli-framework-output": "^2.2.8", "@ionic/utils-subprocess": "^3.0.1", @@ -473,6 +474,7 @@ "semver": "^7.6.3", "tar": "^7.5.3", "tslib": "^2.8.1", + "xcode": "^3.0.1", "xml2js": "^0.6.2" }, "bin": { @@ -484,9 +486,10 @@ } }, "node_modules/@capacitor/core": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.1.tgz", - "integrity": "sha512-UF8ItlHguU1Z6GXfPTeT2gakf+ctNI8pAS1kwSBQlsJMlfD4OPoto/SmKnOxKCQvnF4WRcdWeg6C0zREUNaAQg==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.5.2.tgz", + "integrity": "sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==", + "license": "MIT", "dependencies": { "tslib": "^2.1.0" } @@ -511,11 +514,12 @@ } }, "node_modules/@capacitor/ios": { - "version": "8.3.1", - "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.3.1.tgz", - "integrity": "sha512-BEhLyYYHWJLib4mpaPMaaylbC8meqgxbNYwQJH2svsSLW7yo/hFie+Zoo66a44XnqcMd2tvmAuzimWunXZi/xA==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.5.2.tgz", + "integrity": "sha512-eOT2S67AfTFG7KN0T3oxwAajrJz4rSye+ODfvChiwrk0F/+kWufFM1qU6cDjGJUOfwdAoeuEpZyWHSwZlu4GFg==", + "license": "MIT", "peerDependencies": { - "@capacitor/core": "^8.3.0" + "@capacitor/core": "^8.5.0" } }, "node_modules/@capacitor/local-notifications": { diff --git a/package.json b/package.json index 7a5f6a8..13c968f 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,12 @@ "author": "Sylvain Bettinelli", "license": "UNLICENSED", "dependencies": { - "@capacitor/android": "^8.3.1", + "@capacitor/android": "^8.5.2", "@capacitor/app": "^8.1.0", - "@capacitor/core": "^8.3.1", + "@capacitor/core": "^8.5.2", "@capacitor/geolocation": "^8.2.0", "@capacitor/haptics": "^8.0.2", - "@capacitor/ios": "^8.3.1", + "@capacitor/ios": "^8.5.2", "@capacitor/local-notifications": "^8.0.2", "@capacitor/push-notifications": "^8.0.3", "@capacitor/splash-screen": "^8.0.1", @@ -30,6 +30,6 @@ }, "devDependencies": { "@capacitor/assets": "^3.0.5", - "@capacitor/cli": "^8.3.1" + "@capacitor/cli": "^8.5.2" } }