Vérifié dans les deux dépôts : web/push_notify.py envoie via ntfy, en se servant du token du device comme nom de topic. Aucun enregistrement de token APNs côté serveur (recherche registerToken / /api/devices / device_token : zéro résultat), et le web n'appelle jamais PushNotifications de Capacitor (zéro résultat). La moitié native est prête — plugin installé, aps-environment déclaré, remote-notification dans UIBackgroundModes — et personne ne la branche. Pour recevoir un rappel, il faut installer l'app ntfy et s'abonner à un topic. Deux conséquences. Le README liste « ✅ Push notifications natives » comme PREMIER argument de sa section anti-rejet Guideline 4.2 : l'argument ne tient pas si un examinateur teste les rappels, et 4.2 est justement le risque de cette architecture. Et une capability déclarée mais inutilisée tombe sous 2.3.1(a) (« hidden, dormant, or undocumented features »). En contrepartie, le risque APNs que l'audit supposait en § 5.1 (token production envoyé au sandbox, notification jetée silencieusement) est sans objet : il n'y a pas d'APNs. Un item de moins, un plus sérieux à la place. La décision est produit avant d'être technique : brancher la chaîne APNs, ou assumer ntfy et retirer plugin + entitlement + la ligne du README. L'état actuel est le seul qui cumule les inconvénients des deux. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
525 lines
28 KiB
Markdown
525 lines
28 KiB
Markdown
# 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 risque APNs supposé est sans objet [vérifié le 2026-09-22]** : il n'y a
|
||
pas d'APNs. `web/push_notify.py` envoie via **ntfy**, en utilisant le `token`
|
||
du device comme *nom de topic*. L'en-tête du fichier annonce « V2+ : remplacer
|
||
par APNs (iOS) + FCM (Android) » — ce n'est pas fait. Voir §5.1bis, qui est le
|
||
vrai sujet.
|
||
|
||
### 5.1bis [vérifié] La chaîne de notifications push natives n'existe pas de bout en bout
|
||
|
||
Trois constats, chacun vérifié par recherche dans les deux dépôts :
|
||
|
||
1. `web/push_notify.py:47-52` — le canal est **ntfy**. `platform == "web"`
|
||
rend `False` (« Web Push : à implémenter (V2) »).
|
||
2. **Aucun enregistrement de token APNs côté serveur.** Recherche de
|
||
`registerToken` / `/api/devices` / `device_token` dans `web/` : **zéro
|
||
résultat**.
|
||
3. **Le web n'appelle jamais `PushNotifications`** de Capacitor. Recherche dans
|
||
`web/**/*.js` et `web/**/*.html` : **zéro résultat**.
|
||
|
||
Côté natif, tout est pourtant en place : `@capacitor/push-notifications` est
|
||
installé, `aps-environment` est déclaré, `UIBackgroundModes` contient
|
||
`remote-notification`. **La moitié native est prête et personne ne la branche.**
|
||
Pour recevoir un rappel, l'utilisateur doit installer l'app **ntfy** et
|
||
s'abonner à un topic — ce n'est pas une notification de l'app.
|
||
|
||
Deux conséquences :
|
||
|
||
- **Le README se trompe.** Il liste « ✅ Push notifications natives (rappel
|
||
séances) — implémenté côté iOS » **dans la section anti-rejet Guideline
|
||
4.2**. C'est le premier argument de la liste, et il ne tient pas : un
|
||
examinateur qui teste les rappels ne verra rien. Or 4.2 est précisément le
|
||
risque de cette architecture wrapper (§5.3).
|
||
- **Une capability déclarée et inutilisée** est au mieux inerte. Apple 2.3.1(a) :
|
||
« Don't include any hidden, dormant, or undocumented features in your app ».
|
||
|
||
**Décision à prendre**, et elle est produit avant d'être technique : soit
|
||
brancher la chaîne APNs (demande de permission + récupération du token côté
|
||
web, endpoint d'enregistrement, envoi via APNs côté serveur), soit assumer ntfy
|
||
et **retirer le plugin, l'entitlement et la ligne du README**. La situation
|
||
actuelle — déclarer une capability qu'on n'utilise pas et l'annoncer comme
|
||
implémentée — est la seule qui cumule les inconvénients des deux.
|
||
|
||
### 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.
|
||
- **Le contenu de la politique de confidentialité** n'a pas été relu contre les
|
||
trois exigences de 5.1.1(i) — son existence a été vérifiée, pas son contenu.
|
||
|
||
*(`web/push_notify.py` a été lu après coup : voir §5.1bis, qui remplace le
|
||
risque APNs initialement supposé.)*
|
||
|
||
---
|
||
|
||
## 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. **Trancher la question des push (§5.1bis)** — brancher APNs, ou assumer ntfy
|
||
et retirer plugin + entitlement + la ligne du README. C'est une décision
|
||
produit, et elle conditionne l'argument anti-4.2. Puis vérifier
|
||
`aps-environment` dans le Summary de l'archive (§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.*
|