diff --git a/Strand/App/StrandApp.swift b/Strand/App/StrandApp.swift index 4413b6dc..08cdbac5 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,34 @@ struct StrandApp: App { .menuBarExtraStyle(.window) } } + +#if os(macOS) +final class SingleInstanceAppDelegate: NSObject, NSApplicationDelegate { + func applicationWillFinishLaunching(_ notification: Notification) { + enforceSingleInstance() + } + + 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 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 } + + 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 {