diff --git a/docs/DIAGNOSTIC-NATIF-2026-09-24.md b/docs/DIAGNOSTIC-NATIF-2026-09-24.md index 0e4a827..09f1424 100644 --- a/docs/DIAGNOSTIC-NATIF-2026-09-24.md +++ b/docs/DIAGNOSTIC-NATIF-2026-09-24.md @@ -2,9 +2,17 @@ > ## ⚠️ § 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 -> est décrit en fin de document, § « Ce qui a été corrigé ». **Les quatre autres -> constats restent ouverts, et le gel tient pour eux.** +> Sylvain a levé le gel sur ce point précis (« tu attends quoi ? »), puis sur +> **les quatre autres** (« pour le reste vas-y »). Les cinq sont donc corrigés : +> § 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 > `/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. ⏳ **Reste à voir au poignet** : que la montre affiche ces bornes pendant une 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. diff --git a/ios/App/App/CoachHealthRoute.swift b/ios/App/App/CoachHealthRoute.swift index 64aeda1..5dedd29 100644 --- a/ios/App/App/CoachHealthRoute.swift +++ b/ios/App/App/CoachHealthRoute.swift @@ -184,17 +184,36 @@ public class CoachHealthRoutePlugin: CAPPlugin, CAPBridgedPlugin { var all: [CLLocation] = [] let group = DispatchGroup() 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 { group.enter() let q = HKWorkoutRouteQuery(route: route) { _, locations, done, err in if let err = err { + lock.lock() pluginErr = err + lock.unlock() group.leave() return } if let locs = locations { + lock.lock() all.append(contentsOf: locs) + lock.unlock() } if done { group.leave() diff --git a/ios/App/App/CoachSleep.swift b/ios/App/App/CoachSleep.swift index d542ba2..0558d06 100644 --- a/ios/App/App/CoachSleep.swift +++ b/ios/App/App/CoachSleep.swift @@ -27,7 +27,10 @@ // startDate: '2026-08-01T00:00:00Z', endDate: '2026-08-19T00:00:00Z', // }); // // 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 UIKit @@ -68,6 +71,28 @@ public class CoachSleepPlugin: CAPPlugin, CAPBridgedPlugin { call.resolve(["available": false, "samples": [], "reason": "sleepAnalysis unavailable"]) 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")), let end = Self.parseISO(call.getString("endDate")) else { call.reject("startDate et endDate requis (ISO 8601)") diff --git a/ios/App/CoachWatch/WorkoutManager.swift b/ios/App/CoachWatch/WorkoutManager.swift index 8980fa8..42029e3 100644 --- a/ios/App/CoachWatch/WorkoutManager.swift +++ b/ios/App/CoachWatch/WorkoutManager.swift @@ -381,13 +381,33 @@ extension WorkoutManager: HKWorkoutSessionDelegate { // sauvegarder la séance, et SEULEMENT ensuite clore la trace en // l'associant au workout obtenu. 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 // verrouillée : ce n'est pas un échec, la séance EST écrite dans // HealthKit — seul l'objet manque. Comme `finishRoute(with:)` // exige un `HKWorkout` non optionnel (vérifié dans la doc Apple), // 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 { await LocationTracker.shared.finishRoute(with: workout) } else if let recovered = await self.recentlySavedWorkout() { @@ -395,6 +415,18 @@ extension WorkoutManager: HKWorkoutSessionDelegate { await LocationTracker.shared.finishRoute(with: recovered) } else { 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 @@ -453,9 +485,43 @@ extension WorkoutManager: HKWorkoutSessionDelegate { let message = error.localizedDescription let ns = error as NSError workoutLog.error("session didFailWithError domain=\(ns.domain) code=\(ns.code): \(message)") + let builder = workoutSession.associatedWorkoutBuilder() 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.isPaused = false self.session = nil self.builder = nil ConnectivityManager.shared.sendEnded(reason: "session-failed")