Files
coach-ios/ios/App/App/MainViewController.swift
Sylvain Bettinelli 184c08a447 La séance part sur la Polar en Bluetooth, et refuse de partir mal formée
Contrainte posée aujourd'hui : coach → la montre, sans TrainingPeaks, sans
Polar Flow, sans Intervals.icu. Toutes les autres voies passent par un tiers ;
celle-ci est la seule qui reste, et elle était déjà prouvée en USB le 17/08.
Manquait le transport qui la rende utilisable — brancher la montre au Mac cinq
minutes avant de sortir n'est pas un usage.

`CoachPolarBLE` ne fait que transporter. Les octets viennent du serveur
(GET /api/plan/polar-target), produits par un encodeur verrouillé octet pour
octet contre des fichiers relus sur la montre. Rien n'est réencodé ici : le
faire perdrait la seule garantie que le firmware acceptera le fichier.

La chaîne SDK a été vérifiée sur les sources avant d'écrire une ligne, et elle
est entièrement publique — CBDeviceListenerImpl, search, openSessionDirect,
fetchGattClient(PSFTP_SERVICE), waitPsFtpReady, write(header:data:). Ni fork,
ni symbole interne.

Le morceau qui compte vraiment est ailleurs. `PolarPftpStep` est séparé du
plugin et n'importe que Foundation, pour être exécuté sur Linux : il porte
l'en-tête PbPFtpOperation et les deux règles qui décident si une requête part.
Le 17/08, un PUT de 174 octets vers un chemin terminé par « / » a bloqué la
montre — écran noir, appui long sur OK pour la récupérer. Le firmware ne refuse
pas, il s'effondre, et le Bluetooth utilise le même protocole. Ces règles ne
peuvent donc pas être vérifiées « à la relecture » : 12 tests les exécutent,
dont celui qui interdit de reporter ici le cadrage série [0x05, taille, taille]
de la version USB — en BLE c'est le SDK qui cadre, l'ajouter produirait
précisément une requête malformée.

L'en-tête produit par Swift a été comparé à celui de polar_ftp.py sur trois
chemins, dont un de 205 caractères pour le varint sur deux octets : mêmes
octets. 69 tests au vert sur tests-linux.

Un mode dryRun construit et décrit chaque requête sans rien émettre — le
--dry-run qui manquait le jour du plantage. Le runbook Mac dit de s'en servir au
premier essai, et liste les trois inconnues qui ne se lèveront que montre en
main, la première étant : la V3 accepte-t-elle une connexion BLE tierce pendant
qu'elle est appairée à Flow ?

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 07:06:53 +00:00

126 lines
6.6 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// MainViewController.swift
// Sous-classe de CAPBridgeViewController pour enregistrer manuellement les
// plugins Capacitor in-app (i.e. plugins définis directement dans le target
// App, pas en npm package). Capacitor 8 n'auto-découvre pas ces plugins,
// faut les registrer ici dans capacitorDidLoad().
import UIKit
import Capacitor
import WebKit
class MainViewController: CAPBridgeViewController {
override func capacitorDidLoad() {
// Plugin natif WorkoutKit — pousse des workouts intervalles vers
// l'Apple Watch (cf. CoachWorkoutKit.swift).
bridge?.registerPluginInstance(CoachWorkoutKitPlugin())
// Plugin natif route GPS HealthKit — récupère la trace des workouts
// Apple Watch (HKWorkoutRouteQuery, cf. CoachHealthRoute.swift).
bridge?.registerPluginInstance(CoachHealthRoutePlugin())
// Plugin natif observer HealthKit — détecte fin de séance Watch et
// push notif locale "🎉 Séance terminée" (cf. CoachWorkoutObserver.swift).
bridge?.registerPluginInstance(CoachWorkoutObserverPlugin())
// Plugin natif live workout — reçoit les samples temps réel de l'app
// watchOS CoachWatch via WatchConnectivity, relaie au JS (event
// "liveSample") pour la page /live (cf. CoachLiveBridge.swift).
bridge?.registerPluginInstance(CoachLiveBridgePlugin())
// Plugin natif Sign in with Apple — signIn() natif (AuthenticationServices)
// → identity_token POSTé sur /auth/apple/web (cf. CoachAppleAuth.swift).
bridge?.registerPluginInstance(CoachAppleAuthPlugin())
// Plugin natif Sign in with Google — signIn() via SDK GoogleSignIn
// → id_token POSTé sur /auth/google/web (cf. CoachGoogleAuth.swift).
bridge?.registerPluginInstance(CoachGoogleAuthPlugin())
// Pont widgets : la WebView pousse les données du jour vers l'App Group
// et rafraîchit les widgets d'écran d'accueil (cf. CoachWidgetBridge.swift).
bridge?.registerPluginInstance(CoachWidgetBridgePlugin())
// Pont routine quotidienne : pousse les blocs + l'état du jour vers
// l'app watchOS, et relaie les coches faites sur la montre vers
// /api/routine/day (cf. CoachRoutineBridge.swift).
bridge?.registerPluginInstance(CoachRoutineBridgePlugin())
// Écriture d'un objectif sur la Polar Vantage V3 en Bluetooth (PFTP).
// Ne transporte que des octets produits par le serveur, et refuse toute
// requête malformée avant d'ouvrir la radio — le firmware plante au lieu
// de refuser (cf. CoachPolarBLE.swift et PolarPftpStep.swift).
bridge?.registerPluginInstance(CoachPolarBLEPlugin())
}
/// Injecte le cookie d'auth dans le magasin de la WKWebView.
///
/// ⚠️ `AppDelegate.injectCoachAuthCookie()` écrit dans
/// `HTTPCookieStorage.shared` — c'est le store d'URLSession, PAS celui que
/// lit la WKWebView. Ça a marché jusqu'en mai parce que Capacitor recopiait
/// alors les cookies au démarrage ; ce n'est plus le cas, d'où l'écran de
/// login qui réapparaissait (constaté sur device le 2026-08-03).
///
/// Les deux injections sont conservées : `HTTPCookieStorage.shared` reste
/// utile aux appels natifs via URLSession.
private func injectAuthCookieIntoWebView() {
let token = CoachAuth.kCoachWebToken
guard !token.isEmpty, token != "REPLACE_ME", let webView = self.webView else { return }
let store = webView.configuration.websiteDataStore.httpCookieStore
store.getAllCookies { cookies in
// Ne PAS écraser une session per-user existante (login Apple/Google).
if cookies.contains(where: {
$0.name == "coach_web_token" && $0.domain.contains("coach.hypnotruck.ch")
}) { return }
let oneYear: TimeInterval = 60 * 60 * 24 * 365
guard let cookie = HTTPCookie(properties: [
.name: "coach_web_token",
.value: token,
.domain: "coach.hypnotruck.ch",
.path: "/",
.secure: true,
.expires: Date(timeIntervalSinceNow: oneYear),
]) else { return }
store.setCookie(cookie) {
// La première requête est déjà partie sans cookie (on a atterri
// sur /login) : il faut recharger pour la rejouer avec.
// Pas de boucle possible — au prochain passage le cookie existe
// et on sort au `return` ci-dessus.
DispatchQueue.main.async { webView.reload() }
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
injectAuthCookieIntoWebView()
// Force le background noir sur tous les niveaux pour éliminer la
// bande blanche du bounce iOS WKWebView (cause : scrollView.backgroundColor
// par défaut blanc, exposé lors de l'overscroll). Aucun CSS web ne peut
// modifier ça — il FAUT le faire côté natif.
view.backgroundColor = .black
webView?.isOpaque = false
webView?.backgroundColor = .black
webView?.scrollView.backgroundColor = .black
// Permet l'inspection de la WKWebView depuis Safari Web Inspector
// (Mac : menu Développement → iPhone → coach.hypnotruck.ch) y compris
// pour les builds TestFlight. iOS 16.4+ requis. Sans ça, l'app
// n'apparaît pas dans la liste des cibles inspectables.
if #available(iOS 16.4, *) {
webView?.isInspectable = true
}
// Désactive le rebond élastique iOS du WKWebView (en haut + en bas).
// Sans ça, quand l'user arrive en bas/haut de page et continue à
// swiper, le contenu rebondit visuellement → les éléments
// `position: fixed` (notre bottom tab bar #bottom-tabs) suivent
// le rebond et semblent "remonter" hors de l'écran momentanément.
// Solution Apple-recommandée pour app Capacitor : désactiver bounces
// au niveau UIScrollView native (plus fiable que preventDefault JS).
webView?.scrollView.bounces = false
webView?.scrollView.alwaysBounceVertical = false
webView?.scrollView.alwaysBounceHorizontal = false
// Active le geste natif iOS swipe-back/forward depuis le bord (Apple HIG
// standard). Permet de retirer les flèches "back" web (pattern Bevel/
// Whoop/Oura : navigation 100% via tabs du bas, drill-downs gérés par
// swipe-edge natif). Distinct du swipe-to-delete (qui démarre depuis
// le corps des cartes, pas du bord d'écran).
webView?.allowsBackForwardNavigationGestures = true
}
}