From 4b953c1c9d9e201b542961bc9372a18f3b8e5c7c Mon Sep 17 00:00:00 2001 From: Mr Kabir M Khalil Date: Sat, 13 Jun 2026 03:30:31 +0100 Subject: [PATCH 1/2] Fix strap reconnect after updates Updates could leave the Xcode build and installed app running at the same time, so both processes could compete for the WHOOP BLE connection. Keep the installed macOS app as the single active instance, rotate scans between WHOOP 4.0 and 5/MG when the selected service is stale, and persist the model that actually advertises. --- Strand/App/StrandApp.swift | 33 ++++++++++++++++++++++++++ Strand/BLE/BLEManager.swift | 46 +++++++++++++++++++++++++++++++++---- Strand/BLE/WhoopModel.swift | 13 ++++++++--- 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/Strand/App/StrandApp.swift b/Strand/App/StrandApp.swift index 4413b6dc..5504c697 100644 --- a/Strand/App/StrandApp.swift +++ b/Strand/App/StrandApp.swift @@ -1,7 +1,13 @@ import SwiftUI +#if os(macOS) +import AppKit +#endif @main struct StrandApp: App { + #if os(macOS) + @NSApplicationDelegateAdaptor(SingleInstanceAppDelegate.self) private var singleInstanceDelegate + #endif @StateObject private var model = AppModel() var body: some Scene { @@ -34,3 +40,30 @@ struct StrandApp: App { .menuBarExtraStyle(.window) } } + +#if os(macOS) +final class SingleInstanceAppDelegate: NSObject, NSApplicationDelegate { + func applicationWillFinishLaunching(_ notification: Notification) { + enforceSingleInstance() + } + + private func enforceSingleInstance() { + guard let bundleID = Bundle.main.bundleIdentifier else { return } + let currentPID = ProcessInfo.processInfo.processIdentifier + let currentURL = Bundle.main.bundleURL.standardizedFileURL + let installedURL = URL(fileURLWithPath: "/Applications/NOOP.app").standardizedFileURL + let otherApps = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID) + .filter { $0.processIdentifier != currentPID } + guard !otherApps.isEmpty else { return } + + if currentURL == installedURL { + otherApps.forEach { _ = $0.terminate() } + return + } + + let appToKeep = otherApps.first { $0.bundleURL?.standardizedFileURL == installedURL } ?? otherApps[0] + appToKeep.activate(options: [.activateAllWindows, .activateIgnoringOtherApps]) + NSApp.terminate(nil) + } +} +#endif diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 5d2a439e..742a1195 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -161,6 +161,11 @@ public final class BLEManager: NSObject, ObservableObject { private var keepAliveTimer: DispatchSourceTimer? static let keepAliveIntervalSeconds = 30 private var keepAliveTick = 0 + /// If a persisted/missing strap-family preference points at the wrong service, a service-filtered BLE + /// scan can run forever even though the strap is nearby. Rotate between WHOOP families after short + /// misses and persist whichever family actually advertises. + private var scanFallbackWorkItem: DispatchWorkItem? + static let scanFallbackDelaySeconds: TimeInterval = 8 /// Last time ANY notification arrived — drives the liveness watchdog. private var lastDataAt = Date() /// True while the Live screen wants the (heavy) realtime stream; keep-alive re-arms it. @@ -380,15 +385,12 @@ public final class BLEManager: NSObject, ObservableObject { } return } - log("Scanning for \(model.displayName)…") - central.scanForPeripherals( - withServices: [model.scanService], - options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] - ) + startScan(for: model, allowFallback: true) } public func disconnect() { intentionalDisconnect = true + cancelScanFallback() // A user-initiated teardown is a clean slate: clear any #80 marginal-radio fallback so the next // (manual) reconnect attempts the full R10/R11 stream again rather than inheriting old suspicion. marginalRadio.reset() @@ -1040,6 +1042,37 @@ public final class BLEManager: NSObject, ObservableObject { whoop5NotifyCharacteristics.removeAll() } + private func startScan(for model: WhoopModel, allowFallback: Bool) { + cancelScanFallback() + selectedModel = model + reassembler = Reassembler(family: model.deviceFamily) + router.family = model.deviceFamily + configureCollectorFamily() + central.stopScan() + log("Scanning for \(model.displayName)…") + central.scanForPeripherals( + withServices: [model.scanService], + options: [CBCentralManagerScanOptionAllowDuplicatesKey: false] + ) + guard allowFallback else { return } + let fallback = model.fallbackScanModel + let work = DispatchWorkItem { [weak self] in + guard let self, self.central.isScanning, !self.state.connected else { return } + self.log("No \(model.displayName) found yet — trying \(fallback.displayName)") + self.startScan(for: fallback, allowFallback: true) + } + scanFallbackWorkItem = work + DispatchQueue.main.asyncAfter( + deadline: .now() + BLEManager.scanFallbackDelaySeconds, + execute: work + ) + } + + private func cancelScanFallback() { + scanFallbackWorkItem?.cancel() + scanFallbackWorkItem = nil + } + private func enableLiveNotifications(reason: String) { guard let p = peripheral, p.state == .connected else { return } let chars = [ @@ -1194,6 +1227,8 @@ extension BLEManager: CBCentralManagerDelegate { advertisementData: [String: Any], rssi RSSI: NSNumber) { let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name ?? "unknown" + cancelScanFallback() + UserDefaults.standard.set(selectedModel.rawValue, forKey: "selectedWhoopModel") log("Discovered \(name) (rssi \(RSSI)) — connecting") central.stopScan() preparePeripheral(peripheral) @@ -1201,6 +1236,7 @@ extension BLEManager: CBCentralManagerDelegate { } public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + cancelScanFallback() restoredPeripheral = nil preparePeripheral(peripheral) state.connected = true diff --git a/Strand/BLE/WhoopModel.swift b/Strand/BLE/WhoopModel.swift index a27c068e..0c9a499e 100644 --- a/Strand/BLE/WhoopModel.swift +++ b/Strand/BLE/WhoopModel.swift @@ -1,9 +1,9 @@ import CoreBluetooth import WhoopProtocol -/// Which strap the user is pairing. The user picks this before scanning so we -/// look for exactly one device family instead of guessing — a WHOOP 4.0 scan no -/// longer waits forever on a WHOOP 5/MG wrist, and vice versa. +/// Which strap the user is pairing. The user pick remains the preferred scan +/// target, and reconnect can rotate to the other family if that preference is +/// stale after an update or restore. public enum WhoopModel: String, CaseIterable, Identifiable, Hashable { case whoop4 = "WHOOP 4.0" case whoop5mg = "WHOOP 5.0 / MG" @@ -11,6 +11,13 @@ public enum WhoopModel: String, CaseIterable, Identifiable, Hashable { public var id: String { rawValue } public var displayName: String { rawValue } + var fallbackScanModel: WhoopModel { + switch self { + case .whoop4: return .whoop5mg + case .whoop5mg: return .whoop4 + } + } + /// The protocol-layer device family this model maps to — drives framing (CRC8 vs CRC16), /// characteristic UUIDs, and the CLIENT_HELLO handshake. public var deviceFamily: DeviceFamily { From f4163f805315ca07c0715be9557bcc705c22feb7 Mon Sep 17 00:00:00 2001 From: Mr Kabir M Khalil Date: Sat, 13 Jun 2026 05:52:06 +0100 Subject: [PATCH 2/2] Make the launcher respect personal builds --- Strand/App/StrandApp.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Strand/App/StrandApp.swift b/Strand/App/StrandApp.swift index 5504c697..08cdbac5 100644 --- a/Strand/App/StrandApp.swift +++ b/Strand/App/StrandApp.swift @@ -48,10 +48,14 @@ final class SingleInstanceAppDelegate: NSObject, NSApplicationDelegate { } private func enforceSingleInstance() { + guard !ProcessInfo.processInfo.environment.keys.contains("XCTestConfigurationFilePath") else { return } guard let bundleID = Bundle.main.bundleIdentifier else { return } let currentPID = ProcessInfo.processInfo.processIdentifier let currentURL = Bundle.main.bundleURL.standardizedFileURL - let installedURL = URL(fileURLWithPath: "/Applications/NOOP.app").standardizedFileURL + let displayName = (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String) + ?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String) + ?? "NOOP" + let installedURL = URL(fileURLWithPath: "/Applications/\(displayName).app").standardizedFileURL let otherApps = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID) .filter { $0.processIdentifier != currentPID } guard !otherApps.isEmpty else { return }