Les quatre constats restants, corrigés — dont deux sur le chemin qu'il fallait éviter
Sur demande explicite de Sylvain (« pour le reste vas-y »), après le § 1.2 buildé et vérifié le matin. Les cinq constats de l'audit sont désormais traités. ⚠️ § 2.3 et § 2.4 touchent `WorkoutManager.swift` — la fin de séance du live workout, que la consigne du 22/09 demandait de ne pas approcher. Fait en ne changeant AUCUNE logique : les `try?` deviennent des `do/catch` qui journalisent, et le chemin d'échec gagne la clôture qui lui manquait. La récupération par `recentlySavedWorkout()` est intacte. § 1.3 — `CoachSleep` teste `authorizationStatus` : `.notDetermined` ne se confond plus avec une nuit sans sommeil, et le serveur cessera d'enregistrer des nuits vides sur lesquelles `sleep_score.py` calculait une régularité. Un refus EXPLICITE reste indiscernable d'une absence de données — HealthKit ne le révèle pas en lecture, par conception. L'en-tête du fichier dit maintenant ce que la promesse couvre au lieu de promettre plus. § 2.3 — le `else` final distinguait deux situations opposées qui y tombaient ensemble : montre verrouillée (la séance EST écrite) et `endCollection` qui a levé (rien n'est écrit). Seule la seconde est une perte, et elle seule renseigne `statusMessage` — annoncer une perte sur une montre verrouillée serait une fausse alerte. § 2.4 — `stopTracking()` d'abord : c'est le seul geste qui réussit à coup sûr et le seul dont l'omission se paie en batterie pendant des heures. Puis une tentative de sauvegarde en best-effort, avec `recentlySavedWorkout()` en second recours. `builder = nil` n'intervient plus qu'après les tentatives — il détruisait jusqu'ici la seule référence, rendant la perte définitive dans l'instruction qui l'ignorait. § 2.6 — `NSLock` sur le tampon GPS, comme aux quatre autres endroits du même fichier. ⚠️ Invérifiable sans son déclencheur : un workout à plusieurs HKWorkoutRoute. Sa correction ne se constatera pas plus que le défaut ne se manifestait. ⛔ NON COMPILÉS. Le test qui comptera pour § 2.3 et § 2.4 n'est pas un log : c'est une séance réelle menée jusqu'au bout, puis retrouvée dans Santé et dans coach. Tant qu'elle n'a pas eu lieu, ce sont des intentions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,17 @@
|
|||||||
|
|
||||||
> ## ⚠️ § 1.2 A ÉTÉ CORRIGÉ le 24/09, après ce diagnostic
|
> ## ⚠️ § 1.2 A ÉTÉ CORRIGÉ le 24/09, après ce diagnostic
|
||||||
>
|
>
|
||||||
> Sylvain a levé le gel sur ce point précis (« tu attends quoi ? »). Le correctif
|
> Sylvain a levé le gel sur ce point précis (« tu attends quoi ? »), puis sur
|
||||||
> est décrit en fin de document, § « Ce qui a été corrigé ». **Les quatre autres
|
> **les quatre autres** (« pour le reste vas-y »). Les cinq sont donc corrigés :
|
||||||
> constats restent ouverts, et le gel tient pour eux.**
|
> § 1.2 buildé et vérifié sur l'appareil, § 1.3 / 2.3 / 2.4 / 2.6 écrits et
|
||||||
|
> vérifiés en syntaxe, **pas encore compilés**.
|
||||||
|
>
|
||||||
|
> ⚠️ **§ 2.3 et § 2.4 touchent `WorkoutManager.swift`**, c'est-à-dire le chemin
|
||||||
|
> de fin de séance du live workout — ce que la consigne du 22/09 demandait de ne
|
||||||
|
> pas approcher. Fait sur demande explicite, en ne changeant aucune logique :
|
||||||
|
> les `try?` deviennent des `do/catch` qui journalisent, et le chemin d'échec
|
||||||
|
> gagne la clôture qui lui manquait. **À éprouver en séance réelle avant de
|
||||||
|
> considérer que ça tient.**
|
||||||
>
|
>
|
||||||
> ✅ **BUILD FAIT ET VÉRIFIÉ SUR L'APPAREIL, le 24/09.** Le diagnostic de
|
> ✅ **BUILD FAIT ET VÉRIFIÉ SUR L'APPAREIL, le 24/09.** Le diagnostic de
|
||||||
> `/settings` (« Diagnostiquer et pousser ») affiche sur l'iPhone de Sylvain :
|
> `/settings` (« Diagnostiquer et pousser ») affiche sur l'iPhone de Sylvain :
|
||||||
@@ -328,3 +336,69 @@ zones sans date. La doublure `HeartRateZonesStub.swift` est tenue à l'identique
|
|||||||
désormais `watchPushed` / `watchReason` jusqu'au JS.
|
désormais `watchPushed` / `watchReason` jusqu'au JS.
|
||||||
⏳ **Reste à voir au poignet** : que la montre affiche ces bornes pendant une
|
⏳ **Reste à voir au poignet** : que la montre affiche ces bornes pendant une
|
||||||
séance. L'envoi est accepté par iOS ; la livraison est asynchrone.
|
séance. L'envoi est accepté par iOS ; la livraison est asynchrone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Les quatre autres, corrigés le 24/09 (« pour le reste vas-y »)
|
||||||
|
|
||||||
|
⛔ **Aucun n'est compilé.** Syntaxe vérifiée (`swiftc -parse`), 91 tests du banc
|
||||||
|
Linux verts — mais ces fichiers importent HealthKit, WatchKit et Capacitor,
|
||||||
|
absents de Swift pour Linux. Le banc ne les couvre pas et ne peut pas les
|
||||||
|
couvrir.
|
||||||
|
|
||||||
|
### § 1.3 — `.notDetermined` n'est plus confondu avec une nuit vide
|
||||||
|
|
||||||
|
`CoachSleep.readSamples` teste désormais `authorizationStatus(for: sleepType)`
|
||||||
|
et rend `{available: false, reason: "authorization not determined"}`.
|
||||||
|
|
||||||
|
⚠️ **Ce qui n'est PAS corrigé, et ne peut pas l'être** : un refus *explicite*
|
||||||
|
reste indiscernable d'une absence de données. HealthKit ne le révèle pas en
|
||||||
|
lecture, par conception — c'est une protection de vie privée, pas un oubli
|
||||||
|
d'Apple. Seul `.notDetermined` est fiable, et c'est le cas fréquent
|
||||||
|
(réinstallation, permission jamais demandée). L'en-tête du fichier dit
|
||||||
|
maintenant exactement ce que la promesse couvre, au lieu de promettre plus.
|
||||||
|
|
||||||
|
### § 2.3 — « séance sauvée » et « rien de sauvé » ne se confondent plus
|
||||||
|
|
||||||
|
Les deux `try?` deviennent des `do/catch`. **La logique de récupération n'a pas
|
||||||
|
changé** — `recentlySavedWorkout()` reste le repli quand `finishWorkout()` rend
|
||||||
|
`nil` sur montre verrouillée. Ce qui change : un `endCollection` qui lève est
|
||||||
|
journalisé, et le `else` final distingue les deux cas, dont un seul est une
|
||||||
|
perte. Dans ce cas seulement, `statusMessage` le dit à l'écran — une montre
|
||||||
|
verrouillée n'est pas une perte, et l'annoncer serait une fausse alerte.
|
||||||
|
|
||||||
|
### § 2.4 — le GPS s'arrête, et la séance est sauvée si elle peut l'être
|
||||||
|
|
||||||
|
`didFailWithError` commence par `stopTracking()` : c'est le seul geste qui
|
||||||
|
réussit à coup sûr, et le seul dont l'omission se paie en batterie pendant des
|
||||||
|
heures. Puis une tentative de clôture en best-effort (`endCollection`,
|
||||||
|
`finishWorkout`), avec `recentlySavedWorkout()` en second recours — une session
|
||||||
|
qui a échoué refusera souvent de clore, mais ne pas essayer garantissait la
|
||||||
|
perte. `builder = nil` n'intervient plus qu'à la fin, après les tentatives.
|
||||||
|
|
||||||
|
Trois issues, trois messages distincts : « Séance interrompue, mais
|
||||||
|
enregistrée », « Séance perdue : … », ou le message d'erreur d'origine.
|
||||||
|
|
||||||
|
### § 2.6 — le tampon GPS est verrouillé
|
||||||
|
|
||||||
|
`collectLocations` protège `all` et `pluginErr` par un `NSLock`, comme les
|
||||||
|
quatre autres endroits du même fichier le faisaient déjà.
|
||||||
|
|
||||||
|
⚠️ **Invérifiable sans le déclencheur** : il faut un workout portant plusieurs
|
||||||
|
`HKWorkoutRoute` (multisport, reprise après interruption). Avec une seule route,
|
||||||
|
la boucle n'a qu'une itération — c'est pourquoi le défaut a pu vivre longtemps
|
||||||
|
sans se manifester, et pourquoi sa correction ne se constatera pas non plus.
|
||||||
|
|
||||||
|
### Ce qu'il faut regarder au prochain build
|
||||||
|
|
||||||
|
| Quoi | Où | Ce qui prouve |
|
||||||
|
|---|---|---|
|
||||||
|
| § 1.3 | `/settings` → synchro santé | Retirer « Sommeil » dans Réglages → Santé, réinstaller : la synchro doit dire « non autorisé », pas enregistrer des nuits vides |
|
||||||
|
| § 2.3 | Console, catégorie `workout` | Une séance normale ne doit RIEN journaliser de neuf. Les nouveaux logs ne sortent qu'en cas d'échec |
|
||||||
|
| § 2.4 | Batterie, après une séance interrompue | Le GPS ne doit plus tourner une fois l'écran de fin affiché |
|
||||||
|
| § 2.6 | — | Rien à voir : le correctif supprime une corruption qui ne se manifestait pas de façon lisible |
|
||||||
|
|
||||||
|
⚠️ **§ 2.3 et § 2.4 sont sur le chemin de fin de séance du live workout.** Le
|
||||||
|
test qui compte n'est pas un log, c'est **une séance réelle menée jusqu'au bout,
|
||||||
|
puis retrouvée dans Santé et dans coach**. Tant qu'elle n'a pas eu lieu, ces
|
||||||
|
deux correctifs sont des intentions.
|
||||||
|
|||||||
@@ -184,17 +184,36 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
var all: [CLLocation] = []
|
var all: [CLLocation] = []
|
||||||
let group = DispatchGroup()
|
let group = DispatchGroup()
|
||||||
var pluginErr: Error?
|
var pluginErr: Error?
|
||||||
|
/* ⚠️ `all` et `pluginErr` sont mutés depuis les callbacks de N requêtes,
|
||||||
|
livrées sur des files HealthKit indépendantes. Sans verrou, deux
|
||||||
|
`append` concurrents corrompent le tableau — silencieusement, ou par
|
||||||
|
un crash sans rapport apparent avec la cause.
|
||||||
|
|
||||||
|
Ce fichier verrouillait déjà ce motif à quatre autres endroits
|
||||||
|
(`getEffortScores`, `getWorkoutDetails`, et deux fois plus bas) :
|
||||||
|
c'était une omission ponctuelle, pas une convention absente.
|
||||||
|
§ 2.6 du diagnostic du 24/09/2026.
|
||||||
|
|
||||||
|
Déclencheur : un workout portant PLUSIEURS `HKWorkoutRoute`
|
||||||
|
(multisport, reprise après interruption). Avec une seule route la
|
||||||
|
boucle n'a qu'une itération, et le défaut est inobservable — c'est
|
||||||
|
pourquoi il a pu vivre longtemps sans se manifester. */
|
||||||
|
let lock = NSLock()
|
||||||
|
|
||||||
for route in routes {
|
for route in routes {
|
||||||
group.enter()
|
group.enter()
|
||||||
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
|
let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in
|
||||||
if let err = err {
|
if let err = err {
|
||||||
|
lock.lock()
|
||||||
pluginErr = err
|
pluginErr = err
|
||||||
|
lock.unlock()
|
||||||
group.leave()
|
group.leave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if let locs = locations {
|
if let locs = locations {
|
||||||
|
lock.lock()
|
||||||
all.append(contentsOf: locs)
|
all.append(contentsOf: locs)
|
||||||
|
lock.unlock()
|
||||||
}
|
}
|
||||||
if done {
|
if done {
|
||||||
group.leave()
|
group.leave()
|
||||||
|
|||||||
@@ -27,7 +27,10 @@
|
|||||||
// startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z',
|
// startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z',
|
||||||
// });
|
// });
|
||||||
// // r.samples = [{startDate, endDate, sleepState, sourceName, timeZone, tzSource}, ...]
|
// // r.samples = [{startDate, endDate, sleepState, sourceName, timeZone, tzSource}, ...]
|
||||||
// // r.available = false si HealthKit est indisponible ou non autorisé
|
// // r.available = false si HealthKit est indisponible, si le type manque,
|
||||||
|
// // ou si l'autorisation n'a jamais été demandée (`.notDetermined`).
|
||||||
|
// // ⚠️ Un refus EXPLICITE reste indiscernable d'une absence de données :
|
||||||
|
// // HealthKit ne le révèle pas en lecture, par conception (cf. readSamples).
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
import UIKit
|
||||||
@@ -68,6 +71,28 @@ public class CoachSleepPlugin: CAPPlugin, CAPBridgedPlugin {
|
|||||||
call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"])
|
call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
/* ⚠️ Sans ce test, « on ne nous laisse plus regarder » et « il n'a pas
|
||||||
|
dormi » rendaient la MÊME réponse : `{available: true, samples: []}`.
|
||||||
|
Le serveur enregistrait alors des nuits vides, et `sleep_score.py`
|
||||||
|
calculait une régularité de coucher sur des trous — une valeur fausse,
|
||||||
|
pas une valeur manquante. L'en-tête de ce fichier promettait pourtant
|
||||||
|
`available = false` si non autorisé. § 1.3 du diagnostic du 24/09/2026.
|
||||||
|
|
||||||
|
Ce que HealthKit permet, et ce qu'il ne permet pas : Apple ne
|
||||||
|
distingue PAS volontairement « refusé » de « vide » en LECTURE, pour
|
||||||
|
ne pas révéler qu'une donnée existe. `.sharingDenied` n'est donc pas
|
||||||
|
fiable pour un type lu. En revanche `.notDetermined` l'est, et c'est
|
||||||
|
le cas fréquent : réinstallation de l'app, permission jamais demandée.
|
||||||
|
On ne traite que celui-là — le reste serait une affirmation qu'Apple
|
||||||
|
ne garantit pas. */
|
||||||
|
if store.authorizationStatus(for: sleepType) == .notDetermined {
|
||||||
|
call.resolve([
|
||||||
|
"available": false,
|
||||||
|
"samples": [],
|
||||||
|
"reason": "authorization not determined",
|
||||||
|
])
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let start = Self.parseISO(call.getString("startDate")),
|
guard let start = Self.parseISO(call.getString("startDate")),
|
||||||
let end = Self.parseISO(call.getString("endDate")) else {
|
let end = Self.parseISO(call.getString("endDate")) else {
|
||||||
call.reject("startDate et endDate requis (ISO 8601)")
|
call.reject("startDate et endDate requis (ISO 8601)")
|
||||||
|
|||||||
@@ -381,13 +381,33 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
|
// sauvegarder la séance, et SEULEMENT ensuite clore la trace en
|
||||||
// l'associant au workout obtenu.
|
// l'associant au workout obtenu.
|
||||||
LocationTracker.shared.stopTracking()
|
LocationTracker.shared.stopTracking()
|
||||||
try? await builder.endCollection(at: date)
|
/* ⚠️ Les deux `try?` d'avant rendaient indiscernables deux
|
||||||
|
situations OPPOSÉES, toutes deux menant à `workout == nil` :
|
||||||
|
· montre verrouillée à l'arrêt → la séance EST écrite ;
|
||||||
|
· `endCollection` a levé → il n'y a rien à retrouver.
|
||||||
|
Elles tombaient dans le même `else`, et seul le sort de la TRACE
|
||||||
|
était journalisé — pas celui de la séance. Après deux heures de
|
||||||
|
sortie, l'écran de fin restait le même.
|
||||||
|
§ 2.3 du diagnostic du 24/09/2026. La logique de récupération
|
||||||
|
ci-dessous n'a pas changé : seul l'échec devient visible. */
|
||||||
|
var collecteClose = true
|
||||||
|
do {
|
||||||
|
try await builder.endCollection(at: date)
|
||||||
|
} catch {
|
||||||
|
collecteClose = false
|
||||||
|
workoutLog.error("endCollection a echoue : \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
// ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est
|
// ⚠️ `finishWorkout()` rend `nil` SANS erreur quand la montre est
|
||||||
// verrouillée : ce n'est pas un échec, la séance EST écrite dans
|
// verrouillée : ce n'est pas un échec, la séance EST écrite dans
|
||||||
// HealthKit — seul l'objet manque. Comme `finishRoute(with:)`
|
// HealthKit — seul l'objet manque. Comme `finishRoute(with:)`
|
||||||
// exige un `HKWorkout` non optionnel (vérifié dans la doc Apple),
|
// exige un `HKWorkout` non optionnel (vérifié dans la doc Apple),
|
||||||
// on va rechercher celle qui vient d'être sauvegardée.
|
// on va rechercher celle qui vient d'être sauvegardée.
|
||||||
let workout = try? await builder.finishWorkout()
|
var workout: HKWorkout?
|
||||||
|
do {
|
||||||
|
workout = try await builder.finishWorkout()
|
||||||
|
} catch {
|
||||||
|
workoutLog.error("finishWorkout a echoue : \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
if let workout {
|
if let workout {
|
||||||
await LocationTracker.shared.finishRoute(with: workout)
|
await LocationTracker.shared.finishRoute(with: workout)
|
||||||
} else if let recovered = await self.recentlySavedWorkout() {
|
} else if let recovered = await self.recentlySavedWorkout() {
|
||||||
@@ -395,6 +415,18 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
await LocationTracker.shared.finishRoute(with: recovered)
|
await LocationTracker.shared.finishRoute(with: recovered)
|
||||||
} else {
|
} else {
|
||||||
LocationTracker.shared.discardRoute()
|
LocationTracker.shared.discardRoute()
|
||||||
|
if collecteClose {
|
||||||
|
// La collecte s'est close proprement mais rien n'est
|
||||||
|
// retrouvable : anormal, et jamais journalisé jusqu'ici.
|
||||||
|
workoutLog.error("seance introuvable apres un finishWorkout() nil : rien a associer a la trace")
|
||||||
|
} else {
|
||||||
|
workoutLog.error("SEANCE PERDUE : endCollection a leve, rien n'a ete ecrit dans HealthKit")
|
||||||
|
/* Le dire à l'écran. C'est le cœur du constat : l'utilisateur
|
||||||
|
voyait l'écran de fin habituel après une séance perdue.
|
||||||
|
Uniquement dans ce cas — une montre verrouillée n'est pas
|
||||||
|
une perte, et l'annoncer serait une fausse alerte. */
|
||||||
|
self.statusMessage = "Séance non enregistrée — rien n'a pu être écrit dans Santé"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@@ -453,9 +485,43 @@ extension WorkoutManager: HKWorkoutSessionDelegate {
|
|||||||
let message = error.localizedDescription
|
let message = error.localizedDescription
|
||||||
let ns = error as NSError
|
let ns = error as NSError
|
||||||
workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)")
|
workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)")
|
||||||
|
let builder = workoutSession.associatedWorkoutBuilder()
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.statusMessage = "Erreur séance : \(message)"
|
/* ⚠️ Ce chemin ne clôturait RIEN. Comparé à `.ended`, il manquait
|
||||||
|
`stopTracking()`, `endCollection`, `finishWorkout` et
|
||||||
|
`finishRoute`/`discardRoute` — et `self.builder = nil` détruisait
|
||||||
|
la seule référence au builder : la perte devenait définitive dans
|
||||||
|
l'instruction même qui l'ignorait. Le GPS, lui, continuait de
|
||||||
|
tourner après la fin apparente de la séance, sans que rien ne
|
||||||
|
l'indique. § 2.4 du diagnostic du 24/09/2026.
|
||||||
|
|
||||||
|
Le GPS d'abord : c'est le seul geste qui réussit à coup sûr, et
|
||||||
|
le seul dont l'omission se paie en batterie pendant des heures.
|
||||||
|
La sauvegarde ensuite, en best-effort — une session qui a échoué
|
||||||
|
refusera souvent de clore, mais ne pas essayer garantit la perte. */
|
||||||
|
LocationTracker.shared.stopTracking()
|
||||||
|
var sauvee: HKWorkout?
|
||||||
|
do {
|
||||||
|
try await builder.endCollection(at: Date())
|
||||||
|
sauvee = try await builder.finishWorkout()
|
||||||
|
} catch {
|
||||||
|
workoutLog.error("cloture apres echec impossible : \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
if let sauvee {
|
||||||
|
workoutLog.info("seance sauvee malgre l'echec de session")
|
||||||
|
await LocationTracker.shared.finishRoute(with: sauvee)
|
||||||
|
self.statusMessage = "Séance interrompue, mais enregistrée"
|
||||||
|
} else if let recovered = await self.recentlySavedWorkout() {
|
||||||
|
workoutLog.info("seance retrouvee dans HealthKit apres l'echec de session")
|
||||||
|
await LocationTracker.shared.finishRoute(with: recovered)
|
||||||
|
self.statusMessage = "Séance interrompue, mais enregistrée"
|
||||||
|
} else {
|
||||||
|
LocationTracker.shared.discardRoute()
|
||||||
|
workoutLog.error("SEANCE PERDUE apres didFailWithError : rien n'a pu etre sauve")
|
||||||
|
self.statusMessage = "Séance perdue : \(message)"
|
||||||
|
}
|
||||||
self.isRunning = false
|
self.isRunning = false
|
||||||
|
self.isPaused = false
|
||||||
self.session = nil
|
self.session = nil
|
||||||
self.builder = nil
|
self.builder = nil
|
||||||
ConnectivityManager.shared.sendEnded(reason: "session-failed")
|
ConnectivityManager.shared.sendEnded(reason: "session-failed")
|
||||||
|
|||||||
Reference in New Issue
Block a user