Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions Strand/App/StrandApp.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
46 changes: 41 additions & 5 deletions Strand/BLE/BLEManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -1194,13 +1227,16 @@ 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)
central.connect(peripheral, options: nil)
}

public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
cancelScanFallback()
restoredPeripheral = nil
preparePeripheral(peripheral)
state.connected = true
Expand Down
13 changes: 10 additions & 3 deletions Strand/BLE/WhoopModel.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
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"

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 {
Expand Down
Loading