Skip to content
Open
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
13 changes: 13 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ foreground reconnect and interrupted-ride reset work until the demo is closed.
The demo therefore checks product navigation and local gear behavior only; it
does not add any physical-hardware evidence.

Build 1.0 (17) exposed a real-device discovery regression on 2026-08-21: Settings
found one original Zwift Click but remained on "Checking for others" and never
saved it, so the Ready to shift screen correctly had no configured Click status
to show. The discovery deadline had been owned by a SwiftUI change callback that
could miss scanning starting during first appearance. It is now keyed to both
the service's scan generation and current scanning state, including scans that
start before the view's change observer is installed. Startup now runs Click and
Headwind discovery independently and applies the trainer's tested selection
rule to both: one result connects automatically, while multiple results are not
guessed. The corrected development build was installed over USB on the same
iPhone 17 Pro that exposed the regression; after waking the original Click with
a button press, it was saved, connected automatically and appeared in the app.

## Build and test

Run the hardware-independent test suite:
Expand Down
4 changes: 2 additions & 2 deletions VirtualGears.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = MNW6SJT4V7;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VirtualGearsProduct/Info.plist;
Expand All @@ -345,7 +345,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 17;
CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = MNW6SJT4V7;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VirtualGearsProduct/Info.plist;
Expand Down
11 changes: 11 additions & 0 deletions VirtualGearsProduct/ClickCentralService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ final class ClickCentralService: NSObject {
private var repeatTask: Task<Void, Never>?
private var isSuspendedForDemo = false
private var resumesAfterDemoDisconnect = false
#if DEBUG
private var usesStagedScan = false
#endif
private var edgeTracker = ZwiftClickEdgeTracker()
private var heldButton: ZwiftClickButton?
private var isHolding = false
Expand All @@ -114,6 +117,13 @@ final class ClickCentralService: NSObject {

func startScanning() {
guard !isSuspendedForDemo else { return }
#if DEBUG
if usesStagedScan {
scanGeneration += 1
state = .scanning
return
}
#endif
desiredConnection = false
reconnectTask?.cancel()
scanWhenPoweredOn = true
Expand Down Expand Up @@ -550,6 +560,7 @@ extension ClickCentralService {
self.batteryLevel = batteryLevel
connectionIsStalled = stalled
identificationCandidateID = identifying
usesStagedScan = state == .scanning
self.state = state
}

Expand Down
55 changes: 24 additions & 31 deletions VirtualGearsProduct/SetupView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ private struct TrainerSetupView: View {
candidates: kickr.candidates,
selectedID: kickr.selectedID,
isScanning: kickr.isScanning,
scanGeneration: kickr.scanGeneration,
connectionState: kickr.state,
initialPhase: stagedDiscoveryPhase(for: .trainer),
startScanning: kickr.startScanning,
Expand Down Expand Up @@ -606,6 +607,7 @@ private struct ShiftingSetupView: View {
candidates: click.candidates,
selectedID: click.selectedID,
isScanning: click.isScanning,
scanGeneration: click.scanGeneration,
connectionState: click.state,
initialPhase: stagedDiscoveryPhase(for: .click),
startScanning: click.startScanning,
Expand Down Expand Up @@ -740,6 +742,7 @@ private struct HeadwindSetupView: View {
candidates: headwind.candidates,
selectedID: headwind.selectedID,
isScanning: headwind.state == .scanning,
scanGeneration: headwind.scanGeneration,
connectionState: headwind.state,
initialPhase: stagedDiscoveryPhase(for: .headwind),
startScanning: headwind.startScanning,
Expand Down Expand Up @@ -1656,6 +1659,7 @@ private struct DeviceDiscoverySection: View {
let candidates: [BluetoothCandidate]
let selectedID: UUID?
let isScanning: Bool
let scanGeneration: Int
let connectionState: ProductConnectionState
let startScanning: () -> Void
let stopScanning: () -> Void
Expand All @@ -1666,8 +1670,6 @@ private struct DeviceDiscoverySection: View {
let select: (BluetoothCandidate) -> Void

@State private var discovery = DeviceDiscoveryState()
@State private var timeoutTask: Task<Void, Never>?
@State private var timeoutScheduled = false

private let searchDuration = DeviceDiscoveryPolicy.searchDuration

Expand All @@ -1679,6 +1681,7 @@ private struct DeviceDiscoverySection: View {
candidates: [BluetoothCandidate],
selectedID: UUID?,
isScanning: Bool,
scanGeneration: Int,
connectionState: ProductConnectionState,
initialPhase: DeviceDiscoveryState.Phase = .idle,
startScanning: @escaping () -> Void,
Expand All @@ -1696,6 +1699,7 @@ private struct DeviceDiscoverySection: View {
self.candidates = candidates
self.selectedID = selectedID
self.isScanning = isScanning
self.scanGeneration = scanGeneration
self.connectionState = connectionState
var initialDiscovery = DeviceDiscoveryState()
switch initialPhase {
Expand Down Expand Up @@ -1801,8 +1805,18 @@ private struct DeviceDiscoverySection: View {
beginSearch()
}
}
.onChange(of: isScanning) { _, scanning in
if scanning { scheduleTimeoutIfNeeded() }
.task(id: DiscoveryClock(
scanGeneration: scanGeneration,
isScanning: isScanning
)) {
guard isScanning else { return }
do {
try await Task.sleep(for: searchDuration)
} catch {
return
}
guard !Task.isCancelled, isScanning else { return }
finishSearch()
}
.onChange(of: candidates.count) { _, count in
discovery.observe(candidateCount: count)
Expand All @@ -1812,13 +1826,10 @@ private struct DeviceDiscoverySection: View {
}
.onChange(of: hasSavedDevice) { _, saved in
if saved {
timeoutTask?.cancel()
timeoutScheduled = false
discovery.reset()
}
}
.onDisappear {
timeoutTask?.cancel()
if discovery.phase != .idle || isScanning {
cancelScanning()
}
Expand Down Expand Up @@ -1846,33 +1857,14 @@ private struct DeviceDiscoverySection: View {
}

private func beginSearch() {
timeoutTask?.cancel()
timeoutScheduled = false
discovery.start()
startScanning()
if isScanning {
scheduleTimeoutIfNeeded()
} else {
if !isScanning {
handleConnectionState(connectionState)
}
}

private func scheduleTimeoutIfNeeded() {
guard !timeoutScheduled else { return }
timeoutScheduled = true
timeoutTask = Task { @MainActor in
do {
try await Task.sleep(for: searchDuration)
} catch {
return
}
guard !Task.isCancelled else { return }
finishSearch()
}
}

private func finishSearch() {
timeoutScheduled = false
stopScanning()
if candidates.count == 1, let candidate = candidates.first,
candidate.compatibility.isUsable {
Expand All @@ -1888,19 +1880,20 @@ private struct DeviceDiscoverySection: View {
!isScanning else { return }
if case let .unavailable(reason) = state,
!reason.localizedCaseInsensitiveContains("starting") {
timeoutTask?.cancel()
timeoutScheduled = false
discovery.finish(candidateCount: candidates.count)
}
}

private func choose(_ candidate: BluetoothCandidate) {
timeoutTask?.cancel()
timeoutScheduled = false
select(candidate)
}
}

private struct DiscoveryClock: Equatable {
let scanGeneration: Int
let isScanning: Bool
}

private struct CandidateRow: View {
let candidate: BluetoothCandidate
let selected: Bool
Expand Down
25 changes: 18 additions & 7 deletions VirtualGearsProduct/VirtualGearsApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ enum ScreenshotFixture: String {
case settingsBluetoothIssue = "-shotSettingsBluetoothIssue"
case settingsStalled = "-shotSettingsStalled"
case settingsClickLowBattery = "-shotSettingsClickLowBattery"
case settingsClickSingleCandidate = "-shotSettingsClickSingleCandidate"
case settingsClickDuplicates = "-shotSettingsClickDuplicates"
case settingsClickIdentifying = "-shotSettingsClickIdentifying"
case settingsUnsafeGears = "-shotSettingsUnsafeGears"
Expand Down Expand Up @@ -187,7 +188,8 @@ private struct ScreenshotFixtureView: View {
.settingsUnsupported, .settingsTimedOut,
.settingsBluetoothIssue, .settingsStalled,
.settingsClickLowBattery, .settingsClickDuplicates,
.settingsClickIdentifying, .settingsUnsafeGears,
.settingsClickSingleCandidate, .settingsClickIdentifying,
.settingsUnsafeGears,
.settingsAccessibility:
NavigationStack {
SetupView(
Expand Down Expand Up @@ -244,10 +246,12 @@ private struct ScreenshotFixtureView: View {
id: ScreenshotFixture.kickrID
)
}
configuration.rememberClick(
named: "Zwift Click",
id: ScreenshotFixture.clickID
)
if scenario != .settingsClickSingleCandidate {
configuration.rememberClick(
named: "Zwift Click",
id: ScreenshotFixture.clickID
)
}
configuration.rememberHeadwind(
named: "KICKR HEADWIND 4D21",
id: ScreenshotFixture.headwindID
Expand Down Expand Up @@ -325,11 +329,18 @@ private struct ScreenshotFixtureView: View {
name: "Zwift Click"
),
]
let clickCandidates = scenario == .settingsClickSingleCandidate
? [BluetoothCandidate(
id: ScreenshotFixture.clickID,
name: "Zwift Click"
)]
: (scenario == .settingsClickDuplicates
|| scenario == .settingsClickIdentifying ? duplicateClicks : [])
click.stageScreenshot(
name: configuration.clickName,
batteryLevel: scenario == .settingsClickLowBattery ? 15 : 82,
candidates: scenario == .settingsClickDuplicates
|| scenario == .settingsClickIdentifying ? duplicateClicks : [],
candidates: clickCandidates,
state: scenario == .settingsClickSingleCandidate ? .scanning : .ready,
identifying: scenario == .settingsClickIdentifying
? ScreenshotFixture.clickID : nil
)
Expand Down
91 changes: 54 additions & 37 deletions VirtualGearsProduct/VirtualGearsHomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,58 +78,75 @@ struct VirtualGearsHomeView: View {
let needsClick = !store.configuration.usesClick
let needsHeadwind = !store.configuration.usesHeadwind

needsClick ? click.startScanning() : click.autoConnectSavedDevice()
needsHeadwind
? headwind.startScanning() : headwind.autoConnectSavedDevice()
guard needsClick || needsHeadwind else { return }
async let clickDiscovery: Void = discoverClick(ifNeeded: needsClick)
async let headwindDiscovery: Void = discoverHeadwind(ifNeeded: needsHeadwind)
_ = await (clickDiscovery, headwindDiscovery)
}

// The first Bluetooth permission prompt can outlive the view's initial
// task turn. Start the discovery window only after scanning really began.
private func waitUntilScanning(
_ isScanning: @escaping () -> Bool
) async -> Bool {
for _ in 0..<300 {
let clickStarted = !needsClick || click.isScanning
let headwindStarted = !needsHeadwind || headwind.isScanning
if clickStarted && headwindStarted { break }
if isScanning() { return true }
do {
try await Task.sleep(for: .milliseconds(100))
} catch {
return
return false
}
}
guard !Task.isCancelled else { return }
let clickScanGeneration = click.scanGeneration
let headwindScanGeneration = headwind.scanGeneration
return false
}

private func discoverClick(ifNeeded needed: Bool) async {
guard needed else {
click.autoConnectSavedDevice()
return
}
click.startScanning()
guard await waitUntilScanning({ click.isScanning }) else { return }
let generation = click.scanGeneration
do {
try await Task.sleep(for: DeviceDiscoveryPolicy.searchDuration)
} catch {
return
}

if needsClick, !store.configuration.usesClick,
click.scanGeneration == clickScanGeneration {
if click.candidates.count == 1, let candidate = click.candidates.first {
store.configuration.rememberClick(
named: candidate.name,
id: candidate.id
)
click.selectAndConnect(candidate.id)
} else {
click.stopScanning(reconnectSavedDevice: false)
}
guard !store.configuration.usesClick,
click.scanGeneration == generation else { return }
let seen = click.candidates.map { DiscoveredTrainer(id: $0.id) }
guard case let .connect(id) = TrainerPicker.choice(from: seen),
let candidate = click.candidates.first(where: { $0.id == id })
else {
click.stopScanning(reconnectSavedDevice: false)
return
}
store.configuration.rememberClick(named: candidate.name, id: candidate.id)
click.selectAndConnect(candidate.id)
}

if needsHeadwind, !store.configuration.usesHeadwind,
headwind.scanGeneration == headwindScanGeneration {
if headwind.candidates.count == 1,
let candidate = headwind.candidates.first {
store.configuration.rememberHeadwind(
named: candidate.name,
id: candidate.id
)
headwind.selectAndConnect(candidate.id)
} else {
headwind.stopScanning(reconnectSavedDevice: false)
}
private func discoverHeadwind(ifNeeded needed: Bool) async {
guard needed else {
headwind.autoConnectSavedDevice()
return
}
headwind.startScanning()
guard await waitUntilScanning({ headwind.state == .scanning }) else { return }
let generation = headwind.scanGeneration
do {
try await Task.sleep(for: DeviceDiscoveryPolicy.searchDuration)
} catch {
return
}
guard !store.configuration.usesHeadwind,
headwind.scanGeneration == generation else { return }
let seen = headwind.candidates.map { DiscoveredTrainer(id: $0.id) }
guard case let .connect(id) = TrainerPicker.choice(from: seen),
let candidate = headwind.candidates.first(where: { $0.id == id })
else {
headwind.stopScanning(reconnectSavedDevice: false)
return
}
store.configuration.rememberHeadwind(named: candidate.name, id: candidate.id)
headwind.selectAndConnect(candidate.id)
}
}

Expand Down
Loading