Le plugin affirmait l'envoi sans jamais relire ce que la montre avait reçu

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Bettinelli
2026-09-03 06:24:08 +00:00
parent 0a721c249b
commit 9903424235
2 changed files with 173 additions and 6 deletions

View File

@@ -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<Double>?, 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."
}
}
}

View File

@@ -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 {