From e1adbbb61f78c8a093b6904f1c1a00bf6b1345f8 Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Wed, 1 Jul 2026 06:37:42 +0000 Subject: [PATCH] fix(ios): robustesse bridge widget + Apple Sign-In MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CoachWidgetBridge : lit le score en NSNumber.intValue (le cast `as? Int` renvoyait nil quand le web envoie un float → score widget perdu par intermittence). CoachWidgetStore.save renvoie un Bool ; le plugin fait call.reject si la sauvegarde échoue au lieu de résoudre ok:true à tort. - CoachAppleAuth : sur double-tap, rejette l'ancien pendingCall (Promise JS ne pend plus indéfiniment) ; accès pendingCall confiné au main thread ; erreur d'annulation testée par domaine ASAuthorizationError (pas rawValue nu). Swift pur, non compilé ici (pas de Xcode) — à valider au prochain build Mac. Co-Authored-By: Claude Opus 4.8 (1M context) --- COWORK.md | 1 + ios/App/App/CoachAppleAuth.swift | 11 ++++++++--- ios/App/App/CoachWidgetBridge.swift | 9 +++++++-- ios/App/App/CoachWidgetSnapshot.swift | 8 ++++++-- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/COWORK.md b/COWORK.md index 18dc9e8..8f4bc33 100644 --- a/COWORK.md +++ b/COWORK.md @@ -33,6 +33,7 @@ Les deux ne communiquent **que via git + ce fichier**. ⚠️ La mémoire de cha ## 👉 À reprendre - ✅ **Phase 4 login — FAIT & DÉPLOYÉ** (révocation Apple à la suppression de compte) : code complet côté backend `coach_sportif` (commit `5ae6e2d`) + clé `.p8` déployée sur le VPS prod (vérifié 2026-06-26). Rien à coder. Détails : `coach_sportif/COWORK.md`. - **iOS — bloqué Mac** : builder + uploader TestFlight le natif accumulé (login Apple+Google natif, watchOS live, HeartRateRangeAlert). Sur le Mac : `cd ~/coach-ios && git pull && npm install && npx cap sync ios && open ios/App/App.xcworkspace` → Clean Build Folder → Archive → Upload. + - ⚠️ **Fixes robustesse 2026-07-01 (dev, non compilés)** à valider au prochain build : `CoachWidgetBridge` (score lu en `NSNumber.intValue` → corrige un score float perdu ; `save` renvoie Bool → `call.reject` si App Group KO), `CoachAppleAuth` (double-tap : rejette l'ancien `pendingCall` ; erreur testée par domaine `ASAuthorizationError`). Swift pur, aucun nouveau fichier ni capability. - **watchOS — bloqué Mac** : test device + TestFlight de la cible `CoachWatch` (Phases 1-3 réalisées, build vert sim). Cf. `HANDOFF-WATCHOS.md`. - **Android — bloqué externe** : Play Console ($25 validé), upload AAB + Internal Testing depuis machine avec Android SDK. - Backend/web : voir `coach_sportif/COWORK.md`. diff --git a/ios/App/App/CoachAppleAuth.swift b/ios/App/App/CoachAppleAuth.swift index 40db893..9073f1c 100644 --- a/ios/App/App/CoachAppleAuth.swift +++ b/ios/App/App/CoachAppleAuth.swift @@ -38,8 +38,13 @@ public class CoachAppleAuthPlugin: CAPPlugin, CAPBridgedPlugin { call.reject("Sign in with Apple requires iOS 13+") return } - self.pendingCall = call + // Tout l'accès à pendingCall est confiné au main thread (les callbacks + // delegate arrivent aussi sur main) → pas de data race sur double-tap. DispatchQueue.main.async { + // Un signIn déjà en cours ? on rejette l'ancien pour ne pas laisser sa + // Promise JS pendante indéfiniment (double-tap, retry). + self.pendingCall?.reject("superseded") + self.pendingCall = call let provider = ASAuthorizationAppleIDProvider() let request = provider.createRequest() request.requestedScopes = [.fullName, .email] @@ -83,8 +88,8 @@ extension CoachAppleAuthPlugin: ASAuthorizationControllerDelegate { controller: ASAuthorizationController, didCompleteWithError error: Error ) { - let nsErr = error as NSError - if nsErr.code == ASAuthorizationError.canceled.rawValue { + // Vérifie le domaine ET le code (un code 1001 d'un autre domaine ≠ annulation). + if let authErr = error as? ASAuthorizationError, authErr.code == .canceled { pendingCall?.reject("canceled") } else { pendingCall?.reject("Apple sign-in failed: \(error.localizedDescription)") diff --git a/ios/App/App/CoachWidgetBridge.swift b/ios/App/App/CoachWidgetBridge.swift index 1a891ed..017c000 100644 --- a/ios/App/App/CoachWidgetBridge.swift +++ b/ios/App/App/CoachWidgetBridge.swift @@ -38,13 +38,18 @@ public class CoachWidgetBridgePlugin: CAPPlugin, CAPBridgedPlugin { } var forme: CoachWidgetSnapshot.FormeScore? if let f = call.getObject("forme") { + // `as? Int` échoue si le JS envoie un float (72.0) — le nombre transite + // en NSNumber via le bridge ; `.intValue` couvre Int comme Double. forme = CoachWidgetSnapshot.FormeScore( - score: f["score"] as? Int, + score: (f["score"] as? NSNumber)?.intValue, label: f["label"] as? String ) } let snapshot = CoachWidgetSnapshot(updatedAt: Date(), today: today, forme: forme) - CoachWidgetStore.save(snapshot) + guard CoachWidgetStore.save(snapshot) else { + call.reject("Échec de sauvegarde du snapshot widget (App Group indisponible ?)") + return + } if #available(iOS 14.0, *) { WidgetCenter.shared.reloadAllTimelines() } pushToWatch(snapshot) call.resolve(["ok": true]) diff --git a/ios/App/App/CoachWidgetSnapshot.swift b/ios/App/App/CoachWidgetSnapshot.swift index 393d594..36e5460 100644 --- a/ios/App/App/CoachWidgetSnapshot.swift +++ b/ios/App/App/CoachWidgetSnapshot.swift @@ -18,9 +18,13 @@ enum CoachWidgetStore { static var defaults: UserDefaults? { UserDefaults(suiteName: appGroup) } - static func save(_ snapshot: CoachWidgetSnapshot) { - guard let d = defaults, let data = try? JSONEncoder().encode(snapshot) else { return } + /// Renvoie `false` si l'App Group est indisponible ou l'encodage échoue — + /// permet à l'appelant (bridge) de signaler l'échec au lieu de mentir « ok ». + @discardableResult + static func save(_ snapshot: CoachWidgetSnapshot) -> Bool { + guard let d = defaults, let data = try? JSONEncoder().encode(snapshot) else { return false } d.set(data, forKey: snapshotKey) + return true } static func load() -> CoachWidgetSnapshot? {