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:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user