From 9903424235451d9a4e22ec6f863928895cb987df Mon Sep 17 00:00:00 2001 From: Sylvain Bettinelli Date: Thu, 3 Sep 2026 06:24:08 +0000 Subject: [PATCH] =?UTF-8?q?Le=20plugin=20affirmait=20l'envoi=20sans=20jama?= =?UTF-8?q?is=20relire=20ce=20que=20la=20montre=20avait=20re=C3=A7u?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux épisodes de « la montre ne dit plus rien » ont buté sur la même absence : rien, côté app, ne distinguait « iOS a accepté l'appel » de « la séance est au poignet ». Détail du dossier et chronologie dans coach_sportif, docs/GUIDE-MONTRE.md §5quater et §5quinquies. CoachWorkoutKit : - requestAuthorization() n'avale plus son échec. Il était consigné dans un NSLog et l'envoi continuait : une réinstallation de l'app remet l'autorisation à notDetermined — celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre du silence — et l'écran affichait « Envoyé » alors que rien ne partait. - authorizationState est lu et rendu au JS, par isAvailable() comme par la nouvelle méthode scheduledWorkouts(). isAvailable() ne répondait jusqu'ici qu'« iOS 17+ » : une autorisation révoquée rendait la même réponse. - schedule() est suivi d'une relecture de WorkoutScheduler.scheduledWorkouts, et sendInterval rend ce qu'elle liste (nom, date, blocs, itérations). Une liste vide n'est PAS traitée comme un échec : le scheduler publie de façon asynchrone et Apple ne garantit aucun délai — inventer une erreur à chaque envoi coûterait plus cher que le silence qu'on cherche. CoachWorkoutObserver : à chaque fin de séance, HKWorkout.workoutPlan dit si elle vient d'une séance programmée. Le fait part vers /api/workout/plan-origin, sur le modèle de reportToRoutineIfStrength — faits bruts ici, interprétation côté serveur, donc ajustable sans rebuild. Une lecture en échec n'envoie rien plutôt qu'un faux « lancée à la main » : côté serveur, l'absence s'affiche « inconnu ». ⚠️ Rien de tout cela ne se compile ici : WorkoutKit et HealthKit sont absents de Swift pour Linux, et tests-linux ne couvre que ce qui n'importe que Foundation. À builder sur le Mac mini. Les signatures ont été vérifiées une par une dans la doc DocC — authorizationState (get async, 4 cas), scheduledWorkouts, ScheduledWorkoutPlan.date en DateComponents, WorkoutPlan.workout, CustomWorkout.displayName optionnel, HKWorkout.workoutPlan (get async throws). Co-Authored-By: Claude Opus 5 (1M context) --- ios/App/App/CoachWorkoutKit.swift | 120 +++++++++++++++++++++++-- ios/App/App/CoachWorkoutObserver.swift | 59 ++++++++++++ 2 files changed, 173 insertions(+), 6 deletions(-) diff --git a/ios/App/App/CoachWorkoutKit.swift b/ios/App/App/CoachWorkoutKit.swift index 136469c..14ee8c9 100644 --- a/ios/App/App/CoachWorkoutKit.swift +++ b/ios/App/App/CoachWorkoutKit.swift @@ -40,13 +40,43 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin { public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "sendInterval", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "scheduledWorkouts", returnType: CAPPluginReturnPromise), ] @objc func isAvailable(_ call: CAPPluginCall) { - if #available(iOS 17.0, *) { - call.resolve(["available": true, "min_ios": "17.0"]) - } else { + guard #available(iOS 17.0, *) else { call.resolve(["available": false, "reason": "iOS 17+ required for WorkoutKit"]) + return + } + // `available` disait « iOS 17+ », rien de plus : une autorisation + // révoquée ou jamais accordée rendait exactement la même réponse. + Task { + let state = await WorkoutScheduler.shared.authorizationState + call.resolve([ + "available": true, + "min_ios": "17.0", + "supported": WorkoutScheduler.isSupported, + "authorization": Self.describe(state), + "authorized": state == .authorized, + ]) + } + } + + /// Ce que la montre a réellement en attente, interrogeable sans rien envoyer. + @objc func scheduledWorkouts(_ call: CAPPluginCall) { + guard #available(iOS 17.0, *) else { + call.reject("WorkoutKit requires iOS 17.0 or later") + return + } + Task { + let state = await WorkoutScheduler.shared.authorizationState + let scheduled = await Self.scheduledSummaries() + call.resolve([ + "authorization": Self.describe(state), + "authorized": state == .authorized, + "count": scheduled.count, + "scheduled": scheduled, + ]) } } @@ -131,7 +161,7 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin { Task { do { - try await sendCustomWorkout( + let scheduled = try await sendCustomWorkout( activity: activity, displayName: displayName, warmupMin: warmupMin, @@ -143,9 +173,12 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin { "sent": true, "activityUsed": Int(activity.rawValue), "activityFallback": activity != requestedActivity, + // Ce que le scheduler liste APRÈS l'envoi : c'est la preuve, + // le reste n'est qu'une intention. + "scheduled": scheduled, ]) } catch { - call.reject("Failed to send workout: \(error.localizedDescription)") + call.reject(error.localizedDescription) } } } @@ -199,7 +232,7 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin { stepsSpec: [(min: Double, hrZone: Int?, bpm: ClosedRange?, isWork: Bool, label: String?)], repeats: Int, cooldownMin: Double - ) async throws { + ) async throws -> [[String: Any]] { // 1. Warmup — un WorkoutStep optionnel. Si warmupMin == 0, on passe // nil au CustomWorkout (la signature accepte WorkoutStep?). Passer @@ -273,11 +306,24 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin { // 8. Demande d'autorisation explicite (idempotent — déclenche dialog // iOS la 1re fois, return immédiat si déjà accordé). + // + // ⚠️ L'échec ne se ravale plus. Il l'était jusqu'au 03/09/2026 : un + // NSLog, puis on continuait comme si de rien n'était. Une + // réinstallation de l'app remet l'autorisation à `notDetermined` + // (celle du 31/08, chantier CoachPolarBLE, tombe dans la fenêtre du + // silence de la montre — cf. GUIDE-MONTRE.md §5quater), et rien à + // l'écran n'aurait dit que plus aucune séance n'arrivait au poignet. do { try await WorkoutScheduler.shared.requestAuthorization() NSLog("[CoachWorkoutKit] requestAuthorization OK") } catch { NSLog("[CoachWorkoutKit] requestAuthorization failed: \(error)") + throw CoachWorkoutKitError.authorization(error.localizedDescription) + } + let authState = await WorkoutScheduler.shared.authorizationState + guard authState == .authorized else { + NSLog("[CoachWorkoutKit] authorizationState = %@", String(describing: authState)) + throw CoachWorkoutKitError.notAuthorized(Self.describe(authState)) } // 8bis. Efface les workouts déjà programmés par l'app avant d'en @@ -299,5 +345,67 @@ public class CoachWorkoutKitPlugin: CAPPlugin, CAPBridgedPlugin { ) try await WorkoutScheduler.shared.schedule(plan, at: comps) NSLog("[CoachWorkoutKit] schedule OK at \(scheduleDate)") + + // 10. Relecture : ce qui compte n'est pas que `schedule()` soit rentré + // sans erreur, c'est que la séance soit RÉELLEMENT dans la liste du + // scheduler. C'est la seule chose qui réponde, sans aller regarder + // la montre, à « est-ce que ma séance est programmée ? ». + // ⚠️ Une liste vide n'est PAS traitée comme un échec : le scheduler + // est asynchrone et rien chez Apple ne garantit qu'il ait publié la + // séance à l'instant où on le relit. Inventer une erreur à chaque + // envoi coûterait plus cher que le silence qu'on cherche. On rend + // ce qu'on voit, l'appelant le dit. + return await Self.scheduledSummaries() + } + + /// Les séances programmées PAR CETTE APP, telles que le scheduler les rend. + @available(iOS 17.0, *) + private static func scheduledSummaries() async -> [[String: Any]] { + let plans = await WorkoutScheduler.shared.scheduledWorkouts + return plans.map { sw in + var out: [String: Any] = [ + "date": ISO8601DateFormatter().string( + from: Calendar.current.date(from: sw.date) ?? Date() + ), + "complete": sw.complete, + ] + if case let .custom(custom) = sw.plan.workout { + // `displayName` est optionnel côté Apple : ne jamais poser un + // Optional dans le dictionnaire rendu au JS, il n'est pas + // sérialisable par le bridge Capacitor. + out["displayName"] = custom.displayName ?? "" + out["blocks"] = custom.blocks.count + out["steps"] = custom.blocks.reduce(0) { $0 + $1.steps.count } + out["iterations"] = custom.blocks.map { $0.iterations } + } + return out + } + } + + @available(iOS 17.0, *) + private static func describe(_ state: WorkoutScheduler.AuthorizationState) -> String { + switch state { + case .authorized: return "authorized" + case .denied: return "denied" + case .notDetermined: return "notDetermined" + case .restricted: return "restricted" + @unknown default: return "unknown" + } + } +} + +/// Erreurs qui doivent remonter jusqu'à l'écran, pas jusqu'au seul NSLog. +enum CoachWorkoutKitError: LocalizedError { + case authorization(String) + case notAuthorized(String) + + var errorDescription: String? { + switch self { + case .authorization(let detail): + return "Autorisation « Séances programmées » refusée par iOS : \(detail)" + case .notAuthorized(let state): + return "Autorisation « Séances programmées » absente (\(state)) — " + + "Réglages iPhone → coach → Séances, puis renvoyer la séance." + } } } diff --git a/ios/App/App/CoachWorkoutObserver.swift b/ios/App/App/CoachWorkoutObserver.swift index 2601dce..dbf52fc 100644 --- a/ios/App/App/CoachWorkoutObserver.swift +++ b/ios/App/App/CoachWorkoutObserver.swift @@ -25,6 +25,9 @@ import Foundation import Capacitor import HealthKit import UserNotifications +// Pour l'extension `HKWorkout.workoutPlan` (iOS 17+), qui rend la composition +// du plan dont une séance est issue — ou nil si elle a été lancée à la main. +import WorkoutKit @objc(CoachWorkoutObserverPlugin) public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { @@ -375,8 +378,64 @@ public class CoachWorkoutObserverPlugin: CAPPlugin, CAPBridgedPlugin { }.resume() } + /// Dit au serveur si la séance vient d'une **séance programmée** ou d'un + /// démarrage à la main dans l'app Exercice. + /// + /// C'est la seule chose qui distingue « la montre s'est tue alors qu'elle + /// devait parler » de « la séance n'a jamais été celle qu'on avait + /// poussée » : `tools/watch_alert_check.py` compte les alertes attendues à + /// partir du plan ENVOYÉ, mais rien, jusqu'ici, ne disait ce qui avait été + /// LANCÉ (cf. `coach_sportif/docs/GUIDE-MONTRE.md` §5bis, « angle mort »). + /// Le nom HealthKit ne le dit pas : il vaut « Course extérieure » dans les + /// deux cas. + /// + /// Même parti pris que `reportToRoutineIfStrength` : on n'envoie que des + /// faits bruts, l'interprétation vit côté serveur donc sans rebuild iOS. + private func reportPlanOrigin(_ workout: HKWorkout) { + guard #available(iOS 17.0, *) else { return } + Task { + var fromPlan = false + var planName = "" + do { + if let plan = try await workout.workoutPlan { + fromPlan = true + if case let .custom(custom) = plan.workout { + planName = custom.displayName ?? "" + } + } + } catch { + // Une lecture qui échoue n'est PAS un « lancé à la main » : + // sans réponse, on n'envoie rien plutôt qu'un faux négatif. + NSLog("[CoachWorkoutObserver] workoutPlan illisible: %@", error.localizedDescription) + return + } + guard let url = URL(string: "https://coach.hypnotruck.ch/api/workout/plan-origin") else { return } + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("coach_web_token=\(CoachAuth.kCoachWebToken)", forHTTPHeaderField: "Cookie") + req.httpBody = try? JSONSerialization.data(withJSONObject: [ + "uuid": workout.uuid.uuidString, + "start": ISO8601DateFormatter().string(from: workout.startDate), + "duration_min": workout.duration / 60, + "activity": Int(workout.workoutActivityType.rawValue), + "from_plan": fromPlan, + "plan_name": planName, + ]) + req.timeoutInterval = 15 + URLSession.shared.dataTask(with: req) { _, _, error in + if let error = error { + NSLog("[CoachWorkoutObserver] plan-origin: %@", error.localizedDescription) + } else { + NSLog("[CoachWorkoutObserver] plan-origin envoyé (from_plan=%@)", fromPlan ? "true" : "false") + } + }.resume() + } + } + private func scheduleNotification(for workout: HKWorkout) { reportToRoutineIfStrength(workout) + reportPlanOrigin(workout) let durationMin = Int(workout.duration / 60) var bodyParts: [String] = ["\(durationMin) min"] if let kcal = workout.totalEnergyBurned?.doubleValue(for: .kilocalorie()), kcal > 0 {