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
6 changes: 3 additions & 3 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ body:
required: true

- type: input
id: iphone
id: apple-device
attributes:
label: iPhone and iOS version
placeholder: iPhone 14 Pro, iOS 18.2
label: Apple device and OS version
placeholder: iPhone 17 Pro on iOS 26.6, or iPad mini on iPadOS 26.6
validations:
required: true

Expand Down
16 changes: 14 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,15 +66,27 @@ jobs:
CODE_SIGNING_ALLOWED=NO

ui-tests:
name: UI tests
name: UI tests (${{ matrix.destination }})
runs-on: macos-26
strategy:
fail-fast: false
matrix:
destination:
- iPhone 17 Pro
- iPad mini (A17 Pro)
- iPad Pro 13-inch (M5)
steps:
- uses: actions/checkout@v7

- name: Prepare simulator
run: |
xcrun simctl boot "${{ matrix.destination }}" 2>/dev/null || true
xcrun simctl bootstatus "${{ matrix.destination }}" -b

- name: Run UI regression tests
run: |
xcodebuild test \
-project VirtualGears.xcodeproj \
-scheme VirtualGears \
-destination 'platform=iOS Simulator,name=iPhone 17 Pro' \
-destination "platform=iOS Simulator,name=${{ matrix.destination }}" \
-parallel-testing-enabled NO
89 changes: 81 additions & 8 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,16 @@ physical hardware measurements behind it. Rider instructions live in the
- macOS
- Xcode 26 or later
- Swift 6
- An iPhone running iOS 17 or later for Bluetooth and trainer testing
- An iPhone or iPad running iOS/iPadOS 17 or later for Bluetooth and trainer testing

The simulator can build and display the app, but it cannot prove Bluetooth
behavior with a trainer, controller or fan.

The app target and UI-test target are universal (`TARGETED_DEVICE_FAMILY =
"1,2"`). iPad supports portrait, upside-down portrait and both landscape
orientations without requiring full screen, so normal full screen, Split View
and Stage Manager remain available.

The in-app Demo Mode is intentionally simulator-safe. `DemoRideState` contains
only a drivetrain and selected gear, while its `ConfigurationStore` has no
`UserDefaults` backing. Entering the demo stops discovery and the app suppresses
Expand Down Expand Up @@ -56,6 +61,18 @@ xcodebuild test \
-parallel-testing-enabled NO
```

Run the same suite on the representative iPads used by CI:

```bash
for destination in 'iPad mini (A17 Pro)' 'iPad Pro 13-inch (M5)'; do
xcodebuild test \
-project VirtualGears.xcodeproj \
-scheme VirtualGears \
-destination "platform=iOS Simulator,name=$destination" \
-parallel-testing-enabled NO
done
```

Run the dense setup and Settings journeys on the smaller supported simulator:

```bash
Expand All @@ -79,7 +96,9 @@ The matrix includes the setup guide, startup, ride, Settings, every equipment
destination, virtual and physical gearing, Headwind and Demo Mode. It also
includes Accessibility Dynamic Type for the wizard, Settings and ride;
landscape ride and Headwind layouts; dark-mode ride and Headwind controls; and
the dense wizard and Settings journeys on the smaller iPhone 17e. Assertions
the dense wizard and Settings journeys on the smaller iPhone 17e. The full suite
also runs on an iPad mini and 13-inch iPad, with iPad-specific ready/ride,
sheet, landscape and upside-down assertions. Assertions
check the state-specific message and action, plus important layout and visual
invariants. Whole-screen pixel comparisons are deliberately avoided; pixel
sampling is used only when XCTest cannot expose a meaningful property such as
Expand All @@ -103,22 +122,23 @@ matrix. Every response Virtual Gears owns after those events is still represente
Protocol behavior and equipment lifecycle remain covered by the package tests and
physical-hardware evidence.

Open the iPhone project:
Open the app project:

```bash
open VirtualGears.xcodeproj
```

Select the `VirtualGears` scheme. To run on an iPhone:
Select the `VirtualGears` scheme. To run on a physical iPhone or iPad:

1. Sign in to Xcode with the Apple ID used for device development.
2. Select the `VirtualGears` target.
3. Open **Signing & Capabilities** and choose the development team.
4. Select the physical iPhone as the run destination.
4. Select the physical device as the run destination.
5. Run the app.

CI performs the same package tests and builds the app for an iPhone simulator
without code signing.
CI performs the same package tests, builds the universal app for the iOS
simulator without code signing, and runs UI tests on iPhone 17 Pro, iPad mini
and iPad Pro 13-inch.

## Repository layout

Expand All @@ -130,13 +150,66 @@ without code signing.
| `VirtualGearsUITests` | Simulator UI, navigation, accessibility and layout regression tests |
| `Tools` | macOS tools for inspecting the KICKR, Zwift Click and advertised trainer name |
| `docs` | MkDocs website, screenshots and hardware findings |
| `VirtualGears.xcodeproj` | iPhone app project |
| `VirtualGears.xcodeproj` | Universal iPhone and iPad app project |

`DemoRideStateTests` cover the simulated gear ladder and drivetrain changes.
Bluetooth safety still depends on keeping Demo Mode outside `ProxyCoordinator`
and the CoreBluetooth services; do not replace its local state with staged
production services.

## Native iPad release gate

Simulator coverage verifies app-owned layout and navigation at compact iPhone
widths and regular full-screen iPad widths. The same geometry-driven layout is
used when an iPad window becomes compact, but Split View and Stage Manager still
need the physical iPad check below. CoreBluetooth behavior must also be checked
on physical hardware.

On 25 August 2026 the iPad was connected by USB and paired successfully:

```text
xcrun devicectl list devices
iPad Air (2) — iPad14,10 — iPadOS 26.6.1 (23G83)
UDID 00008112-001C381E3441A01E — wired — available (paired)
```

Developer Mode was enabled, and automatic provisioning registered the iPad.
The signed development build was then built, installed and launched successfully
with bundle identifier `com.sbroenne.VirtualGears`. `devicectl` confirmed the app
was running in the foreground on the unlocked iPad at its native 2732×2048
display resolution.

```text
Virtual Gears 1.0 (18) — com.sbroenne.VirtualGears — installed
/private/var/containers/Bundle/Application/.../VirtualGears.app/VirtualGears
```

Physical XCTest automation was attempted twice, including a single test, but
the runner timed out after 60 seconds while enabling automation mode. Launch is
therefore physically verified while app-owned layouts and multitasking remain
covered by the simulator matrix rather than physical XCTest.

The first physical Click discovery attempt found the original Click but remained
on "Found one. Checking for others…" after the search window. A direct Mac probe
then found the same Click at -59 dBm, connected, reported a 100% battery and read
its button presses, ruling out a sleeping or unavailable accessory. The settings
discovery window now keeps an absolute view-state deadline driven by a timer
rather than a SwiftUI `.task(id:)`, so candidate-driven view updates cannot
discard the deadline. The single-candidate regression passes on iPhone and iPad
simulators. The corrected build was installed on the iPad on 25 August 2026;
after the Click was woken, the startup screen found and selected the sole
original Click automatically. Physical sole-device discovery is therefore
validated.

The complete required Bluetooth gate was then checked manually on the same iPad
Air with the physically validated KICKR V5 and original Zwift Click: KICKR
discovery and connection, FTMS advertising to a riding app, Start Shifting,
confirmed easier and harder shifts including Click input, Stop Shifting, normal
wheel-circumference restoration and an uninterrupted riding-app connection all
worked. The Headwind path was not repeated on iPad; its existing physical iPhone
evidence remains the supported claim. With this evidence recorded, build 19 is
the first universal build eligible for TestFlight upload.

The proxy and shifting have deliberately separate lifecycles. Once the saved
KICKR is ready, `ProxyCoordinator.makeProxyAvailable()` publishes the FTMS
trainer and transparently forwards data and supported commands. `startShifting`
Expand Down
39 changes: 28 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
**Virtual shifting for Wahoo KICKR trainers — even in riding apps that do not
offer it.**

Virtual Gears puts an iPhone between your trainer and the app you ride with.
Virtual Gears puts an iPhone or iPad between your trainer and the app you ride
with.
Your riding app still controls the route and its hills; Virtual Gears adds the
gears. Because it appears as a normal FTMS trainer, the riding app does not need
to know anything about virtual shifting.
Expand Down Expand Up @@ -36,17 +37,22 @@ available to ordinary FTMS riding apps without changing their route data.

| | Requirement |
|---|---|
| **iPhone** | iOS 17 or later |
| **Apple device** | iPhone or iPad running iOS/iPadOS 17 or later |
| **Trainer** | A compatible direct-drive Wahoo KICKR |
| **Riding app** | An app that can connect to an FTMS trainer |
| **Shifting** | Large iPhone buttons; optional original Zwift Click |
| **Shifting** | Large on-screen buttons; optional original Zwift Click |
| **Fan** | Optional Wahoo KICKR HEADWIND |

Virtual Gears was built and physically tested with a KICKR V5. Other
direct-drive KICKR models are expected to work but have not yet been physically
tested. The KICKR SNAP, KICKR BIKE and trainers from other brands are not
supported.

The Bluetooth path has been physically tested on iPhone and iPad with a KICKR
V5 and original Zwift Click. The interface and ready/ride flows are also covered
on iPad mini and 13-inch iPad simulators, including portrait, landscape and
accessibility text.

On the riding-app side, FulGaz on macOS and Windows, plus RealVelo and MyWhoosh
on Windows, have been ridden end to end. Other FTMS riding apps are expected to
work but have not been tested.
Expand All @@ -56,15 +62,15 @@ work but have not been tested.
## Your first ride

1. Wake the KICKR by turning the pedals.
2. Open Virtual Gears on the iPhone. It finds the KICKR, connects and makes it
available to your riding app.
2. Open Virtual Gears on the iPhone or iPad. It finds the KICKR, connects and
makes it available to your riding app.
3. Tell the required first-run setup what is physically on the bike: chainrings,
and either a cassette or a Zwift Cog/other single sprocket. Your first ride
uses Standard 24 virtual gears automatically.
4. Move the chain to the quiet, reachable gear Virtual Gears recommends and
confirm it.
5. In your riding app, connect to the trainer named **Virtual Gears**. Some apps
may show the iPhone's name instead.
may show the device's name instead.
6. Tap **Start Shifting**, then shift with the large **Easier** and **Harder**
buttons.

Expand All @@ -75,7 +81,7 @@ gears; **Stop Shifting** removes them without disconnecting or stopping the ride
in your riding app. If it finds more than one trainer, it asks you to choose
yours by name.

The iPhone screen stays awake while the trainer proxy is available. This keeps
The device screen stays awake while the trainer proxy is available. This keeps
Virtual Gears discoverable to riding apps on Windows and other computers before
they connect.

Expand All @@ -99,7 +105,8 @@ with physical hardware.
- **Gears that shift the way an electronic groupset shifts**, one cog at a time
with the front change folded in, so there are no dead shifts and no invented
gaps.
- **On-phone shifting** with large controls in portrait and landscape.
- **On-device shifting** with large controls in portrait and landscape, including
iPad full screen, Split View and Stage Manager.
- **Accessible ride controls** with VoiceOver gear feedback, adjustable gear
control and support for larger text.
- **Persistent equipment statuses** that keep the trainer, Click, fan and
Expand All @@ -114,6 +121,16 @@ with physical hardware.

## Screenshots

<p align="center">
<a href="docs/app-store/ipad-13/riding.png">
<img src="docs/app-store/ipad-13/riding.png" width="45%" alt="Virtual Gears ride controls on a 13-inch iPad, showing gear 12 and large Easier and Harder buttons">
</a>
<a href="docs/app-store/ipad-13/setup.png">
<img src="docs/app-store/ipad-13/setup.png" width="45%" alt="Virtual Gears first-run bike setup on a 13-inch iPad">
</a>
</p>
<p align="center"><strong>Native iPad ride and setup layouts</strong></p>

<table>
<tr>
<td width="50%" align="center">
Expand Down Expand Up @@ -178,7 +195,7 @@ Every gear change waits for confirmation from the trainer.
- **ERG workouts are not supported.** ERG mode controls target power, while
Virtual Gears controls how hard a gear feels.
- **The riding app cannot display the selected gear.** Bluetooth FTMS has no
message for reporting it, so the gear is shown on the iPhone.
message for reporting it, so the gear is shown on your iPhone or iPad.
- **This is not Zwift's native virtual shifting.** It works independently of the
riding app. Virtual Gears supplies and displays the gears itself.
- **Virtual Gears cannot read the trainer's current wheel circumference.** It
Expand All @@ -188,8 +205,8 @@ Every gear change waits for confirmation from the trainer.
custom value in the Wahoo app, enter the same value in Virtual Gears before
shifting.
- **A riding app on Windows may not reconnect on its own after a Bluetooth
drop.** If the wireless link between your phone and the computer times out
mid-ride, some Windows riding apps do not scan for the phone again by
drop.** If the wireless link between your Apple device and the computer times
out mid-ride, some Windows riding apps do not scan for the device again by
themselves; you may need to restart the riding app to see Virtual Gears once
more. This is the riding app's own reconnect behaviour, not something Virtual
Gears controls.
Expand Down
12 changes: 6 additions & 6 deletions VirtualGears.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = VirtualGears;
};
name = Debug;
Expand All @@ -276,7 +276,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = VirtualGears;
};
name = Release;
Expand Down Expand Up @@ -320,7 +320,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = MNW6SJT4V7;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VirtualGearsProduct/Info.plist;
Expand All @@ -336,7 +336,7 @@
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
Expand All @@ -345,7 +345,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 18;
CURRENT_PROJECT_VERSION = 19;
DEVELOPMENT_TEAM = MNW6SJT4V7;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = VirtualGearsProduct/Info.plist;
Expand All @@ -361,7 +361,7 @@
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 6.0;
TARGETED_DEVICE_FAMILY = 1;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
Expand Down
4 changes: 4 additions & 0 deletions VirtualGearsProduct/ClickCentralService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,10 @@ extension ClickCentralService {
func stageScreenshotPressedButton(_ button: ZwiftClickButton) {
pressedButton = button
}

func stageDiscoveredCandidate(_ candidate: BluetoothCandidate) {
candidates = [candidate]
}
}
#endif

Expand Down
7 changes: 7 additions & 0 deletions VirtualGearsProduct/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,12 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
Loading