// CoachPolarBLE.swift // Écrit une séance du plan sur une Polar Vantage V3, en Bluetooth, sans cloud // ni compte ni API tierce. // // POURQUOI CE PLUGIN EXISTE // // L'écriture d'un objectif sur la montre par le protocole PFTP est prouvée // depuis le 2026-08-17 : la montre accepte le fichier et l'affiche. Mais elle // ne l'était qu'en USB, donc il fallait brancher la montre à un Mac cinq // minutes avant de sortir — ce qui n'est pas un usage. // // Le Bluetooth n'est donc pas un confort : c'est ce qui rend le chemin // utilisable. Et c'est le seul chemin qui satisfasse la contrainte posée le // 2026-08-31 — `coach → la montre`, sans TrainingPeaks, sans Polar Flow, sans // Intervals.icu. Toutes les autres voies passent par quelqu'un d'autre. // // CE QUE CE PLUGIN FAIT, ET CE QU'IL NE FAIT PAS // // Il transporte. Rien d'autre. Les octets viennent du serveur // (`GET /api/plan/polar-target`), qui les produit avec un encodeur verrouillé // octet pour octet contre des fichiers relus sur la montre. **Ne jamais // réencoder un objectif ici** : ce serait perdre la seule garantie dont on // dispose que le firmware acceptera le fichier. // // ⚠️ LE FIRMWARE PLANTE SUR REQUÊTE MALFORMÉE // // Le 2026-08-17, un PUT de 174 octets vers un chemin terminé par « / » — donc // « écrire du contenu dans un dossier » — a bloqué une Vantage V3 : logo Polar // puis écran noir, récupérée par un appui long sur OK. Le firmware ne refuse // pas proprement, il s'effondre. Le BLE utilise LE MÊME protocole PFTP : le // risque est identique ici. // // D'où deux verrous, repris de `tools/polar/polar_ftp.py` : // - un chemin terminé par « / » est un DOSSIER : contenu obligatoirement vide ; // - un chemin sans « / » final est un FICHIER : contenu obligatoirement non vide. // Et un mode `dryRun` qui construit chaque requête et la décrit sans rien // émettre — l'équivalent du `--dry-run` qui a manqué le jour du plantage. // // CHAÎNE SDK — vérifiée sur les sources le 2026-08-31, tout est public // // CBDeviceListenerImpl(queue, clients:identifier:) ble/endpoints/corebluetooth/central // listener.search(_:identifiers:fetchKnownDevices:) -> AnyPublisher // listener.openSessionDirect(_:) // session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE) // CBUUID("FEEE") // client.waitPsFtpReady(_:) async throws // client.write(_ header: NSData, data: InputStream) -> AsyncThrowingStream // // Aucun fork, aucun symbole interne. // // ⚠️ Le cadrage série `[0x05, taille, taille]` de la version USB N'A PAS SA // PLACE ICI. Il appartient au transport RFC76 sur CDC-ACM ; en Bluetooth c'est // le SDK qui s'en charge. On ne passe que l'en-tête PbPFtpOperation et les // données brutes. Reporter le cadrage produirait une requête malformée — c'est- // à-dire précisément ce qui plante la montre. // // USAGE CÔTÉ JS // // await window.Capacitor.Plugins.CoachPolarBLE.isAvailable() // → { available: bool } // // await window.Capacitor.Plugins.CoachPolarBLE.send({ // mkdir: ['/U/0/20260831/TST/', '/U/0/20260831/TST/180000/'], // dir: '/U/0/20260831/TST/180000/', // files: [{ name: 'TST.BPB', b64: '...' }, { name: 'ID.BPB', b64: '...' }], // dryRun: true, // par défaut FALSE, mais à utiliser au premier essai // timeoutSec: 30, // }) // → { sent: bool, dryRun: bool, log: [string] } // // Le corps de `send` est exactement la réponse de `/api/plan/polar-target` : // le JS n'a rien à recomposer. import Foundation import Capacitor #if canImport(PolarBleSdk) import PolarBleSdk import CoreBluetooth import Combine #endif @objc(CoachPolarBLEPlugin) public class CoachPolarBLEPlugin: CAPPlugin, CAPBridgedPlugin { // CAPBridgedPlugin : sans cette déclaration explicite, Capacitor 8 // n'expose pas les méthodes au bridge et `Capacitor.Plugins.CoachPolarBLE` // reste undefined (même piège que CoachWorkoutKit). public let identifier = "CoachPolarBLEPlugin" public let jsName = "CoachPolarBLE" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "isAvailable", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "send", returnType: CAPPluginReturnPromise), ] @objc func isAvailable(_ call: CAPPluginCall) { #if canImport(PolarBleSdk) call.resolve(["available": true]) #else call.resolve(["available": false, "reason": "PolarBleSdk absent de la cible — ajouter le paquet dans Xcode"]) #endif } @objc func send(_ call: CAPPluginCall) { guard let etapes = Self.parseSteps(call) else { call.reject("payload invalide : `dir`, `files[]` (name + b64) requis") return } let dryRun = call.getBool("dryRun") ?? false let timeout = call.getDouble("timeoutSec") ?? 30 // Les verrous d'abord, la radio ensuite. Une requête refusée ici est une // montre qui ne plante pas. do { try etapes.forEach { try $0.validate() } } catch { call.reject("\(error)") return } if dryRun { call.resolve(["sent": false, "dryRun": true, "log": etapes.map { $0.describe }]) return } #if canImport(PolarBleSdk) Task { do { let journal = try await PolarPsFtpWriter().ecrire(etapes, timeout: timeout) call.resolve(["sent": true, "dryRun": false, "log": journal]) } catch { call.reject("\(error)") } } #else call.reject("PolarBleSdk absent de la cible — ajouter le paquet dans Xcode") #endif } /// Traduit le corps JS en étapes PFTP, dans l'ordre où elles doivent partir. static func parseSteps(_ call: CAPPluginCall) -> [PftpStep]? { guard let dir = call.getString("dir"), !dir.isEmpty else { return nil } var etapes: [PftpStep] = [] // Les dossiers d'abord, du parent vers l'enfant : `/U/0//TST/` puis // `/`. Aucun des deux n'existe d'avance pour une date neuve, et un // mkdir dans le désordre échoue. for chemin in call.getArray("mkdir", String.self) ?? [] { etapes.append(PftpStep(path: chemin, data: Data())) } guard let fichiers = call.getArray("files") as? [[String: Any]], !fichiers.isEmpty else { return nil } for fichier in fichiers { guard let nom = fichier["name"] as? String, let b64 = fichier["b64"] as? String, let octets = Data(base64Encoded: b64) else { return nil } etapes.append(PftpStep(path: dir + nom, data: octets)) } return etapes } } // MARK: - Le transport #if canImport(PolarBleSdk) /// Ouvre une session PsFTP sur la première montre qui répond, et écrit. /// /// ⚠️ Le filtrage se fait sur « expose PsFTP », pas sur le nom ni l'identifiant /// annoncé : ce que la V3 met dans son advertisement n'a pas été observé, et /// s'appuyer dessus serait une supposition. À resserrer une fois qu'on l'aura vu /// — en attendant, un capteur Polar (H10) ne portant pas PsFTP est écarté de /// lui-même. final class PolarPsFtpWriter { private let queue = DispatchQueue(label: "ch.hypnotruck.coach.polarble") private var abonnements = Set() /// Le publisher de `search()` a-t-il émis quoi que ce soit — valeur, fin ou /// erreur ? S'il reste muet, `monitorBleState()` n'a jamais émis /// `.poweredOn` et le scan n'a jamais démarré : ce n'est pas « aucun /// appareil », c'est « on n'a jamais cherché ». private var publisherAParle = false private var derniereErreurSdk: String? func ecrire(_ etapes: [PftpStep], timeout: Double) async throws -> [String] { // ⚠️ PRÉAMBULE INDISPENSABLE — sans lui, rien ne se passe et rien ne le // dit. `CBDeviceListenerImpl.search()` commence par // `monitorBleState().filter { $0 == .poweredOn }` : tant que cet état // n'arrive pas, le flux n'émet jamais, le scan ne démarre pas, et on // conclut « aucun appareil » au bout du timeout. // // Or l'état ne peut arriver que si un `CBCentralManager` existe — et // c'est sa création qui déclenche l'alerte d'autorisation d'iOS. // Mesuré le 2026-08-31 : le scan a tourné 30 s sans qu'iOS demande quoi // que ce soit, et « Bluetooth » n'apparaissait même pas dans les // réglages de l'app. L'autorisation n'avait jamais été sollicitée. // // On crée donc le manager nous-mêmes, on attend son premier état, et on // traduit ce qu'il dit — au lieu de laisser un silence passer pour une // absence de montre. let sonde = SondeBluetooth() try await sonde.attendreEtatUtilisable(timeout: min(timeout, 15)) let listener = CBDeviceListenerImpl( queue, clients: [{ transport in BlePsFtpClient(gattServiceTransmitter: transport) }], identifier: 1) // ⚠️ NE PAS FILTRER À L'AVEUGLE — on trie après avoir vu. // // Une version précédente posait `scanPreFilter` sur « polarDeviceId non // vide OU nom contenant polar ». Si la Vantage ne s'annonce pas ainsi, // elle était rejetée par NOUS, aucune session n'était créée, et le // publisher de `search()` ne disait rien — ce que le message // interprétait à tort comme « le scan n'a pas démarré ». // // On laisse donc tout remonter et on trie dans la boucle : les appareils // qui ressemblent à du Polar d'abord, et on n'ouvre de session que sur // ceux-là. Si aucun ne ressemble, on RAPPORTE ce qu'on a vu au lieu de // conclure. // ⚠️ RÉVEILLER LA LAZY, PUIS ATTENDRE — dans cet ordre, et avant tout // abonnement. // // `CBDeviceListenerImpl.manager` est une `lazy var` : le // CBCentralManager n'existe qu'au premier accès, et `search()` ne le // touche jamais avant son filtre `$0 == .poweredOn`. Sans réveil, on // attend un état que rien ne peut produire. // // Deux détails, tirés des sources, qui décident du succès : // // 1. `bleStateSubject` est un **CurrentValueSubject**, initialisé à // `.unknown`. Il REJOUE sa valeur à chaque abonnement — inutile donc // d'être abonné au moment de l'émission. Une version précédente // réveillait après l'abonnement « pour ne pas rater l'événement » : // raisonnement de PassthroughSubject, faux ici, et qui exposait au // piège suivant. // // 2. `centralManagerDidUpdateState` fait // `BleState(rawValue: self.manager.state.rawValue)` — il accède à la // lazy DEPUIS le délégué. Si CoreBluetooth appelle le délégué avant // que l'initialisation de la lazy soit terminée, la propriété se // réentre. D'où : réveiller tôt, hors de tout abonnement, et laisser // l'état se poser. // // ⚠️ ET SURTOUT : attendre le SUJET, pas l'état du manager. // // `blePowered()` lit `manager.state` — l'état de CoreBluetooth. Mais // `search()` filtre sur `bleStateSubject`, qui n'est alimenté que par // `centralManagerDidUpdateState`. Le manager peut donc être allumé // (`blePowered()` vrai) sans que le délégué ait encore publié quoi que // ce soit : le sujet reste à `.unknown`, le filtre bloque, et le // publisher se tait. C'est exactement ce qui a été mesuré le // 2026-08-31, y compris après avoir attendu `blePowered()`. // // On attend donc sur `monitorBleState()`, qui est public et qui EST la // source lue par `search()`. `blePowered()` ne sert plus qu'à une // chose : instancier la lazy pour que le délégué puisse tourner. _ = listener.blePowered() let etat = await listener.premierEtatPret(timeout: min(timeout, 12)) if !etat { throw PolarPftpError.bluetoothUnusable( "le listener du SDK Polar n'a jamais publié l'état « prêt » " + "(son délégué n'a pas été appelé). Bluetooth actif côté " + "système, mais inutilisable par le SDK.") } let (client, session) = try await trouverClient(listener, timeout: timeout, sonde: sonde) defer { listener.closeSessionDirect(session) } var journal: [String] = [] for etape in etapes { // Revalidé juste avant l'émission : entre la validation d'entrée et // ce point, rien ne doit avoir introduit un chemin de dossier avec // du contenu. try etape.validate() try await put(client, etape) journal.append("✔ " + etape.describe) } return journal } private func trouverClient(_ listener: CBDeviceListenerImpl, timeout: Double, sonde: SondeBluetooth) async throws -> (BlePsFtpClient, BleDeviceSession) { let debut = Date() var vues = 0 // appareils BLE aperçus, tous confondus var candidats = 0 // ceux qui ressemblent à un Polar var noms: [String] = [] var sansPsFtp = 0 // aperçus mais ne portant pas le service FEEE var muettes = 0 // portant FEEE mais dont waitPsFtpReady échoue // ⚠️ UN SEUL ABONNEMENT, SUR TOUTE LA DURÉE — pas des fenêtres. // // Non pas parce qu'une émission serait ratée — `bleStateSubject` est un // CurrentValueSubject, il rejoue sa valeur à chaque abonnement — mais // parce que chaque abonnement relance le cycle `addClient()` / // `removeClient()` du scanner. Redémarrer un scan toutes les 4 s // l'empêche de découvrir quoi que ce soit, et la découverte BLE demande // plusieurs secondes ininterrompues. // // L'état, lui, est acquis avant d'arriver ici : `blePowered()` a été // sondé jusqu'à devenir vrai. do { // `AllowDuplicates` est armé côté SDK : le même appareil revient à // chaque advertisement (4020 émissions pour une poignée d'appareils // réels). On déduplique avant de compter et d'ouvrir quoi que ce soit. var dejaVues = Set() let toutes = try await sessionsVues(listener, fenetre: timeout) .filter { dejaVues.insert(Self.etiquette($0)).inserted } vues = toutes.count noms = toutes.map { Self.etiquette($0) } // Ouvrir une session coûte plusieurs secondes : on ne les tente que // sur ce qui ressemble à un Polar, sinon 43 appareils feraient des // minutes d'attente (mesuré, avec des erreurs sur l'Apple Watch). let sessions = toutes.filter { Self.ressembleAPolar($0) } candidats = sessions.count for session in sessions { // Marge au-delà du scan : ouvrir une session prend du temps, et // couper ici ferait échouer la seule montre trouvée. if Date().timeIntervalSince(debut) > timeout * 2 { break } listener.openSessionDirect(session) guard let client = session.fetchGattClient(BlePsFtpClient.PSFTP_SERVICE) as? BlePsFtpClient else { sansPsFtp += 1 listener.closeSessionDirect(session) continue } do { // ⚠️ `waitPsFtpReady` n'a AUCUNE limite de temps. Si la // montre ne finit pas la négociation — canal déjà pris, // écran éteint, appairage en cours — l'attente ne rend // jamais la main et l'envoi paraît figé, sans message. // Constaté le 2026-08-31 : « envoi » tournant sans fin. // On lui donne donc une échéance, et un dépassement compte // comme une montre muette : c'est ce qu'il est. try await Self.avecEcheance(seconds: 12) { try await client.waitPsFtpReady(true) } return (client, session) } catch { muettes += 1 listener.closeSessionDirect(session) } } } if vues > 0 && candidats == 0 { // Le scan marche, mais rien ne ressemble à un Polar. Dire ce qu'on // a vu : c'est la seule façon de savoir sous quel nom la montre // s'annonce — ou si elle ne s'annonce pas du tout. throw PolarPftpError.aucunPolarParmi(vues: vues, exemples: Array(noms.prefix(8))) } if vues == 0 { // Le SDK n'a rien remonté : est-ce la radio, ou notre usage du SDK ? // Un scan nu tranche, et son résultat part dans le message. let bruts = await sonde.compterAppareils(pendant: 6) throw PolarPftpError.sdkSilencieux(vusParCoreBluetooth: bruts, publisherAParle: publisherAParle, erreurSdk: derniereErreurSdk) } throw PolarPftpError.watchNotFound(timeout, vues: vues, sansPsFtp: sansPsFtp, muettes: muettes) } /// Les appareils vus pendant une fenêtre de recherche, appairés compris. private func sessionsVues(_ listener: CBDeviceListenerImpl, fenetre: Double) async throws -> [BleDeviceSession] { try await withCheckedThrowingContinuation { suite in var vues: [BleDeviceSession] = [] var rendu = false let rendre = { guard !rendu else { return } rendu = true suite.resume(returning: vues) } // ⚠️ PASSER L'UUID DU SERVICE — c'est ce qui trouve une montre DÉJÀ // CONNECTÉE. // // Mesuré le 2026-08-31 : 4020 advertisements vus (HomePod, Furbo…), // aucune Polar. Normal — **un appareil BLE connecté cesse // d'émettre**. La Vantage est liée à l'iPhone par l'app Flow : elle // est donc invisible au scan, par conception, et l'attendre était // sans espoir. // // Avec un `uuids` non nil, `search()` emprunte une autre branche : // `manager.retrieveConnectedPeripherals(withServices: uuids!)`, qui // rend les périphériques déjà connectés au système exposant ce // service, et leur fabrique une session. Passer `nil` — ce que // faisait ce code — n'exécutait jamais cette branche. listener.search([BlePsFtpClient.PSFTP_SERVICE], identifiers: nil, fetchKnownDevices: true) .sink(receiveCompletion: { [weak self] fin in // Distinguer « le publisher a terminé » de « il n'a // jamais rien dit » : dans le second cas, `flatMap` // n'a pas été atteint, donc `monitorBleState()` n'a // jamais émis `.poweredOn` — le scan n'a pas démarré. if case .failure(let e) = fin { self?.derniereErreurSdk = String(describing: e) } self?.publisherAParle = true rendre() }, receiveValue: { [weak self] session in self?.publisherAParle = true vues.append(session) }) .store(in: &abonnements) // La recherche ne se termine pas d'elle-même : on lui donne une // fenêtre, puis on travaille avec ce qu'on a vu. queue.asyncAfter(deadline: .now() + fenetre) { rendre() } } } /// Ce qu'on peut dire d'un appareil vu, pour l'afficher. static func etiquette(_ session: BleDeviceSession) -> String { let c = session.advertisementContent let nom = c.name.isEmpty ? "(sans nom)" : c.name return c.polarDeviceId.isEmpty ? nom : "\(nom) [\(c.polarDeviceId)]" } /// Vrai si l'appareil a une chance d'être une montre Polar. /// /// ⚠️ Volontairement LARGE : mieux vaut tenter une session de trop que /// d'exclure la montre et conclure qu'elle n'existe pas. static func ressembleAPolar(_ session: BleDeviceSession) -> Bool { let c = session.advertisementContent if !c.polarDeviceId.isEmpty { return true } if !c.polarDeviceType.isEmpty { return true } let nom = c.name.lowercased() return nom.contains("polar") || nom.contains("vantage") || nom.contains("grit") } /// Attend qu'une condition devienne vraie, en la sondant. Rend faux au bout /// du délai. /// /// ⚠️ Conservé pour d'autres usages, mais NE PAS s'en servir pour l'état /// BLE : `blePowered()` lit le manager, pas le sujet que `search()` écoute. /// /// Sonder plutôt qu'écouter : l'état du SDK se lit (`blePowered()`), et /// c'est justement cette lecture qui instancie le CBCentralManager. Le /// premier appel réveille, les suivants observent. static func attendre(seconds: Double, _ condition: @escaping () -> Bool) async -> Bool { let echeance = Date().addingTimeInterval(seconds) while Date() < echeance { if condition() { return true } try? await Task.sleep(nanoseconds: 200_000_000) } return condition() } /// Court `operation` avec une échéance, et lève si elle est dépassée. /// /// Le SDK Polar rend des `async` sans limite de temps : une montre qui ne /// répond pas fige l'appel indéfiniment. Un envoi qui n'aboutit pas doit /// dire pourquoi, pas tourner en silence. static func avecEcheance(seconds: Double, _ operation: @escaping @Sendable () async throws -> T) async throws -> T { try await withThrowingTaskGroup(of: T.self) { groupe in groupe.addTask { try await operation() } groupe.addTask { try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) throw PolarPftpError.echeanceDepassee(seconds) } guard let premier = try await groupe.next() else { throw PolarPftpError.echeanceDepassee(seconds) } groupe.cancelAll() return premier } } private func put(_ client: BlePsFtpClient, _ etape: PftpStep) async throws { let entree = InputStream(data: etape.data) // Même raison que pour waitPsFtpReady : une écriture qui n'aboutit pas // doit échouer, pas figer l'app. // `write` rend un flux de progression ; on le consomme jusqu'au bout, // et l'absence d'erreur vaut acquittement — c'est l'équivalent BLE du // `05 00 00` observé en USB. try await Self.avecEcheance(seconds: 20) { for try await _ in client.write(etape.header() as NSData, data: entree) {} } } } #endif /// Vérifie que le Bluetooth est utilisable, et dit pourquoi il ne l'est pas. /// /// Ne scanne rien : elle instancie un `CBCentralManager` et lit son premier /// état. C'est cette instanciation qui provoque la demande d'autorisation iOS /// — et donc l'apparition de la ligne « Bluetooth » dans les réglages de l'app. private final class SondeBluetooth: NSObject, CBCentralManagerDelegate { private var manager: CBCentralManager? private var suite: CheckedContinuation? private var repondu = false private var vus = Set() func attendreEtatUtilisable(timeout: Double) async throws { let etat = await withCheckedContinuation { (c: CheckedContinuation) in suite = c // `showPowerAlert: false` : c'est nous qui expliquons, pas une // alerte système au milieu d'un envoi. manager = CBCentralManager(delegate: self, queue: nil, options: [CBCentralManagerOptionShowPowerAlertKey: false]) DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { [weak self] in self?.repondre(self?.manager?.state ?? .unknown) } } switch etat { case .poweredOn: return case .unauthorized: throw PolarPftpError.bluetoothUnusable( "coach n'a pas l'autorisation d'utiliser le Bluetooth. " + "Réglages → coach → activer Bluetooth.") case .poweredOff: throw PolarPftpError.bluetoothUnusable( "le Bluetooth est désactivé sur l'iPhone.") case .unsupported: throw PolarPftpError.bluetoothUnusable( "cet appareil ne prend pas en charge le Bluetooth LE.") default: throw PolarPftpError.bluetoothUnusable( "le Bluetooth n'a pas répondu (état « \(etat.rawValue) »). " + "Réessayer ; si ça persiste, redémarrer l'app.") } } /// Compte les appareils BLE qu'un scan CoreBluetooth NU voit, sans le SDK. /// /// Sert à trancher un diagnostic, pas à travailler : si CoreBluetooth voit /// des appareils et que le SDK n'en remonte aucun, le problème est dans /// notre usage du SDK ; si les deux voient zéro, il est dans la radio ou /// l'environnement. Sans cette mesure on ne peut que deviner — ce qui a /// coûté trois allers-retours le 2026-08-31. func compterAppareils(pendant: Double) async -> Int { vus.removeAll() manager?.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) try? await Task.sleep(nanoseconds: UInt64(pendant * 1_000_000_000)) manager?.stopScan() return vus.count } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { vus.insert(peripheral.identifier) } private func repondre(_ etat: CBManagerState) { guard !repondu else { return } repondu = true suite?.resume(returning: etat) suite = nil } func centralManagerDidUpdateState(_ central: CBCentralManager) { // `.unknown` est l'état transitoire du démarrage : ne pas conclure // dessus, le vrai état suit. if central.state != .unknown { repondre(central.state) } } } extension CBDeviceListenerImpl { /// Vrai dès que le listener PUBLIE l'état `.poweredOn` — c'est-à-dire dès /// que `search()` pourra franchir son filtre. /// /// `monitorBleState()` rend un `CurrentValueSubject`, donc l'abonnement /// reçoit immédiatement la valeur courante (`.unknown` au départ) puis les /// suivantes. On attend la première qui vaut `.poweredOn`. /// Porte son échéance elle-même plutôt que d'être enveloppée : le listener /// n'est pas `Sendable`, et le faire traverser une TaskGroup se heurterait /// à la concurrence stricte de Swift 6. func premierEtatPret(timeout: Double) async -> Bool { await withCheckedContinuation { suite in let boite = BoiteJeton() boite.jeton = monitorBleState() .sink(receiveCompletion: { _ in boite.rendre(false, suite) }, receiveValue: { etat in if etat == .poweredOn { boite.rendre(true, suite) } }) DispatchQueue.main.asyncAfter(deadline: .now() + timeout) { boite.rendre(false, suite) } } } } /// Garde l'abonnement en vie et garantit une reprise unique de la continuation /// — la reprendre deux fois est un crash, et il y a ici deux chemins de sortie /// concurrents : l'état publié et l'échéance. private final class BoiteJeton: @unchecked Sendable { var jeton: AnyCancellable? private var rendu = false private let verrou = NSLock() func rendre(_ valeur: Bool, _ suite: CheckedContinuation) { verrou.lock() defer { verrou.unlock() } guard !rendu else { return } rendu = true suite.resume(returning: valeur) jeton?.cancel() jeton = nil } }