Audit complet de l'app mobile : natif, conformité Apple, ergonomie
Trois axes, chaque constat marqué [vérifié] / [rapporté] / [doc] — la distinction n'est pas cosmétique : un constat non revérifié relayé comme un fait est la dérive que la règle 3 interdit. Le plus grave (§1.1, vérifié ligne à ligne) : WorkoutManager.swift:199 garde la dernière FC connue quand le capteur décroche, et :218 la réémet avec un horodatage NEUF. Le watchdog de LiveStore ne peut donc jamais basculer en .lost — il reçoit des samples frais. Au poignet, une FC morte s'affiche stable, avec sa zone colorée, indéfiniment. Pour un post-infarctus sous bêtabloquants qui lit ce chiffre pour décider d'une pente, ce n'est pas un bug d'affichage. Quatre affirmations d'audits antérieurs corrigées, toutes vérifiées : - la politique de confidentialité EXISTE (HTTP 200) — le TODO du README est périmé, et « missing privacy policy » circulait encore ; - la suppression de compte EXISTE (5.1.1(v), motif de rejet fréquent) ; - 88 tests Swift verts, pas 57 ; - npm audit réellement propre, et aucun secret jamais commité (git log vide). aps-environment = development n'est pas le problème qu'on croyait : Apple documente que la valeur vient du provisioning profile et que TestFlight utilise production. Le vrai risque est côté backend, sur l'endpoint APNs. Côté produit : le mot « Démarrer » n'existe nulle part pour une séance sportive, et widgets/today-session.html:6 documente un CTA que le markup ne contient pas. L'app ne permet pas de démarrer une séance. Ce qui n'a pas été audité est dit en § 7 — aucun build Xcode possible ici, CoachPolarBLE non revu, backend non audité au-delà de l'ergonomie. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
483
docs/AUDIT-2026-09-22.md
Normal file
483
docs/AUDIT-2026-09-22.md
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
# Audit complet — app mobile Coach Hypnotruck
|
||||||
|
|
||||||
|
**Date** : 2026-09-22 · **Portée** : `coach-ios` (natif iOS/watchOS) + `coach_sportif/web`
|
||||||
|
(l'UI réelle, servie dans la WKWebView) · **Branche auditée** : `feat/watch-outdoor` @ `e6cdc67`
|
||||||
|
|
||||||
|
## Comment lire ce document
|
||||||
|
|
||||||
|
Chaque constat porte son statut de vérification. La distinction n'est pas
|
||||||
|
cosmétique : c'est une app de santé, et un constat non vérifié relayé comme un
|
||||||
|
fait est exactement la dérive que la règle 3 interdit.
|
||||||
|
|
||||||
|
| Marque | Signification |
|
||||||
|
|---|---|
|
||||||
|
| **[vérifié]** | Lu ligne à ligne dans le fichier cité, ou calculé, pendant cet audit |
|
||||||
|
| **[rapporté]** | Remonté par une revue automatisée, **non revérifié** — à confirmer avant d'agir |
|
||||||
|
| **[doc]** | Adossé à une source Apple citée verbatim, URL donnée |
|
||||||
|
|
||||||
|
⚠️ Ce document date du jour de son écriture. Il constate, il ne prescrit pas un
|
||||||
|
état. `git log` fait foi pour ce qui a bougé depuis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ce qui va bien, et qu'il ne faut pas casser
|
||||||
|
|
||||||
|
Le socle est sain, et plusieurs points sont au-dessus de la moyenne du domaine.
|
||||||
|
|
||||||
|
- **[vérifié] Aucun secret n'a jamais été commité.** `git log --all` sur
|
||||||
|
`coach-ios.local.json` et `ios/App/App/CoachAuth.swift` est vide. Le
|
||||||
|
`.gitignore` les couvre (lignes 23-24).
|
||||||
|
- **[vérifié] `npm audit` : 0 vulnérabilité.** Capacitor 8.3.1 homogène sur
|
||||||
|
tous les paquets.
|
||||||
|
- **[vérifié] 88 tests Swift verts sur Linux** (10 DayRollover + 7 FormeBand +
|
||||||
|
17 IntervalEngine + 31 PolarPftpStep + 8 QuickDrink + 15 RouteFilter). Le
|
||||||
|
chiffre de 57 qui circulait est faux.
|
||||||
|
- **[vérifié] La politique de confidentialité existe** — `HTTP 200` sur
|
||||||
|
`/privacy`, liée depuis `settings.html:796` et `landing.html:207`. Le TODO du
|
||||||
|
README (« à héberger ») est **périmé**, de même que la ligne « missing
|
||||||
|
privacy policy » de tout audit antérieur.
|
||||||
|
- **[vérifié] La suppression de compte existe** (`settings.html:797` →
|
||||||
|
`POST /account/delete`, `routers/auth.py:313`). C'est l'exigence 5.1.1(v),
|
||||||
|
motif de rejet fréquent : elle est déjà satisfaite.
|
||||||
|
- **[vérifié] Universal Links opérationnels** — AASA servi en
|
||||||
|
`application/json`, Team ID `TZ2PVTRKNY`, routes `/live` `/routine` `/forme`
|
||||||
|
`/calendar`.
|
||||||
|
- **[vérifié] Purpose strings HealthKit précises et en français**, nommant les
|
||||||
|
types de données et leur finalité. Conforme à la lettre de 5.1.1(ii).
|
||||||
|
- **[rapporté] `CoachWorkoutKit.swift` est le fichier le plus sain du chemin
|
||||||
|
santé** : autorisation vérifiée deux fois, échec remonté jusqu'à l'écran,
|
||||||
|
relecture post-envoi qui rend ce qui est constaté et non ce qui est supposé.
|
||||||
|
- **[rapporté] Aucune confusion kcal/kJ, m/s ↔ km/h, ni mètres/miles** dans le
|
||||||
|
code Swift. Aucun `@MainActor` manquant, aucun cycle de rétention.
|
||||||
|
- **[vérifié] La barre d'onglets web est le seul composant d'UI protégé par un
|
||||||
|
test** (`tests/test_bottom_tabs.py`) — et c'est le seul qui n'a pas dérivé.
|
||||||
|
Le lien de cause à effet mérite d'être noté.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Gravité 1 — sécurité des données de santé
|
||||||
|
|
||||||
|
### 1.1 [vérifié] La FC au poignet se fige sur la dernière valeur connue, sans le dire
|
||||||
|
|
||||||
|
`ios/App/CoachWatch/WorkoutManager.swift:199` et `:217-224`
|
||||||
|
|
||||||
|
```swift
|
||||||
|
if let hr { heartRate = hr } // 199 — hr nil → l'ancienne valeur reste
|
||||||
|
...
|
||||||
|
ConnectivityManager.shared.sendSample([
|
||||||
|
"heartRate": heartRate, // 218 — la propriété stockée, PAS `hr`
|
||||||
|
...
|
||||||
|
"ts": now.timeIntervalSince1970 // 223 — horodatage NEUF
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
`didCollectDataOf` se déclenche dès qu'*un* type est collecté. Quand le capteur
|
||||||
|
optique décroche — bracelet desserré, froid, arythmie : **la FA thermoablée est
|
||||||
|
précisément le terrain à risque** — les calories continuent d'arriver, `hr` vaut
|
||||||
|
`nil`, et la ligne 218 réémet la dernière FC connue avec un horodatage frais.
|
||||||
|
|
||||||
|
Conséquence en trois étages :
|
||||||
|
1. Au poignet, l'écran affiche une FC stable et sa zone colorée, indéfiniment.
|
||||||
|
2. Sur l'iPhone, `LiveStore.ingest` pose `lastSampleAt = Date()` et
|
||||||
|
`status = .live`. Le watchdog **ne basculera jamais en `.lost`** : il reçoit
|
||||||
|
des samples frais.
|
||||||
|
3. Aucun log, aucun compteur.
|
||||||
|
|
||||||
|
**C'est le défaut le plus grave de l'audit.** Un utilisateur post-infarctus sous
|
||||||
|
bêtabloquants lit 138 bpm en Z3, conclut qu'il peut tenir la pente, et sa FC
|
||||||
|
réelle est inconnue. Ce n'est pas une erreur de logique : c'est une information
|
||||||
|
absente du modèle. `heartRate` a besoin d'un `heartRateAt: Date` posé uniquement
|
||||||
|
quand `hr != nil`, d'un seuil d'âge au-delà duquel la vue affiche « — » (elle
|
||||||
|
sait déjà le faire, `LiveStore.heartRateText:152`), et `sendSample` d'un champ
|
||||||
|
d'âge pour que l'iPhone applique la même règle.
|
||||||
|
|
||||||
|
### 1.2 [rapporté] Les zones cardiaques peuvent ne jamais atteindre la montre
|
||||||
|
|
||||||
|
`ios/App/App/CoachWidgetBridge.swift:183` — `try? session.updateApplicationContext(...)`
|
||||||
|
|
||||||
|
`try?` avale l'échec (payload trop gros, session non activée, montre non
|
||||||
|
appairée). Ce snapshot transporte les bornes de zones affichées pendant
|
||||||
|
l'effort. Deux fichiers plus loin, `CoachRoutineBridge.pushToWatch:92-101`
|
||||||
|
appelle **la même API** avec `do/catch`, log et `call.reject`.
|
||||||
|
|
||||||
|
Le chemin instrumenté est celui de la routine de gainage ; le chemin muet est
|
||||||
|
celui qui porte les bornes cardiaques d'un patient post-infarctus. Aggravant :
|
||||||
|
`HeartRateZones` ne porte aucune date de validité, donc après une révision des
|
||||||
|
bornes la montre peut afficher les anciennes indéfiniment. C'est le motif
|
||||||
|
« zones datées » déjà tranché côté serveur (`app.current_zones()`), non
|
||||||
|
répercuté au poignet.
|
||||||
|
|
||||||
|
### 1.3 [rapporté] « Accès refusé » et « aucune donnée » sont rendus identiques
|
||||||
|
|
||||||
|
`ios/App/App/CoachSleep.swift:92-96` — l'en-tête du fichier (ligne 30) promet
|
||||||
|
`available = false` si non autorisé ; le code ne teste jamais l'autorisation et
|
||||||
|
rend `{available: true, samples: []}`.
|
||||||
|
|
||||||
|
Si l'utilisateur retire « Sommeil » dans Réglages → Santé, le backend enregistre
|
||||||
|
des nuits vides et `sleep_score.py` calcule une régularité sur des trous. Rien
|
||||||
|
ne distingue « il n'a pas dormi » de « on ne nous laisse plus regarder ».
|
||||||
|
HealthKit ne distingue effectivement pas refusé de vide en lecture, mais
|
||||||
|
`authorizationStatus(for:)` couvre `.notDetermined`, qui est le cas fréquent
|
||||||
|
(réinstallation, permission jamais accordée). Même famille :
|
||||||
|
`CoachHealthRoute.swift:173` et `:277`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Gravité 2 — perte silencieuse de données
|
||||||
|
|
||||||
|
Le projet a déjà vécu ce scénario côté backend : un cron a privé quatre mois de
|
||||||
|
séances de leur charge cardiaque en répondant « rien de neuf » — ce qui était
|
||||||
|
vrai de son point de vue. Les trois défauts ci-dessous ont la même forme.
|
||||||
|
|
||||||
|
### 2.1 [rapporté] Séances multiples : toutes sauf une sont perdues définitivement
|
||||||
|
|
||||||
|
`ios/App/App/CoachWorkoutObserver.swift:297-319`. L'ancre est sauvegardée
|
||||||
|
(l. 298) **avant** que seul `latest` (l. 315) soit traité. Or
|
||||||
|
`scheduleNotification` est le seul appelant de `reportToRoutineIfStrength` et
|
||||||
|
`reportPlanOrigin`. Renfo le matin + vélo le soir, iPhone resté à la maison :
|
||||||
|
au premier fire, le vélo est notifié, le renfo n'est jamais POSTé, et l'ancre
|
||||||
|
ayant avancé, il ne reviendra jamais. Le log dit « 2 new workout(s), notifying
|
||||||
|
latest » — exact, et parfaitement muet sur ce qui vient d'être jeté.
|
||||||
|
|
||||||
|
**Correction** : boucler sur `workouts` pour les rapports serveur ; garder
|
||||||
|
`latest` uniquement pour la notification locale.
|
||||||
|
|
||||||
|
### 2.2 [rapporté] Les POST natifs partent, puis l'app se déclare suspendable
|
||||||
|
|
||||||
|
Même fichier, `:266-268`, `:352-382`, `:398-438`. Le `defer { completion() }`
|
||||||
|
s'exécute dès que le corps rend la main, alors que deux requêtes sont en vol sur
|
||||||
|
`URLSession.shared`, sans `beginBackgroundTask`. iOS est libre de suspendre.
|
||||||
|
|
||||||
|
**Aggravant, indépendamment vérifiable** : le statut HTTP n'est jamais lu
|
||||||
|
(l. 368 et 431). Un 401 sur token rotaté, un 500, un 404 ne produisent pas
|
||||||
|
d'`error` — et `plan-origin envoyé` est journalisé comme un succès. Le bon
|
||||||
|
pattern existe déjà à deux fichiers de là (`CoachRoutineBridge.postDay:154-159`
|
||||||
|
vérifie `(200..<300).contains(http.statusCode)`).
|
||||||
|
|
||||||
|
### 2.3 [rapporté] Une séance entière peut disparaître sans un seul log
|
||||||
|
|
||||||
|
`ios/App/CoachWatch/WorkoutManager.swift:310-324`. `try? await
|
||||||
|
builder.endCollection(...)` puis `try? await builder.finishWorkout()` : si le
|
||||||
|
premier lève, le second lève aussi, la récupération ne trouve rien, et le chemin
|
||||||
|
finit sur `discardRoute()` qui logue la perte de la *trace* mais pas de la
|
||||||
|
*séance*. `statusMessage` n'est pas touché : l'utilisateur voit l'écran de fin
|
||||||
|
normal après 2 h de sortie.
|
||||||
|
|
||||||
|
### 2.4 [rapporté] `didFailWithError` ne clôt rien et laisse le GPS allumé
|
||||||
|
|
||||||
|
Même fichier, `:377-389`. Comparé au chemin `.ended`, il manque
|
||||||
|
`stopTracking()`, `endCollection`, `finishWorkout`, `finishRoute`/`discardRoute`.
|
||||||
|
Tout ce que le builder a collecté est perdu sans tentative de sauvegarde, et le
|
||||||
|
GPS continue de tourner après la fin apparente de la séance.
|
||||||
|
|
||||||
|
### 2.5 [vérifié] Chaque pause efface jusqu'à 24 points de trace *antérieurs*
|
||||||
|
|
||||||
|
`ios/App/CoachWatch/LocationTracker.swift:147` — `pendingRoute.removeAll()`.
|
||||||
|
|
||||||
|
Le commentaire vise les points *postérieurs* à la pause. Mais `pendingRoute` ne
|
||||||
|
contient que des points **déjà acceptés par le filtre et déjà comptés dans la
|
||||||
|
distance affichée**, en attente d'écriture (seuil de flush : 25). À 1 Hz, c'est
|
||||||
|
une douzaine de points en moyenne, jusqu'à 24. L'écart est donc visible : la
|
||||||
|
distance les inclut, la trace HealthKit non. Six arrêts sur une sortie VTTAE →
|
||||||
|
jusqu'à 144 points disparus, et un D+ recalculé qui diverge de celui affiché.
|
||||||
|
|
||||||
|
**Correction** : `await flushPendingRoute()` avant de vider, ou ne pas vider —
|
||||||
|
le point de reprise est de toute façon filtré par `implausibleSpeed`.
|
||||||
|
|
||||||
|
### 2.6 [rapporté] Accès concurrent non protégé sur le tampon GPS
|
||||||
|
|
||||||
|
`ios/App/App/CoachHealthRoute.swift:182-203` : `all` et `pluginErr` sont mutés
|
||||||
|
depuis les callbacks de N requêtes livrées sur des files HealthKit
|
||||||
|
indépendantes, sans verrou — alors que `getEffortScores:282` et
|
||||||
|
`getWorkoutDetails:379` du **même fichier** utilisent un `NSLock` pour ce motif
|
||||||
|
exact. Déclencheur : un workout portant plusieurs `HKWorkoutRoute` (multisport,
|
||||||
|
reprise après interruption).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Gravité 3 — justesse et cohérence
|
||||||
|
|
||||||
|
| # | Constat | Emplacement |
|
||||||
|
|---|---|---|
|
||||||
|
| 3.1 | **[rapporté]** `humidity_pct` vaut 0 ou 1, jamais 65 — `HKUnit.percent()` est une fraction (1.0 = 100 %), le `× 100` manque | `CoachHealthRoute.swift:415-417` |
|
||||||
|
| 3.2 | **[rapporté]** Une séance `traditionalStrengthTraining` est POSTée en `functionalStrengthTraining` (valeur en dur l. 363 alors que le garde accepte deux types). Le payload n'a ni UUID ni horodatage de début → serveur incapable de dédupliquer | `CoachWorkoutObserver.swift:352-364` |
|
||||||
|
| 3.3 | **[rapporté]** `value > 0` confond un zéro mesuré avec une absence de mesure, alors que le commentaire l. 399 promet l'inverse. L'erreur de la requête est jetée | `CoachHealthRoute.swift:449-462` |
|
||||||
|
| 3.4 | **[rapporté]** `startObserving` répond `observing: true` même sur refus (le `success` est ignoré, et Apple le met à `true` dès l'affichage de la feuille). `getStatus` montre trois voyants verts et zéro donnée | `CoachWorkoutObserver.swift:218-228` |
|
||||||
|
| 3.5 | **[rapporté]** La garde anti-doublon `lastNotifiedKey` est écrite (l. 317) et **jamais lue**. Le commentaire affirme une protection absente | `CoachWorkoutObserver.swift:60-63` |
|
||||||
|
| 3.6 | **[rapporté]** Un cookie d'auth périmé ne peut jamais être remplacé : les deux injections testent le *nom* du cookie, jamais sa valeur ni son expiration. Les appels natifs continuent de partir avec la constante compilée | `AppDelegate.swift:31-34`, `MainViewController.swift:63-65` |
|
||||||
|
| 3.7 | **[vérifié]** `STYLE_GUIDE.md:233` annonce `--text-secondary` à 9,7:1 ; le calcul donne **6,36:1** sur noir, 5,95:1 sur `.ap-card` | `docs/STYLE_GUIDE.md` |
|
||||||
|
|
||||||
|
Le flux d'injection du cookie lui-même (3.6) est correct et bien documenté — la
|
||||||
|
règle non négociable n° 2 du CLAUDE.md est respectée. Le défaut est le critère
|
||||||
|
de remplacement, pas le mécanisme.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Conformité App Store — sources Apple
|
||||||
|
|
||||||
|
Méthode : `developer.apple.com` uniquement, verbatim + URL. La distinction
|
||||||
|
exige / recommande / **ne dit pas** est le cœur de cette section.
|
||||||
|
|
||||||
|
### 5.1 [doc] `aps-environment = development` — non-problème, sous réserve d'une vérification
|
||||||
|
|
||||||
|
`App.entitlements` porte `development`. La
|
||||||
|
[doc Apple](https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment)
|
||||||
|
dit verbatim :
|
||||||
|
|
||||||
|
> « **Xcode sets the value of the entitlement based on your app's current
|
||||||
|
> provisioning profile.** […] Production provisioning profile and [prerelease
|
||||||
|
> versions and beta testers] use `production`. »
|
||||||
|
|
||||||
|
TestFlight utilise donc `production`, et [TN2265](https://developer.apple.com/library/archive/technotes/tn2265/_index.html)
|
||||||
|
ajoute que Xcode **re-signe** à la soumission avec le profil de distribution.
|
||||||
|
|
||||||
|
**Ce que la doc ne dit pas** : si Xcode réécrit le *fichier source*
|
||||||
|
`.entitlements`. N'éditez pas le fichier sur la foi d'un blog. Apple publie une
|
||||||
|
procédure de vérification, et c'est la seule qui conclut :
|
||||||
|
|
||||||
|
> Organizer → archive → Distribute/Validate → feuille Summary → dépliant
|
||||||
|
> **Binary and Entitlements** → lire la valeur réelle dans le binaire signé.
|
||||||
|
|
||||||
|
**Le vrai risque est ailleurs, et il est côté backend** : un token production
|
||||||
|
envoyé vers `api.sandbox.push.apple.com` (ou l'inverse) est traité comme
|
||||||
|
invalide et **la notification est jetée silencieusement** (TN2265). À vérifier
|
||||||
|
dans `web/push_notify.py`.
|
||||||
|
|
||||||
|
### 5.2 [doc] Ce qu'Apple exige, et où on en est
|
||||||
|
|
||||||
|
| Exigence | Source | État |
|
||||||
|
|---|---|---|
|
||||||
|
| Privacy policy URL dans App Store Connect | 5.1.1(i) + ASC Help, « required for all apps » | à renseigner à la soumission |
|
||||||
|
| Privacy policy accessible **dans** l'app | 5.1.1(i), « in an easily accessible manner » | **[vérifié] fait** — `settings.html:796` |
|
||||||
|
| Privacy policy pour toute app HealthKit | [doc HealthKit](https://developer.apple.com/documentation/healthkit/protecting-user-privacy) | **[vérifié] fait** |
|
||||||
|
| Contenu : collecte + usages + tiers + **rétention/suppression et révocation** | 5.1.1(i) | à relire — c'est le point que les policies génériques oublient |
|
||||||
|
| Suppression de compte dans l'app | 5.1.1(v) | **[vérifié] fait** — `POST /account/delete` |
|
||||||
|
| Purpose strings précises | 5.1.1(ii) + doc HealthKit | **[vérifié] fait** |
|
||||||
|
| Nutrition labels renseignés | « required to submit » | **à faire** : Health, Fitness, Precise Location, identifiants, photos — tous « Linked to You » (il y a un compte) |
|
||||||
|
| Aucune donnée HealthKit en pub/marketing | 5.1.3(i), 5.1.2(vi), 2.5.18 | conforme |
|
||||||
|
| Aucune donnée de santé dans iCloud | 5.1.3(ii) | **[vérifié]** aucun entitlement iCloud |
|
||||||
|
| Notes for Review spécifiques + compte de démo **avec backend allumé** | 2.3.1(a), 2.1(a) | à préparer |
|
||||||
|
|
||||||
|
⚠️ **Piège propre à l'architecture webview**, [User Privacy and Data Use](https://developer.apple.com/app-store/user-privacy-and-data-use/) :
|
||||||
|
|
||||||
|
> « **If you are using a webview for app functionality, it should be treated the
|
||||||
|
> same way as native functionality in your app** »
|
||||||
|
|
||||||
|
Tout ce que fait `coach.hypnotruck.ch` dans la WKWebView compte comme du natif
|
||||||
|
pour les nutrition labels **et** pour l'ATT. Si le site embarque le moindre
|
||||||
|
analytics tiers, il doit être déclaré.
|
||||||
|
|
||||||
|
### 5.3 [doc] Ce que la doc Apple **ne tranche pas** — à connaître, pas à corriger
|
||||||
|
|
||||||
|
- **Guideline 4.2 n'a aucun critère mesurable.** « Useful, unique, app-like »
|
||||||
|
n'est pas quantifié. Personne ne peut certifier que HealthKit + widgets +
|
||||||
|
Live Activity + watchOS + BLE « suffit ». Le levier réel est 2.3.1(a) :
|
||||||
|
**décrire nommément les fonctions natives dans les Notes for Review**.
|
||||||
|
- **2.5.2** (« may not download […] code which introduces or changes features »)
|
||||||
|
n'a pas d'exception publiée pour une UI web distante chargée via `server.url`.
|
||||||
|
Zone non tranchée — c'est le risque documentaire réel de l'architecture, et il
|
||||||
|
est structurel, pas corrigeable.
|
||||||
|
- **« Third party »** n'est pas défini : la doc ne dit pas si le backend du
|
||||||
|
développeur en est un. Aucune exigence technique publiée sur le stockage
|
||||||
|
(chiffrement, juridiction, rétention).
|
||||||
|
- **`limitsNavigationsToAppBoundDomains: false`** : la page Apple ne décrit
|
||||||
|
**aucune** conséquence, et `WKAppBoundDomains` est absente de la référence
|
||||||
|
Info.plist. Aucun lien publié entre app-bound domains et données de santé.
|
||||||
|
La config actuelle est cohérente (pas de `WKAppBoundDomains` + flag `false` =
|
||||||
|
comportement pré-iOS 14, sans restriction), et passer à `true` casserait
|
||||||
|
`evaluateJavaScript` et `messageHandlers`, dont Capacitor a besoin.
|
||||||
|
**Ne pas y toucher pour des raisons de conformité.**
|
||||||
|
- **Aucune clé Info.plist de privacy policy n'existe.** Si une source en
|
||||||
|
réclame une, c'est inventé.
|
||||||
|
|
||||||
|
### 5.4 [doc] Signal de positionnement
|
||||||
|
|
||||||
|
5.1.1(ix) vise les « highly regulated fields (such as […] healthcare) » avec un
|
||||||
|
« should » (pas « must »). Mais 1.4.1 prévient :
|
||||||
|
|
||||||
|
> « **Medical apps that could provide inaccurate data or information […] may be
|
||||||
|
> reviewed with greater scrutiny.** »
|
||||||
|
|
||||||
|
Le vocabulaire de la fiche App Store a donc un effet documenté sur le niveau de
|
||||||
|
scrutin : « coaching sportif » et « suivi cardiaque post-infarctus » ne
|
||||||
|
déclenchent pas la même lecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Ergonomie — l'app est-elle utilisable **par un sportif** ?
|
||||||
|
|
||||||
|
C'est l'exigence produit : simple, ergonomique, pour des sportifs. Rappel
|
||||||
|
d'architecture — l'UI réelle vit dans `coach_sportif/web/`, le wrapper ne fait
|
||||||
|
que l'afficher. Les constats ci-dessous portent donc sur le web.
|
||||||
|
|
||||||
|
### 6.1 [vérifié] Le geste central du sportif n'existe pas
|
||||||
|
|
||||||
|
Le mot « Démarrer » n'apparaît que **deux fois** dans tout `web/templates/`, et
|
||||||
|
les deux fois pour la routine anti-douleurs (`routine.html:27,42`). La troisième
|
||||||
|
occurrence est un commentaire qui décrit une fonctionnalité absente :
|
||||||
|
`widgets/today-session.html:6` annonce un CTA « Démarrer » / « Voir détails »
|
||||||
|
que le markup ne contient pas.
|
||||||
|
|
||||||
|
| Action | Taps |
|
||||||
|
|---|---|
|
||||||
|
| Voir la séance du jour | 0 (mais ~420 px sous le haut de page) |
|
||||||
|
| Voir ses étapes et sa zone cardiaque cible | 2 |
|
||||||
|
| Envoyer la séance à la Watch | 3 + scroll dans la modale |
|
||||||
|
| **Démarrer la séance** | **impossible** — `/live` est passif, et hors onglets par défaut |
|
||||||
|
|
||||||
|
Avant l'effort : 3 taps pour préparer. Pendant l'effort : l'app ne sert à rien
|
||||||
|
sans la montre. Pour une app « prévue pour les sportifs », c'est le trou central.
|
||||||
|
|
||||||
|
### 6.2 [vérifié] La typographie descend à 8 px sur l'information la plus sensible
|
||||||
|
|
||||||
|
17 règles CSS descendent à ≤ 0,62 rem, la plupart en `--text-tertiary` :
|
||||||
|
|
||||||
|
| Sélecteur | Taille | Ce que ça porte |
|
||||||
|
|---|---|---|
|
||||||
|
| `.ap-tile__pill` (`style.css:7053`) | **0,5 rem = 8 px** | **l'intensité de la séance**, « ADAPTÉ IA », « FAIT ✓ » |
|
||||||
|
| `.ap-tile__meta` (`:7068`) | 0,62 rem ≈ 9,9 px | durée de la séance, km, FC |
|
||||||
|
| `.entry-src`, `.entry-ecart` | 0,58 rem ≈ 9,3 px | provenance / écart d'une mesure |
|
||||||
|
|
||||||
|
**L'intensité d'effort d'un post-infarctus est affichée en majuscules de 8 px,
|
||||||
|
en gris, sur noir.** Au total, 127 règles combinent `--text-tertiary` avec une
|
||||||
|
police < 0,78 rem.
|
||||||
|
|
||||||
|
**Ratios réels [vérifié par calcul]**, fonction validée sur le témoin WCAG
|
||||||
|
officiel (`#767676`/blanc = 4,54) :
|
||||||
|
|
||||||
|
| Token | sur `#000` | sur `.ap-card` | sur `--bg-tertiary` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--text-secondary` | **6,36** (le guide annonce 9,7 — faux) | 5,95 | 5,27 |
|
||||||
|
| `--text-tertiary` | 5,42 | 5,22 | **4,68** (limite AA) |
|
||||||
|
|
||||||
|
Les tokens passent AA, mais avec deux fois moins de marge que le guide ne le
|
||||||
|
croit. Et le problème n'est pas le ratio : c'est la taille. **Aucune
|
||||||
|
échappatoire** : pas de mode clair (`_layout.html:20` force
|
||||||
|
`background:#000 !important`, et les tokens clairs de `critical.css:153-166`
|
||||||
|
sont du code mort), pas de Dynamic Type (`-webkit-text-size-adjust: 100%` +
|
||||||
|
`rem` sur racine fixe), aucun réglage de taille dans `/settings`.
|
||||||
|
|
||||||
|
Un thème sombre est le pire cas en extérieur : c'est le fond — la plus grande
|
||||||
|
surface — qui est noyé par la lumière réfléchie.
|
||||||
|
|
||||||
|
### 6.3 [rapporté] Les cibles tactiles fautives sont exactement les actions fréquentes
|
||||||
|
|
||||||
|
Les composants génériques respectent le 44×44 du style guide (`.ap-icon-btn`,
|
||||||
|
`.tab-item`, `.more-item`, `.coach-fab`). Mais :
|
||||||
|
|
||||||
|
| Élément | Taille | Rôle |
|
||||||
|
|---|---|---|
|
||||||
|
| `.qr-action` | ~**29 × 35** | ajout rapide d'un favori — le geste le plus fréquent |
|
||||||
|
| `.hero-tab` | ~**23 px** | sélecteur primaire de `/meals` |
|
||||||
|
| `.ap-home-hero__cta-mini` | **32 × 32** | **le CTA du hero d'accueil** |
|
||||||
|
| `.meal-section__add-btn` | **34 × 34** | porte d'entrée de la saisie de repas |
|
||||||
|
| `.ap-icon-btn.--sm` | **36 × 36** | navigation jour de `/meals` — usage **primaire**, ce que §2 du guide interdit |
|
||||||
|
|
||||||
|
Saisir un repas connu = 3 taps, **dont 2 sous le seuil**. Pire : dans
|
||||||
|
`.quick-row`, le bouton favori (étoile) et le bouton ajouter (+) sont adjacents,
|
||||||
|
même taille ~29 px : avec des doigts moites ou des gants, on met en favori au
|
||||||
|
lieu d'ajouter, **sans retour d'erreur** puisque les deux « marchent ».
|
||||||
|
|
||||||
|
Aucun test ne verrouille le 44×44 ni le contraste — alors que le CLAUDE.md pose
|
||||||
|
« un garde-fou contre la récidive → un test, pas une phrase ». Les règles
|
||||||
|
d'ergonomie sont les seules du projet à n'être qu'une phrase. D'où la dérive.
|
||||||
|
|
||||||
|
### 6.4 [rapporté] Densité et navigation
|
||||||
|
|
||||||
|
- **Accueil : 28 chiffres, dont un seul concerne la séance à faire** (sa durée,
|
||||||
|
en 9,9 px gris). Les 27 autres sont du constat sur le passé.
|
||||||
|
- **`/workout` : 20 tuiles toutes visibles par défaut**, 100-130 valeurs avec
|
||||||
|
les tableaux. `/sleep` : 8 tuiles.
|
||||||
|
- **19 sections de navigation, 4 visibles**, avec des doublons sémantiques
|
||||||
|
réels : `/mesures` + `/body` + `/profile` pour le corps ; `/calendar` +
|
||||||
|
`/week` + `/dashboards` + `/reviews` pour la planification.
|
||||||
|
- **La place la plus accessible au pouce** — le FAB 56 px, fixe, sur toutes les
|
||||||
|
pages — **est occupée par le chat IA**, pas par « logger » ou « démarrer ».
|
||||||
|
|
||||||
|
### 6.5 Le meilleur travail d'ergonomie de l'app est éteint
|
||||||
|
|
||||||
|
`drinks-quick.html` et `meal-capture.html` ont des boutons `min-height: 44px`,
|
||||||
|
un geste unique, et un commentaire qui pose le bon raisonnement (« boire se note
|
||||||
|
vingt fois par jour ; un geste qui coûte deux navigations ne se fait pas »).
|
||||||
|
**Ils ne sont ni dans le layout par défaut, ni en production.** Les allumer dans
|
||||||
|
`_default_home_layout()` est un gain immédiat pour un coût nul.
|
||||||
|
|
||||||
|
### 6.6 La tension honnêteté / ergonomie, à trancher
|
||||||
|
|
||||||
|
Le hero affiche TSB signé, HRV en ms, FC repos + écart % — quatre grandeurs à
|
||||||
|
interpréter, dont aucune ne dit quoi faire. La seule phrase actionnable est
|
||||||
|
suivie d'un encadré de 40 mots à 11,2 px expliquant qu'aucune publication ne la
|
||||||
|
fonde.
|
||||||
|
|
||||||
|
Ce disclaimer est **juste** : c'est la règle 3ter, et sur une app de santé il
|
||||||
|
n'est pas négociable. Mais son coût est payé au pire endroit. La question de
|
||||||
|
conception est : *comment rester honnête sans occuper le premier écran ?* Le
|
||||||
|
composant existe déjà — `<details class="settings-fold">`, dont §3c du guide
|
||||||
|
décrit les deux règles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Ce qui n'a pas été audité
|
||||||
|
|
||||||
|
Par honnêteté sur la couverture :
|
||||||
|
|
||||||
|
- **Aucun build Xcode** — impossible depuis Linux. WidgetKit, SwiftUI, HealthKit
|
||||||
|
et WatchKit ne compilent pas ici. Rien de ce qui touche à l'UI native, à la
|
||||||
|
signature ou au comportement sur device n'a été vérifié autrement que par
|
||||||
|
lecture.
|
||||||
|
- **`CoachPolarBLE.swift`** (644 L, 86 commits de diagnostic entre le 11/08 et
|
||||||
|
le 20/09) n'a pas été revu. C'est le chantier le plus instable du dépôt.
|
||||||
|
- **Android** : seul le manifest a été lu. Health Connect n'est pas intégré, et
|
||||||
|
la validation Play Console reste en attente.
|
||||||
|
- **Le backend** (`coach_sportif/web`) n'a été audité que sur l'ergonomie et
|
||||||
|
deux points de conformité. Ni sécurité serveur, ni justesse des formules.
|
||||||
|
- **`web/push_notify.py`** n'a pas été lu — c'est pourtant là que se joue le
|
||||||
|
risque APNs de §5.1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Ordre de traitement proposé
|
||||||
|
|
||||||
|
**D'abord, parce que la conséquence est une décision d'intensité prise sur un
|
||||||
|
chiffre mort :**
|
||||||
|
|
||||||
|
1. **§1.1 — horodater la FC** et afficher « — » au-delà d'un seuil d'âge, des
|
||||||
|
deux côtés du pont. Correctif petit et local.
|
||||||
|
2. **§6.2 — remonter le plancher typographique** : interdire < 0,7 rem, bannir
|
||||||
|
`--text-tertiary` sous 0,78 rem. En priorité `.ap-tile__pill` (l'intensité de
|
||||||
|
séance en 8 px).
|
||||||
|
|
||||||
|
**Ensuite, la perte de données :**
|
||||||
|
|
||||||
|
3. **§2.1 + §2.2 ensemble** — même scénario, même correctif structurel : boucler
|
||||||
|
sur le lot, tenir un `beginBackgroundTask`, lire le statut HTTP comme
|
||||||
|
`CoachRoutineBridge` le fait déjà.
|
||||||
|
4. **§2.3 + §2.4** — deux faces de la même omission dans le cycle de vie de la
|
||||||
|
séance watchOS.
|
||||||
|
5. **§2.5** — une ligne.
|
||||||
|
|
||||||
|
**Puis le produit :**
|
||||||
|
|
||||||
|
6. **§6.1 — un bouton « Démarrer »** ≥ 44 px sur la tuile du jour, avec la zone
|
||||||
|
cible lisible à côté.
|
||||||
|
7. **§6.5 — allumer `drinks-quick` et `meal-capture`.** Coût nul.
|
||||||
|
8. **§6.3 — porter les cibles primaires à 44 px**, et écarter l'étoile du `+`.
|
||||||
|
|
||||||
|
**Avant soumission :**
|
||||||
|
|
||||||
|
9. Vérifier `aps-environment` dans le Summary de l'archive **et** l'endpoint
|
||||||
|
APNs du backend (§5.1).
|
||||||
|
10. Nutrition labels, Notes for Review nommant les fonctions natives, compte de
|
||||||
|
démo avec backend allumé (§5.2).
|
||||||
|
|
||||||
|
**Garde-fous, parce que « un test, pas une phrase » :**
|
||||||
|
|
||||||
|
11. Deux tests web : 44×44 sur les classes primaires, ratio de contraste des
|
||||||
|
tokens. Ce sont les seules règles du projet non verrouillées, et ce sont
|
||||||
|
celles qui ont dérivé.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Audit mené par lecture du code et des sources Apple. Les constats **[rapporté]**
|
||||||
|
proviennent de revues automatisées et n'ont pas été revérifiés ligne à ligne :
|
||||||
|
les confirmer avant d'agir.*
|
||||||
Reference in New Issue
Block a user