iOS: Add the Galaxy companion with live device pages and CarPlay

Add LAN and Bluetooth connections, secure pairing, device-matched Galaxy page
caching, phone location and CarPlay navigation. Include connection recovery,
diagnostics, regression tests and a simulator CI build. Add shared companion
docs and host tests, and remove obsolete bundled-web lint exceptions.
This commit is contained in:
Danny
2026-10-22 15:37:20 -07:00
parent f63e715d2d
commit c777a9ec8a
73 changed files with 7548 additions and 3 deletions
-1
View File
@@ -12,7 +12,6 @@ openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl -text
*.woff2 -text
*.wav -text
openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js -text
openpilot/starpilot/companion/ios/GalaxyBluetooth/Resources/Web/vendor/vue/vue.esm-browser.js -text
*.apk -text
openpilot/starpilot/galaxy/bin/hev-socks5-tunnel_linux_arm64 -text
openpilot/starpilot/soloist/pulseaudio/bin/pulseaudio -text
+69
View File
@@ -0,0 +1,69 @@
name: iOS companion
on:
push:
branches: [SecretGoodStarPilot, secretgood-ios-integration]
paths:
- 'openpilot/starpilot/companion/ios/**'
- 'openpilot/starpilot/galaxy_bluetooth/**'
- '.github/workflows/ios-companion.yaml'
pull_request:
paths:
- 'openpilot/starpilot/companion/ios/**'
- 'openpilot/starpilot/galaxy_bluetooth/**'
- '.github/workflows/ios-companion.yaml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ios-companion-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
app:
name: Swift tests and iOS Simulator build
runs-on: macos-26
timeout-minutes: 25
defaults:
run:
working-directory: openpilot/starpilot/companion/ios
steps:
- uses: actions/checkout@v4
with:
submodules: false
lfs: false
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Run companion regressions
env:
CI_PRIMARY_REPOSITORY_PATH: ${{ github.workspace }}/openpilot/starpilot/companion/ios
run: sh ci_scripts/ci_post_clone.sh
- name: Build for iOS Simulator
run: >-
xcodebuild -project GalaxyBluetooth.xcodeproj
-scheme GalaxyBluetooth -configuration Debug
-sdk iphonesimulator -destination 'generic/platform=iOS Simulator'
-derivedDataPath "$RUNNER_TEMP/galaxy-ios-build"
CODE_SIGNING_ALLOWED=NO build
bridge:
name: Comma Bluetooth bridge tests
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
submodules: false
lfs: false
persist-credentials: false
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install bridge test dependencies
run: python -m pip install pycryptodome
- name: Run bridge regressions
run: python -m unittest discover -s openpilot/starpilot/galaxy_bluetooth/tests -v
+125
View File
@@ -0,0 +1,125 @@
# StarPilot companion apps
Phone apps for Galaxy. Both talk to the comma over Wi-Fi/LAN or Bluetooth and
share one pairing key.
```
companion/
├── android/ Android app (Gradle project, build_apk.sh, host tests)
├── ios/ iPhone app (Xcode project, CarPlay, tests) — see ios/README.md
└── run_host_tests.py
```
The comma side lives elsewhere:
- `openpilot/starpilot/galaxy_bluetooth/` — Bluetooth bridge, LAN discovery and
the pairing screen, run by the `galaxy_phone` process.
- `openpilot/starpilot/galaxy/companion*.py` — phone internet sharing.
Over Bluetooth, pairing, phone internet sharing, APK downloads, recordings,
replay, cameras and map tiles are blocked; they need Wi-Fi.
## Pairing and phone music
One step pairs the app and phone music. On comma open Settings → Bluetooth →
**Pair phone**; comma opens a two-minute window that takes one phone.
Before pairing over Wi-Fi/LAN the app asks comma `GET /api/companion/bluetooth-ready` and waits out a boot or a
Bluetooth bridge restart; it stops with the reason (not parked, Bluetooth off or unavailable) instead of scanning.
If the app already reaches comma over Wi-Fi/LAN, **Add Bluetooth Phone Pairing** (Android) or **Pair with comma** (iPhone) opens that window itself (the call
Galaxy's Bluetooth page makes), so nothing needs touching on comma. Without a Wi-Fi/LAN session it waits for
the window as above.
- Android: tap **Add Bluetooth Phone Pairing** in the app. If the app can't reach comma over Wi-Fi it first asks you to open
**Pair phone** on comma and keep it open. It then gets its key, bonds for music and shows one system prompt to approve.
The first time Galaxy opens over Wi-Fi/LAN on a phone with no comma key (or an expired one), the app offers
**Add pairing**. It opens comma's pairing window itself, shows each step, and asks for the Nearby devices permission if
needed. **Not now** asks again next launch; **Don't ask again** leaves it to **Add Bluetooth Phone Pairing** in the menu.
- iPhone: tap **Pair with comma** in the app, then tap comma once in
Settings → Bluetooth (iOS cannot start a Bluetooth bond from an app).
- No app: tap comma in the phone's Bluetooth settings to pair music only.
Forgetting a Bluetooth device in comma Settings also removes its app key. On Android, forgetting comma in the phone's
Bluetooth settings removes the app pairing too, and if comma no longer recognizes the phone's key the app stops retrying
and says the pairing expired until you pair again. Installing
Spotify (`/api/spotify/upload`) is blocked over Bluetooth like the other large uploads.
## Phone GPS
Each app can send the phone's GPS to comma about once a second, over Bluetooth or
Galaxy Wi-Fi. Comma carries it inside the navigation message as `phoneGps`, keeps the last fix for
10 seconds, and navigation uses it only when comma's own GPS and the car's GPS have no fix. Galaxy → Navigation → GPS Source can
instead ignore the phone or prefer it; mapd follows the same choice.
The 10-second hold is an expiry allowance, not an update interval. Navigation
checks for new phone fixes five times a second and forwards each new measurement
at the next check; without new fixes it publishes its usual once-a-second heartbeat.
The car map smooths movement between fixes using speed and heading for at most
2.5 seconds, then holds the position until another fix arrives or the hold expires.
Stationary phones retain the full 10-second allowance without predicted movement.
- Android: Connections → **Share phone GPS with comma**. Runs as a foreground
service with a notification.
`POST /api/companion/location` with JSON: `latitude`, `longitude`,
`horizontalAccuracy` (m) and `ageMs` (phone-measured age, at most 2000) are
required; `altitude`, `verticalAccuracy`, `speed`, `speedAccuracy`, `bearing`,
`bearingAccuracy` and `timestamp` (Unix ms) are optional. Leave out unknown
values. Comma dates the fix by its own clock minus `ageMs`, never by `timestamp`.
Refused through the remote tunnel.
## Android
Install: join the Galaxy hotspot, open Galaxy → **Galaxy Access & Install** →
**Android companion**, and install the APK. Later updates install from inside
the app: tap **Update app** while connected over Galaxy Wi-Fi/LAN, allow
installation from StarPilot if Android asks, and confirm the Android update dialog.
The APK streams into Android's installer; there is no file to find in Downloads.
After replacement, an **Update installed → Reopen** notification opens the new app.
Enable app notifications for this prompt. If notifications are disabled, open
StarPilot normally; the app confirms the completed update on launch.
### When an APK rebuild is needed
| Change | Rebuild Android APK? |
| --- | --- |
| Kotlin/Java code, native bridge, updater, phone GPS, Bluetooth or services | Yes |
| Android manifest, permissions, resources, icons, Gradle config or dependencies | Yes |
| Files in `android/app/src/main/assets/`, including `bluetooth-fetch.js` | Yes |
| Galaxy HTML/CSS/JS used over Bluetooth | No: the app downloads these pages from comma over Wi-Fi/LAN (see below) |
| Galaxy HTML/CSS/JS used over Wi-Fi/LAN | No: screens load from comma |
| Comma-side Python/services/API changes only | No, unless the phone protocol also needs a change |
### Galaxy screens over Bluetooth
Neither app bundles Galaxy's pages. Bluetooth Galaxy shows a copy the app saved from comma, so **connect to comma over
Wi-Fi/LAN once** before using Bluetooth. Until then the Bluetooth route says it needs one Wi-Fi visit.
- Whenever the phone reaches comma over Wi-Fi/LAN (and from **Refresh Galaxy screens** in the menu), the app reads
`GET /api/companion/web-manifest`, which lists every file in `galaxy/web` with its SHA-256 and comma's commit. It downloads
only the files that changed, verifies each hash, and keeps the old copy if anything fails.
- The pages come from comma itself, so custom and unpushed branches work and nothing depends on GitHub.
- If comma updated since the last Wi-Fi visit, Bluetooth still opens the saved copy and says it is from an older comma build.
Join comma's Wi-Fi to refresh.
- A comma without the manifest route is too old to share its screens; update comma.
### Handing the signing key to another developer
Updates install over an existing app only when signed with the same `companion.jks`; a different key forces every user to
uninstall first. To pass signing on, copy `~/.starpilot/companion.jks` and `~/.starpilot/companion-signing-password` to the
new developer over a private channel (a password manager share, not git or chat), or have them point `COMPANION_KEYSTORE`
and `COMPANION_PASSWORD_FILE` at wherever the files live. Keep a backup: losing the key means a forced reinstall.
## Tests
From the repo root:
```sh
uv run --extra testing --with pycryptodome==3.23.0 --with dbus-next==0.2.3 --with zeroconf==0.151.3 \
python openpilot/starpilot/companion/run_host_tests.py
node --test openpilot/starpilot/companion/android/tests/test_bluetooth_fetch.mjs
(cd openpilot/starpilot/companion/android && ./gradlew testDebugUnitTest)
```
`run_host_tests.py` stubs native modules so it runs on a laptop; it also takes
explicit test paths. iPhone tests are in `ios/README.md`.
@@ -0,0 +1,5 @@
build/
DerivedData/
*.xcuserstate
xcuserdata/
.DS_Store
@@ -0,0 +1,16 @@
# carplay testing
carplay support is in `GalaxyBluetooth/CarPlaySceneDelegate.swift`. it uses native CarPlay templates for the overview, temperatures, rates and Galaxy settings.
physical CarPlay needs Apple's approved entitlement and a matching signing profile. check the target's signing setup before testing; a TestFlight invitation alone does not provide the entitlement.
## check on a head unit
- open each top-level page and confirm values stay readable in light and dark mode.
- open settings categories and use the top-bar arrows to reach the remaining pages.
- check Back navigation and numeric controls. change and restore a display preference to confirm saving.
- Disconnect and reconnect the phone. check that stale readings clear and the connection recovers.
keep initial tests parked. Share the head unit, iPhone/iOS version and a photo of any layout problem. Copy the app's connection reports after a failure.
the old Dom full-screen video capture instructions do not apply to SecretGood. carplay does not show a full comma-screen video stream.
@@ -0,0 +1,301 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 56;
objects = {
/* Begin PBXBuildFile section */
C0AA00000000000000000007 /* Lucide-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = C0AA00000000000000000008 /* Lucide-LICENSE.txt */; };
C0AA00000000000000000005 /* Sora-OFL.txt in Resources */ = {isa = PBXBuildFile; fileRef = C0AA00000000000000000006 /* Sora-OFL.txt */; };
C0AA00000000000000000003 /* Sora.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C0AA00000000000000000004 /* Sora.ttf */; };
C0AA00000000000000000001 /* ConnectionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AA00000000000000000002 /* ConnectionSettingsView.swift */; };
D92135C445C249E6A5DC0011 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D92135C445C249E6A5DC0012 /* PrivacyInfo.xcprivacy */; };
140107256F4F4DF2910BED61 /* BluetoothTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D47658A09FCC6A6C73D1A1A3 /* BluetoothTransport.swift */; };
1698566BBEF2185D9C80B6BB /* HTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34DE7063A67F0755E6E69BF7 /* HTTP.swift */; };
28D504D2BD92FF8A8AEB68A7 /* CompanionDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AD85D1A585138AF80FEADB9 /* CompanionDiagnostics.swift */; };
36EBF9BB2BE027F139572ADA /* GalaxyBluetoothApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3356A9DC86451A9D92F074F8 /* GalaxyBluetoothApp.swift */; };
5992DC6B04748581DE47AA02 /* LoopbackServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B07071562661895558F74EE6 /* LoopbackServer.swift */; };
606A672A792903E98EFFC2D9 /* Resources/Branding.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9F0B8B04FE0E80BA046C0042 /* Resources/Branding.xcassets */; };
754B6F8C853BDD47D7593948 /* NetworkTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114E3D37646B3A8626A8A232 /* NetworkTransport.swift */; };
7F54642B30B38C648001D4ED /* Wire.swift in Sources */ = {isa = PBXBuildFile; fileRef = F48B8D06AA8D8C4AC21E5982 /* Wire.swift */; };
887D84D1E3F00F0A5A8AD090 /* PrivateLANHTTP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5492C1BC71EB8D0DBF12896E /* PrivateLANHTTP.swift */; };
8BA587954B4079F504AAD35E /* GalaxyRequestTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D0F5CDB02B33E408C75D8C /* GalaxyRequestTransport.swift */; };
A6D597850E6DD0CDFB754EF8 /* CarPlaySceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F213860FC7A532DBD355109 /* CarPlaySceneDelegate.swift */; };
B2DE76B1CE686BDFB635FAAE /* GalaxyAssetStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3BBE1488FD8A83E3983FEDE /* GalaxyAssetStore.swift */; };
BC3706D8A74BDB5B0AA8063E /* PairingKeyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43A1199899003FF3F5F30831 /* PairingKeyStore.swift */; };
DE64BADD40C934E610D4D505 /* GalaxyWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF41C7E4AB86DEB71B85E4F6 /* GalaxyWebView.swift */; };
FACE00000000000000000001 /* PairingCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FACE00000000000000000002 /* PairingCode.swift */; };
C1A100000000000000000001 /* ClaimPairing.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1A100000000000000000002 /* ClaimPairing.swift */; };
D15C00000000000000000001 /* LANDiscovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D15C00000000000000000002 /* LANDiscovery.swift */; };
D15C00000000000000000011 /* AudioPairing.swift in Sources */ = {isa = PBXBuildFile; fileRef = D15C00000000000000000012 /* AudioPairing.swift */; };
D15C00000000000000000013 /* AudioPairingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D15C00000000000000000014 /* AudioPairingSheet.swift */; };
D15C00000000000000000015 /* PhoneLocation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D15C00000000000000000016 /* PhoneLocation.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
C0AA00000000000000000008 /* Lucide-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = Resources/Lucide-LICENSE.txt; sourceTree = "<group>"; };
C0AA00000000000000000006 /* Sora-OFL.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = Resources/Sora-OFL.txt; sourceTree = "<group>"; };
C0AA00000000000000000004 /* Sora.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Resources/Sora.ttf; sourceTree = "<group>"; };
C0AA00000000000000000002 /* ConnectionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionSettingsView.swift; sourceTree = "<group>"; };
D92135C445C249E6A5DC0012 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
114E3D37646B3A8626A8A232 /* NetworkTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkTransport.swift; sourceTree = "<group>"; };
3356A9DC86451A9D92F074F8 /* GalaxyBluetoothApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalaxyBluetoothApp.swift; sourceTree = "<group>"; };
34DE7063A67F0755E6E69BF7 /* HTTP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTP.swift; sourceTree = "<group>"; };
43A1199899003FF3F5F30831 /* PairingKeyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingKeyStore.swift; sourceTree = "<group>"; };
5492C1BC71EB8D0DBF12896E /* PrivateLANHTTP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateLANHTTP.swift; sourceTree = "<group>"; };
80D0F5CDB02B33E408C75D8C /* GalaxyRequestTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalaxyRequestTransport.swift; sourceTree = "<group>"; };
8AD85D1A585138AF80FEADB9 /* CompanionDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompanionDiagnostics.swift; sourceTree = "<group>"; };
8F213860FC7A532DBD355109 /* CarPlaySceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarPlaySceneDelegate.swift; sourceTree = "<group>"; };
9CEEC13202AFBF12EE3ABB99 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9F0B8B04FE0E80BA046C0042 /* Resources/Branding.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Resources/Branding.xcassets; sourceTree = "<group>"; };
A172CEDCAE47474B615C54D5 /* GalaxyBluetooth.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; path = GalaxyBluetooth.app; sourceTree = BUILT_PRODUCTS_DIR; };
B07071562661895558F74EE6 /* LoopbackServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopbackServer.swift; sourceTree = "<group>"; };
BF41C7E4AB86DEB71B85E4F6 /* GalaxyWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalaxyWebView.swift; sourceTree = "<group>"; };
D3BBE1488FD8A83E3983FEDE /* GalaxyAssetStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalaxyAssetStore.swift; sourceTree = "<group>"; };
D47658A09FCC6A6C73D1A1A3 /* BluetoothTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothTransport.swift; sourceTree = "<group>"; };
F48B8D06AA8D8C4AC21E5982 /* Wire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Wire.swift; sourceTree = "<group>"; };
FACE00000000000000000002 /* PairingCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PairingCode.swift; sourceTree = "<group>"; };
C1A100000000000000000002 /* ClaimPairing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaimPairing.swift; sourceTree = "<group>"; };
D15C00000000000000000002 /* LANDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LANDiscovery.swift; sourceTree = "<group>"; };
D15C00000000000000000012 /* AudioPairing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPairing.swift; sourceTree = "<group>"; };
D15C00000000000000000014 /* AudioPairingSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPairingSheet.swift; sourceTree = "<group>"; };
D15C00000000000000000016 /* PhoneLocation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneLocation.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
E2AA269EB967A8853A258BFA /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
0A3E27B8CA818264D75C8D81 /* Products */ = {
isa = PBXGroup;
children = (
A172CEDCAE47474B615C54D5 /* GalaxyBluetooth.app */,
);
name = Products;
sourceTree = "<group>";
};
9E61AFF607A3704A97803240 /* GalaxyBluetooth */ = {
isa = PBXGroup;
children = (
D92135C445C249E6A5DC0012 /* PrivacyInfo.xcprivacy */,
D15C00000000000000000002 /* LANDiscovery.swift */,
D15C00000000000000000014 /* AudioPairingSheet.swift */,
D15C00000000000000000012 /* AudioPairing.swift */,
D15C00000000000000000016 /* PhoneLocation.swift */,
114E3D37646B3A8626A8A232 /* NetworkTransport.swift */,
D3BBE1488FD8A83E3983FEDE /* GalaxyAssetStore.swift */,
5492C1BC71EB8D0DBF12896E /* PrivateLANHTTP.swift */,
D47658A09FCC6A6C73D1A1A3 /* BluetoothTransport.swift */,
FACE00000000000000000002 /* PairingCode.swift */,
C1A100000000000000000002 /* ClaimPairing.swift */,
3356A9DC86451A9D92F074F8 /* GalaxyBluetoothApp.swift */,
C0AA00000000000000000002 /* ConnectionSettingsView.swift */,
C0AA00000000000000000004 /* Sora.ttf */,
C0AA00000000000000000006 /* Sora-OFL.txt */,
C0AA00000000000000000008 /* Lucide-LICENSE.txt */,
80D0F5CDB02B33E408C75D8C /* GalaxyRequestTransport.swift */,
BF41C7E4AB86DEB71B85E4F6 /* GalaxyWebView.swift */,
34DE7063A67F0755E6E69BF7 /* HTTP.swift */,
B07071562661895558F74EE6 /* LoopbackServer.swift */,
43A1199899003FF3F5F30831 /* PairingKeyStore.swift */,
F48B8D06AA8D8C4AC21E5982 /* Wire.swift */,
9CEEC13202AFBF12EE3ABB99 /* Info.plist */,
8AD85D1A585138AF80FEADB9 /* CompanionDiagnostics.swift */,
8F213860FC7A532DBD355109 /* CarPlaySceneDelegate.swift */,
9F0B8B04FE0E80BA046C0042 /* Resources/Branding.xcassets */,
);
path = GalaxyBluetooth;
sourceTree = "<group>";
};
C69913C7EE4DFDD011BE172F = {
isa = PBXGroup;
children = (
9E61AFF607A3704A97803240 /* GalaxyBluetooth */,
0A3E27B8CA818264D75C8D81 /* Products */,
);
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
34A04005BCAF206EEC990BD9 /* GalaxyBluetooth */ = {
isa = PBXNativeTarget;
buildConfigurationList = EE8ED050E3386ACB51F5825B /* Build configuration list for PBXNativeTarget "GalaxyBluetooth" */;
buildPhases = (
878A52FC5FF6A57D50B7B870 /* Sources */,
E2AA269EB967A8853A258BFA /* Frameworks */,
41D311A605520FC7B8B9A980 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = GalaxyBluetooth;
productName = GalaxyBluetooth;
productReference = A172CEDCAE47474B615C54D5 /* GalaxyBluetooth.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
244210E48437B6556980A702 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1600;
};
buildConfigurationList = 442D61E217DD044EA4245BF2 /* Build configuration list for PBXProject "GalaxyBluetooth" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = C69913C7EE4DFDD011BE172F;
productRefGroup = 0A3E27B8CA818264D75C8D81 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
34A04005BCAF206EEC990BD9 /* GalaxyBluetooth */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
41D311A605520FC7B8B9A980 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D92135C445C249E6A5DC0011 /* PrivacyInfo.xcprivacy in Resources */,
606A672A792903E98EFFC2D9 /* Resources/Branding.xcassets in Resources */,
C0AA00000000000000000003 /* Sora.ttf in Resources */,
C0AA00000000000000000005 /* Sora-OFL.txt in Resources */,
C0AA00000000000000000007 /* Lucide-LICENSE.txt in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
878A52FC5FF6A57D50B7B870 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D15C00000000000000000001 /* LANDiscovery.swift in Sources */,
D15C00000000000000000013 /* AudioPairingSheet.swift in Sources */,
D15C00000000000000000011 /* AudioPairing.swift in Sources */,
D15C00000000000000000015 /* PhoneLocation.swift in Sources */,
754B6F8C853BDD47D7593948 /* NetworkTransport.swift in Sources */,
B2DE76B1CE686BDFB635FAAE /* GalaxyAssetStore.swift in Sources */,
887D84D1E3F00F0A5A8AD090 /* PrivateLANHTTP.swift in Sources */,
140107256F4F4DF2910BED61 /* BluetoothTransport.swift in Sources */,
FACE00000000000000000001 /* PairingCode.swift in Sources */,
C1A100000000000000000001 /* ClaimPairing.swift in Sources */,
28D504D2BD92FF8A8AEB68A7 /* CompanionDiagnostics.swift in Sources */,
A6D597850E6DD0CDFB754EF8 /* CarPlaySceneDelegate.swift in Sources */,
36EBF9BB2BE027F139572ADA /* GalaxyBluetoothApp.swift in Sources */,
C0AA00000000000000000001 /* ConnectionSettingsView.swift in Sources */,
8BA587954B4079F504AAD35E /* GalaxyRequestTransport.swift in Sources */,
DE64BADD40C934E610D4D505 /* GalaxyWebView.swift in Sources */,
1698566BBEF2185D9C80B6BB /* HTTP.swift in Sources */,
5992DC6B04748581DE47AA02 /* LoopbackServer.swift in Sources */,
BC3706D8A74BDB5B0AA8063E /* PairingKeyStore.swift in Sources */,
7F54642B30B38C648001D4ED /* Wire.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
35B1014F135DE9B45529B733 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
SDKROOT = iphoneos;
};
name = Release;
};
488864B3D19480A82F7F1786 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
SDKROOT = iphoneos;
};
name = Debug;
};
952548237D7F645F11D1E7B6 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = GalaxyBluetooth/CarPlay.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 5P9DLC338G;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = GalaxyBluetooth/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
PRODUCT_BUNDLE_IDENTIFIER = link.galaxy.bluetooth.natehuby76;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
E2D3A31BEAA1368FC62DA77A /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = GalaxyBluetooth/CarPlay.entitlements;
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 5P9DLC338G;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = GalaxyBluetooth/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
PRODUCT_BUNDLE_IDENTIFIER = link.galaxy.bluetooth.natehuby76;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
442D61E217DD044EA4245BF2 /* Build configuration list for PBXProject "GalaxyBluetooth" */ = {
isa = XCConfigurationList;
buildConfigurations = (
488864B3D19480A82F7F1786 /* Debug */,
35B1014F135DE9B45529B733 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EE8ED050E3386ACB51F5825B /* Build configuration list for PBXNativeTarget "GalaxyBluetooth" */ = {
isa = XCConfigurationList;
buildConfigurations = (
952548237D7F645F11D1E7B6 /* Debug */,
E2D3A31BEAA1368FC62DA77A /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 244210E48437B6556980A702 /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "34A04005BCAF206EEC990BD9"
BuildableName = "GalaxyBluetooth.app"
BlueprintName = "GalaxyBluetooth"
ReferencedContainer = "container:GalaxyBluetooth.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "34A04005BCAF206EEC990BD9"
BuildableName = "GalaxyBluetooth.app"
BlueprintName = "GalaxyBluetooth"
ReferencedContainer = "container:GalaxyBluetooth.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "34A04005BCAF206EEC990BD9"
BuildableName = "GalaxyBluetooth.app"
BlueprintName = "GalaxyBluetooth"
ReferencedContainer = "container:GalaxyBluetooth.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,149 @@
import Foundation
#if os(iOS)
import AVFAudio
#endif
/// After this iPhone is paired for Galaxy, also pair it with comma for Bluetooth music.
/// iOS apps cannot start a classic Bluetooth pairing, so comma opens a short window and the
/// person taps comma in Settings → Bluetooth. Comma accepts only one phone and needs the code approved.
@MainActor
final class AudioPairing: ObservableObject {
enum Phase: Equatable {
case idle
case opening
case waiting(commaName: String)
case confirm(code: String, promptID: String, commaName: String)
case done
case connected
case message(String)
}
struct Snapshot: Equatable {
var adapterAddress: String
var adapterName: String
var state: String?
var promptID: String?
var promptKind: String?
var code: String
var displayOnly: Bool
}
@Published private(set) var phase: Phase = .idle
private let transport: () -> (any GalaxyRequestTransport)?
private let pause: () async -> Void
private let audioOutput: (String) -> Bool
private let attempts: Int
private var task: Task<Void, Never>?
init(transport: @escaping () -> (any GalaxyRequestTransport)?, attempts: Int = 125,
audioOutput: ((String) -> Bool)? = nil,
pause: @escaping () async -> Void = { try? await Task.sleep(nanoseconds: 1_000_000_000) }) {
self.transport = transport; self.attempts = attempts; self.pause = pause; self.audioOutput = audioOutput ?? { AudioPairing.isAudioOutput($0) }
}
static func isAudioOutput(_ commaName: String) -> Bool {
#if os(iOS)
return AVAudioSession.sharedInstance().currentRoute.outputs.contains {
$0.portType == .bluetoothA2DP && $0.portName == commaName
}
#else
return false
#endif
}
var active: Bool { phase != .idle }
nonisolated static func validAddress(_ value: String) -> Bool {
value.range(of: "^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", options: .regularExpression) != nil
}
/// Only the window this phone opened counts; any other pairing belongs to someone else.
nonisolated static func parse(_ data: Data) -> Snapshot? {
guard let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
let pairing = (value["pairing"] as? [String: Any]).flatMap { ($0["incoming"] as? Bool) == true ? $0 : nil }
let prompt = pairing?["prompt"] as? [String: Any]
return Snapshot(adapterAddress: (value["adapterAddress"] as? String) ?? "", adapterName: (value["adapterName"] as? String) ?? "",
state: pairing?["state"] as? String, promptID: prompt?["id"] as? String, promptKind: prompt?["kind"] as? String,
code: (prompt?["value"] as? String) ?? "", displayOnly: (prompt?["displayOnly"] as? Bool) ?? false)
}
/// `windowOpen`: comma's pairing window is already open because the person just paired the app from comma's Pair phone.
func start(windowOpen: Bool = false) {
guard task == nil else { return }
phase = .opening
task = Task { [weak self] in
await self?.run(windowOpen: windowOpen)
self?.task = nil
}
}
func respond(accepted: Bool) {
guard case .confirm(_, let promptID, let name) = phase else { return }
phase = .waiting(commaName: name)
Task { [weak self] in
guard let self else { return }
_ = try? await self.post(["operation": "pairing_response", "promptId": promptID, "accepted": accepted])
}
}
func cancel() {
task?.cancel()
task = nil
let wasOpen = phase != .idle && phase != .done && phase != .connected
phase = .idle
if wasOpen { Task { [weak self] in _ = try? await self?.post(["operation": "cancel_pair"]) } }
}
func dismiss() { if case .message = phase { phase = .idle } else if phase == .done || phase == .connected { phase = .idle } else { cancel() } }
private func post(_ payload: [String: Any]) async throws -> BridgeResponse? {
guard let transport = transport(), transport.connected else { return nil }
let body = try JSONSerialization.data(withJSONObject: payload)
return try await transport.request(path: "/api/bluetooth/action", method: "POST",
headers: ["Content-Type": "application/json"], body: body)
}
private func status() async -> Snapshot? {
guard let transport = transport(), transport.connected,
let response = try? await transport.request(path: "/api/bluetooth/status", method: "GET", headers: [:], body: Data()),
response.status == 200 else { return nil }
return Self.parse(response.bodyData)
}
private func run(windowOpen: Bool) async {
guard let first = await status() else { phase = .message("Phone music wasn't set up. Connect to your comma and try again."); return }
// An older comma reports no adapter address and has no pairing window.
guard Self.validAddress(first.adapterAddress) else { phase = .idle; return }
let name = first.adapterName.isEmpty ? "comma" : first.adapterName
if audioOutput(name) { phase = .connected; return }
if !windowOpen {
let opened: BridgeResponse?
do { opened = try await post(["operation": "accept_pair"]) } catch { opened = nil }
switch opened?.status {
case 200?: break
case 400?: phase = .idle; return
case 409?: phase = .message("Phone music wasn't set up because comma's Bluetooth is busy. Park, stop Android Auto pairing, then try Set up phone music again."); return
default: phase = .message("Phone music wasn't set up. Try Set up phone music again."); return
}
}
phase = .waiting(commaName: name)
var answered: String?
for _ in 0..<attempts {
await pause()
if Task.isCancelled { return }
if audioOutput(name) { phase = .connected; return }
guard let current = await status() else { continue }
if let id = current.promptID, !current.displayOnly, current.promptKind == "confirmation", id != answered {
answered = id
phase = .confirm(code: current.code, promptID: id, commaName: name)
}
switch current.state {
case "paired"?: phase = .done; return
case "failed"?, "canceled"?:
phase = .message("Phone music pairing didn't finish. Try Set up phone music again."); return
default: break
}
}
phase = .message("The pairing window closed. Try Set up phone music again.")
}
}
@@ -0,0 +1,37 @@
import SwiftUI
struct AudioPairingSheet: View {
@ObservedObject var pairing: AudioPairing
let close: () -> Void
var body: some View {
VStack(spacing: 18) {
Image(systemName: "music.note").font(.system(size: 40))
Text("Bluetooth music").font(.title3.weight(.semibold))
switch pairing.phase {
case .idle, .opening:
ProgressView("Asking comma to open pairing…")
case .waiting(let name):
Text("On your iPhone, open **Settings → Bluetooth** and select **\(name)** to pair music.")
.multilineTextAlignment(.center)
ProgressView()
Text("Already says Connected? In Control Center, check whether comma is available as an audio output.")
.font(.callout).multilineTextAlignment(.center)
case .confirm(let code, _, _):
Text("Check that comma and this iPhone both show").multilineTextAlignment(.center)
Text(code).font(.system(size: 34, weight: .semibold, design: .monospaced))
HStack {
Button("Cancel") { pairing.respond(accepted: false) }.buttonStyle(.bordered)
Button("Pair") { pairing.respond(accepted: true) }.buttonStyle(.borderedProminent)
}
case .done:
Text("Bluetooth music paired. In Control Center, choose comma as the audio output.").multilineTextAlignment(.center)
case .connected:
Text("Music connected. This iPhone is already using comma as its Bluetooth audio output.").multilineTextAlignment(.center)
case .message(let text):
Text(text).multilineTextAlignment(.center)
}
Button((pairing.phase == .done || pairing.phase == .connected) ? "Done" : "Close") { close() }.padding(.top, 6)
}.padding(24).presentationDetents([.medium, .large])
}
}
@@ -0,0 +1,579 @@
import Foundation
import Combine
import CoreBluetooth
import CryptoKit
import UIKit
struct NearbyDevice: Identifiable {
let id: UUID
let name: String
let signal: Int
}
@MainActor
final class BluetoothTransport: NSObject, ObservableObject, GalaxyRequestTransport, @preconcurrency CBCentralManagerDelegate, @preconcurrency CBPeripheralDelegate {
@Published var devices: [NearbyDevice] = []
@Published var status = "Turn on Bluetooth, then scan for your comma."
@Published var isScanning = false
var onEvent: ((String) -> Void)?
@Published var connected = false
@Published var connecting = false
@Published private(set) var fastReadFallback = false
@Published var fastReads = UserDefaults.standard.bool(forKey: "galaxy.bluetooth.fastReads") {
didSet {
UserDefaults.standard.set(fastReads, forKey: "galaxy.bluetooth.fastReads")
if fastReads != oldValue {
fastReadFallback = false
onEvent?("Bluetooth read mode: \(fastReads ? "experimental notifications" : "read stream"); applies to the next request")
}
}
}
private var central: CBCentralManager!
private var peripherals: [UUID: CBPeripheral] = [:]
private var peripheral: CBPeripheral?
private var rx: CBCharacteristic?
private var tx: CBCharacteristic?
private var notify: CBCharacteristic?
private var key: SymmetricKey?
private var keyText = ""
private var session = ""
var sessionIdentifier: String { session }
private(set) var catalogSHA256: String?
private var supportsReadStream = false
private var activeReadStream = false
private var supportsNotifications = false
private var activeNotifications = false
private var pairingVerified = false
private var notificationTag = Data()
private var notificationCount = 0
private var notificationBuffer: [Data] = []
private var notificationTimeout: Task<Void, Never>?
private var counter: UInt64 = 0
private var queue: [Pending] = []
private var priorityStreak = 0
private var requestStarted = 0.0
private var receivedPackets = 0
private var receivedWireBytes = 0
private var active: Pending?
private var outgoing: [Data] = []
private var writeIndex = 0
private var assembler = FrameAssembler()
private var awaitingAck = false
private var completedResponse: BridgeResponse?
private var activeCancelled = false
private var timeoutTask: Task<Void, Never>?
private var scanTimeout: Task<Void, Never>?
private var connectionTimeout: Task<Void, Never>?
private struct Pending {
let id: String
let path: String
let method: String
let headers: [String: String]
let body: Data
let phone: BridgePhone?
let enqueuedAt = ProcessInfo.processInfo.systemUptime
let completion: CheckedContinuation<BridgeResponse, Error>
}
override init() {
super.init()
central = CBCentralManager(delegate: self, queue: .main)
}
func reconnectSaved(pairingKey: String) {
guard !connected, !connecting, central.state == .poweredOn,
let value = UserDefaults.standard.string(forKey: "galaxy.bluetooth.device"),
let id = UUID(uuidString: value),
let found = central.retrievePeripherals(withIdentifiers: [id]).first else { return }
peripherals[id] = found
connect(NearbyDevice(id: id, name: found.name ?? "comma", signal: 0), pairingKey: pairingKey)
}
func forgetSavedDevice() {
disconnect()
UserDefaults.standard.removeObject(forKey: "galaxy.bluetooth.device")
}
func scan() {
guard central.state == .poweredOn else { onEvent?("Bluetooth scan blocked: state=\(central.state.rawValue)"); status = "Bluetooth is unavailable or permission is required."; return }
devices = []
peripherals = [:]
onEvent?("Bluetooth service scan started; timeout=15s")
for found in central.retrieveConnectedPeripherals(withServices: [CBUUID(string: Wire.service)]) {
peripherals[found.identifier] = found
devices.append(NearbyDevice(id: found.identifier, name: found.name ?? "Galaxy", signal: 0))
}
onEvent?("Bluetooth scan found \(devices.count) already-connected service candidates")
central.scanForPeripherals(withServices: [CBUUID(string: Wire.service)], options: nil)
isScanning = true
status = "Looking for the Galaxy Bluetooth bridge…"
scanTimeout?.cancel()
scanTimeout = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 15_000_000_000)
guard !Task.isCancelled, let self else { return }
self.onEvent?("Bluetooth service scan finished: candidates=\(self.devices.count)")
self.central.stopScan()
self.isScanning = false
self.status = self.devices.isEmpty ? "No bridge found. Start the bridge on your comma and enable Bluetooth in StarPilot." : "Select your comma to connect."
}
}
func connect(_ device: NearbyDevice, pairingKey: String) {
guard let bytes = Data(hex: pairingKey), bytes.count == 32 else {
status = "Paste the 64-character pairing key created on your comma."
return
}
guard let found = peripherals[device.id] else { return }
disconnect()
key = SymmetricKey(data: bytes)
keyText = bytes.hex
peripheral = found
found.delegate = self
connecting = true
status = "Connecting to \(device.name)…"
onEvent?("Starting Bluetooth connection")
central.connect(found, options: nil)
connectionTimeout = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 20_000_000_000)
guard !Task.isCancelled, let self, !self.connected else { return }
self.failConnection("Bluetooth connection timed out. Check the comma bridge and pairing key.")
}
}
func disconnect(reason: String = "Bluetooth connection closed") {
let wasConnected = connected
central.stopScan()
scanTimeout?.cancel()
connectionTimeout?.cancel()
isScanning = false
connected = false
connecting = false
failAll(BridgeError.message("Bluetooth connection closed."))
if let peripheral { central.cancelPeripheralConnection(peripheral) }
peripheral = nil
rx = nil
tx = nil
notify = nil
session = ""
counter = 0
priorityStreak = 0
catalogSHA256 = nil
supportsReadStream = false
activeReadStream = false
supportsNotifications = false
activeNotifications = false
pairingVerified = false
notificationBuffer = []
if wasConnected { onEvent?(reason) }
}
private func failConnection(_ message: String) {
if Self.shouldPauseFastReads(method: active?.method, notifications: activeNotifications,
cancelled: message.lowercased().contains("cancelled")) {
fastReadFallback = true
onEvent?("Fast Bluetooth reads paused after a transfer failure. Reliable reads will be used after reconnecting; toggle fast reads off and on to retry.")
}
let lower = message.lowercased()
let reason: String
if lower.contains("request timed out") { reason = "Bluetooth request timed out" }
else if lower.contains("push stalled") { reason = "Bluetooth notification transfer stalled" }
else if lower.contains("cancelled") { reason = "Bluetooth request cancelled during transfer" }
else if lower.contains("timed out") { reason = "Bluetooth connection timed out" }
else if lower.contains("pairing") { reason = "Bluetooth pairing or authentication failed" }
else if lower.contains("encrypt") || lower.contains("verify bluetooth") { reason = "Bluetooth encrypted response failed" }
else { reason = "Bluetooth connection failed" }
if let active {
onEvent?("\(reason): \(active.method) \(LoopbackServer.reportPath(active.path)), queued=\(queue.count), sent=\(writeIndex)/\(outgoing.count), received packets=\(notificationCount), awaiting ack=\(awaitingAck)")
}
disconnect(reason: reason)
onEvent?(reason)
status = message
}
func request(path: String, method: String, headers: [String: String], body: Data) async throws -> BridgeResponse {
try await request(path: path, method: method, headers: headers, body: body, phone: nil)
}
private func request(path: String, method: String, headers: [String: String], body: Data,
phone: BridgePhone?) async throws -> BridgeResponse {
let id = UUID().uuidString
return try await withTaskCancellationHandler(operation: {
try Task.checkCancellation()
return try await withCheckedThrowingContinuation { continuation in
guard rx != nil, tx != nil, !session.isEmpty else {
continuation.resume(throwing: BridgeError.message("Connect to your comma over Bluetooth first."))
return
}
guard queue.count < 32, body.count <= Wire.maxBody else {
continuation.resume(throwing: BridgeError.message("Bluetooth queue is full or upload exceeds 1 MiB."))
return
}
queue.append(Pending(id: id, path: path, method: method, headers: headers, body: body, phone: phone, completion: continuation))
pump()
}
}, onCancel: { [weak self] in
DispatchQueue.main.async { self?.cancel(id) }
})
}
private func cancel(_ id: String) {
if let index = queue.firstIndex(where: { $0.id == id }) {
queue.remove(at: index).completion.resume(throwing: CancellationError())
} else if active?.id == id {
if Self.canDrainCancellation(method: active?.method, requestSent: writeIndex >= outgoing.count) {
// Finish read-only requests even if their outgoing frame is still being sent.
activeCancelled = true
} else {
failConnection("Request cancelled. Reconnect before sending another request.")
}
}
}
static func canDrainCancellation(method: String?, requestSent: Bool) -> Bool {
method == "GET" || requestSent
}
static func useNotifications(method: String, path: String, supported: Bool, fastReads: Bool = false) -> Bool {
supported && (method != "GET" || fastReads) && path != "/_bridge/health"
}
static func shouldPauseFastReads(method: String?, notifications: Bool, cancelled: Bool) -> Bool {
method == "GET" && notifications && !cancelled
}
static func backgroundRead(_ path: String) -> Bool {
let endpoint = path.split(separator: "?", maxSplits: 1).first.map(String.init) ?? path
return ["/api/device/state", "/api/system/monitor", "/api/local-access", "/api/models/status", "/api/software/status", "/api/drives/stats"].contains(endpoint)
}
static func nextRequestIndex(methods: [String], paths: [String], priorityStreak: Int) -> Int {
guard methods.count == paths.count, !methods.isEmpty, methods[0] == "GET", priorityStreak < 3 else { return 0 }
// A queued write is a barrier: never move a read from after it to before it.
for index in methods.indices {
if methods[index] != "GET" { break }
if !backgroundRead(paths[index]) { return index }
}
return 0
}
private func pump() {
guard active == nil, !queue.isEmpty, let peripheral, let key, !session.isEmpty else { return }
let index = Self.nextRequestIndex(methods: queue.map(\.method), paths: queue.map(\.path), priorityStreak: priorityStreak)
active = queue.remove(at: index)
guard let active else { return }
priorityStreak = active.method == "GET" && !Self.backgroundRead(active.path) ? min(3, priorityStreak + 1) : 0
requestStarted = ProcessInfo.processInfo.systemUptime
receivedPackets = 0
receivedWireBytes = 0
counter += 1
assembler = FrameAssembler()
completedResponse = nil
activeCancelled = false
awaitingAck = false
writeIndex = 0
activeNotifications = Self.useNotifications(method: active.method, path: active.path, supported: supportsNotifications, fastReads: fastReads && !fastReadFallback)
notificationCount = 0
notificationBuffer = []
activeReadStream = supportsReadStream && active.path != "/_bridge/health"
onEvent?("Bluetooth request started: \(LoopbackServer.reportPath(active.path)), queued=\(queue.count), waited=\(String(format: "%.2f", requestStarted - active.enqueuedAt))s, response=\(activeNotifications ? "notifications" : activeReadStream ? "read stream" : "encrypted reads")")
do {
notificationTag = try Wire.streamTag(session: session, counter: counter)
let request = BridgeRequest(id: active.id, session: session, counter: counter, path: active.path,
method: active.method, headers: active.headers, body: active.body.base64EncodedString(),
phone: active.phone, responseCodec: 2, responseFlow: activeNotifications ? "notify-window8" : activeReadStream ? "read-stream" : nil)
outgoing = Wire.packets(try Wire.seal(request, key: key, direction: "request"),
size: min(supportsNotifications ? 512 : 180, peripheral.maximumWriteValueLength(for: .withoutResponse)))
timeoutTask = Task { @MainActor [weak self] in
let seconds: UInt64 = active.path == "/_bridge/health" ? 10 : 90
try? await Task.sleep(nanoseconds: seconds * 1_000_000_000)
guard !Task.isCancelled, let self, self.active?.id == active.id else { return }
self.failConnection("Bluetooth request timed out. Its outcome may be unknown; reconnect to check before retrying.")
}
writeNext()
} catch {
finish(.failure(error))
}
}
private func writeNext() {
guard let peripheral, let rx, active != nil else { return }
if writeIndex < outgoing.count {
peripheral.writeValue(outgoing[writeIndex], for: rx, type: .withResponse)
} else if activeNotifications {
drainNotifications()
} else if let tx {
peripheral.readValue(for: tx)
}
}
private func finish(_ result: Result<BridgeResponse, Error>) {
timeoutTask?.cancel()
notificationTimeout?.cancel()
timeoutTask = nil
let previous = active
if let previous {
let outcome: String
switch result {
case .success(let response): outcome = "HTTP \(response.status), \(response.bodyData.count) bytes"
case .failure: outcome = "failed"
}
let elapsed = max(0.001, ProcessInfo.processInfo.systemUptime - requestStarted)
onEvent?("Bluetooth request finished: \(LoopbackServer.reportPath(previous.path)), \(outcome), packets=\(receivedPackets), wire=\(receivedWireBytes) bytes, rate=\(String(format: "%.1f", Double(receivedWireBytes) / elapsed / 1024)) KiB/s, transfer=\(String(format: "%.2f", ProcessInfo.processInfo.systemUptime - requestStarted))s")
}
active = nil
outgoing = []
notificationBuffer = []
if activeCancelled { previous?.completion.resume(throwing: CancellationError()) }
else { previous?.completion.resume(with: result) }
activeCancelled = false
pump()
}
private func failAll(_ error: Error) {
timeoutTask?.cancel()
notificationTimeout?.cancel()
let pending = queue
queue = []
let previous = active
active = nil
outgoing = []
notificationBuffer = []
previous?.completion.resume(throwing: error)
pending.forEach { $0.completion.resume(throwing: error) }
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
onEvent?("iOS Bluetooth state changed: \(central.state.rawValue)")
switch central.state {
case .poweredOn: status = "Ready to scan for your comma."
case .unauthorized: failConnection("Allow Bluetooth access in iPhone Settings.")
case .poweredOff: failConnection("Bluetooth is turned off.")
default: failConnection("Bluetooth is unavailable.")
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
peripherals[peripheral.identifier] = peripheral
let device = NearbyDevice(id: peripheral.identifier,
name: advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.name ?? "Galaxy",
signal: RSSI.intValue)
if let index = devices.firstIndex(where: { $0.id == device.id }) { devices[index] = device }
else { devices.append(device); onEvent?("Bluetooth service candidate discovered: RSSI=\(RSSI.intValue), candidates=\(devices.count)") }
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
guard peripheral == self.peripheral else { return }
onEvent?("Bluetooth link established; discovering Galaxy service")
peripheral.discoverServices([CBUUID(string: Wire.service)])
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
guard peripheral == self.peripheral else { return }
failConnection(error?.localizedDescription ?? "Could not connect to your comma.")
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
guard peripheral == self.peripheral else { return }
disconnect(reason: "Bluetooth peer disconnected: \(error.map { LANTransport.failureCode($0) } ?? "no OS error supplied")")
status = error?.localizedDescription ?? "Your comma disconnected. Scan to reconnect."
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard peripheral == self.peripheral else { return }
guard error == nil, let service = peripheral.services?.first(where: { $0.uuid == CBUUID(string: Wire.service) }) else {
failConnection("StarPilot BT service was not found."); return
}
onEvent?("Bluetooth Galaxy service found; discovering characteristics")
peripheral.discoverCharacteristics([CBUUID(string: Wire.rx), CBUUID(string: Wire.tx), CBUUID(string: Wire.info), CBUUID(string: Wire.notify)], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard peripheral == self.peripheral else { return }
guard error == nil else { failConnection(error!.localizedDescription); return }
rx = service.characteristics?.first { $0.uuid == CBUUID(string: Wire.rx) }
tx = service.characteristics?.first { $0.uuid == CBUUID(string: Wire.tx) }
notify = service.characteristics?.first { $0.uuid == CBUUID(string: Wire.notify) }
guard rx != nil, tx != nil, let info = service.characteristics?.first(where: { $0.uuid == CBUUID(string: Wire.info) }) else {
failConnection("The comma bridge has incompatible Bluetooth characteristics."); return
}
onEvent?("Bluetooth characteristics ready; reading handshake challenge")
peripheral.readValue(for: info)
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
guard peripheral == self.peripheral, characteristic.uuid == CBUUID(string: Wire.rx), active != nil else { return }
if let error { failConnection(error.localizedDescription); return }
if awaitingAck {
if activeNotifications { onEvent?("Bluetooth notification window acknowledged: packets=\(notificationCount), complete=\(completedResponse != nil)") }
awaitingAck = false
if let response = completedResponse {
completedResponse = nil
finish(.success(response))
} else if activeNotifications {
drainNotifications()
} else if let tx {
peripheral.readValue(for: tx)
}
} else {
writeIndex += 1
writeNext()
}
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard peripheral == self.peripheral else { return }
if let error { failConnection(error.localizedDescription); return }
guard let data = characteristic.value else { failConnection("Empty Bluetooth response."); return }
if characteristic.uuid == CBUUID(string: Wire.notify) {
receiveNotification(data)
return
}
if characteristic.uuid == CBUUID(string: Wire.info) {
guard data.count == 17, data.first == 1 else { failConnection("Unsupported bridge protocol."); return }
session = Data(data.dropFirst()).hex
let handshakeSession = session
status = "Verifying the pairing key…"
Task { @MainActor [weak self, weak peripheral] in
guard let self, let peripheral else { return }
do {
let response = try await self.request(path: "/_bridge/health", method: "GET", headers: [:], body: Data(),
phone: PairingKeyStore.phone(for: self.keyText, name: UIDevice.current.name))
guard peripheral == self.peripheral, self.session == handshakeSession else { return }
guard response.status == 200 else { throw BridgeError.message("Bridge verification failed.") }
let health = try JSONDecoder().decode(BridgeHealth.self, from: response.bodyData)
guard health.protocol == 1, health.transport == "bluetooth" else {
throw BridgeError.message("Unsupported bridge protocol.")
}
self.catalogSHA256 = health.catalogSHA256
self.supportsReadStream = health.readStream == true
self.pairingVerified = true
if health.notificationStream == true, let notify = self.notify, notify.properties.contains(.notify) {
self.status = "Enabling fast Bluetooth transfers…"
peripheral.setNotifyValue(true, for: notify)
} else {
try self.completeConnection()
}
} catch {
guard peripheral == self.peripheral, self.session == handshakeSession else { return }
self.failConnection("Pairing failed: \(error.localizedDescription)")
}
}
return
}
guard characteristic.uuid == CBUUID(string: Wire.tx), active != nil, let rx else { return }
if data == Data([0]) {
let requestID = active?.id
Task { @MainActor [weak self, weak peripheral] in
try? await Task.sleep(nanoseconds: 75_000_000)
guard !Task.isCancelled, let self, self.active?.id == requestID, self.active != nil, let tx = self.tx,
let peripheral, peripheral == self.peripheral else { return }
peripheral.readValue(for: tx)
}
return
}
do {
receivedPackets += 1
receivedWireBytes += data.count
if let message = try assembler.append(data), let key {
let response = try Wire.open(message, as: BridgeResponse.self, key: key, direction: "response")
guard response.id == active?.id, response.session == session, response.counter == counter,
(100...599).contains(response.status), Data(base64Encoded: response.body) != nil else {
throw BridgeError.message("Bluetooth response identity did not match the request.")
}
completedResponse = response
}
if activeReadStream, completedResponse == nil, let tx {
peripheral.readValue(for: tx)
return
}
awaitingAck = true
peripheral.writeValue(Data([2]) + data.subdata(in: 1..<5), for: rx, type: .withResponse)
} catch { failConnection("Could not verify the encrypted response: \(error.localizedDescription)") }
}
private func completeConnection() throws {
try PairingKeyStore.save(keyText)
connectionTimeout?.cancel()
if let peripheral { UserDefaults.standard.set(peripheral.identifier.uuidString, forKey: "galaxy.bluetooth.device") }
connected = true
connecting = false
status = supportsNotifications ? "Connected with fast Bluetooth transfers" : "Connected over Bluetooth"
onEvent?("Bluetooth encrypted handshake completed")
}
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
guard peripheral == self.peripheral, characteristic.uuid == CBUUID(string: Wire.notify), pairingVerified else { return }
guard error == nil, characteristic.isNotifying else {
supportsNotifications = false
if activeNotifications && active != nil {
failConnection("Bluetooth push subscription ended. Reconnect to check any pending setting change.")
} else if !connected {
do { try completeConnection() } catch { failConnection(error.localizedDescription) }
}
onEvent?("Bluetooth notifications unavailable; using encrypted reads.")
return
}
supportsNotifications = true
do { try completeConnection() }
catch { failConnection(error.localizedDescription) }
}
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
guard peripheral == self.peripheral, invalidatedServices.contains(where: { $0.uuid == CBUUID(string: Wire.service) }) else { return }
failConnection("The bridge's Bluetooth services changed. Scan and reconnect.")
}
private func receiveNotification(_ data: Data) {
guard active != nil, activeNotifications else { return }
do {
// Unrelated client/request frames must not enter this assembler.
guard try Wire.notificationPacket(data, tag: notificationTag) != nil else { return }
notificationTimeout?.cancel()
let requestID = active?.id
notificationTimeout = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 15_000_000_000)
guard !Task.isCancelled, let self, self.active?.id == requestID else { return }
self.failConnection("Bluetooth push stalled. Reconnect to check any pending setting change.")
}
if awaitingAck || writeIndex < outgoing.count {
guard notificationBuffer.count < Wire.notificationWindow else {
throw BridgeError.message("Bluetooth notification window exceeded.")
}
notificationBuffer.append(data)
} else {
try consumeNotification(data)
}
} catch { failConnection("Could not verify Bluetooth push data: \(error.localizedDescription)") }
}
private func drainNotifications() {
while active != nil, activeNotifications, !awaitingAck, !notificationBuffer.isEmpty {
let data = notificationBuffer.removeFirst()
do { try consumeNotification(data) }
catch { failConnection("Could not verify Bluetooth push data: \(error.localizedDescription)") }
}
}
private func consumeNotification(_ data: Data) throws {
guard let packet = try Wire.notificationPacket(data, tag: notificationTag), let peripheral, let rx else { return }
if let message = try assembler.append(packet), let key {
let response = try Wire.open(message, as: BridgeResponse.self, key: key, direction: "response")
guard response.id == active?.id, response.session == session, response.counter == counter,
(100...599).contains(response.status), let body = Data(base64Encoded: response.body), body.count <= Wire.maxBody else {
throw BridgeError.message("Bluetooth response identity did not match the request.")
}
completedResponse = response
}
notificationCount += 1
receivedPackets += 1
receivedWireBytes += data.count
if completedResponse != nil || notificationCount % Wire.notificationWindow == 0 {
awaitingAck = true
let sequence = Wire.number(packet.subdata(in: 1..<5))
peripheral.writeValue(Wire.notificationAck(tag: notificationTag, sequence: sequence), for: rx, type: .withResponse)
}
}
}
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.carplay-driving-task</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,554 @@
import CarPlay
import UIKit
import CoreFoundation
@MainActor
final class CarPlaySceneDelegate: NSObject, CPTemplateApplicationSceneDelegate, CPTabBarTemplateDelegate, CPSessionConfigurationDelegate {
private final class GridAction: NSObject {
var perform: @MainActor () -> Void
var signature: String
init(signature: String, perform: @escaping @MainActor () -> Void) {
self.signature = signature; self.perform = perform
}
}
private let gridActions = NSMapTable<CPGridButton, GridAction>.weakToStrongObjects()
private final class WeakGrid {
weak var page: CPGridTemplate?
init(_ page: CPGridTemplate) { self.page = page }
}
private var sessionConfiguration: CPSessionConfiguration?
private var controller: CPInterfaceController?
private var timer: Timer?
private var work: Task<Void, Never>?
private var overview: CPGridTemplate?
private var temperatures: CPGridTemplate?
private var rates: CPGridTemplate?
private var toggles: CPGridTemplate?
private var catalog: [[String: Any]] = []
private var values: [String: Any] = [:]
private var valuesSession = ""
private var valuesAt = Date.distantPast
private var categoryPage = 0
private var busy = false
private var navigating = false
private final class GridRefresh: NSObject {
let perform: @MainActor () -> Void
init(_ perform: @escaping @MainActor () -> Void) { self.perform = perform }
}
private let settingsRefresh = NSMapTable<CPGridTemplate, GridRefresh>.weakToStrongObjects()
private let overflowParents = NSMapTable<CPTemplate, CPTemplate>.weakToStrongObjects()
private let gridSizes = NSMapTable<CPGridTemplate, NSNumber>.weakToStrongObjects()
private final class MetricPageState {
let title: String
let kind: String
var index = 0
init(title: String, kind: String) { self.title = title; self.kind = kind }
}
func templateApplicationScene(_ scene: CPTemplateApplicationScene, didConnect interfaceController: CPInterfaceController) {
controller = interfaceController
sessionConfiguration = CPSessionConfiguration(delegate: self)
overview = nil; temperatures = nil; rates = nil; toggles = nil
let model = AppModel.shared
model.setCarPlayActive(true)
refreshTelemetry()
catalog = localCatalogue()
toggles = grid("Toggles", icon: "switch.2", buttons: categoryButtons())
guard let overview, let temperatures, let rates, let toggles else { return }
let tabs = CPTabBarTemplate(templates: [overview, temperatures, rates, toggles])
tabs.delegate = self
interfaceController.setRootTemplate(tabs, animated: false, completion: nil)
showCategories()
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in
Task { @MainActor in self?.refreshTelemetry() }
}
}
func templateApplicationScene(_ scene: CPTemplateApplicationScene, didDisconnectInterfaceController interfaceController: CPInterfaceController) {
timer?.invalidate(); timer = nil
work?.cancel(); work = nil
controller = nil
sessionConfiguration?.delegate = nil; sessionConfiguration = nil
overview = nil; temperatures = nil; rates = nil; toggles = nil
catalog = []; values = [:]; busy = false; categoryPage = 0; valuesSession = ""; valuesAt = .distantPast
gridActions.removeAllObjects(); gridSizes.removeAllObjects(); overflowParents.removeAllObjects(); settingsRefresh.removeAllObjects(); navigating = false
AppModel.shared.setCarPlayActive(false)
}
func tabBarTemplate(_ tabBarTemplate: CPTabBarTemplate, didSelect selectedTemplate: CPTemplate) {
if selectedTemplate === toggles { loadGalaxy() }
}
private var carAppearance: UIUserInterfaceStyle {
if let style = sessionConfiguration?.contentStyle {
if style.contains(.dark) && !style.contains(.light) { return .dark }
if style.contains(.light) && !style.contains(.dark) { return .light }
}
return controller?.carTraitCollection.userInterfaceStyle == .dark ? .dark : .light
}
func sessionConfiguration(_ sessionConfiguration: CPSessionConfiguration, contentStyleChanged contentStyle: CPContentStyle) {
let appearance: UIUserInterfaceStyle = contentStyle.contains(.dark) && !contentStyle.contains(.light) ? .dark :
contentStyle.contains(.light) && !contentStyle.contains(.dark) ? .light : carAppearance
let traits = UITraitCollection(userInterfaceStyle: appearance)
let templates = controller?.templates ?? []
var visited = Set<ObjectIdentifier>()
for template in templates.flatMap({ ($0 as? CPTabBarTemplate)?.templates ?? [$0] }) {
guard let grid = template as? CPGridTemplate, visited.insert(ObjectIdentifier(grid)).inserted else { continue }
for button in grid.gridButtons {
if #available(iOS 26.0, *), let asset = button.image.imageAsset { button.updateImage(asset.image(with: traits)) }
}
}
}
private func pushTemplate(_ template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)?) {
guard let controller, !navigating else { completion?(false, nil); return }
navigating = true
let finished: (Bool, Error?) -> Void = { [weak self, weak controller] success, error in
guard let self, let controller, self.controller === controller else { return }
self.navigating = false
completion?(success, error)
if !success { self.notice(error?.localizedDescription ?? "This page could not be opened.") }
}
// A tab container also occupies a hierarchy level. Reserve that level before pushing.
if controller.templates.count >= 4, let parent = controller.topTemplate {
controller.popTemplate(animated: false) { [weak self, weak controller] success, error in
guard let self, let controller, self.controller === controller else { return }
guard success else { finished(false, error); return }
self.overflowParents.setObject(parent, forKey: template)
if let page = template as? CPBarButtonProviding {
page.backButton = CPBarButton(image: UIImage(systemName: "chevron.left")!) { [weak self] _ in
self?.popTemplate(animated: true, completion: nil)
}
}
controller.pushTemplate(template, animated: animated, completion: finished)
}
} else {
controller.pushTemplate(template, animated: animated, completion: finished)
}
}
private func popTemplate(animated: Bool, completion: ((Bool, Error?) -> Void)?) {
guard let controller, !navigating else { completion?(false, nil); return }
let top = controller.topTemplate
let parent = top.flatMap { overflowParents.object(forKey: $0) }
navigating = true
controller.popTemplate(animated: animated) { [weak self, weak controller] success, error in
guard let self, let controller, self.controller === controller else { return }
guard success else { self.navigating = false; completion?(false, error); return }
if let top { self.overflowParents.removeObject(forKey: top) }
if let parent {
controller.pushTemplate(parent, animated: false) { [weak self] restored, error in
self?.navigating = false; completion?(restored, error)
}
} else {
self.navigating = false; completion?(true, nil)
}
}
}
private func paddedButtons(_ buttons: [CPGridButton], capacity: Int = 8) -> [CPGridButton] {
var result = Array(buttons.prefix(capacity))
while result.count < capacity {
let empty = UIGraphicsImageRenderer(size: CGSize(width: 1, height: 1)).image { _ in }
let action = GridAction(signature: "empty", perform: {})
let spacer = CPGridButton(titleVariants: [" "], image: empty) { _ in action.perform() }
gridActions.setObject(action, forKey: spacer)
spacer.isEnabled = false
result.append(spacer)
}
return result
}
private func makeGrid(title: String?, gridButtons: [CPGridButton], compact: Bool = false) -> CPGridTemplate {
let capacity = compact ? 4 : 8
let page = CPGridTemplate(title: title, gridButtons: paddedButtons(gridButtons, capacity: capacity))
gridSizes.setObject(NSNumber(value: capacity), forKey: page)
return page
}
private func grid(_ title: String, icon: String, buttons: [CPGridButton]) -> CPGridTemplate {
precondition(!buttons.isEmpty)
let template = makeGrid(title: title, gridButtons: buttons)
template.tabTitle = title
template.tabImage = UIImage(systemName: icon)
return template
}
private func button(_ title: String, icon: String, value: String? = nil, action: @escaping @MainActor () -> Void = {}) -> CPGridButton {
let configuration = UIImage.SymbolConfiguration(pointSize: 32, weight: .regular)
let symbol = UIImage(systemName: icon, withConfiguration: configuration) ?? UIImage(systemName: "circle")!
let image: UIImage
if let value {
let format = UIGraphicsImageRendererFormat()
format.scale = controller?.carTraitCollection.displayScale ?? 2
let text = value.replacingOccurrences(of: " °C", with: "°").replacingOccurrences(of: " FPS", with: "")
let asset = UIImageAsset()
for (style, color) in [(UIUserInterfaceStyle.light, UIColor.black), (.dark, UIColor.white)] {
let rendered = UIGraphicsImageRenderer(size: CGSize(width: 72, height: 72), format: format).image { _ in
if value == "On" || value == "Off" {
let on = value == "On"
(on ? UIColor.systemGreen : (style == .dark ? UIColor.darkGray : UIColor.systemGray)).setFill()
UIBezierPath(roundedRect: CGRect(x: 3, y: 7, width: 66, height: 36), cornerRadius: 18).fill()
UIColor.white.setFill()
UIBezierPath(ovalIn: CGRect(x: on ? 36 : 6, y: 10, width: 30, height: 30)).fill()
} else {
symbol.withTintColor(color, renderingMode: .alwaysOriginal).draw(in: CGRect(x: 22, y: 0, width: 28, height: 28))
}
let paragraph = NSMutableParagraphStyle(); paragraph.alignment = .center
let size: CGFloat = text.count <= 6 ? 24 : text.count <= 10 ? 18 : 14
(text as NSString).draw(in: CGRect(x: 0, y: value == "On" || value == "Off" ? 47 : 36, width: 72, height: 25), withAttributes: [
.font: UIFont.monospacedDigitSystemFont(ofSize: size, weight: .semibold),
.foregroundColor: color, .paragraphStyle: paragraph
])
}.withRenderingMode(.alwaysOriginal)
asset.register(rendered, with: UITraitCollection(traitsFrom: [
UITraitCollection(userInterfaceStyle: style), UITraitCollection(displayScale: format.scale)
]))
}
image = asset.image(with: UITraitCollection(traitsFrom: [
UITraitCollection(userInterfaceStyle: carAppearance), UITraitCollection(displayScale: format.scale)
]))
} else { image = symbol.withRenderingMode(.alwaysTemplate) }
let label = title
let state = GridAction(signature: "\(title)|\(icon)|\(value ?? "")", perform: action)
let result = CPGridButton(titleVariants: [label, compactCaption(label)], image: image) { _ in state.perform() }
gridActions.setObject(state, forKey: result)
return result
}
private func compactCaption(_ label: String) -> String {
label.replacingOccurrences(of: "Advanced ", with: "Adv. ")
.replacingOccurrences(of: "Longitudinal", with: "Speed")
.replacingOccurrences(of: "Lateral", with: "Steering")
.replacingOccurrences(of: "Acceleration", with: "Accel.")
.replacingOccurrences(of: "Deceleration", with: "Braking")
.replacingOccurrences(of: "Conditional ", with: "")
.replacingOccurrences(of: "Controller", with: "Control")
}
private func updateGrid(_ page: CPGridTemplate, buttons rawButtons: [CPGridButton]) {
precondition(!rawButtons.isEmpty)
let buttons = paddedButtons(rawButtons, capacity: gridSizes.object(forKey: page)?.intValue ?? 8)
if #available(iOS 26.0, *), page.gridButtons.count == buttons.count {
for (existing, replacement) in zip(page.gridButtons, buttons) {
guard let current = gridActions.object(forKey: existing),
let next = gridActions.object(forKey: replacement) else {
page.updateGridButtons(buttons)
return
}
current.perform = next.perform
existing.isEnabled = replacement.isEnabled
if current.signature != next.signature {
existing.updateTitleVariants(replacement.titleVariants)
existing.updateImage(replacement.image)
current.signature = next.signature
}
}
} else {
page.updateGridButtons(buttons)
}
}
private func telemetryGrid(_ existing: CPGridTemplate?, title: String, icon: String,
buttons: [CPGridButton]) -> CPGridTemplate {
if let existing { updateGrid(existing, buttons: buttons); return existing }
return grid(title, icon: icon, buttons: buttons)
}
private func external(_ metric: DiagnosticMetric) -> Bool {
let name = metric.name.lowercased()
return name.contains("external gpu") || name.contains("chestnut") || name.hasPrefix("sensor: amdgpu")
}
private func thermalGroup(_ metric: DiagnosticMetric) -> String {
let name = metric.name.lowercased()
if external(metric) { return "External GPU" }
if name.hasPrefix("sensor:") { return "Other sensors" }
if name.contains("cpu") || name.contains("dsp") || name.contains("soc") { return "Processors" }
if name.contains("gpu") { return "Onboard GPU" }
if name.contains("memory") { return "Memory" }
if name.contains("intake") || name.contains("exhaust") { return "Cooling" }
if name.contains("pmic") { return "Power" }
if name.contains("modem") || name.contains("gnss") { return "Connectivity" }
return "Other sensors"
}
private func thermalLabel(_ group: String) -> String {
switch group {
case "Processors": return "CPU / DSP"
case "Onboard GPU": return "GPU"
case "Connectivity": return "Radios"
case "Other sensors": return "Sensors"
default: return group
}
}
private func thermalIcon(_ group: String) -> String {
switch group {
case "Processors", "Onboard GPU", "External GPU": return "cpu"
case "Memory": return "memorychip"
case "Cooling": return "fan"
case "Power": return "bolt"
case "Connectivity": return "antenna.radiowaves.left.and.right"
default: return "thermometer.medium"
}
}
private func maximum(_ metrics: [DiagnosticMetric]) -> String {
guard let value = metrics.compactMap(\.value).filter(\.isFinite).max() else { return "—" }
return String(format: "%.1f °C", value)
}
private func refreshTelemetry() {
let model = AppModel.shared
let system = model.diagnostics.fresh ? model.diagnostics.system ?? [:] : [:]
let memory = system["memory"] as? [String: Any] ?? [:]
let storage = system["storage"] as? [String: Any] ?? [:]
func value(_ source: [String: Any], _ key: String, _ unit: String) -> String {
guard let number = source[key] as? NSNumber, number.doubleValue.isFinite else { return "—" }
return String(format: "%.1f %@", number.doubleValue, unit)
}
overview = telemetryGrid(overview, title: "Overview", icon: "sparkles", buttons: [
button(model.transport.label, icon: "antenna.radiowaves.left.and.right"),
button("CPU", icon: "cpu", value: value(system, "cpuPercent", "%")),
button("Memory", icon: "memorychip", value: value(memory, "percent", "%")),
button("Storage used", icon: "internaldrive", value: value(storage, "usedGiB", "GiB")),
button("Uptime", icon: "clock", value: value(system, "uptimeSeconds", "s")),
button("Processes", icon: "list.bullet", value: value(system, "processCount", ""))
])
let cores = system["cores"] as? [[String: Any]] ?? []
rates = telemetryGrid(rates, title: "CPU", icon: "cpu", buttons: cores.isEmpty ? [button("Waiting for CPU readings", icon: "cpu")] : cores.prefix(8).map {
button($0["name"] as? String ?? "CPU", icon: "cpu", value: value($0, "percent", "%"))
})
temperatures = telemetryGrid(temperatures, title: "Memory", icon: "memorychip", buttons: [
button("Used", icon: "memorychip", value: value(memory, "usedMiB", "MiB")),
button("Available", icon: "memorychip", value: value(memory, "availableMiB", "MiB")),
button("Total", icon: "memorychip", value: value(memory, "totalMiB", "MiB")),
button("Storage total", icon: "internaldrive", value: value(storage, "totalGiB", "GiB"))
])
}
private func rateGroup(_ metric: DiagnosticMetric) -> String {
let name = metric.name.lowercased()
if external(metric) { return "external" }
if name.contains("camera") { return "cameras" }
if name.contains("model") || name.contains("plan") { return "models" }
if name.contains("carstate") || name.contains("carcontrol") || name.contains("selfdrive") { return "driving" }
if name == "ui" { return "display" }
return "other"
}
private func showMetrics(_ title: String, kind: String) {
guard controller != nil else { return }
let page = makeGrid(title: title, gridButtons: [button("Loading readings…", icon: "hourglass")])
page.userInfo = MetricPageState(title: title, kind: kind)
updateMetrics(page)
pushTemplate(page, animated: true, completion: nil)
}
private func updateMetrics(_ page: CPGridTemplate) {
guard let state = page.userInfo as? MetricPageState else { return }
let diagnostics = AppModel.shared.diagnostics
guard diagnostics.fresh, let sample = diagnostics.snapshot else {
updateGrid(page, buttons: [button("Telemetry unavailable", icon: "exclamationmark.triangle") { [weak self] in self?.notice(diagnostics.status) }])
return
}
let readings = sample.temperatures.filter { thermalGroup($0) == state.kind } + sample.rates.filter { rateGroup($0) == state.kind }
let count = max(1, (readings.count + 7) / 8)
state.index = min(state.index, count - 1)
var buttons = readings.dropFirst(state.index * 8).prefix(8).map { button($0.name, icon: "gauge", value: $0.display) }
if buttons.isEmpty { buttons = [button("No readings available", icon: "questionmark.circle")] }
paging(page, index: state.index, count: count) { [weak self, weak page] index in
guard let self, let page else { return }; state.index = index; self.updateMetrics(page)
}
page.updateTitle(count > 1 ? "\(state.title) · \(state.index + 1)/\(count)" : state.title)
updateGrid(page, buttons: buttons)
}
private final class SettingsPageState {
let name: String
var snapshot: [String: Any] = [:]
var session = ""
var index = 0
init(_ name: String) { self.name = name }
}
private var session: String {
let model = AppModel.shared
return model.transport.usesLAN ? "lan-" + model.lan.generation : "ble-" + model.bluetooth.sessionIdentifier
}
private func request(_ path: String, payload: [String: Any]? = nil) async throws -> [String: Any] {
let original = session
let response = try await AppModel.shared.transport.request(path: path, method: payload == nil ? "GET" : "POST",
headers: payload == nil ? [:] : ["Content-Type": "application/json"],
body: try payload.map { try JSONSerialization.data(withJSONObject: $0) } ?? Data())
try Task.checkCancellation()
guard original == session else { throw BridgeError.message("Connection changed. Refresh the page.") }
let data = (try? JSONSerialization.jsonObject(with: response.bodyData)) as? [String: Any] ?? [:]
guard response.status == 200 else {
throw BridgeError.message(data["error"] as? String ?? "Galaxy request failed (HTTP \(response.status)).")
}
return data
}
private func localCatalogue() -> [[String: Any]] { [] }
private func categoryButtons() -> [CPGridButton] {
[button("Open toggles", icon: "switch.2") { [weak self] in self?.loadGalaxy() }]
}
private func showCategories() { loadGalaxy() }
private func loadGalaxy() {
guard let toggles else { return }
if toggles.userInfo == nil { toggles.userInfo = SettingsPageState("hub") }
loadSettings(toggles)
}
private func openSettings(_ name: String, title: String) {
let page = makeGrid(title: title, gridButtons: [button("Loading…", icon: "hourglass")])
page.userInfo = SettingsPageState(name)
pushTemplate(page, animated: true) { [weak self, weak page] success, _ in
if success, let page { self?.loadSettings(page) }
}
}
private func loadSettings(_ page: CPGridTemplate) {
guard let state = page.userInfo as? SettingsPageState, !busy else { return }
work?.cancel()
work = Task { [weak self, weak page] in
guard let self, let page else { return }
do {
let name = state.name.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed.subtracting(CharacterSet(charactersIn: "/")))!
state.snapshot = try await self.request("/api/settings/pages/" + name)
state.session = self.session
self.drawSettings(page)
} catch {
if !Task.isCancelled {
self.updateGrid(page, buttons: [self.button("Retry", icon: "arrow.clockwise") { [weak self, weak page] in
if let page { self?.loadSettings(page) }
}])
self.notice(error.localizedDescription)
}
}
}
}
private func drawSettings(_ page: CPGridTemplate) {
guard let state = page.userInfo as? SettingsPageState else { return }
let rows = state.snapshot["rows"] as? [[String: Any]] ?? []
let count = max(1, (rows.count + 7) / 8)
state.index = min(state.index, count - 1)
var buttons: [CPGridButton] = []
for index in (state.index * 8)..<min(rows.count, (state.index + 1) * 8) {
let row = rows[index]
let child = row["page"] as? String ?? ""
let value = row["value"].map { String(describing: $0) } ?? ""
let choices = row["choices"] as? [String] ?? []
let icon = !child.isEmpty ? "folder" : choices.contains("On") && choices.contains("Off") ?
(value == "On" ? "switch.2" : "switch.2") : "slider.horizontal.3"
let tile = button(row["label"] as? String ?? "Setting", icon: icon, value: child.isEmpty ? value : nil) { [weak self, weak page] in
guard let self, let page else { return }
guard state.session == self.session else { self.loadSettings(page); return }
if !child.isEmpty { self.openSettings(child, title: row["label"] as? String ?? "Toggles") }
else { self.editSetting(page, index: index, row: row) }
}
tile.isEnabled = row["available"] as? Bool == true && (!child.isEmpty || row["action"] as? Bool == true) && !busy
buttons.append(tile)
}
updateGrid(page, buttons: buttons.isEmpty ? [button("No settings available", icon: "info.circle")] : buttons)
var navigation: [CPBarButton] = []
if state.index > 0 {
navigation.append(CPBarButton(image: UIImage(systemName: "chevron.left")!) { [weak self, weak page] _ in
guard let page else { return }; state.index -= 1; self?.drawSettings(page)
})
}
if state.index + 1 < count {
navigation.append(CPBarButton(image: UIImage(systemName: "chevron.right")!) { [weak self, weak page] _ in
guard let page else { return }; state.index += 1; self?.drawSettings(page)
})
}
page.trailingNavigationBarButtons = navigation
}
private func editSetting(_ parent: CPGridTemplate, index: Int, row: [String: Any]) {
let choices = row["choices"] as? [String] ?? []
if choices.count == 2, choices.contains("On"), choices.contains("Off") {
preview(parent, index: index, selection: row["value"] as? String == "On" ? "Off" : "On")
return
}
let editor = makeGrid(title: row["label"] as? String, gridButtons: [button("Loading…", icon: "hourglass")])
var buttons: [CPGridButton] = []
if !choices.isEmpty {
var choicePage = 0
func redraw() {
let count = max(1, (choices.count + 7) / 8)
let start = choicePage * 8
let tiles = choices[start..<min(start + 8, choices.count)].map { choice in
button(choice, icon: String(describing: row["value"] ?? "") == choice ? "checkmark.circle.fill" : "circle") { [weak self] in
self?.preview(parent, index: index, selection: choice)
}
}
updateGrid(editor, buttons: tiles)
paging(editor, index: choicePage, count: count) { next in choicePage = next; redraw() }
}
redraw()
pushTemplate(editor, animated: true, completion: nil)
return
} else if (row["step"] as? NSNumber)?.doubleValue ?? 0 > 0 {
buttons = [button("Decrease", icon: "minus.circle", value: String(describing: row["value"] ?? "")) { [weak self] in self?.preview(parent, index: index, direction: -1) },
button("Increase", icon: "plus.circle", value: String(describing: row["value"] ?? "")) { [weak self] in self?.preview(parent, index: index, direction: 1) }]
} else {
buttons = [button("Continue", icon: "checkmark.circle") { [weak self] in self?.preview(parent, index: index, direction: 0) }]
}
updateGrid(editor, buttons: buttons)
pushTemplate(editor, animated: true, completion: nil)
}
private func preview(_ page: CPGridTemplate, index: Int, selection: String? = nil, direction: Int = 0) {
guard !busy, let state = page.userInfo as? SettingsPageState, state.session == session,
let view = state.snapshot["view"] as? String else { loadSettings(page); return }
busy = true
let original = session
Task { [weak self, weak page] in
guard let self, let page else { return }
defer { self.busy = false }
do {
var payload: [String: Any] = ["view": view, "row": index, "direction": direction]
if let selection { payload["value"] = selection }
let pending = try await self.request("/api/settings/preview", payload: payload)
guard let intent = pending["intent"] as? String else { throw BridgeError.message("Refresh settings and try again.") }
let confirm = CPAlertAction(title: "Save", style: .default) { [weak self, weak page] _ in
guard let self, let page else { return }
self.controller?.dismissTemplate(animated: true) { _, _ in
guard original == self.session else { self.notice("Connection changed. Refresh settings."); return }
self.commit(intent, page: page)
}
}
let cancel = CPAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in self?.controller?.dismissTemplate(animated: true, completion: nil) }
self.controller?.presentTemplate(CPAlertTemplate(titleVariants: [pending["question"] as? String ?? "Save this preference?"], actions: [cancel, confirm]), animated: true, completion: nil)
} catch { self.notice(error.localizedDescription); self.busy = false; self.loadSettings(page) }
}
}
private func commit(_ intent: String, page: CPGridTemplate) {
guard !busy else { return }
busy = true
Task { [weak self, weak page] in
guard let self, let page else { return }
do {
let result = try await self.request("/api/settings/confirm", payload: ["intent": intent, "confirmed": true])
if result["saved"] as? Bool != true { self.notice("Save could not be confirmed. Check the current value.") }
} catch { self.notice(error.localizedDescription) }
self.busy = false
if self.controller?.topTemplate !== page {
self.popTemplate(animated: true) { [weak self, weak page] _, _ in
if let page { self?.loadSettings(page) }
}
} else { self.loadSettings(page) }
}
}
private func paging(_ page: CPGridTemplate, index: Int, count: Int, change: @escaping @MainActor (Int) -> Void) {
var items: [CPBarButton] = []
if index > 0 { items.append(CPBarButton(image: UIImage(systemName: "chevron.left")!) { _ in change(index - 1) }) }
if index + 1 < count { items.append(CPBarButton(image: UIImage(systemName: "chevron.right")!) { _ in change(index + 1) }) }
page.trailingNavigationBarButtons = items
}
private func notice(_ message: String) {
guard controller?.presentedTemplate == nil else { return }
let action = CPAlertAction(title: "OK", style: .cancel) { [weak self] _ in self?.controller?.dismissTemplate(animated: true, completion: nil) }
controller?.presentTemplate(CPAlertTemplate(titleVariants: [String(message.prefix(240))], actions: [action]), animated: true, completion: nil)
}
}
@@ -0,0 +1,234 @@
import Foundation
import CryptoKit
import CoreBluetooth
/// What comma hands a phone when its pairing window is open (Settings → Bluetooth → Pair phone on comma).
struct ClaimResult: Equatable {
let key: String
let id: String?
let adapterAddress: String
let adapterName: String
}
/// Byte layout: galaxy_bluetooth/README.md, "Pairing a phone". The phone sends a P-256 public key and gets its own
/// key back, sealed under an ECDH-derived key, so nothing a nearby listener can read ever contains the key.
enum ClaimCrypto {
static let requestMagic = Data("GCL1".utf8)
static let replyMagic = Data("GCR1".utf8)
static let info = Data("galaxy-claim-v1".utf8)
static let aad = Data("galaxy-claim-v1/reply".utf8)
static let point = 65
static func request(publicKey: P256.KeyAgreement.PublicKey, name: String) -> Data {
requestMagic + publicKey.x963Representation + Data(String(name.prefix(40)).utf8)
}
static func derive(_ privateKey: P256.KeyAgreement.PrivateKey, peer: Data, challenge: Data) throws -> SymmetricKey {
let secret = try privateKey.sharedSecretFromKeyAgreement(with: P256.KeyAgreement.PublicKey(x963Representation: peer))
return secret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: challenge, sharedInfo: info, outputByteCount: 32)
}
static func open(_ reply: Data, privateKey: P256.KeyAgreement.PrivateKey, challenge: Data) throws -> ClaimResult {
let reply = Data(reply)
guard reply.count > 4 + point + 12 + 16, reply.prefix(4) == replyMagic else {
throw BridgeError.message("comma sent an unexpected reply.")
}
let key = try derive(privateKey, peer: reply.subdata(in: 4..<(4 + point)), challenge: challenge)
let nonce = try AES.GCM.Nonce(data: reply.subdata(in: (4 + point)..<(4 + point + 12)))
let rest = reply.dropFirst(4 + point + 12)
let box = try AES.GCM.SealedBox(nonce: nonce, ciphertext: rest.dropLast(16), tag: rest.suffix(16))
return try parse(try AES.GCM.open(box, using: key, authenticating: aad))
}
static func parse(_ json: Data) throws -> ClaimResult {
guard let value = try JSONSerialization.jsonObject(with: json) as? [String: Any],
let key = value["key"] as? String, key.utf8.count == 64,
key.utf8.allSatisfy({ (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }) else {
throw BridgeError.message("comma sent an unexpected pairing key.")
}
let address = (value["adapterAddress"] as? String) ?? ""
return ClaimResult(key: key.lowercased(), id: PairingCode.validID(value["id"] as? String),
adapterAddress: address.range(of: "^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", options: .regularExpression) != nil ? address.uppercased() : "",
adapterName: String(((value["adapterName"] as? String) ?? "").prefix(80)))
}
}
/// A one-shot Bluetooth connection that asks the comma with its pairing window open for a key.
/// It is separate from `BluetoothTransport` so the normal encrypted connection code is untouched.
@MainActor
final class ClaimTransport: NSObject, @preconcurrency CBCentralManagerDelegate, @preconcurrency CBPeripheralDelegate {
struct Outcome {
let result: ClaimResult
let deviceID: UUID
}
var onEvent: ((String) -> Void)?
private var central: CBCentralManager?
private var peripheral: CBPeripheral?
private var rx: CBCharacteristic?
private var tx: CBCharacteristic?
private var continuation: CheckedContinuation<Outcome, Error>?
private var privateKey = P256.KeyAgreement.PrivateKey()
private var name = ""
private var challenge = Data()
private var outgoing: [Data] = []
private var writeIndex = 0
private var assembler = FrameAssembler()
private var claimed: ClaimResult?
private var handingOff = false
private var timeout: Task<Void, Never>?
func claim(name: String) async throws -> Outcome {
guard continuation == nil else { throw BridgeError.message("Pairing is already in progress.") }
self.name = name
challenge = Data()
outgoing = []
writeIndex = 0
assembler = FrameAssembler()
claimed = nil
handingOff = false
onEvent?("Phone pairing started")
privateKey = P256.KeyAgreement.PrivateKey()
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
central = CBCentralManager(delegate: self, queue: .main)
timeout = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 30_000_000_000)
guard !Task.isCancelled else { return }
self?.fail("comma didn’t answer. On comma open Settings → Bluetooth → Pair phone, then try again.")
}
}
}
func cancel() {
guard continuation != nil else { return }
onEvent?("Phone pairing cancelled")
finish(.failure(CancellationError()))
}
private func fail(_ message: String) {
onEvent?("Phone pairing failed: " + message)
finish(.failure(BridgeError.message(message)))
}
private func finish(_ result: Result<Outcome, Error>) {
guard let continuation else { return }
self.continuation = nil
timeout?.cancel()
central?.stopScan()
if let peripheral, peripheral.state != .disconnected { central?.cancelPeripheralConnection(peripheral) }
peripheral = nil; rx = nil; tx = nil; central = nil
continuation.resume(with: result)
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard central === self.central, continuation != nil else { return }
switch central.state {
case .poweredOn: central.scanForPeripherals(withServices: [CBUUID(string: Wire.service)], options: nil)
case .unauthorized: fail("Allow Bluetooth access in iPhone Settings.")
case .poweredOff: fail("Turn on Bluetooth to pair with comma.")
case .unknown, .resetting: break
default: fail("Bluetooth is unavailable.")
}
}
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
guard central === self.central, continuation != nil, self.peripheral == nil else { return }
onEvent?("Phone pairing found comma; connecting")
central.stopScan()
self.peripheral = peripheral
peripheral.delegate = self
central.connect(peripheral, options: nil)
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
guard central === self.central, peripheral == self.peripheral else { return }
onEvent?("Phone pairing connected; discovering service")
peripheral.discoverServices([CBUUID(string: Wire.service)])
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
guard central === self.central, peripheral == self.peripheral else { return }
fail(error?.localizedDescription ?? "Could not connect to comma.")
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
guard central === self.central, peripheral == self.peripheral, continuation != nil else { return }
if handingOff, let claimed {
onEvent?("Phone pairing complete; starting encrypted connection")
finish(.success(Outcome(result: claimed, deviceID: peripheral.identifier)))
} else {
fail(error?.localizedDescription ?? "comma disconnected.")
}
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard peripheral == self.peripheral else { return }
guard error == nil, let service = peripheral.services?.first(where: { $0.uuid == CBUUID(string: Wire.service) }) else {
fail("Galaxy Bluetooth service was not found."); return
}
peripheral.discoverCharacteristics([CBUUID(string: Wire.rx), CBUUID(string: Wire.tx), CBUUID(string: Wire.info)], for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard peripheral == self.peripheral else { return }
rx = service.characteristics?.first { $0.uuid == CBUUID(string: Wire.rx) }
tx = service.characteristics?.first { $0.uuid == CBUUID(string: Wire.tx) }
guard error == nil, rx != nil, tx != nil,
let info = service.characteristics?.first(where: { $0.uuid == CBUUID(string: Wire.info) }) else {
fail("The comma bridge has incompatible Bluetooth characteristics."); return
}
peripheral.readValue(for: info)
}
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
guard peripheral == self.peripheral, characteristic.uuid == CBUUID(string: Wire.rx) else { return }
if let error { fail(error.localizedDescription); return }
if claimed != nil {
// Release this connection before the normal transport connects to the same comma.
handingOff = true
onEvent?("Phone pairing key received; closing setup connection")
central?.cancelPeripheralConnection(peripheral)
return
}
writeIndex += 1
if writeIndex < outgoing.count, let rx { peripheral.writeValue(outgoing[writeIndex], for: rx, type: .withResponse) }
else if let tx { peripheral.readValue(for: tx) }
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard peripheral == self.peripheral else { return }
if let error { fail(error.localizedDescription); return }
guard let data = characteristic.value else { fail("Empty Bluetooth response."); return }
if characteristic.uuid == CBUUID(string: Wire.info) {
guard data.count == 17, data.first == 1, let rx else { fail("Unsupported bridge protocol."); return }
challenge = Data(data.dropFirst())
let body = ClaimCrypto.request(publicKey: privateKey.publicKey, name: name)
outgoing = Wire.packets(Wire.uint32(UInt32(body.count)) + body,
size: min(180, peripheral.maximumWriteValueLength(for: .withoutResponse)))
writeIndex = 0
assembler = FrameAssembler()
peripheral.writeValue(outgoing[0], for: rx, type: .withResponse)
return
}
guard characteristic.uuid == CBUUID(string: Wire.tx), let tx, let rx else { return }
if data == Data([0]) {
// Comma is still working on its answer.
Task { @MainActor [weak self, weak peripheral] in
try? await Task.sleep(nanoseconds: 75_000_000)
guard let self, let peripheral, peripheral == self.peripheral, self.continuation != nil else { return }
peripheral.readValue(for: tx)
}
return
}
do {
if let reply = try assembler.append(data) {
claimed = try ClaimCrypto.open(reply, privateKey: privateKey, challenge: challenge)
peripheral.writeValue(Data([2]) + data.subdata(in: 1..<5), for: rx, type: .withResponse)
} else {
peripheral.readValue(for: tx)
}
} catch { fail("Could not read comma’s pairing reply: \(error.localizedDescription)") }
}
}
@@ -0,0 +1,348 @@
import SwiftUI
import CryptoKit
import UIKit
import ImageIO
struct DiagnosticMetric: Decodable, Identifiable {
let name: String
let value: Double?
let unit: String
var id: String { name }
var display: String { value.map { String(format: "%.1f %@", $0, unit) } ?? "Not reported" }
}
struct DiagnosticSnapshot: Decodable {
let ageSeconds: Double
let engaged: Bool?
let temperatures: [DiagnosticMetric]
let rates: [DiagnosticMetric]
let capture: CaptureStatus?
}
struct CaptureStatus: Codable {
let hookAgeSeconds: Double?
let frameAgeSeconds: Double?
let queuedImages: Int?
let captureError: String?
let encoderError: String?
let encoderStderr: String?
let encoderExitCode: Int?
let videoSize: [Int]?
let encoderRunning: Bool?
}
@MainActor
final class CompanionDiagnostics: ObservableObject {
@Published var system: [String: Any]?
@Published var snapshot: DiagnosticSnapshot?
@Published var screen: UIImage?
@Published var status = "Connect and pair with comma to read diagnostics." { didSet { record(status) } }
@Published var screenStatus = "Live View uses local Wi-Fi." { didSet { if !screenStatus.hasPrefix("Full comma display") { record(screenStatus) } } }
@Published var live = true { didSet { if live { waitingSince = Date() } } }
@Published var phoneVisible = false { didSet { if phoneVisible && !oldValue { waitingSince = Date(); copiedLog = false }; updateActivity() } }
@Published var carPlayActive = false { didSet { updateActivity() } }
@Published private(set) var receivedAt: Date?
@Published private(set) var frameAt: Date?
@Published var copiedLog = false
var transport: PreferredTransport?
var lan: LANTransport?
var phoneActive = true { didSet { updateActivity() } }
private var task: Task<Void, Never>?
private var telemetryTask: Task<Void, Never>?
private var reconnectCount = 0
private var retryDelay = 1.0
private var streamHasFrame = false
private var streamHost = ""
private var streamStarted = Date.distantPast
private var streamGeneration = UUID()
private var lastStreamAttempt = Date.distantPast
private var frameCount = 0
private var fpsStarted = Date()
@Published private(set) var displayFPS = 0.0
private var lastDiagnostics = Date.distantPast
private var lastDiagnosticsHTTP: Int?
private var waitingSince = Date()
private var events: [String] = []
private var lastEvent = ""
private var captureSample: CaptureStatus?
private var captureSampleAt: Date?
var canCopyLog: Bool {
live && phoneVisible && !frameFresh && Date().timeIntervalSince(waitingSince) >= 10
}
private func record(_ message: String) {
guard message != lastEvent else { return }
lastEvent = message
events.append("\(ISO8601DateFormatter().string(from: Date())) \(message.prefix(500))")
if events.count > 30 { events.removeFirst(events.count - 30) }
}
func diagnosticLog() -> String {
let info = Bundle.main.infoDictionary ?? [:]
var lines = ["StarPilot BT Live diagnostics", ISO8601DateFormatter().string(from: Date()),
"App: \(info["CFBundleShortVersionString"] ?? "unknown") (\(info["CFBundleVersion"] ?? "unknown"))",
"iOS: \(UIDevice.current.systemVersion)",
"Connection: \(transport?.label ?? "Disconnected")",
"LAN connected: \(lan?.connected == true)",
"Live mode: \(live); phone visible: \(phoneVisible); phone active: \(phoneActive); CarPlay active: \(carPlayActive)",
"Stream reconnect attempts: \(reconnectCount)",
"Telemetry: \(status)", "Telemetry HTTP: \(lastDiagnosticsHTTP.map(String.init) ?? "No response yet")", "Stream: \(screenStatus)",
"No frame for: \(Int(Date().timeIntervalSince(waitingSince))) seconds"]
if let captureSample, let captureSampleAt,
let data = try? JSONEncoder().encode(captureSample), let text = String(data: data, encoding: .utf8) {
lines.append("Capture sample age: \(Int(Date().timeIntervalSince(captureSampleAt))) seconds")
lines.append("Comma capture: \(text)")
} else { lines.append("Comma capture details unavailable; update the comma fork if telemetry is connected.") }
lines.append("Recent events:")
lines.append(contentsOf: events)
return lines.joined(separator: "\n")
}
var fresh: Bool {
guard let snapshot, let receivedAt, transport?.connected == true else { return false }
return snapshot.ageSeconds + Date().timeIntervalSince(receivedAt) < 5
}
var frameFresh: Bool {
guard let frameAt, lan?.connected == true else { return false }
return Date().timeIntervalSince(frameAt) < 3
}
private func updateActivity() {
let active = carPlayActive || (phoneVisible && phoneActive)
if !active {
task?.cancel(); task = nil; telemetryTask?.cancel(); telemetryTask = nil
stopStream(); return
}
guard task == nil else { return }
task = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
self.maintainStream()
do { try await Task.sleep(nanoseconds: 500_000_000) } catch { return }
}
}
telemetryTask = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
await self.pollDiagnostics()
do { try await Task.sleep(nanoseconds: 2_000_000_000) } catch { return }
}
}
}
private func pollDiagnostics() async {
guard Date().timeIntervalSince(lastDiagnostics) >= 3, let transport, transport.connected else { return }
lastDiagnostics = Date()
do {
let response = try await transport.request(path: "/api/system/monitor", method: "GET", headers: [:], body: Data())
try Task.checkCancellation()
lastDiagnosticsHTTP = response.status
let value = try JSONSerialization.jsonObject(with: response.bodyData) as? [String: Any] ?? [:]
guard response.status == 200, value["schemaVersion"] as? Int == 1 else {
throw BridgeError.message(value["error"] as? String ?? "System monitor unavailable.")
}
let cpu = (value["cpuPercent"] as? NSNumber)?.doubleValue
let memory = value["memory"] as? [String: Any] ?? [:]
let cores = value["cores"] as? [[String: Any]] ?? []
system = value
snapshot = DiagnosticSnapshot(ageSeconds: 0, engaged: nil, temperatures: [], rates:
[DiagnosticMetric(name: "CPU load", value: cpu, unit: "%"),
DiagnosticMetric(name: "Memory", value: (memory["percent"] as? NSNumber)?.doubleValue, unit: "%")] +
cores.map { DiagnosticMetric(name: $0["name"] as? String ?? "CPU", value: ($0["percent"] as? NSNumber)?.doubleValue, unit: "%") }, capture: nil)
receivedAt = Date(); status = "Connected · read only"
} catch {
if !Task.isCancelled { status = error.localizedDescription; system = nil; snapshot = nil; receivedAt = nil }
}
}
private func maintainStream() {
stopStream()
screenStatus = "Open Cameras & Monitoring in Galaxy for SecretGood’s camera views. Cameras require local Wi-Fi."
}
private func stopStream(preserveFrame: Bool = false) {
if !preserveFrame { screen = nil; frameAt = nil; displayFPS = 0 }
}
}
struct CompanionView: View {
@ObservedObject var diagnostics: CompanionDiagnostics
private let purple = Color(red: 0.55, green: 0.36, blue: 0.96)
var body: some View {
TimelineView(.periodic(from: .now, by: 1)) { _ in
if diagnostics.live { liveView } else { diagnosticView }
}
.background(Color(uiColor: .systemBackground))
.onAppear { diagnostics.phoneVisible = true; UIApplication.shared.isIdleTimerDisabled = diagnostics.live }
.onChange(of: diagnostics.live) { _, live in UIApplication.shared.isIdleTimerDisabled = live }
.onDisappear { diagnostics.phoneVisible = false; UIApplication.shared.isIdleTimerDisabled = false }
}
private var liveView: some View {
VStack(spacing: 16) {
GeometryReader { space in
if diagnostics.frameFresh, let image = diagnostics.screen {
Image(uiImage: image).resizable().scaledToFit()
.frame(width: space.size.width, height: space.size.height)
.accessibilityLabel("Live comma display")
} else {
ContentUnavailableView("Galaxy cameras", systemImage: "play.rectangle", description: Text(diagnostics.screenStatus))
.frame(width: space.size.width, height: space.size.height)
}
}.background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 20))
Label(diagnostics.screenStatus, systemImage: diagnostics.frameFresh ? "wifi" : "clock")
.font(.caption).foregroundStyle(.secondary)
if diagnostics.canCopyLog {
Text("No frame received. Copy diagnostics to share what happened.")
.font(.caption).foregroundStyle(.secondary)
copyButton
}
}.padding(16)
}
private var diagnosticView: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text("Live diagnostics").font(.title2.bold())
Text(diagnostics.fresh ? "Updating every 2 seconds" : diagnostics.status)
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text("READ ONLY").font(.system(size: 9, weight: .bold)).tracking(1)
.padding(8).background(purple.opacity(0.18), in: Capsule()).foregroundStyle(purple)
}
HStack(spacing: 12) {
Image(systemName: "car.side.fill").font(.title2).foregroundStyle(purple)
VStack(alignment: .leading, spacing: 4) {
Text(drivingStatus).font(.headline)
Text(diagnostics.fresh ? "Telemetry connected" : "Waiting for fresh telemetry")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Circle().fill(diagnostics.fresh ? Color.green : Color.orange).frame(width: 8, height: 8)
}.padding(18).background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 18))
metricSection("Temperatures", icon: "thermometer.medium", values: temperatures.filter { !isExternalGPU($0) }, thermal: true)
metricSection("Frame rates", icon: "speedometer", values: rates.filter { $0.unit == "FPS" && !isExternalGPU($0) }, thermal: false)
metricSection("Message rates", icon: "waveform.path", values: rates.filter { $0.unit != "FPS" && !isExternalGPU($0) }, thermal: false)
externalGPUView
Text("Unavailable sensors show no reading. Message rates reflect updates observed by the comma UI.")
.font(.caption).foregroundStyle(.secondary)
copyButton.frame(maxWidth: .infinity)
}.padding(20).frame(maxWidth: 700).frame(maxWidth: .infinity)
}
}
private var temperatures: [DiagnosticMetric] {
diagnostics.fresh ? diagnostics.snapshot?.temperatures ?? [] : []
}
private var rates: [DiagnosticMetric] {
diagnostics.fresh ? diagnostics.snapshot?.rates ?? [] : []
}
private func isExternalGPU(_ metric: DiagnosticMetric) -> Bool {
let name = metric.name.lowercased()
return name.contains("external gpu") || name.contains("chestnut") || name.hasPrefix("sensor: amdgpu")
}
private var externalGPUView: some View {
let sensors = temperatures.filter(isExternalGPU)
let updates = rates.filter(isExternalGPU)
return VStack(alignment: .leading, spacing: 16) {
HStack {
Label("External GPU", systemImage: "cpu").font(.title3.bold())
Spacer()
Text("CHESTNUT").font(.system(size: 9, weight: .bold)).tracking(1).foregroundStyle(purple)
}
if sensors.isEmpty && updates.isEmpty {
Text("No Chestnut telemetry reported.").font(.subheadline).foregroundStyle(.secondary)
} else {
if !sensors.isEmpty { metricCards("Temperatures", icon: "thermometer.medium", values: sensors) }
if !updates.isEmpty { metricCards("Telemetry rates", icon: "waveform.path", values: updates) }
}
}.padding(18).background(purple.opacity(0.08), in: RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(purple.opacity(0.25), lineWidth: 1))
}
private func metricName(_ metric: DiagnosticMetric) -> String {
switch metric.name {
case "External GPU": "GPU temperature"
case "External GPU memory": "Memory temperature"
case "chestnutState": "Telemetry updates"
default: metric.name
}
}
private func component(_ metric: DiagnosticMetric, thermal: Bool) -> String {
let name = metric.name.lowercased()
if thermal {
if name.hasPrefix("sensor:") { return "Additional hardware sensors" }
if name.contains("cpu") || name.contains("dsp") || name.contains("soc") { return "Processors" }
if name.contains("gpu") { return "Onboard GPU" }
if name.contains("memory") { return "Memory" }
if name.contains("pmic") { return "Power management" }
if name.contains("modem") || name.contains("gnss") { return "Connectivity & positioning" }
if name.contains("intake") || name.contains("exhaust") { return "Cooling" }
return "Other temperatures"
}
if name.contains("camera") { return "Cameras" }
if name == "ui" { return "Display" }
if name.contains("model") || name.contains("plan") { return "Models & planning" }
if name.contains("carstate") || name.contains("carcontrol") || name.contains("selfdrive") { return "Driving" }
if name.contains("device") || name.contains("peripheral") { return "Device" }
return "Other services"
}
private func metricSection(_ title: String, icon: String, values: [DiagnosticMetric], thermal: Bool) -> some View {
let order = thermal
? ["Processors", "Onboard GPU", "Memory", "Power management", "Cooling", "Connectivity & positioning", "Other temperatures", "Additional hardware sensors"]
: ["Display", "Cameras", "Models & planning", "Driving", "Device", "Other services"]
return VStack(alignment: .leading, spacing: 16) {
Label(title, systemImage: icon).font(.title3.bold())
if values.isEmpty {
Text("No readings available").font(.subheadline).foregroundStyle(.secondary)
}
ForEach(order, id: \.self) { group in
let readings = values.filter { component($0, thermal: thermal) == group }
if !readings.isEmpty {
if group == "Additional hardware sensors" || group == "Other services" || group == "Other temperatures" {
DisclosureGroup {
metricCards("", icon: icon, values: readings).padding(.top, 12)
} label: {
HStack {
Text(group).font(.subheadline.weight(.medium))
Spacer()
Text("\(readings.count)").font(.caption).foregroundStyle(.secondary)
}
}.tint(purple).padding(16)
.background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 16))
} else {
metricCards(group, icon: icon, values: readings)
}
}
}
}
}
private var drivingStatus: String {
guard diagnostics.fresh, let engaged = diagnostics.snapshot?.engaged else { return "Driving status unavailable" }
return engaged ? "StarPilot engaged" : "StarPilot not engaged"
}
private var copyButton: some View {
Button(diagnostics.copiedLog ? "Copied diagnostics" : "Copy diagnostics", systemImage: diagnostics.copiedLog ? "checkmark" : "doc.on.doc") {
UIPasteboard.general.string = diagnostics.diagnosticLog()
diagnostics.copiedLog = true
}.buttonStyle(.bordered).tint(purple)
}
private func metricCards(_ title: String, icon: String, values: [DiagnosticMetric]) -> some View {
VStack(alignment: .leading, spacing: 12) {
if !title.isEmpty { Text(title).font(.subheadline.weight(.semibold)).foregroundStyle(.secondary) }
if values.isEmpty {
Text("No readings available").font(.subheadline).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading).padding(18)
.background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 16))
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 145), spacing: 12)], spacing: 12) {
ForEach(Array(values.enumerated()), id: \.offset) { _, metric in
VStack(alignment: .leading, spacing: 12) {
Text(metricName(metric)).font(.caption.weight(.medium)).foregroundStyle(.secondary)
.lineLimit(2).frame(minHeight: 30, alignment: .topLeading)
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(metric.value.map { String(format: "%.1f", $0) } ?? "—")
.font(.system(size: 29, weight: .semibold, design: .rounded)).monospacedDigit()
Text(metric.unit).font(.caption).foregroundStyle(purple).lineLimit(1).minimumScaleFactor(0.6)
}.minimumScaleFactor(0.7).lineLimit(1)
}.frame(maxWidth: .infinity, alignment: .leading).padding(16)
.background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 16))
}
}
}
}
}
}
@@ -0,0 +1,280 @@
import SwiftUI
struct ConnectionSettingsView: View {
@ObservedObject var model: AppModel
@ObservedObject var bluetooth: BluetoothTransport
@ObservedObject var lan: LANTransport
@ObservedObject var assets: GalaxyAssetStore
@ObservedObject var server: LoopbackServer
@ObservedObject var discovery: LANDiscovery
@ObservedObject var location: PhoneLocation
@ObservedObject var audio: AudioPairing
let returningFromGalaxy: Bool
let select: (CompanionTab) -> Void
@Environment(\.openURL) private var openURL
@State private var advanced = false
@State private var confirmForget = false
@State private var checked = false
@State private var openAfterReconnect = false
private let accent = Color(red: 0.68, green: 0.48, blue: 0.96)
private let background = Color(red: 0.035, green: 0.035, blue: 0.078)
private let panel = Color(red: 0.09, green: 0.065, blue: 0.145)
private let muted = Color(red: 0.70, green: 0.65, blue: 0.77)
private var connected: Bool { model.transport.connected }
private var pairing: Bool { model.pairingInProgress }
private var needsPairing: Bool { model.mode != .lan && model.pairingKey.isEmpty }
private var connecting: Bool { !connected && (bluetooth.connecting || bluetooth.isScanning) }
private var primaryTitle: String {
if pairing { return "Pairing…" }
if connected { return model.opened ? "Open Galaxy" : "Preparing Galaxy…" }
if connecting { return "Connecting…" }
return needsPairing ? "Pair with comma" : "Reconnect"
}
private var checking: Bool { model.matchingGalaxy || assets.checking }
private var status: String { pairing ? "Pairing with comma…" : connected ? "Connected via \(model.transport.label)" : connecting ? "Connecting to comma…" : "Not connected" }
init(model: AppModel, returningFromGalaxy: Bool, select: @escaping (CompanionTab) -> Void) {
self.model = model; self.returningFromGalaxy = returningFromGalaxy; self.select = select
bluetooth = model.bluetooth; lan = model.lan; assets = model.assets; server = model.server
discovery = model.discovery; location = model.phoneLocation; audio = model.audioPairing
}
var body: some View {
ScrollView {
VStack(spacing: 8) {
HStack {
if returningFromGalaxy && model.opened {
Button { select(.galaxy) } label: {
Image(systemName: "chevron.left").frame(width: 44, height: 44)
}.accessibilityLabel("Back to Galaxy")
} else { Color.clear.frame(width: 44, height: 44) }
Spacer(minLength: 0)
Text(returningFromGalaxy ? "Settings" : "StarPilot BT")
.font(.custom("Sora-Regular", size: 22).weight(.medium)).lineLimit(1)
Spacer(minLength: 0)
Color.clear.frame(width: 44, height: 44)
}
ConnectionOrbit(route: connected ? model.transport.label : "", pairing: pairing)
.padding(.bottom, 10)
connectionPanel
Text("PREFERENCES").font(.system(size: 11, weight: .medium)).tracking(1.2)
.foregroundStyle(muted).frame(maxWidth: .infinity, alignment: .leading)
preferences
DisclosureGroup(isExpanded: $advanced) { advancedControls.padding(.top, 8) } label: {
Text("Advanced connection options").font(.system(size: 13)).frame(minHeight: 30)
}.padding(.horizontal, 14).padding(.vertical, 7).background(panel, in: RoundedRectangle(cornerRadius: 16))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(accent.opacity(0.16)))
HStack(spacing: 18) {
Button(model.loadingLegacyURL ? "Getting link…" : "Legacy Galaxy") {
Task { if let url = await model.fetchLegacyURL() {
openURL(url) { accepted in if !accepted { model.legacyMessage = "Couldn’t open your browser. Try again." } }
} }
}.disabled(model.loadingLegacyURL || pairing)
Link("Support", destination: URL(string: "https://wiki.firestar.link/ticketsystem/")!)
Link("Privacy", destination: URL(string: "https://github.com/natehuby76/ios-clone/blob/codex/secretgood-galaxy/docs/privacy.md")!)
}.font(.system(size: 12)).frame(minHeight: 44)
if !model.legacyMessage.isEmpty { message(model.legacyMessage) }
if !server.error.isEmpty { message(server.error) }
Text("StarPilot BT · \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")")
.font(.system(size: 11)).foregroundStyle(muted)
}.padding(.horizontal, 18).padding(.top, 0).padding(.bottom, 12)
.frame(maxWidth: 460).frame(maxWidth: .infinity)
}.scrollBounceBehavior(.basedOnSize).background(background).foregroundStyle(Color.white)
.onChange(of: connected) { _, _ in openReconnectedGalaxy() }
.onChange(of: model.opened) { _, _ in openReconnectedGalaxy() }
.onChange(of: model.wantsConnection) { _, value in if !value { openAfterReconnect = false } }
.tint(accent).preferredColorScheme(.dark)
.confirmationDialog("Forget Bluetooth pairing?", isPresented: $confirmForget, titleVisibility: .visible) {
Button("Forget pairing", role: .destructive) {
model.disconnect(); bluetooth.forgetSavedDevice(); PairingKeyStore.remove(); model.pairingKey = ""
lan.bind(to: nil); UserDefaults.standard.removeObject(forKey: "galaxy.comma.identity")
}
} message: { Text("This removes the saved app pairing. Pair your phone again to reconnect.") }
}
private func openReconnectedGalaxy() {
guard openAfterReconnect, connected, model.opened else { return }
openAfterReconnect = false
select(.galaxy)
}
private var connectionPanel: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Image(model.transport.label == "Wi-Fi" ? "ConnectionWiFi" : "ConnectionBluetooth").renderingMode(.template).resizable().scaledToFit().frame(width: 20, height: 20).foregroundStyle(accent)
.frame(width: 38, height: 38).background(accent.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 2) {
Text("Your comma").font(.system(size: 17, weight: .medium))
Text(status).font(.system(size: 11)).foregroundStyle(muted).accessibilityAddTraits(.updatesFrequently)
}
Spacer(minLength: 4)
Text(pairing ? "Pairing…" : connected ? "Connected" : connecting ? "Connecting…" : "Offline")
.font(.system(size: 10, weight: .medium)).foregroundStyle(pairing ? accent : connected ? .mint : muted)
.padding(.horizontal, 9).padding(.vertical, 6)
.background((pairing ? accent : connected ? Color.mint : muted).opacity(0.13), in: Capsule())
}
Picker("Connection mode", selection: $model.mode) {
Text("Automatic").tag(ConnectionMode.automatic)
Text("Bluetooth").tag(ConnectionMode.bluetooth)
Text("Wi-Fi").tag(ConnectionMode.lan)
}.pickerStyle(.segmented).disabled(pairing)
Text(pairing ? "Keep Pair phone open on comma." : model.mode == .automatic ? "Wi-Fi first, Bluetooth as backup." : model.mode == .bluetooth ? "Local controls over Bluetooth." : "Uses your local Wi-Fi network.")
.font(.system(size: 11)).foregroundStyle(muted)
Button {
if connected && model.opened { select(.galaxy) }
else if needsPairing { model.pairWithComma() }
else { openAfterReconnect = true; model.connect(); openReconnectedGalaxy() }
} label: {
HStack(spacing: 7) {
if pairing { PairingSignal() }
else if connecting || (connected && !model.opened) { ProgressView().controlSize(.small) }
Text(primaryTitle)
}.font(.system(size: 12, weight: .medium)).frame(maxWidth: .infinity, minHeight: 44)
.background(LinearGradient(colors: [Color(red: 0.73, green: 0.55, blue: 1), accent], startPoint: .leading, endPoint: .trailing), in: Capsule())
.foregroundStyle(Color(red: 0.12, green: 0.06, blue: 0.19))
}.buttonStyle(.plain).disabled(pairing || connecting || (connected && !model.opened))
if pairing {
Button("Cancel pairing") { model.cancelPairing() }.font(.system(size: 12)).frame(maxWidth: .infinity, minHeight: 30)
} else if model.connectionMessage != "Connected over \(model.transport.label)." && !connected {
message(model.connectionMessage)
}
}.padding(14).background(LinearGradient(colors: [panel, background], startPoint: .topLeading, endPoint: .bottomTrailing), in: RoundedRectangle(cornerRadius: 22))
.overlay(RoundedRectangle(cornerRadius: 22).stroke(accent.opacity(0.25)))
}
private var preferences: some View {
VStack(spacing: 0) {
row("music.note", title: "Phone music", subtitle: audio.phase == .connected ? "Comma is the audio output" : pairing ? "Waiting for app pairing" : "Audio not verified") {
Button("Set up") { model.startAudioPairing() }.disabled(!connected || pairing).font(.system(size: 12)).frame(minHeight: 44)
}
separator
row("location", title: "Share phone location", subtitle: location.status.isEmpty ? "Optional" : location.status) {
Toggle("Share phone location", isOn: Binding(get: { location.sharing }, set: { location.setSharing($0) })).labelsHidden()
}
separator
row("rectangle.on.rectangle", title: "Galaxy screens", subtitle: pairing ? "Waiting for pairing" : checking ? "Checking version…" : checked && assets.readyRoot != nil ? "Up to date · just checked" : assets.readyRoot != nil ? "Saved from your comma" : "Not downloaded · needs Wi-Fi") {
Button {
Task { await model.checkGalaxyUpdates(); checked = true }
} label: {
if checking { ProgressView().controlSize(.small).accessibilityLabel("Checking Galaxy") }
else { Text(checked ? assets.readyRoot != nil ? "✓ Checked" : "Retry" : "Check") }
}.font(.system(size: 12)).frame(minHeight: 44).disabled(checking || !connected || !model.transport.usesLAN || pairing)
}
if checked && !checking { message(assets.status).padding(.horizontal, 14).padding(.bottom, 10) }
separator
row("chevron.left.forwardslash.chevron.right", title: "Developer mode", subtitle: "Diagnostics and logs") {
Toggle("Developer mode", isOn: $model.developerMode).labelsHidden()
}
}.background(panel, in: RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(accent.opacity(0.23)))
}
private var advancedControls: some View {
VStack(alignment: .leading, spacing: 12) {
if model.developerMode {
Toggle("Fast Bluetooth reads", isOn: $bluetooth.fastReads)
message(bluetooth.fastReadFallback ? "Fast reads paused after an error. Reliable reads are active." : "Experimental. Turn off to use reliable reads.")
Button("Open Dev / Logs") { select(.logs) }
Divider()
}
if model.mode != .bluetooth {
HStack {
Button(discovery.searching ? "Stop discovery" : "Find comma on Wi-Fi") {
if discovery.searching { discovery.stop() } else { discovery.start() }
}.disabled(model.selectingDiscoveredComma || pairing)
}
message(discovery.status)
ForEach(discovery.devices) { device in
Button { Task { await model.selectDiscoveredComma(device) } } label: {
VStack(alignment: .leading) { Text(device.name); Text(device.address).font(.caption) }
}.disabled(model.selectingDiscoveredComma || pairing)
}
Toggle("Find address over Bluetooth", isOn: $model.findAddressAutomatically)
TextField("Comma IP address", text: $model.address).keyboardType(.numbersAndPunctuation)
.textInputAutocapitalization(.never).autocorrectionDisabled().textFieldStyle(.roundedBorder)
message(lan.status)
}
if model.mode != .lan {
Button("Pair again") { model.pairWithComma() }.disabled(pairing)
SecureField("64-character pairing key", text: $model.pairingKey)
.textInputAutocapitalization(.never).autocorrectionDisabled().textFieldStyle(.roundedBorder)
Button(bluetooth.isScanning ? "Scanning…" : "Scan for comma") { model.wantsConnection = true; bluetooth.scan() }
.disabled(bluetooth.isScanning || bluetooth.connecting || pairing)
message(bluetooth.status)
ForEach(bluetooth.devices) { device in
Button(device.name) {
model.opened = false; model.wantsConnection = true; lan.clearEndpoint(); lan.bind(to: nil); model.address = ""
UserDefaults.standard.removeObject(forKey: "galaxy.comma.identity")
UserDefaults.standard.removeObject(forKey: "galaxy.lan.address")
bluetooth.connect(device, pairingKey: model.pairingKey)
}.disabled(bluetooth.connecting || pairing)
}
if bluetooth.connecting { Button("Cancel Bluetooth connection") { bluetooth.disconnect() } }
Button("Forget Bluetooth pairing", role: .destructive) { confirmForget = true }.disabled(pairing)
}
message(assets.status)
if let url = model.legacyURL { Text(url.absoluteString).font(.caption).textSelection(.enabled) }
Button("Disconnect") { model.disconnect() }.disabled(pairing)
}.font(.system(size: 13)).buttonStyle(.bordered).frame(maxWidth: .infinity, alignment: .leading)
}
private var separator: some View { Rectangle().fill(accent.opacity(0.12)).frame(height: 1).padding(.leading, 42) }
private func message(_ text: String) -> some View {
Text(text).font(.system(size: 11)).foregroundStyle(muted).fixedSize(horizontal: false, vertical: true)
}
private func row<Control: View>(_ symbol: String, title: String, subtitle: String, @ViewBuilder control: () -> Control) -> some View {
HStack(spacing: 10) {
Image(systemName: symbol).foregroundStyle(accent).frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.system(size: 13, weight: .medium))
Text(subtitle).font(.system(size: 11)).foregroundStyle(muted).fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 4)
control().fixedSize(horizontal: true, vertical: false)
}.padding(.horizontal, 13).padding(.vertical, 6).frame(minHeight: 56)
}
}
private struct PairingSignal: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var pulse = false
var body: some View {
HStack(alignment: .bottom, spacing: 2) {
ForEach(0..<3) { index in
Capsule().frame(width: 3, height: CGFloat(5 + index * 4))
.opacity(reduceMotion || pulse ? 1 : 0.25)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.7).repeatForever(autoreverses: true).delay(Double(index) * 0.15), value: pulse)
}
}.frame(height: 15).onAppear { pulse = true }.accessibilityHidden(true)
}
}
private struct ConnectionOrbit: View {
let route: String
let pairing: Bool
private let purple = Color(red: 0.68, green: 0.48, blue: 0.96)
var body: some View {
ZStack {
Ellipse().stroke(purple.opacity(0.5), lineWidth: 1).frame(width: 232, height: 64).rotationEffect(.degrees(12)).position(x: 132, y: 48)
Ellipse().stroke(purple.opacity(0.35), style: StrokeStyle(lineWidth: 1, dash: [2, 4]))
.frame(width: 258, height: 76).rotationEffect(.degrees(-9)).position(x: 132, y: 48)
Circle().fill(LinearGradient(colors: [Color(red: 0.74, green: 0.53, blue: 1), purple], startPoint: .topLeading, endPoint: .bottomTrailing))
.overlay(Circle().stroke(Color.white.opacity(0.6)))
.overlay(Image("StarPilotMark").resizable().scaledToFit().padding(5))
.shadow(color: purple.opacity(0.5), radius: 16).frame(width: 60, height: 60).position(x: 132, y: 43)
node("ConnectionWiFi", angle: 155, active: route == "Wi-Fi")
node("ConnectionBluetooth", angle: 110, active: route == "Bluetooth" || pairing)
node("ConnectionNetwork", angle: -25, active: false)
}.frame(width: 264, height: 88).accessibilityHidden(true)
}
private func node(_ symbol: String, angle: Double, active: Bool) -> some View {
let theta = angle * .pi / 180, rotation = 12.0 * .pi / 180
let x = 132 + 116 * cos(theta) * cos(rotation) - 32 * sin(theta) * sin(rotation)
let y = 48 + 116 * cos(theta) * sin(rotation) + 32 * sin(theta) * cos(rotation)
return Image(symbol).renderingMode(.template).resizable().scaledToFit().frame(width: 15, height: 15).foregroundStyle(active ? Color.white : Color.white.opacity(0.65))
.frame(width: 30, height: 30).background(Color(red: 0.09, green: 0.075, blue: 0.15), in: Circle())
.overlay(Circle().stroke(Color.white.opacity(active ? 0.75 : 0.4)))
.shadow(color: purple.opacity(active ? 0.35 : 0), radius: 7).position(x: x, y: y)
}
}
@@ -0,0 +1,162 @@
import Foundation
import CryptoKit
import Combine
struct GalaxyAssetManifest: Codable {
struct File: Codable { let path: String; let sha256: String; let size: Int }
let schema: Int
let nativeBridge: Int
let files: [File]
func validate() throws {
guard schema == 1, nativeBridge == 1, (1...512).contains(files.count),
Set(files.map(\.path)).count == files.count else { throw BridgeError.message("Galaxy update needs a compatible app version.") }
var total = 0
for file in files {
let parts = file.path.split(separator: "/", omittingEmptySubsequences: false)
guard !parts.isEmpty, parts.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }),
file.path.utf8.allSatisfy({ (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) || [45, 46, 47, 95].contains($0) }),
["index.html", "manifest.webmanifest", "sentry-push-worker.js"].contains(file.path) || ["assets/", "css/", "js/", "vendor/", "data/"].contains(where: { file.path.hasPrefix($0) }),
(0...4_194_304).contains(file.size), Self.isHash(file.sha256) else {
throw BridgeError.message("Invalid Galaxy update file.")
}
total += file.size
}
guard total <= 32 * 1_048_576, files.contains(where: { $0.path == "index.html" }),
files.contains(where: { $0.path == "js/boot.js" }),
files.contains(where: { $0.path == "js/app.js" }),
files.contains(where: { $0.path == "data/catalog.json" }),
!files.contains(where: { $0.path == "data/runtime.json" || $0.path.hasSuffix(".sample.json") }) else { throw BridgeError.message("Galaxy update is incomplete or too large.") }
}
static func isHash(_ value: String) -> Bool { value.count == 64 && value.utf8.allSatisfy { (48...57).contains($0) || (97...102).contains($0) } }
static func digest(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }
}
/// Comma's inventory of its Galaxy pages, from `GET /api/companion/web-manifest`.
struct GalaxyListing: Decodable {
let schema: Int
let commit: String?
let files: [GalaxyAssetManifest.File]
}
/// One GET to comma over Wi-Fi/LAN: the HTTP status and body.
typealias GalaxyPageFetch = @Sendable (_ path: String, _ limit: Int) async throws -> (status: Int, data: Data)
/// Keeps the Bluetooth copy of Galaxy matched to the pages comma serves, downloaded over Wi-Fi/LAN.
@MainActor
final class GalaxyAssetStore: ObservableObject {
static let notDownloaded = "Galaxy screens aren’t downloaded yet. Connect to comma over Wi-Fi once."
static let tooOld = "Comma’s software is too old to share Galaxy screens; update comma."
@Published private(set) var status = GalaxyAssetStore.notDownloaded
@Published private(set) var checking = false
@Published private(set) var readyRoot: URL?
var inUseRoot: URL?
private let cacheRoot: URL
private let defaults: UserDefaults
init(cacheRoot: URL? = nil, defaults: UserDefaults = .standard) {
self.cacheRoot = cacheRoot ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("DeviceGalaxyUpdates", isDirectory: true)
self.defaults = defaults
// Validate saved assets before trusting a previous completed update.
if let hash = defaults.string(forKey: "galaxy.device.assets.manifest"), GalaxyAssetManifest.isHash(hash) {
let root = self.cacheRoot.appendingPathComponent(hash, isDirectory: true)
if Self.verify(root) { readyRoot = root; status = "Using Galaxy screens saved from comma." }
}
}
private static func verify(_ root: URL) -> Bool {
guard let data = try? Data(contentsOf: root.appendingPathComponent("manifest.json")),
GalaxyAssetManifest.digest(data) == root.lastPathComponent,
let manifest = try? JSONDecoder().decode(GalaxyAssetManifest.self, from: data),
(try? manifest.validate()) != nil else { return false }
return manifest.files.allSatisfy { file in
guard let data = try? Data(contentsOf: root.appendingPathComponent(file.path)) else { return false }
return data.count == file.size && GalaxyAssetManifest.digest(data) == file.sha256
}
}
/// True when comma now runs a different build than the one the saved screens came from.
func olderThan(commit: String?) -> Bool {
guard readyRoot != nil, let saved = defaults.string(forKey: "galaxy.device.commit"), !saved.isEmpty,
let commit, !commit.isEmpty else { return false }
return saved != commit
}
/// Over Bluetooth nothing can be downloaded; tell the user when the saved screens are behind comma.
func noteCommaBuild(commit: String?) {
if olderThan(commit: commit) { status = "Galaxy screens are from an older comma build. Connect over Wi-Fi to refresh." }
}
/// Compares comma's inventory with the saved copy and downloads only what changed. Never skips on an unchanged commit.
func check(fetch: @escaping GalaxyPageFetch, stillCurrent: () -> Bool = { true }) async {
guard !checking else { return }
checking = true
status = "Checking comma for Galaxy updates…"
defer { checking = false }
var stage: URL?
do {
let (code, listingData) = try await fetch("/api/companion/web-manifest", 1_048_576)
// Older comma builds answer 401 (no session) or 404 for the inventory path.
if code == 401 || code == 404 { throw BridgeError.message(Self.tooOld) }
guard code == 200 else { throw BridgeError.message("Comma returned HTTP \(code) for the Galaxy inventory.") }
let listing = try JSONDecoder().decode(GalaxyListing.self, from: listingData)
guard listing.schema == 1 else { throw BridgeError.message(Self.tooOld) }
let manifest = GalaxyAssetManifest(schema: 1, nativeBridge: 1, files: listing.files.sorted { $0.path < $1.path })
try manifest.validate()
let encoder = JSONEncoder(); encoder.outputFormatting = .sortedKeys
let data = try encoder.encode(manifest)
let hash = GalaxyAssetManifest.digest(data)
guard stillCurrent() else { throw CancellationError() }
if readyRoot?.lastPathComponent == hash { defaults.set(listing.commit ?? "", forKey: "galaxy.device.commit"); status = "Galaxy matches comma."; return }
let manager = FileManager.default
try manager.createDirectory(at: cacheRoot, withIntermediateDirectories: true)
let staging = cacheRoot.appendingPathComponent("staging-" + UUID().uuidString, isDirectory: true)
stage = staging
try manager.createDirectory(at: staging, withIntermediateDirectories: true)
let saved = readyRoot
var done = 0
for offset in stride(from: 0, to: manifest.files.count, by: 4) {
try Task.checkCancellation()
let batch = Array(manifest.files[offset..<min(offset + 4, manifest.files.count)])
try await withThrowingTaskGroup(of: (String, Data).self) { group in
for file in batch {
group.addTask {
func matches(_ bytes: Data) -> Bool { bytes.count == file.size && GalaxyAssetManifest.digest(bytes) == file.sha256 }
if let saved, let bytes = try? Data(contentsOf: saved.appendingPathComponent(file.path)), matches(bytes) { return (file.path, bytes) }
let (code, bytes) = try await fetch("/" + file.path, max(file.size, 1))
guard code == 200, matches(bytes) else { throw BridgeError.message("Galaxy update verification failed.") }
return (file.path, bytes)
}
}
for try await (path, bytes) in group {
let destination = staging.appendingPathComponent(path)
try manager.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
try bytes.write(to: destination, options: .atomic)
}
}
done += batch.count
status = "Preparing Galaxy update (\(done)/\(manifest.files.count))…"
}
guard stillCurrent() else { throw CancellationError() }
try data.write(to: staging.appendingPathComponent("manifest.json"), options: .atomic)
let destination = cacheRoot.appendingPathComponent(hash, isDirectory: true)
if manager.fileExists(atPath: destination.path) { try manager.removeItem(at: destination) }
try manager.moveItem(at: staging, to: destination)
stage = nil
// Keep one previous version while an open WebView is still using it.
readyRoot = destination
defaults.set(hash, forKey: "galaxy.device.assets.manifest")
defaults.set(listing.commit ?? "", forKey: "galaxy.device.commit")
if let contents = try? manager.contentsOfDirectory(at: cacheRoot, includingPropertiesForKeys: nil) {
for root in contents where root.standardizedFileURL.path != destination.standardizedFileURL.path && root.standardizedFileURL.path != saved?.standardizedFileURL.path && root.standardizedFileURL.path != inUseRoot?.standardizedFileURL.path { try? manager.removeItem(at: root) }
}
status = "Galaxy screens updated from comma. Open Galaxy to use them."
} catch {
if let stage { try? FileManager.default.removeItem(at: stage) }
let using = readyRoot == nil ? "No Galaxy screens are saved yet." : "Using your saved Galaxy screens."
if let failure = error as? URLError {
status = "Couldn’t reach comma to check Galaxy updates (network error \(failure.code.rawValue)). \(using)"
} else if let failure = error as? BridgeError {
status = "\(failure.localizedDescription) \(using)"
} else if error is CancellationError {
status = "Galaxy update cancelled. \(using)"
} else {
status = "Galaxy update could not be read or saved. \(using)"
}
}
}
}
@@ -0,0 +1,722 @@
import SwiftUI
import Network
@main
struct GalaxyBluetoothApp: App {
@StateObject private var model = AppModel.shared
var body: some Scene { WindowGroup { GalaxyRootView(model: model) } }
}
enum CompanionTab: String, CaseIterable, Identifiable {
case galaxy = "Galaxy", live = "Live View", diagnostics = "Diagnostics", logs = "Dev / Logs", connections = "Settings"
var id: String { rawValue }
var isDeveloperPage: Bool { self == .live || self == .diagnostics || self == .logs }
var icon: String {
switch self {
case .galaxy: "sparkles"
case .live: "play.rectangle"
case .diagnostics: "chart.xyaxis.line"
case .logs: "ladybug"
case .connections: "gearshape.fill"
}
}
}
struct LegacyGalaxyStatus: Decodable {
let paired: Bool
let url: String
var browserURL: URL? {
guard let parsed = URL(string: url), parsed.scheme == "https",
parsed.host == "galaxy.firestar.link", parsed.port == nil,
parsed.user == nil, parsed.password == nil, parsed.query == nil, parsed.fragment == nil,
parsed.path.range(of: "^/[A-Za-z0-9]{16}$", options: .regularExpression) != nil else { return nil }
return parsed
}
}
@MainActor
final class AppModel: ObservableObject {
static let shared = AppModel()
let diagnostics = CompanionDiagnostics()
let galaxyPage = GalaxyPageDiagnostics()
let connectionLog = ConnectionEventLog()
let discovery = LANDiscovery()
@Published var selectingDiscoveredComma = false
private var lastRoute = "Reconnecting…"
@Published var showDiagnostics = false
@Published var showAudioPairing = false
private var offerAudioPairing = false
private var audioWindowOpen = false
private let claimer = ClaimTransport()
lazy var audioPairing = AudioPairing(transport: { [weak self] in self?.transport })
lazy var phoneLocation: PhoneLocation = {
let value = PhoneLocation(transport: { [weak self] in self?.transport })
value.onSharingChanged = { [weak self] _ in self?.updateMonitoring() }
return value
}()
@Published var tab: CompanionTab = .connections
@Published var developerMode = UserDefaults.standard.bool(forKey: "galaxy.app.developerMode") {
didSet {
UserDefaults.standard.set(developerMode, forKey: "galaxy.app.developerMode")
if !developerMode {
if tab.isDeveloperPage { tab = opened ? .galaxy : .connections }
diagnostics.live = false
diagnostics.phoneVisible = false
showDiagnostics = false
}
}
}
private var galaxyRecoveryTask: Task<Void, Never>?
private var lastAutomaticGalaxyRecovery: TimeInterval = -.infinity
func recoverGalaxy(automatic: Bool = true, reload: Bool = true) {
guard phoneActive, galaxyRecoveryTask == nil else { return }
let now = ProcessInfo.processInfo.systemUptime
if automatic && reload {
guard now - lastAutomaticGalaxyRecovery >= 30 else {
galaxyPage.record("Automatic page recovery paused; use Retry Galaxy")
return
}
lastAutomaticGalaxyRecovery = now
}
galaxyRecoveryTask = Task { @MainActor in
defer { self.galaxyRecoveryTask = nil }
let previousURL = self.server.url
let healthy = await self.server.ensureAvailable()
guard self.phoneActive else { return }
if healthy {
if self.opened && (reload || self.galaxyPage.stalled || previousURL != self.server.url) {
self.galaxyPage.record("Local server checked; recreating Galaxy web page")
self.galaxyPage.retry()
}
} else { self.galaxyPage.fail("Local server unavailable after recovery attempt") }
}
}
private var phoneActive = false
private var carPlayActive = false
private var monitorTask: Task<Void, Never>?
func setPhoneActive(_ active: Bool) { phoneActive = active; if !active { automaticLAN?.cancel() }; diagnostics.phoneActive = active; if !active { discovery.stop() }; updateMonitoring() }
func setCarPlayActive(_ active: Bool) { carPlayActive = active; diagnostics.carPlayActive = active; updateMonitoring() }
private func updateMonitoring() {
// Sharing phone GPS keeps the app running in the background, so keep reconnecting too.
if phoneActive || carPlayActive || phoneLocation.sharing {
if monitorTask == nil { monitorTask = Task { await monitor() } }
} else { monitorTask?.cancel(); monitorTask = nil }
}
@Published var pairingKey = PairingKeyStore.load()
@Published var address = UserDefaults.standard.string(forKey: "galaxy.lan.address") ?? ""
@Published var findAddressAutomatically = UserDefaults.standard.object(forKey: "galaxy.lan.auto") as? Bool ?? true {
didSet { UserDefaults.standard.set(findAddressAutomatically, forKey: "galaxy.lan.auto") }
}
@Published var networkDetails = "Comma can use a hotspot, Wi-Fi or its SIM for internet. Your connection here controls how this iPhone reaches comma."
@Published var mode = ConnectionMode(rawValue: UserDefaults.standard.string(forKey: "galaxy.connection.mode") ?? "") ?? .automatic {
didSet {
automaticLAN?.cancel(); lanProbeTask?.cancel()
transport.mode = mode
UserDefaults.standard.set(mode.rawValue, forKey: "galaxy.connection.mode")
if mode == .lan { bluetooth.disconnect() }
if mode == .bluetooth { discovery.stop(); lan.invalidate(reason: "Bluetooth-only mode selected") }
logConnection("Connection mode selected: \(mode.title)")
}
}
@Published var showConnections = false
@Published var pairingInProgress = false
@Published var opened = false
@Published var connectionMessage = "Choose a connection, or use Automatic for Wi-Fi with Bluetooth fallback."
@Published var wantsConnection = true
@Published var loadingLegacyURL = false
@Published var legacyMessage = ""
@Published var legacyURL: URL?
let bluetooth: BluetoothTransport
let lan: LANTransport
let transport: PreferredTransport
let assets: GalaxyAssetStore
let server: LoopbackServer
private let pathMonitor = NWPathMonitor()
private var lastNetworkPath = ""
private var lastReconnect = Date.distantPast
private var lastAddressCheck = Date.distantPast
private var identitySession = ""
private var addressCheckSession = ""
private var localAddressNetwork = ""
private var automaticLAN: Task<Void, Never>?
private var lanProbeTask: Task<Void, Never>?
private var lastLANDiscovery = Date.distantPast
private var triedLANAddresses = Set<String>()
/// One step for the app and music: comma's open pairing window gives this iPhone its key, then music is set up in the same window.
func pairWithComma() {
guard !pairingInProgress else { return }
pairingInProgress = true
offerAudioPairing = false
showAudioPairing = false
bluetooth.disconnect(reason: "Starting new phone pairing")
connectionMessage = "Open Settings → Bluetooth → Pair phone on comma (or Pair phone in Galaxy). Looking for comma…"
Task { @MainActor in
defer { pairingInProgress = false }
if await LANPairing.openWindow(over: lan) {
connectionMessage = "Opened comma’s pairing window over Wi-Fi. Looking for comma…"
}
do { importClaim(try await claimer.claim(name: UIDevice.current.name)) }
catch is CancellationError { connectionMessage = "Pairing cancelled." }
catch { connectionMessage = error.localizedDescription }
}
}
func cancelPairing() { claimer.cancel() }
private func importClaim(_ outcome: ClaimTransport.Outcome) {
// The key is saved only after the encrypted handshake, like any imported key.
opened = false
wantsConnection = true
bluetooth.forgetSavedDevice()
PairingKeyStore.stage(PairingCode(key: outcome.result.key, id: outcome.result.id))
lan.clearEndpoint()
lan.bind(to: nil)
address = ""
identitySession = ""
addressCheckSession = ""
UserDefaults.standard.removeObject(forKey: "galaxy.comma.identity")
UserDefaults.standard.removeObject(forKey: "galaxy.lan.address")
UserDefaults.standard.set(outcome.deviceID.uuidString, forKey: "galaxy.bluetooth.device")
pairingKey = outcome.result.key
if mode == .lan { mode = .automatic }
connectionMessage = "Paired. Connecting to comma…"
offerAudioPairing = true
audioWindowOpen = true
lastReconnect = Date()
bluetooth.reconnectSaved(pairingKey: pairingKey)
if !bluetooth.connecting { bluetooth.scan() }
}
init() {
let bluetooth = BluetoothTransport()
let lan = LANTransport()
let transport = PreferredTransport(lan: lan, bluetooth: bluetooth, invalidateLAN: { lan.invalidate(reason: $0) })
let assets = GalaxyAssetStore()
self.bluetooth = bluetooth; self.lan = lan; self.transport = transport; self.assets = assets
lan.bind(to: UserDefaults.standard.string(forKey: "galaxy.comma.identity"))
let cache = GalaxyReadCache(transport: transport, sessionID: {
transport.usesLAN ? "lan-" + lan.generation : "ble-" + bluetooth.sessionIdentifier
})
server = LoopbackServer(transport: cache, webRoot: assets.readyRoot)
transport.mode = mode
diagnostics.transport = transport; diagnostics.lan = lan
galaxyPage.recover = { [weak self] automatic in self?.recoverGalaxy(automatic: automatic) }
server.start()
discovery.onEvent = { [weak self] reason in self?.connectionLog.record(reason) }
discovery.onDevicesChanged = { [weak self] in self?.discoverAutomaticLAN() }
lan.onEvent = { [weak self] reason in self?.logConnection(reason) }
bluetooth.onEvent = { [weak self] reason in self?.logConnection(reason) }
claimer.onEvent = { [weak self] reason in self?.connectionLog.record(reason) }
pathMonitor.pathUpdateHandler = { [weak self] path in
Task { @MainActor in
guard let self else { return }
let route = path.usesInterfaceType(.wifi) ? "Wi-Fi" : path.usesInterfaceType(.cellular) ? "cellular" : "other"
let reason = "iOS network path updated: \(path.status == .satisfied ? "available" : "unavailable"), \(route), expensive=\(path.isExpensive), constrained=\(path.isConstrained)"
let interfaces = path.availableInterfaces
.filter { path.usesInterfaceType($0.type) }
.map { "\($0.name):\($0.index)" }.sorted().joined(separator: ",")
let fingerprint = "\(path.status)|\(route)|\(interfaces)|ipv4=\(path.supportsIPv4)|\(LANDiscovery.networkSignature())"
guard fingerprint != self.lastNetworkPath else { return }
self.lastNetworkPath = fingerprint
self.connectionLog.record(reason)
self.automaticLAN?.cancel(); self.lanProbeTask?.cancel()
self.discovery.networkChanged()
self.triedLANAddresses.removeAll()
self.lastLANDiscovery = .distantPast
self.lan.invalidate(reason: reason)
self.lastAddressCheck = .distantPast
self.discoverAutomaticLAN()
}
}
pathMonitor.start(queue: .main)
updateMonitoring()
}
deinit { pathMonitor.cancel() }
private func logConnection(_ reason: String) {
let route = transport.label
if route != lastRoute {
connectionLog.record("\(lastRoute) → \(route): \(reason)")
lastRoute = route
} else { connectionLog.record("\(route): \(reason)") }
}
func connect() {
legacyURL = nil
do {
if mode != .bluetooth, !address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
try lan.configure(address: address)
} else if mode == .lan {
throw BridgeError.message("Enter comma’s current local IP address, or use Automatic and pair Bluetooth to find it.")
}
UserDefaults.standard.set(address.trimmingCharacters(in: .whitespacesAndNewlines), forKey: "galaxy.lan.address")
wantsConnection = true
lastReconnect = .distantPast
lastAddressCheck = .distantPast
localAddressNetwork = ""
connectionMessage = "Checking your comma…"
} catch { connectionMessage = error.localizedDescription }
}
func startAudioPairing() {
guard transport.connected else { return }
showAudioPairing = true
let windowOpen = audioWindowOpen
audioWindowOpen = false
audioPairing.start(windowOpen: windowOpen)
}
private var openingGalaxy = false
@Published private(set) var matchingGalaxy = false
private var lastGalaxyMatch = Date.distantPast
func applyMatchedGalaxy() {
guard opened, let root = assets.readyRoot, root != assets.inUseRoot else { return }
assets.inUseRoot = root
server.useWebRoot(root)
recoverGalaxy(automatic: false)
}
func checkGalaxyUpdates() async {
guard wantsConnection, transport.connected, !matchingGalaxy else { return }
matchingGalaxy = true
lastGalaxyMatch = Date()
defer { matchingGalaxy = false }
let bleSession = bluetooth.sessionIdentifier
let lanSession = lan.generation
let stillCurrent = { self.wantsConnection && self.transport.connected && self.bluetooth.sessionIdentifier == bleSession && self.lan.generation == lanSession }
if transport.usesLAN {
// Pages come straight from comma, never through the Bluetooth bridge or the read cache.
let lan = self.lan
await assets.check(fetch: { path, _ in
let response = path.hasPrefix("/api/") ? try await lan.request(path: path, method: "GET", headers: [:], body: Data()) : try await lan.page(path: path)
return (response.status, response.bodyData)
}, stillCurrent: stillCurrent)
} else if assets.readyRoot != nil {
// Over Bluetooth nothing can be downloaded; just say when the saved screens are behind comma.
guard let response = try? await transport.request(path: "/api/software/status?history=0", method: "GET", headers: [:], body: Data()),
response.status == 200, stillCurrent(),
let status = try? JSONSerialization.jsonObject(with: response.bodyData) as? [String: Any],
let installed = status["installed"] as? [String: Any] else { return }
assets.noteCommaBuild(commit: installed["commit"] as? String)
}
}
func openIfReady() {
guard wantsConnection, transport.connected, !opened, !openingGalaxy else { return }
openingGalaxy = true
Task { @MainActor in
defer { openingGalaxy = false }
if transport.usesLAN ? Date().timeIntervalSince(lastGalaxyMatch) > 15 : assets.readyRoot != nil && !matchingGalaxy {
await checkGalaxyUpdates()
}
guard wantsConnection, transport.connected, !opened else { return }
// Bluetooth Galaxy runs from screens saved from comma; the first download needs Wi-Fi/LAN.
// Over Wi-Fi/LAN an older comma that cannot share its screens still opens, read live.
guard let root = assets.readyRoot else {
guard transport.usesLAN else { return }
assets.inUseRoot = nil
opened = true
return
}
assets.inUseRoot = root
server.useWebRoot(root)
opened = true
}
}
func disconnect() {
legacyURL = nil
automaticLAN?.cancel(); lanProbeTask?.cancel()
discovery.stop()
wantsConnection = false; opened = false
bluetooth.disconnect(reason: "User selected Disconnect"); lan.invalidate(reason: "User selected Disconnect")
connectionMessage = "Disconnected."
}
func selectDiscoveredComma(_ device: DiscoveredComma) async {
guard !selectingDiscoveredComma, mode != .bluetooth else { return }
discovery.stop()
selectingDiscoveredComma = true
defer { selectingDiscoveredComma = false }
// Advertised names and addresses are discovery hints, not pairing credentials.
let candidate = LANTransport()
candidate.bind(to: lan.expectedDeviceID)
let originalGeneration = lan.generation
let originalMode = mode
do {
try candidate.configure(address: device.address)
guard await candidate.probe() else {
connectionMessage = "Found a device, but Galaxy is unavailable or it doesn’t match your paired comma."
connectionLog.record("Discovered comma rejected by LAN verification")
return
}
guard lan.generation == originalGeneration, mode == originalMode, phoneActive else {
connectionMessage = "Connection changed. Select the comma again."
return
}
lan.bind(to: candidate.verifiedDeviceID)
UserDefaults.standard.set(candidate.verifiedDeviceID, forKey: "galaxy.comma.identity")
address = device.address
connect()
connectionLog.record("User selected a discovered comma; LAN verification passed")
} catch {
connectionMessage = "Couldn’t connect to the discovered comma. Try again or enter its IP."
}
}
func fetchLegacyURL() async -> URL? {
guard !loadingLegacyURL else { return nil }
legacyMessage = ""
legacyURL = nil
guard wantsConnection, transport.connected else {
legacyMessage = "Connect to your comma first to get its Galaxy link."
return nil
}
loadingLegacyURL = true
defer { loadingLegacyURL = false }
let lanSession = lan.generation
let bluetoothSession = bluetooth.sessionIdentifier
do {
let response = try await transport.request(path: "/api/galaxy/status", method: "GET", headers: [:], body: Data())
guard wantsConnection, transport.connected, lan.generation == lanSession,
bluetooth.sessionIdentifier == bluetoothSession else {
throw BridgeError.message("Connection changed. Try opening legacy Galaxy again.")
}
guard response.status == 200 else {
throw BridgeError.message("Couldn’t get your comma’s Galaxy link (HTTP \(response.status)).")
}
let status = try JSONDecoder().decode(LegacyGalaxyStatus.self, from: response.bodyData)
guard status.paired else {
throw BridgeError.message("Set up legacy Galaxy pairing on your comma first. Bluetooth pairing is separate.")
}
guard let url = status.browserURL else {
throw BridgeError.message("Your comma hasn’t provided a valid Galaxy link. Check its legacy Galaxy pairing.")
}
legacyURL = url
connectionLog.record("Comma-specific legacy Galaxy link validated")
return url
} catch {
connectionLog.record("Comma-specific legacy Galaxy link unavailable")
legacyMessage = (error as? BridgeError)?.localizedDescription ?? "Couldn’t get your comma’s Galaxy link. Check the connection and try again."
return nil
}
}
private func learnAddress() async {
guard bluetooth.connected, Date().timeIntervalSince(lastAddressCheck) >= 20 || addressCheckSession != bluetooth.sessionIdentifier else { return }
lastAddressCheck = Date()
let session = bluetooth.sessionIdentifier
let networkPath = lastNetworkPath
addressCheckSession = session
do {
if identitySession != session {
let identity = try await bluetooth.request(path: "/data/runtime.json", method: "GET", headers: [:], body: Data())
guard !Task.isCancelled, wantsConnection, mode != .bluetooth, bluetooth.sessionIdentifier == session else { return }
// Only the authenticated BLE peer may establish the automatic LAN identity.
guard identity.status == 200, let value = LANTransport.deviceID(identity.bodyData) else {
networkDetails = "Automatic local discovery needs a registered device identifier. Bluetooth still works; enter comma’s IP manually for Wi-Fi."
return
}
lan.bind(to: value)
UserDefaults.standard.set(value, forKey: "galaxy.comma.identity")
identitySession = session
}
let network = session + "|" + networkPath
guard findAddressAutomatically, !lan.connected, localAddressNetwork != network else { return }
let generation = lan.generation
let response = try await bluetooth.request(path: "/api/local-access", method: "GET", headers: [:], body: Data())
guard !Task.isCancelled, wantsConnection, mode != .bluetooth, bluetooth.sessionIdentifier == session, response.status == 200,
lastNetworkPath == networkPath, lan.generation == generation, !lan.connected,
!response.bodyData.isEmpty else { return }
if let ip = LANTransport.reportedAddress(response.bodyData) {
localAddressNetwork = network
try lan.configure(address: ip)
address = ip
UserDefaults.standard.set(ip, forKey: "galaxy.lan.address")
networkDetails = "Trying comma’s current local address. If this hotspot or Wi-Fi network blocks direct access, Bluetooth stays available."
} else {
networkDetails = "Comma has no usable local Wi-Fi address. Bluetooth still controls local settings; comma can use its SIM for online downloads."
}
// Galaxy's online:true means the server responds, not that comma can reach the internet.
} catch { /* Discovery is optional; it must not disrupt working BLE. */ }
}
private func discoverAutomaticLAN() {
guard phoneActive, wantsConnection, mode == .automatic,
!pairingInProgress, !selectingDiscoveredComma, !lan.connected,
let identity = lan.expectedDeviceID else { return }
if !discovery.searching, Date().timeIntervalSince(lastLANDiscovery) >= 20 {
lastLANDiscovery = Date()
triedLANAddresses.removeAll()
discovery.start()
}
guard automaticLAN == nil else { return }
let addresses = discovery.devices.map(\.address).filter { !triedLANAddresses.contains($0) }
guard !addresses.isEmpty else { return }
triedLANAddresses.formUnion(addresses)
let generation = lan.generation
automaticLAN = Task { @MainActor in
defer { self.automaticLAN = nil }
let stillCurrent = {
self.phoneActive && self.wantsConnection && self.mode == .automatic
&& !self.pairingInProgress && !self.selectingDiscoveredComma && !self.lan.connected
&& self.lan.generation == generation && self.lan.expectedDeviceID == identity
}
let address = await LANDiscoveryMatch.first(addresses: addresses, expectedDeviceID: identity, stillCurrent: stillCurrent) { address, identity in
let candidate = LANTransport()
candidate.bind(to: identity)
guard (try? candidate.configure(address: address)) != nil else { return false }
return await candidate.probe()
}
guard let address, !Task.isCancelled, stillCurrent() else { return }
do { try self.lan.configure(address: address) } catch { return }
self.address = address
UserDefaults.standard.set(address, forKey: "galaxy.lan.address")
if await self.lan.probe() {
self.discovery.stop()
self.connectionLog.record("Automatic Wi-Fi discovery verified the paired comma")
}
}
}
private func probeLocalConnection() {
guard lanProbeTask == nil else { return }
lanProbeTask = Task { @MainActor in
defer { self.lanProbeTask = nil }
await self.learnAddress()
guard !Task.isCancelled, self.wantsConnection, self.mode != .bluetooth else { return }
if self.lan.endpoint == nil, !self.address.isEmpty {
do { try self.lan.configure(address: self.address) }
catch { self.connectionMessage = error.localizedDescription }
}
_ = await self.lan.probe()
if !Task.isCancelled, self.lan.connected { self.discovery.stop() }
}
}
func monitor() async {
while !Task.isCancelled {
if wantsConnection && !pairingInProgress {
if mode != .lan, !bluetooth.connected, !bluetooth.connecting, !bluetooth.isScanning,
!pairingKey.isEmpty, Date().timeIntervalSince(lastReconnect) >= 25 {
lastReconnect = Date()
bluetooth.reconnectSaved(pairingKey: pairingKey)
}
if mode != .bluetooth {
discoverAutomaticLAN()
probeLocalConnection()
}
guard !pairingInProgress else { continue }
if transport.label != lastRoute { logConnection("Connection availability changed") }
if transport.connected {
openIfReady()
if Date().timeIntervalSince(lastGalaxyMatch) > 60 && !openingGalaxy {
Task { await self.checkGalaxyUpdates() }
}
connectionMessage = "Connected over \(transport.label)." + (opened || assets.readyRoot != nil ? "" : " " + assets.status)
if offerAudioPairing { offerAudioPairing = false; startAudioPairing() }
} else if mode == .bluetooth {
connectionMessage = "Reconnecting over Bluetooth. If you forgot this phone on comma, open Pair phone on comma and Pair with comma here."
} else {
connectionMessage = "Wi-Fi unavailable. Pair over Bluetooth below, or check your comma’s IP address."
}
}
do { try await Task.sleep(nanoseconds: 3_000_000_000) } catch { return }
}
}
}
struct GalaxyRootView: View {
@ObservedObject var model: AppModel
@ObservedObject var bluetooth: BluetoothTransport
@ObservedObject var lan: LANTransport
@ObservedObject var assets: GalaxyAssetStore
@ObservedObject var server: LoopbackServer
@ObservedObject var discovery: LANDiscovery
@ObservedObject var galaxyPage: GalaxyPageDiagnostics
@State private var hasVisitedGalaxy = false
private var galaxyColorScheme: ColorScheme? {
guard model.tab == .galaxy, let dark = galaxyPage.darkAppearance else { return nil }
return dark ? .dark : .light
}
private var editorFocused: Bool { model.tab == .galaxy && galaxyPage.landscapeEditor }
@Environment(\.openURL) private var openURL
@Environment(\.scenePhase) private var scenePhase
init(model: AppModel) {
self.model = model; bluetooth = model.bluetooth; lan = model.lan; assets = model.assets; server = model.server; discovery = model.discovery; galaxyPage = model.galaxyPage
}
var body: some View {
VStack(spacing: 0) {
if !editorFocused && model.tab != .connections {
VStack(spacing: 14) {
HStack {
if !model.developerMode && model.tab == .connections {
if model.opened {
Button { select(.galaxy) } label: {
Image(systemName: "chevron.left").font(.body.weight(.semibold))
.frame(width: 36, height: 36)
}.buttonStyle(.plain).accessibilityLabel("Back to Galaxy")
}
Text("Settings").font(.title3.bold())
} else {
Text("StarPilot BT").font(.title3.bold())
}
Spacer()
HStack(spacing: 5) {
Circle().fill(model.transport.connected ? Color.green : Color.orange).frame(width: 6, height: 6)
Text(model.transport.label).font(.caption.weight(.medium))
}.padding(.horizontal, 10).padding(.vertical, 6)
.background(Color(uiColor: .secondarySystemBackground), in: Capsule())
if !model.developerMode && model.tab != .connections {
Button { select(.connections) } label: {
Image(systemName: "gearshape.fill").font(.body.weight(.semibold))
.frame(width: 36, height: 36)
.background(Color(uiColor: .secondarySystemBackground), in: Circle())
}.buttonStyle(.plain).accessibilityLabel("Settings")
}
}
if model.developerMode {
HStack(spacing: 4) {
ForEach(CompanionTab.allCases.filter { $0 != .connections }) { tab in
Button { select(tab) } label: {
VStack(spacing: 5) {
Image(systemName: tab.icon).font(.system(size: 17, weight: .semibold))
Text(tab.rawValue).font(.system(size: 10, weight: .semibold)).lineLimit(1).minimumScaleFactor(0.8)
}.frame(maxWidth: .infinity).padding(.vertical, 10)
.foregroundStyle(model.tab == tab ? Color.white : Color.secondary)
.background(model.tab == tab ? brandPurple : Color.clear, in: RoundedRectangle(cornerRadius: 12))
}.buttonStyle(.plain).accessibilityAddTraits(model.tab == tab ? .isSelected : [])
}
Button { select(.connections) } label: {
VStack(spacing: 4) {
Image(systemName: "gearshape.fill").font(.system(size: 17, weight: .semibold))
.frame(width: 36, height: 36)
.background(model.tab == .connections ? brandPurple : Color(uiColor: .tertiarySystemBackground), in: Circle())
.foregroundStyle(model.tab == .connections ? Color.white : Color.secondary)
Text("Settings").font(.system(size: 10, weight: .semibold))
}.frame(width: 60).padding(.vertical, 4)
.foregroundStyle(model.tab == .connections ? Color.primary : Color.secondary)
}.buttonStyle(.plain).accessibilityAddTraits(model.tab == .connections ? .isSelected : [])
}.padding(4).background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 16))
}
}.padding(.horizontal, 16).padding(.top, 10).padding(.bottom, 12)
}
ZStack {
if model.opened, let url = server.url {
GalaxyWebView(url: url, requestKey: server.localSecret, diagnostics: model.galaxyPage,
isActive: model.tab == .galaxy && scenePhase == .active) {
model.galaxyPage.report(server: server, connection: model.transport.label)
}
.opacity(model.tab == .galaxy ? 1 : 0)
.allowsHitTesting(model.tab == .galaxy)
.accessibilityHidden(model.tab != .galaxy)
} else if model.opened && model.tab == .galaxy {
VStack(spacing: 16) {
if server.error.isEmpty { ProgressView("Reconnecting Galaxy…") }
else { Text(server.error).multilineTextAlignment(.center) }
Button("Retry Galaxy") { model.recoverGalaxy(automatic: false) }.buttonStyle(.bordered)
}.padding(24)
}
if model.developerMode && model.tab == .logs {
DeveloperLogsView(model: model, log: model.connectionLog)
} else if model.developerMode && (model.tab == .live || model.tab == .diagnostics) {
CompanionView(diagnostics: model.diagnostics)
} else if model.tab == .connections || !model.opened {
ConnectionSettingsView(model: model, returningFromGalaxy: hasVisitedGalaxy, select: select)
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(model.tab == .galaxy && galaxyPage.darkAppearance == true
? Color(red: 0.06, green: 0.05, blue: 0.09) : Color(uiColor: .systemBackground))
.preferredColorScheme(galaxyColorScheme)
.ignoresSafeArea(.container, edges: editorFocused ? .all : [])
.statusBarHidden(editorFocused)
.onChange(of: model.showConnections) { _, show in
if show { select(.connections); model.showConnections = false }
}
.onChange(of: model.showDiagnostics) { _, show in
if show { if model.developerMode { select(.live) }; model.showDiagnostics = false }
}
.onChange(of: bluetooth.connected) { _, connected in if connected { model.openIfReady() } }
.sheet(isPresented: $model.showAudioPairing, onDismiss: { model.audioPairing.dismiss() }) {
AudioPairingSheet(pairing: model.audioPairing) { model.showAudioPairing = false }
}
.task(id: scenePhase) {
model.setPhoneActive(scenePhase == .active)
if scenePhase == .active { model.recoverGalaxy(reload: false); await model.checkGalaxyUpdates() }
}
}
private func select(_ requestedTab: CompanionTab) {
let tab = requestedTab.isDeveloperPage && !model.developerMode ? (model.opened ? CompanionTab.galaxy : .connections) : requestedTab
if tab != .connections { discovery.stop() }
model.tab = tab
if tab == .galaxy { hasVisitedGalaxy = true; model.applyMatchedGalaxy() }
model.diagnostics.live = tab == .live
}
private let brandPurple = Color(red: 139.0 / 255, green: 92.0 / 255, blue: 246.0 / 255)
}
@MainActor
final class ConnectionEventLog: ObservableObject {
@Published private(set) var events: [String] = []
@Published var section = "Connections"
@Published var detail = ""
@Published var copied = false
private var lastEvent = ""
func record(_ event: String) {
guard event != lastEvent else { return }
lastEvent = event
events.append("\(ISO8601DateFormatter().string(from: Date())) \(event.prefix(300))")
if events.count > 100 { events.removeFirst(events.count - 100) }
}
func clear() { events.removeAll(); lastEvent = ""; detail = ""; copied = false }
func report(model: AppModel) -> String {
let info = Bundle.main.infoDictionary ?? [:]
return """
StarPilot BT connection diagnostics
\(ISO8601DateFormatter().string(from: Date()))
App: \(info["CFBundleShortVersionString"] ?? "?") (\(info["CFBundleVersion"] ?? "?"))
iOS: \(UIDevice.current.systemVersion)
Mode: \(model.mode.title)
Selected route: \(model.transport.label)
LAN verified: \(model.lan.connected)
Bluetooth verified: \(model.bluetooth.connected)
Recent connection events:
\(events.joined(separator: "\n"))
"""
}
}
private struct DeveloperLogsView: View {
@ObservedObject var model: AppModel
@ObservedObject var log: ConnectionEventLog
private func selectedReport() -> String {
switch log.section {
case "Galaxy": return model.galaxyPage.report(server: model.server, connection: model.transport.label)
case "Live View": return model.diagnostics.diagnosticLog()
default: return log.report(model: model)
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 18) {
Text("Dev / Logs").font(.title2.bold())
Text("Recent events stay in memory. Reports are shared only when you copy them.")
.font(.subheadline).foregroundStyle(.secondary)
Picker("Report", selection: $log.section) {
ForEach(["Connections", "Galaxy", "Live View"], id: \.self) { Text($0).tag($0) }
}.pickerStyle(.segmented)
HStack {
Button(log.copied ? "Copied" : "Copy report", systemImage: "doc.on.doc") {
UIPasteboard.general.string = selectedReport(); log.copied = true
}.buttonStyle(.borderedProminent).tint(.purple)
Button("Refresh") { log.detail = selectedReport(); log.copied = false }.buttonStyle(.bordered)
}
Button("Copy all reports") {
UIPasteboard.general.string = [log.report(model: model), model.galaxyPage.report(server: model.server, connection: model.transport.label), model.diagnostics.diagnosticLog(), "Galaxy screen updates\n" + model.assets.status].joined(separator: "\n\n")
log.copied = true
}
Text(log.section == "Connections" ? log.report(model: model) : log.detail)
.font(.system(.caption, design: .monospaced)).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(14).background(Color(uiColor: .secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
Button("Clear connection history") { log.clear(); log.detail = selectedReport() }
.font(.caption).foregroundStyle(.secondary)
}.padding(20)
}.background(Color(uiColor: .systemBackground))
.onAppear { log.detail = selectedReport() }
.onChange(of: log.section) { _, _ in log.detail = selectedReport(); log.copied = false }
}
}
@@ -0,0 +1,124 @@
import Foundation
@MainActor
protocol GalaxyRequestTransport: AnyObject {
var connected: Bool { get }
var catalogSHA256: String? { get }
func request(path: String, method: String, headers: [String: String], body: Data) async throws -> BridgeResponse
/// GET one Galaxy page file (not an API call). Only the Wi-Fi/LAN transport can do this.
func page(path: String) async throws -> BridgeResponse
}
extension GalaxyRequestTransport {
var catalogSHA256: String? { nil }
func page(path: String) async throws -> BridgeResponse {
throw BridgeError.message("Galaxy screens can only be downloaded from comma over Wi-Fi.")
}
}
/// When the phone already reaches comma over Wi-Fi/LAN it opens comma's two-minute pairing window itself, the same call
/// as Pair phone on Galaxy's Bluetooth page, so nobody has to touch comma before the Bluetooth key claim.
enum LANPairing {
static let windowPath = "/api/bluetooth/action"
static let windowBody = Data("{\"operation\":\"accept_pair\"}".utf8)
@MainActor static func openWindow(over lan: any GalaxyRequestTransport) async -> Bool {
guard lan.connected,
let response = try? await lan.request(path: windowPath, method: "POST", headers: [:], body: windowBody) else { return false }
return response.status == 200
}
}
enum SecretGoodGalaxy {
static func validTarget(_ path: String) -> Bool {
guard path.hasPrefix("/"), !path.hasPrefix("//"),
let parts = URLComponents(string: path), parts.scheme == nil, parts.host == nil, parts.fragment == nil,
let decoded = parts.percentEncodedPath.removingPercentEncoding,
!decoded.contains("%"), !decoded.contains("\\"), !decoded.contains("//"),
!decoded.unicodeScalars.contains(where: { $0.value < 32 }),
!decoded.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) else { return false }
return decoded.hasPrefix("/api/") || decoded == "/data/runtime.json"
}
/// A Galaxy page file under the same roots the app accepts when it saves comma's screens.
static func validPage(_ path: String) -> Bool {
guard path.hasPrefix("/"), path.utf8.allSatisfy({ (48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0) || [45, 46, 47, 95].contains($0) }) else { return false }
let file = String(path.dropFirst())
let parts = file.split(separator: "/", omittingEmptySubsequences: false)
guard !parts.isEmpty, parts.allSatisfy({ !$0.isEmpty && $0 != "." && $0 != ".." }), file != "data/runtime.json" else { return false }
return ["index.html", "manifest.webmanifest", "sentry-push-worker.js"].contains(file)
|| ["assets/", "css/", "js/", "vendor/", "data/"].contains(where: { file.hasPrefix($0) })
}
static func needsWiFi(_ path: String) -> Bool {
let path = URLComponents(string: path)?.path ?? path
return ["/api/recordings/", "/api/replay/", "/api/cameras/", "/api/maps/tiles/",
"/api/android-auto/upload", "/api/android-auto/apk", "/api/phone-internet/", "/api/spotify/upload"].contains(where: { path.hasPrefix($0) })
|| ["/api/auth/login", "/api/auth/logout"].contains(path)
}
}
/// Coalesce simultaneous identical reads. View/intent tokens are never cached for later use.
@MainActor
final class GalaxyReadCache: GalaxyRequestTransport {
private let transport: any GalaxyRequestTransport
private let sessionID: () -> String
private var session = ""
private var generation: UInt64 = 0
private var pending: [String: (UUID, Task<BridgeResponse, Error>)] = [:]
var connected: Bool { transport.connected }
var catalogSHA256: String? { transport.catalogSHA256 }
init(transport: any GalaxyRequestTransport, sessionID: @escaping () -> String,
now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) {
self.transport = transport
self.sessionID = sessionID
}
private func invalidate() {
generation += 1
// Already-sent reads still drain, but new reads cannot join an old one.
pending.removeAll()
}
func page(path: String) async throws -> BridgeResponse { try await transport.page(path: path) }
func request(path: String, method: String, headers: [String: String], body: Data) async throws -> BridgeResponse {
try Task.checkCancellation()
if session != sessionID() {
session = sessionID()
invalidate()
}
guard connected else { throw BridgeError.message("Connect to your comma over Bluetooth first.") }
if method != "GET" {
invalidate()
defer { invalidate() }
return try await transport.request(path: path, method: method, headers: headers, body: body)
}
guard body.isEmpty, path.hasPrefix("/api/"), headers.keys.allSatisfy({ !["authorization", "range"].contains($0.lowercased()) }) else {
return try await transport.request(path: path, method: method, headers: headers, body: body)
}
let relevantHeaders = headers
.map { "\($0.key.lowercased()):\($0.value)" }.sorted().joined(separator: "\n")
let cacheKey = path + "\n" + relevantHeaders
let token: UUID
let task: Task<BridgeResponse, Error>
if let existing = pending[cacheKey] {
(token, task) = existing
} else {
// Bound cache variants even when a local client changes headers.
guard pending.count < 16 else {
return try await transport.request(path: path, method: method, headers: headers, body: body)
}
token = UUID()
task = Task { try await transport.request(path: path, method: method, headers: headers, body: body) }
pending[cacheKey] = (token, task)
}
let readGeneration = generation
let readSession = session
defer { if pending[cacheKey]?.0 == token { pending.removeValue(forKey: cacheKey) } }
let response = try await task.value
guard readGeneration == generation, readSession == sessionID(), connected else {
throw BridgeError.message("Connection or saved settings changed during this read. Load the page again.")
}
try Task.checkCancellation()
return response
}
}
@@ -0,0 +1,463 @@
import SwiftUI
import WebKit
private struct GalaxyDownloadedFile: Identifiable {
let id = UUID()
let url: URL
}
@MainActor
final class GalaxyPageDiagnostics: ObservableObject {
@Published fileprivate var downloadedFile: GalaxyDownloadedFile?
@Published var downloadError: String?
private var exportDirectory: URL?
fileprivate func offerDownload(_ url: URL) {
exportDirectory = url.deletingLastPathComponent()
downloadedFile = GalaxyDownloadedFile(url: url)
record("Download ready to save or share")
}
func cleanUpDownload() {
if let directory = exportDirectory { try? FileManager.default.removeItem(at: directory) }
exportDirectory = nil
}
func downloadFailed(_ error: Error) {
let value = error as NSError
record("Download failed: \(value.domain) \(value.code)")
downloadError = "The file couldn’t be downloaded. Please try again."
}
var recover: ((Bool) -> Void)?
@Published var darkAppearance: Bool?
@Published var landscapeEditor = false
@Published var copied = false
@Published var stalled = false
@Published var mounted = false
@Published var reloadID = UUID()
private(set) var started = Date()
private(set) var events: [String] = []
private var deadline: Task<Void, Never>?
func record(_ event: String) {
events.append("\(ISO8601DateFormatter().string(from: Date())) \(event.prefix(300))")
if events.count > 40 { events.removeFirst(events.count - 40) }
}
func begin() {
deadline?.cancel()
started = Date(); mounted = false; stalled = false; landscapeEditor = false
record("Page loading")
deadline = Task { [weak self] in
do { try await Task.sleep(nanoseconds: 12_000_000_000) } catch { return }
guard let self, !self.mounted else { return }
self.record("No rendered Galaxy content after 12 seconds")
self.stalled = true
}
}
func rendered() {
mounted = true; stalled = false; deadline?.cancel()
record("Galaxy content rendered")
}
func fail(_ event: String) { record(event); mounted = false; stalled = true; deadline?.cancel() }
func retry() { reloadID = UUID() }
func report(server: LoopbackServer, connection: String) -> String {
let info = Bundle.main.infoDictionary ?? [:]
return """
StarPilot BT Galaxy diagnostics
\(ISO8601DateFormatter().string(from: Date()))
App: \(info["CFBundleShortVersionString"] ?? "?") (\(info["CFBundleVersion"] ?? "?"))
iOS: \(UIDevice.current.systemVersion)
Connection: \(connection)
Galaxy content rendered: \(mounted)
Page age: \(Int(Date().timeIntervalSince(started))) seconds
\(server.diagnosticSummary)
Recent page events:
\(events.joined(separator: "\n"))
Recent local requests (no headers, query values or bodies):
\(server.recentRequests.joined(separator: "\n"))
"""
}
}
struct GalaxyWebView: View {
let url: URL
let requestKey: String
@ObservedObject var diagnostics: GalaxyPageDiagnostics
var isActive = true
let report: () -> String
var body: some View {
ZStack {
GalaxyPage(url: url, requestKey: requestKey, diagnostics: diagnostics, isActive: isActive)
.id(url.absoluteString + diagnostics.reloadID.uuidString)
if diagnostics.stalled {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.bubble").font(.largeTitle).foregroundStyle(.purple)
Text("Galaxy hasn’t loaded").font(.title2.bold())
Text("Copy the report so we can find what failed, or try loading the page again.")
.foregroundStyle(.secondary).multilineTextAlignment(.center)
Button(diagnostics.copied ? "Report copied" : "Copy error report") {
UIPasteboard.general.string = report(); diagnostics.copied = true
}.buttonStyle(.borderedProminent).tint(.purple)
Button("Retry Galaxy") { diagnostics.copied = false; diagnostics.recover?(false) }.buttonStyle(.bordered)
}.padding(28).frame(maxWidth: 420)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20)).padding(20)
}
}
.sheet(item: $diagnostics.downloadedFile, onDismiss: diagnostics.cleanUpDownload) { file in
GalaxyFileShareSheet(url: file.url) { diagnostics.downloadedFile = nil }
}
.alert("Couldn’t download file", isPresented: Binding(
get: { diagnostics.downloadError != nil },
set: { if !$0 { diagnostics.downloadError = nil } }
)) {
Button("OK", role: .cancel) { diagnostics.downloadError = nil }
} message: { Text(diagnostics.downloadError ?? "") }
}
}
private struct GalaxyFileShareSheet: UIViewControllerRepresentable {
let url: URL
let finished: () -> Void
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: [url], applicationActivities: nil)
controller.completionWithItemsHandler = { _, _, _, _ in
Task { @MainActor in finished() }
}
return controller
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}
private struct GalaxyPage: UIViewRepresentable {
let url: URL
let requestKey: String
let diagnostics: GalaxyPageDiagnostics
let isActive: Bool
func makeCoordinator() -> Coordinator {
let coordinator = Coordinator(origin: url, diagnostics: diagnostics)
coordinator.isVisible = isActive
return coordinator
}
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.defaultWebpagePreferences.allowsContentJavaScript = true
diagnostics.begin()
config.userContentController.add(context.coordinator, name: "galaxyDiagnostics")
let script = """
(() => {
if (location.origin !== '\(url.scheme!)://\(url.host!):\(url.port!)') return;
window.__galaxyNative = true;
const phoneAppearance = window.matchMedia('(prefers-color-scheme: dark)');
const syncTheme = () => {
const theme = phoneAppearance.matches ? 'dark' : 'light';
localStorage.setItem('galaxy-preview-theme', theme);
document.documentElement.dataset.theme = theme;
};
syncTheme();
phoneAppearance.addEventListener('change', () => {
const dark = phoneAppearance.matches;
if ((document.documentElement.dataset.theme === 'dark') !== dark) {
document.querySelector('.gx-theme-toggle')?.click();
}
syncTheme();
});
const reportTheme = () => {
const theme = document.documentElement?.dataset.theme;
if (theme !== 'dark' && theme !== 'light') return;
try { window.webkit.messageHandlers.galaxyDiagnostics.postMessage({kind: 'theme', dark: theme === 'dark'}); } catch (_) {}
};
const watchTheme = () => {
if (!document.documentElement) return;
new MutationObserver(reportTheme).observe(document.documentElement, {attributes: true, attributeFilter: ['data-theme']});
reportTheme();
};
if (document.documentElement) watchTheme();
else document.addEventListener('DOMContentLoaded', watchTheme, {once: true});
let nativeVisible = \(isActive ? "true" : "false");
Object.defineProperty(document, 'visibilityState', {configurable: true, get: () => nativeVisible ? 'visible' : 'hidden'});
Object.defineProperty(document, 'hidden', {configurable: true, get: () => !nativeVisible});
window.__galaxySetVisible = visible => {
if (nativeVisible === !!visible) return;
nativeVisible = !!visible;
document.dispatchEvent(new Event('visibilitychange'));
};
const emit = (kind, path = '', code = 0) => {
try { window.webkit.messageHandlers.galaxyDiagnostics.postMessage({kind, path, code}); } catch (_) {}
};
// Keep this native presentation independent of downloaded Galaxy assets.
const editorScreen = matchMedia('(orientation: landscape) and (max-height: 600px)');
let editorFocused = false;
const editorCSS = document.createElement('style');
editorCSS.textContent = `
html.native-layout-editor .blur-nav,
html.native-layout-editor .gx-appbar__pill,
html.native-layout-editor .gx-appbar__menu,
html.native-layout-editor .gx-layout > .gx-page-header { display: none !important; }
html.native-layout-editor .gx-app { animation: none; }
html.native-layout-editor .gx-appbar {
position: fixed; inset: max(4px, env(safe-area-inset-top)) auto auto max(8px, env(safe-area-inset-left));
width: 44px; padding: 0; margin: 0; z-index: 20;
}
html.native-layout-editor .gx-appbar__back { width: 44px; height: 44px; min-width: 44px; min-height: 44px; }
html.native-layout-editor .gx-content {
max-width: none; padding: max(4px, env(safe-area-inset-top)) max(8px, env(safe-area-inset-right)) max(8px, env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-left));
}
html.native-layout-editor .gx-layout { --layout-sticky-top: max(4px, env(safe-area-inset-top)); display: grid; gap: 6px; }
html.native-layout-editor .gx-layout__savebar {
grid-row: 1; margin-left: 52px; padding: 0 4px 0 10px; min-height: 44px; gap: 4px;
}
html.native-layout-editor .gx-layout__savebar .gx-btn { min-height: 44px; padding-inline: 12px; }
html.native-layout-editor .gx-layout__targets { grid-row: 2; margin: 0; gap: 8px; }
html.native-layout-editor .gx-layout__targets .gx-btn { min-height: 44px; padding: 4px 12px; }
html.native-layout-editor .gx-layout__workspace { gap: 8px; grid-template-columns: minmax(0, 1.35fr) minmax(260px, 1fr); }
html.native-layout-editor .gx-layout__preview-card { padding: 4px; gap: 4px; }
html.native-layout-editor .gx-layout__preview-card > p.gx-note:not([role]) { display: none; }
html.native-layout-editor .gx-layout__preview-card .gx-layout__preview {
width: auto; max-width: 100%; max-height: calc(100svh - 200px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); margin-inline: auto;
}
html.native-layout-editor .gx-layout__inspector {
padding: 8px; gap: 8px; max-height: calc(100svh - 124px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); overflow: auto;
}
html.native-layout-editor .gx-layout__sections { margin: -8px -8px 0; padding: 4px; gap: 2px; top: -8px; }
html.native-layout-editor .gx-layout__sections .gx-btn { min-height: 44px; padding: 4px 8px; white-space: nowrap; }
`;
const syncEditor = () => {
const focused = editorScreen.matches && !!document.querySelector('.gx-layout');
if (focused === editorFocused) return;
editorFocused = focused;
document.documentElement.classList.toggle('native-layout-editor', focused);
try { window.webkit.messageHandlers.galaxyDiagnostics.postMessage({kind: 'editorFocus', active: focused}); } catch (_) {}
};
const startEditorPresentation = () => {
document.head.appendChild(editorCSS);
new MutationObserver(syncEditor).observe(document.body, {childList: true, subtree: true});
editorScreen.addEventListener('change', syncEditor);
syncEditor();
};
document.addEventListener('DOMContentLoaded', startEditorPresentation, {once: true});
const pathOnly = value => {
try { return new URL(value, location.href).pathname; } catch (_) { return ''; }
};
window.addEventListener('error', event => {
if (event.target && event.target !== window) {
emit('resource', pathOnly(event.target.src || event.target.href || ''));
} else { emit('script', pathOnly(event.filename || ''), event.lineno || 0); }
}, true);
window.addEventListener('unhandledrejection', () => emit('promise'));
let rendered = false;
const checkContent = () => {
const root = document.querySelector('#galaxy-app');
if (root && !root.hasAttribute('v-cloak') && root.children.length && root.innerText.trim().length) {
if (!rendered) { rendered = true; emit('rendered'); }
} else if (rendered) { rendered = false; emit('blank'); }
};
window.addEventListener('DOMContentLoaded', () => {
checkContent(); setInterval(checkContent, 1000);
}, {once: true});
const key = '\(requestKey)';
const local = (u) => new URL(u, location.href).origin === location.origin;
const originalFetch = window.fetch.bind(window);
window.fetch = (input, init = {}) => {
const req = new Request(input, init);
if (!local(req.url)) return originalFetch(req);
const headers = new Headers(req.headers);
headers.set('X-Galaxy-Local', key);
return originalFetch(new Request(req, { headers })).then(async response => {
if (response.status === 501) {
const message = await response.clone().json().catch(() => null);
let notice = document.getElementById('native-wifi-notice');
if (!notice) {
notice = document.createElement('button'); notice.id = 'native-wifi-notice';
notice.setAttribute('role', 'status');
notice.style.cssText = 'position:fixed;top:12px;left:12px;right:12px;z-index:10000;padding:16px;border-radius:16px;background:Canvas;color:CanvasText;border:1px solid currentColor;font:inherit;';
notice.onclick = () => notice.remove(); document.body.appendChild(notice);
}
notice.textContent = typeof message?.error === 'string' ? message.error : 'Connect to comma over Wi-Fi for this feature.';
}
return response;
});
};
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
this.__galaxyLocal = local(url);
return originalOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function(body) {
if (this.__galaxyLocal) this.setRequestHeader('X-Galaxy-Local', key);
return originalSend.call(this, body);
};
})();
"""
config.userContentController.addUserScript(WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: false))
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.isOpaque = false
webView.backgroundColor = UIColor(red: 0.06, green: 0.05, blue: 0.09, alpha: 1)
webView.scrollView.contentInsetAdjustmentBehavior = .never
webView.load(URLRequest(url: url))
return webView
}
func updateUIView(_ uiView: WKWebView, context: Context) {
context.coordinator.isVisible = isActive
context.coordinator.syncVisibility(uiView)
}
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
uiView.configuration.userContentController.removeScriptMessageHandler(forName: "galaxyDiagnostics")
uiView.navigationDelegate = nil; uiView.uiDelegate = nil
coordinator.cancelDownloads()
uiView.stopLoading()
}
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, WKDownloadDelegate {
private var downloads: [ObjectIdentifier: (WKDownload, URL?)] = [:]
var isVisible = true
func syncVisibility(_ webView: WKWebView) {
webView.evaluateJavaScript("window.__galaxySetVisible?.(\(isVisible ? "true" : "false"))", completionHandler: nil)
}
let origin: URL
let diagnostics: GalaxyPageDiagnostics
init(origin: URL, diagnostics: GalaxyPageDiagnostics) { self.origin = origin; self.diagnostics = diagnostics }
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
let frame = message.frameInfo.securityOrigin
guard frame.protocol == origin.scheme, frame.host == origin.host, frame.port == origin.port,
let body = message.body as? [String: Any], let kind = body["kind"] as? String else { return }
let path = LoopbackServer.reportPath(body["path"] as? String ?? "")
switch kind {
case "theme":
if message.frameInfo.isMainFrame, let dark = body["dark"] as? Bool {
diagnostics.darkAppearance = dark
let background = dark ? UIColor(red: 0.06, green: 0.05, blue: 0.09, alpha: 1) : .systemBackground
message.webView?.backgroundColor = background
message.webView?.scrollView.backgroundColor = background
}
case "editorFocus":
if message.frameInfo.isMainFrame, let active = body["active"] as? Bool {
diagnostics.landscapeEditor = active
}
case "rendered": if message.frameInfo.isMainFrame { diagnostics.rendered() }
case "blank": if message.frameInfo.isMainFrame { diagnostics.begin() }
case "resource": diagnostics.record("Resource failed: \(path)")
case "script": diagnostics.record("JavaScript error: \(path), line \(body["code"] as? Int ?? 0)")
case "promise": diagnostics.record("Unhandled JavaScript promise rejection")
case "search":
if let phase = body["phase"] as? String, ["started", "results", "partial", "ready"].contains(phase),
let count = body["count"] as? Int, (0...50_000).contains(count) {
diagnostics.record("Toggle search \(phase): \(count) settings indexed")
}
default: break
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
syncVisibility(webView)
diagnostics.record("HTML navigation finished; waiting for Galaxy content")
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
navigationFailed(error)
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { navigationFailed(error) }
private func navigationFailed(_ error: Error) {
let value = error as NSError
if !downloads.isEmpty, value.code == NSURLErrorCancelled || (value.domain == "WKErrorDomain" && value.code == 102) { return }
if value.domain == NSURLErrorDomain && value.code == NSURLErrorCancelled { return }
diagnostics.fail("Navigation failed: \(value.domain) \(value.code)")
if isVisible, value.domain == NSURLErrorDomain,
[NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost].contains(value.code) {
diagnostics.recover?(true)
}
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
diagnostics.fail("Galaxy web content process terminated")
if isVisible { diagnostics.recover?(true) }
}
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = action.request.url else { decisionHandler(.cancel); return }
if url.scheme == origin.scheme, url.host == origin.host, url.port == origin.port {
decisionHandler(action.shouldPerformDownload ? .download : .allow)
} else if url.scheme == "blob" {
let frame = action.sourceFrame.securityOrigin
guard frame.protocol == origin.scheme, frame.host == origin.host,
frame.port == origin.port else { decisionHandler(.cancel); return }
decisionHandler(action.shouldPerformDownload ? .download : .allow)
} else if url.scheme == "about" {
decisionHandler(.allow)
} else {
if action.navigationType == .linkActivated, ["https", "http"].contains(url.scheme ?? "") {
UIApplication.shared.open(url)
}
decisionHandler(.cancel)
}
}
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
track(download)
}
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
track(download)
}
private func track(_ download: WKDownload) {
guard downloads.isEmpty, diagnostics.downloadedFile == nil else {
download.cancel { _ in }
return
}
downloads[ObjectIdentifier(download)] = (download, nil)
download.delegate = self
diagnostics.record("File download started")
}
func download(_ download: WKDownload, decideDestinationUsing response: URLResponse,
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) {
let id = ObjectIdentifier(download)
guard downloads[id] != nil else { completionHandler(nil); return }
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let name = (suggestedFilename as NSString).lastPathComponent
let safeName = name.isEmpty || name == "." || name == ".." ? "toggle-backup.json" : name
let destination = directory.appendingPathComponent(safeName)
downloads[id] = (download, destination)
completionHandler(destination)
} catch {
try? FileManager.default.removeItem(at: directory)
completionHandler(nil)
diagnostics.downloadFailed(error)
}
}
func downloadDidFinish(_ download: WKDownload) {
guard let (_, destination) = downloads.removeValue(forKey: ObjectIdentifier(download)),
let destination else { return }
diagnostics.offerDownload(destination)
}
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
if let (_, destination) = downloads.removeValue(forKey: ObjectIdentifier(download)),
let destination {
try? FileManager.default.removeItem(at: destination.deletingLastPathComponent())
}
diagnostics.downloadFailed(error)
}
func cancelDownloads() {
let active = downloads.values
downloads.removeAll()
for (download, destination) in active {
download.delegate = nil
download.cancel { _ in }
if let destination { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()) }
}
}
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
if action.targetFrame == nil, let url = action.request.url, ["https", "http"].contains(url.scheme ?? "") {
UIApplication.shared.open(url)
}
return nil
}
}
}
@@ -0,0 +1,76 @@
import Foundation
struct LocalRequest {
let method: String
let target: String
let headers: [String: String]
let body: Data
// Package uploads stay on LAN; ordinary requests retain the Bluetooth-sized limit.
static let maxPackageBody = 256 * 1024 * 1024
static func isPackageUpload(method: String, target: String) -> Bool {
method == "POST" && ["/api/android-auto/upload", "/api/spotify/upload"].contains(target)
}
static func bodyLimit(method: String, target: String) -> Int {
isPackageUpload(method: method, target: target) ? maxPackageBody : Wire.maxBody
}
static func parse(_ data: Data) throws -> LocalRequest? {
guard let separator = data.range(of: Data("\r\n\r\n".utf8)) else {
if data.count > 16_384 { throw BridgeError.message("HTTP headers are too large.") }
return nil
}
guard separator.lowerBound <= 16_384,
let head = String(data: data[..<separator.lowerBound], encoding: .utf8) else {
throw BridgeError.message("Invalid HTTP headers.")
}
let lines = head.components(separatedBy: "\r\n")
let first = (lines.first ?? "").split(separator: " ")
guard first.count == 3, ["HTTP/1.1", "HTTP/1.0"].contains(String(first[2])),
["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"].contains(String(first[0])) else {
throw BridgeError.message("Invalid HTTP request.")
}
var headers: [String: String] = [:]
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of: ":"), colon != line.startIndex else { throw BridgeError.message("Malformed header.") }
let name = line[..<colon].lowercased()
guard headers[name] == nil else { throw BridgeError.message("Duplicate HTTP header.") }
headers[name] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
}
guard headers["transfer-encoding"] == nil else { throw BridgeError.message("Chunked uploads are not supported.") }
let length: Int
if let raw = headers["content-length"] {
guard let parsed = Int(raw), parsed >= 0, parsed <= bodyLimit(method: String(first[0]), target: String(first[1])) else { throw BridgeError.message("Invalid upload size.") }
length = parsed
} else { length = 0 }
let available = data.count - separator.upperBound
guard available >= length else { return nil }
guard available == length else { throw BridgeError.message("HTTP pipelining is not supported.") }
return LocalRequest(method: String(first[0]), target: String(first[1]), headers: headers,
body: data.subdata(in: separator.upperBound..<data.count))
}
}
struct LocalResponse {
let status: Int
let headers: [String: String]
let body: Data
static func error(_ status: Int, _ message: String) -> LocalResponse {
LocalResponse(status: status, headers: ["content-type": "application/json"],
body: (try? JSONSerialization.data(withJSONObject: ["error": message])) ?? Data())
}
func encoded(headOnly: Bool = false) -> Data {
var text = "HTTP/1.1 \(status) \(HTTPURLResponse.localizedString(forStatusCode: status))\r\n"
for (key, value) in headers where !["content-length", "connection", "cache-control"].contains(key.lowercased()) {
guard !key.contains("\r"), !key.contains("\n"), !value.contains("\r"), !value.contains("\n") else { continue }
text += "\(key): \(value)\r\n"
}
text += "Content-Length: \(body.count)\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\n\r\n"
return Data(text.utf8) + (headOnly ? Data() : body)
}
}
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIAppFonts</key>
<array><string>Sora.ttf</string></array>
<key>CFBundleDisplayName</key>
<string>StarPilot BT</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key>
<string>StarPilot BT</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.1</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>127.0.0.1</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Connect directly to your comma's Galaxy Bluetooth bridge.</string>
<key>NSBonjourServices</key>
<array>
<string>_sp-galaxy._tcp</string>
</array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Share this iPhone's GPS with your comma for navigation when its own GPS has no fix.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>Share this iPhone's GPS with your comma for navigation when its own GPS has no fix.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>Connect to Galaxy on your comma over your local Wi-Fi network.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>CPTemplateApplicationSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>CPTemplateApplicationScene</string>
<key>UISceneConfigurationName</key>
<string>StarPilot BT CarPlay</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).CarPlaySceneDelegate</string>
</dict>
</array>
</dict>
</dict>
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>location</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1,126 @@
import Foundation
import Darwin
struct DiscoveredComma: Identifiable {
let id: String
let name: String
let address: String
}
@MainActor
final class LANDiscovery: NSObject, ObservableObject, @preconcurrency NetServiceBrowserDelegate, @preconcurrency NetServiceDelegate {
static let serviceType = "_sp-galaxy._tcp."
@Published private(set) var devices: [DiscoveredComma] = []
@Published private(set) var searching = false
@Published private(set) var status = "Find comma on the same Wi-Fi or hotspot, without Bluetooth."
var onEvent: ((String) -> Void)?
var onDevicesChanged: (() -> Void)?
private var browser: NetServiceBrowser?
private var services: [String: NetService] = [:]
private var deadline: Task<Void, Never>?
func start() {
stop()
devices = []
searching = true
status = "Looking for comma on your local network…"
onEvent?("Wi-Fi discovery started")
let browser = NetServiceBrowser()
self.browser = browser
browser.delegate = self
browser.searchForServices(ofType: Self.serviceType, inDomain: "local.")
deadline = Task { [weak self] in
try? await Task.sleep(nanoseconds: 12_000_000_000)
guard !Task.isCancelled, let self else { return }
self.stop()
self.status = self.devices.isEmpty
? "No comma found. Use the same Wi-Fi, allow Local Network access, and update comma for discovery. You can still enter its IP."
: "Select your comma below."
self.onEvent?("Wi-Fi discovery finished: \(self.devices.count) result(s)")
}
}
func stop() {
deadline?.cancel(); deadline = nil
browser?.delegate = nil; browser?.stop(); browser = nil
for service in services.values { service.delegate = nil; service.stop() }
services.removeAll()
if searching { status = devices.isEmpty ? "Search stopped." : "Select your comma below." }
searching = false
}
func networkChanged() {
stop()
devices = []
status = "Network changed. Looking for comma on the current network."
}
private func key(_ service: NetService) -> String { service.name + "/" + service.type + "/" + service.domain }
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
guard searching, browser === self.browser, services.count < 16, services[key(service)] == nil else { return }
services[key(service)] = service
service.delegate = self
service.resolve(withTimeout: 5)
}
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
guard browser === self.browser else { return }
let id = key(service)
services.removeValue(forKey: id)?.stop()
devices.removeAll { $0.id == id }
onDevicesChanged?()
}
func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
guard browser === self.browser else { return }
let denied = errorDict[NetService.errorCode]?.intValue == -72008
stop()
status = denied ? "Allow Local Network access for this app in iPhone Settings, then search again."
: "Wi-Fi discovery couldn’t start. Check Local Network permission or enter comma’s IP."
onEvent?("Wi-Fi discovery failed: code \(errorDict[NetService.errorCode]?.intValue ?? 0)")
}
func netServiceDidResolveAddress(_ sender: NetService) {
guard searching, services[key(sender)] === sender, sender.port == 8082 else { return }
let txt = sender.txtRecordData().map(NetService.dictionary(fromTXTRecord:)) ?? [:]
guard txt["protocol"] == Data("galaxy-lan-1".utf8) else { return }
guard let address = (sender.addresses ?? []).compactMap(Self.privateIPv4).first else { return }
let suffix = "-" + address.replacingOccurrences(of: ".", with: "-")
let name = sender.name.hasSuffix(suffix) ? String(sender.name.dropLast(suffix.count)) : sender.name
let device = DiscoveredComma(id: key(sender), name: String(name.prefix(80)), address: address)
devices.removeAll { $0.id == device.id }
devices.append(device)
devices.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
status = "Found \(devices.count) comma device(s). Select one below."
onEvent?("Wi-Fi discovery found a Galaxy service")
onDevicesChanged?()
}
static func networkSignature() -> String {
var interfaces: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&interfaces) == 0 else { return "" }
defer { freeifaddrs(interfaces) }
var values: [String] = []
var current = interfaces
while let pointer = current {
let interface = pointer.pointee
current = interface.ifa_next
guard let address = interface.ifa_addr, address.pointee.sa_family == sa_family_t(AF_INET),
interface.ifa_flags & UInt32(IFF_UP) != 0 else { continue }
var ip = UnsafeRawPointer(address).assumingMemoryBound(to: sockaddr_in.self).pointee.sin_addr
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
if inet_ntop(AF_INET, &ip, &buffer, socklen_t(buffer.count)) != nil {
values.append(String(cString: interface.ifa_name) + ":" + String(cString: buffer))
}
}
return values.sorted().joined(separator: ",")
}
static func privateIPv4(_ data: Data) -> String? {
guard data.count >= MemoryLayout<sockaddr_in>.size else { return nil }
var socketAddress = sockaddr_in()
_ = withUnsafeMutableBytes(of: &socketAddress) { data.copyBytes(to: $0, count: MemoryLayout<sockaddr_in>.size) }
guard socketAddress.sin_family == sa_family_t(AF_INET), UInt16(bigEndian: socketAddress.sin_port) == 8082 else { return nil }
var ip = socketAddress.sin_addr
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
guard inet_ntop(AF_INET, &ip, &buffer, socklen_t(buffer.count)) != nil else { return nil }
let address = String(cString: buffer)
return (try? LANTransport.endpoint(address)) != nil ? address : nil
}
}
@@ -0,0 +1,398 @@
import Foundation
import CryptoKit
import Network
import Combine
import UniformTypeIdentifiers
@MainActor
final class LoopbackServer: ObservableObject {
@Published var url: URL?
@Published var error = ""
private(set) var lifecycleEvents: [String] = []
private(set) var listenerState = "stopped"
private var availabilityTask: Task<Bool, Never>?
private(set) var recentRequests: [String] = []
var diagnosticSummary: String {
let mobile = webRoot.map { FileManager.default.fileExists(atPath: $0.appendingPathComponent("index.html").path) } ?? false
let script = webRoot.map { FileManager.default.fileExists(atPath: $0.appendingPathComponent("js/app.js").path) } ?? false
return "Local server ready: \(url != nil)\nLocal listener state: \(listenerState)\nRecent local server events:\n\(lifecycleEvents.joined(separator: "\n"))\nAsset source: \(webRoot == nil ? "not downloaded" : "saved from comma")\nMobile HTML exists: \(mobile)\nGalaxy script exists: \(script)\nLocal server error present: \(!error.isEmpty)"
}
static func reportPath(_ target: String) -> String {
let path = String(target.prefix(512)).split(separator: "?", maxSplits: 1).first.map(String.init) ?? ""
if ["/assets/", "/js/", "/css/", "/vendor/"].contains(where: { path.hasPrefix($0) }) { return String(path.prefix(200)) }
if path.hasPrefix("/api/") { return "/api/" + (path.split(separator: "/").dropFirst().first.map(String.init) ?? "") + "/…" }
return path == "/" ? "/" : "[other path]"
}
let localSecret = UUID().uuidString + UUID().uuidString
private var listener: NWListener?
private var connections: [UUID: LocalConnection] = [:]
private var webRoot: URL?
private let transport: any GalaxyRequestTransport
private let probe: (URL) async -> Bool
private var catalog: Data?
private var catalogSHA256: String?
init(transport: any GalaxyRequestTransport, webRoot: URL? = nil,
probe: @escaping (URL) async -> Bool = { url in
var request = URLRequest(url: url.appendingPathComponent("_native/health"))
request.timeoutInterval = 2
guard let (data, response) = try? await BoundedHTTP.load(request, limit: 256) else { return false }
return response.statusCode == 200 && data == Data("galaxy-loopback-ready".utf8)
}) {
self.transport = transport
self.probe = probe
self.webRoot = webRoot
}
func useWebRoot(_ root: URL) {
webRoot = root
}
private func record(_ event: String) {
lifecycleEvents.append("\(ISO8601DateFormatter().string(from: Date())) \(event)")
if lifecycleEvents.count > 20 { lifecycleEvents.removeFirst(lifecycleEvents.count - 20) }
}
// Probe real HTTP service after suspension; an old URL is not proof of readiness.
func ensureAvailable() async -> Bool {
if let availabilityTask { return await availabilityTask.value }
let task = Task { @MainActor in
self.start()
await self.waitUntilReady()
if let url = self.url, await self.probe(url) {
self.record("Local HTTP health check passed")
return true
}
self.record("Local HTTP health check failed; restarting listener")
self.stop()
self.start()
await self.waitUntilReady()
guard let url = self.url, await self.probe(url) else {
self.stop()
self.error = "Galaxy couldn’t reconnect. Try again."
self.record("Local HTTP recovery failed")
return false
}
self.record("Local HTTP recovery succeeded")
return true
}
availabilityTask = task
defer { availabilityTask = nil }
return await task.value
}
private func waitUntilReady() async {
for _ in 0..<40 {
if url != nil || listener == nil { return }
try? await Task.sleep(nanoseconds: 50_000_000)
}
}
func start() {
guard listener == nil else { return }
error = ""
listenerState = "starting"
record("Starting local listener")
do {
let parameters = NWParameters.tcp
parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any)
let listener = try NWListener(using: parameters)
self.listener = listener
listener.stateUpdateHandler = { [weak self, weak listener] state in
Task { @MainActor in
guard let self, let listener, self.listener === listener else { return }
switch state {
case .ready:
self.listenerState = "ready"
self.error = ""
if let port = listener.port { self.url = URL(string: "http://127.0.0.1:\(port.rawValue)/") }
self.record("Local listener ready")
case .failed(let failure):
self.stop()
self.listenerState = "failed"
self.error = failure.localizedDescription
self.record("Local listener failed: \(failure)")
case .waiting:
self.url = nil
self.listenerState = "waiting"
self.record("Local listener waiting")
case .cancelled:
self.stop()
default: break
}
}
}
listener.newConnectionHandler = { [weak self, weak listener] connection in
Task { @MainActor in
guard let self, let listener, self.listener === listener else { connection.cancel(); return }
self.accept(connection)
}
}
listener.start(queue: .main)
} catch { listenerState = "failed"; self.error = error.localizedDescription; record("Could not create local listener") }
}
func stop() {
let oldListener = listener
listener = nil
oldListener?.cancel()
listenerState = "stopped"
record("Local listener stopped")
url = nil
for client in Array(connections.values) { client.close() }
}
private func accept(_ connection: NWConnection) {
guard connections.count < 64 else { connection.cancel(); return }
let id = UUID()
let client = LocalConnection(connection: connection, handle: { [weak self] request in
guard let self else { return .error(503, "App is shutting down.") }
return await self.handle(request)
}, onClose: { [weak self] in self?.connections.removeValue(forKey: id) })
connections[id] = client
client.start()
}
private func handle(_ request: LocalRequest) async -> LocalResponse {
let start = Date()
let response = await respond(request)
recentRequests.append("\(ISO8601DateFormatter().string(from: Date())) \(request.method) \(Self.reportPath(request.target)) → \(response.status), \(response.body.count) bytes, \(String(format: "%.2f", Date().timeIntervalSince(start)))s")
if recentRequests.count > 60 { recentRequests.removeFirst(recentRequests.count - 60) }
return response
}
static let nativeSearchScript = #"""
// Native Bluetooth search adapter v1. The verified upstream assets stay unchanged on disk.
if (window.__galaxyNative) {
const reportSearch = (phase, count) => {
try { window.webkit?.messageHandlers?.galaxyDiagnostics?.postMessage({ kind: 'search', phase, count }) } catch (_) {}
}
SettingsSearchIndex.prototype.collect = async function(generation, controller) {
const seen = new Set(INITIAL_PAGES)
const queue = [...INITIAL_PAGES]
let failed = 0, reportedResults = false
reportSearch('started', 0)
const current = () => this.generation === generation && !controller.signal.aborted
for (let cursor = 0; cursor < queue.length && current(); cursor++) {
const page = queue[cursor]
const timed = new AbortController()
let timer, abort
try {
const expired = new Promise((_, reject) => {
abort = () => { timed.abort(); reject(new DOMException('Search stopped', 'AbortError')) }
controller.signal.addEventListener('abort', abort, { once: true })
timer = setTimeout(abort, 20000)
})
const read = (async () => {
const response = await this.fetcher(`./api/settings/pages/${encodeURIComponent(page)}`,
{ credentials: 'same-origin', cache: 'no-store', signal: timed.signal })
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
if (!response.ok) throw new Error('Settings unavailable')
const data = await response.json()
if (data?.page !== page || !Array.isArray(data.rows) || typeof data.title !== 'string') throw new Error('Invalid settings page')
return data
})()
const data = await Promise.race([read, expired])
if (!current() || !data) return
const section = PAGE_SECTIONS.get(page.split('/')[0]) || 'Toggles'
for (const row of data.rows) {
if (typeof row?.label !== 'string' || !row.label) continue
if (typeof row.page === 'string' && row.page && !seen.has(row.page) && seen.size < 64) {
seen.add(row.page); queue.push(row.page)
}
if (row.page || isPipCropRow(row)) continue
this.entries.push({ page, label: row.label, title: data.title, section,
search: searchable(row, data.title, section) })
}
} catch {
if (!current()) return
failed++
} finally {
clearTimeout(timer)
if (abort) controller.signal.removeEventListener('abort', abort)
}
// Publish each page immediately; one slow page must not hide earlier matches.
if (current() && this.entries.length && !reportedResults) {
reportedResults = true; reportSearch('results', this.entries.length)
}
if (current()) this.publish({ status: 'loading', entries: [...this.entries], failed })
}
if (!current()) return
this.status = failed ? 'partial' : 'ready'
reportSearch(this.status, this.entries.length)
this.publish({ status: this.status, entries: [...this.entries], failed })
}
const closeSearch = ToggleSearch.methods.close
ToggleSearch.methods.close = function() { this.index.stop(); closeSearch.call(this) }
const searchKey = ToggleSearch.methods.onKey
ToggleSearch.methods.onKey = function(event) {
if (event.key === 'Escape') this.index.stop()
searchKey.call(this, event)
}
const chooseSearchHit = ToggleSearch.methods.choose
ToggleSearch.methods.choose = function(hit) { this.index.stop(); chooseSearchHit.call(this, hit) }
ToggleSearch.template = ToggleSearch.template.replace("state.status === 'partial' && !state.entries.length", "state.status === 'partial' && !results.length")
}
"""#
private func respond(_ request: LocalRequest) async -> LocalResponse {
guard let base = url, let port = base.port, request.headers["host"] == "127.0.0.1:\(port)" else {
return .error(403, "Invalid local host.")
}
if let origin = request.headers["origin"], origin != "http://127.0.0.1:\(port)" {
return .error(403, "Cross-origin requests are blocked.")
}
guard request.target.hasPrefix("/"), !request.target.hasPrefix("//"),
let components = URLComponents(string: request.target), components.scheme == nil, components.host == nil,
let path = components.percentEncodedPath.removingPercentEncoding,
!path.contains("\\"), !path.contains("%"), !path.contains("//"),
!path.split(separator: "/").contains(where: { $0 == ".." || $0 == "." }) else {
return .error(400, "Invalid local path.")
}
if path == "/_native/health", request.method == "GET" {
return LocalResponse(status: 200, headers: ["content-type": "text/plain"], body: Data("galaxy-loopback-ready".utf8))
}
if path.hasPrefix("/_gateway/") { return .error(404, "Choose your connection in the native app.") }
let dynamic = path.hasPrefix("/api/") || path == "/data/runtime.json"
if dynamic {
guard request.headers["x-galaxy-local"] == localSecret else { return .error(403, "Missing app request key.") }
guard transport.connected else { return .error(503, "Your comma is disconnected. Open Connections to reconnect.") }
do {
let response = try await transport.request(path: request.target, method: request.method,
headers: request.headers, body: request.body)
return LocalResponse(status: response.status, headers: response.headers, body: response.bodyData)
} catch { return .error(502, error.localizedDescription) }
}
guard ["GET", "HEAD"].contains(request.method) else { return .error(405, "Method is not supported for Galaxy files.") }
let assetPath = path == "/" ? "index.html" : String(path.dropFirst())
var data: Data
if let webRoot {
let file = webRoot.appendingPathComponent(assetPath).standardizedFileURL
guard file.path.hasPrefix(webRoot.standardizedFileURL.path + "/"), let saved = try? Data(contentsOf: file) else {
return .error(404, "Galaxy asset was not found.")
}
data = saved
} else {
// No saved copy yet (an older comma that cannot share its screens): over Wi-Fi/LAN read the pages live from comma.
let notYet = "Galaxy screens aren’t downloaded yet. Connect to comma over Wi-Fi once."
guard transport.connected, SecretGoodGalaxy.validPage("/" + assetPath) else { return .error(503, notYet) }
guard let live = try? await transport.page(path: "/" + assetPath) else { return .error(503, notYet) }
guard live.status == 200 else { return .error(live.status, "Comma could not serve this Galaxy asset.") }
data = live.bodyData
}
let pathExtension = (assetPath as NSString).pathExtension
if assetPath == "js/toggle-search.js", let source = String(data: data, encoding: .utf8),
source.contains("export class SettingsSearchIndex"), source.contains("export const ToggleSearch") {
data.append(Data(("\n" + Self.nativeSearchScript).utf8))
record("Native incremental toggle search enabled")
}
let mime: String
switch pathExtension {
case "js", "mjs": mime = "text/javascript"
case "css": mime = "text/css"
case "html": mime = "text/html; charset=utf-8"
case "json", "webmanifest": mime = "application/json"
case "svg": mime = "image/svg+xml"
default: mime = UTType(filenameExtension: pathExtension)?.preferredMIMEType ?? "application/octet-stream"
}
return LocalResponse(status: 200, headers: ["content-type": mime], body: data)
}
}
@MainActor
private final class LocalConnection {
let connection: NWConnection
let handle: @MainActor (LocalRequest) async -> LocalResponse
let onClose: @MainActor () -> Void
private var data = Data()
private var requestTask: Task<Void, Never>?
private var headerTimeout: Task<Void, Never>?
private var closed = false
init(connection: NWConnection, handle: @escaping @MainActor (LocalRequest) async -> LocalResponse,
onClose: @escaping @MainActor () -> Void) {
self.connection = connection
self.handle = handle
self.onClose = onClose
}
func start() {
connection.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
if case .failed = state { self?.close() }
if case .cancelled = state { self?.close() }
}
}
connection.start(queue: .main)
headerTimeout = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 10_000_000_000)
if !Task.isCancelled { self?.close() }
}
receive()
}
fileprivate func close() {
guard !closed else { return }
closed = true
headerTimeout?.cancel()
requestTask?.cancel()
connection.stateUpdateHandler = nil
connection.cancel()
onClose()
}
private func receive() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] chunk, _, ended, failure in
Task { @MainActor in self?.consume(chunk, ended: ended, failure: failure) }
}
}
private var packageTimeout = false
private func consume(_ chunk: Data?, ended: Bool, failure: NWError?) {
guard !closed else { return }
data.append(chunk ?? Data())
guard failure == nil, data.count <= LocalRequest.maxPackageBody + 16_388 else { close(); return }
if !packageTimeout, let end = data.range(of: Data("\r\n\r\n".utf8)),
let header = String(data: data[..<end.lowerBound], encoding: .utf8),
let first = header.components(separatedBy: "\r\n").first {
let parts = first.split(separator: " ")
if parts.count == 3, LocalRequest.isPackageUpload(method: String(parts[0]), target: String(parts[1])) {
packageTimeout = true
headerTimeout?.cancel()
headerTimeout = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 120_000_000_000)
if !Task.isCancelled { self?.close() }
}
}
}
do {
guard let request = try LocalRequest.parse(data) else {
if ended { close() } else { receive() }
return
}
headerTimeout?.cancel()
data = Data()
// Detect browser aborts while a Bluetooth request is queued.
connection.receive(minimumIncompleteLength: 1, maximumLength: 1) { [weak self] bytes, _, done, failure in
Task { @MainActor in
if done || failure != nil || bytes?.isEmpty == false { self?.close() }
}
}
requestTask = Task { @MainActor in
let response = await handle(request)
guard !Task.isCancelled, !closed else { close(); return }
send(response, headOnly: request.method == "HEAD")
}
} catch { send(.error(400, error.localizedDescription), headOnly: false) }
}
private func send(_ response: LocalResponse, headOnly: Bool) {
headerTimeout?.cancel()
connection.send(content: response.encoded(headOnly: headOnly), completion: .contentProcessed { [weak self] _ in
Task { @MainActor in self?.close() }
})
}
}
@@ -0,0 +1,326 @@
import Foundation
import Combine
/// Each request has an isolated, bounded buffer.
final class BoundedHTTP: NSObject, URLSessionDataDelegate, @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<(Data, HTTPURLResponse), Error>?
private var buffer = Data()
private var response: HTTPURLResponse?
private var task: URLSessionDataTask?
private var session: URLSession?
private var cancelled = false
private let limit: Int
init(limit: Int) { self.limit = limit }
static func load(_ request: URLRequest, limit: Int) async throws -> (Data, HTTPURLResponse) {
let client = BoundedHTTP(limit: limit)
return try await withTaskCancellationHandler(operation: {
try Task.checkCancellation()
return try await withCheckedThrowingContinuation { client.start(request, continuation: $0) }
}, onCancel: { client.cancel() })
}
private func start(_ request: URLRequest, continuation: CheckedContinuation<(Data, HTTPURLResponse), Error>) {
lock.lock(); defer { lock.unlock() }
guard !cancelled else { continuation.resume(throwing: CancellationError()); return }
self.continuation = continuation
let config = URLSessionConfiguration.ephemeral
config.urlCache = nil
config.httpCookieStorage = nil
config.requestCachePolicy = .reloadIgnoringLocalCacheData
config.connectionProxyDictionary = [:]
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)
self.session = session
task = session.dataTask(with: request)
task?.resume()
}
private func cancel() {
lock.lock(); cancelled = true; let task = task; lock.unlock()
task?.cancel()
}
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
guard let http = response as? HTTPURLResponse, response.expectedContentLength <= limit else {
completionHandler(.cancel); return
}
self.response = http
completionHandler(.allow)
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
guard buffer.count + data.count <= limit else { dataTask.cancel(); return }
buffer.append(data)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
lock.lock(); let callback = continuation; continuation = nil; let cancelled = cancelled; lock.unlock()
if cancelled { callback?.resume(throwing: CancellationError()) }
else if let error { callback?.resume(throwing: error) }
else if let response { callback?.resume(returning: (buffer, response)) }
else { callback?.resume(throwing: BridgeError.message("Invalid HTTP response.")) }
session.finishTasksAndInvalidate()
self.session = nil
}
}
@MainActor
final class LANTransport: ObservableObject, GalaxyRequestTransport {
@Published private(set) var connected = false
@Published private(set) var status = "Local connection not checked yet."
var onEvent: ((String) -> Void)?
private(set) var endpoint: URL?
private(set) var generation = UUID().uuidString
private(set) var expectedDeviceID: String?
private(set) var verifiedDeviceID: String?
private var cookie: String?
private var sessionTask: (UUID, Task<String, Error>)?
private let load: (URLRequest, Int) async throws -> (Data, HTTPURLResponse)
init(load: @escaping (URLRequest, Int) async throws -> (Data, HTTPURLResponse) = { try await PrivateLANHTTP.load($0, limit: $1) }) {
self.load = load
}
// An explicit private IPv4 address prevents cloud/loopback endpoints from being treated as the local comma.
static func endpoint(_ address: String) throws -> URL {
let parts = address.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4, parts.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }),
parts.allSatisfy({ String(Int($0) ?? -1) == $0 }),
let a = Int(parts[0]), let b = Int(parts[1]), parts.allSatisfy({ (0...255).contains(Int($0) ?? -1) }),
a == 10 || (a == 172 && (16...31).contains(b)) || (a == 192 && b == 168),
let url = URL(string: "http://\(parts.joined(separator: ".")):8082") else {
throw BridgeError.message("Enter your comma’s local IP address, such as 192.168.10.85.")
}
return url
}
func configure(address: String) throws {
let new = try Self.endpoint(address)
if endpoint != new { endpoint = new; invalidate(reason: "Comma LAN address changed") }
}
static func reportedAddress(_ data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let addresses = json["addresses"] as? [[String: Any]] else { return nil }
return addresses.compactMap { row -> String? in
guard let text = row["url"] as? String, let url = URL(string: text),
let host = url.host, let expected = try? endpoint(host),
url.scheme == "http", url.port == 8082, url.user == nil, url.password == nil,
url.host == expected.host else { return nil }
return host
}.first
}
static func deviceID(_ data: Data) -> String? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
json["schemaVersion"] as? Int == 1, json["monitor"] as? String == "local",
let value = json["instance"] as? String,
value.count == 16, value.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }) else { return nil }
return value
}
func bind(to identity: String?) {
if expectedDeviceID != identity { expectedDeviceID = identity; invalidate(reason: "Paired comma identity changed") }
}
func clearEndpoint() { endpoint = nil; invalidate(reason: "Saved LAN endpoint cleared") }
func invalidate(reason: String = "LAN verification reset") {
cookie = nil; sessionTask?.1.cancel(); sessionTask = nil
connected = false; verifiedDeviceID = nil; generation = UUID().uuidString
onEvent?(reason)
}
func probe() async -> Bool {
guard endpoint != nil else { return false }
let expectedGeneration = generation
do {
let (data, response) = try await authenticated(path: "/data/runtime.json", method: "GET", headers: [:], body: Data(), limit: 4096)
guard !Task.isCancelled, expectedGeneration == generation else { return false }
let identity = Self.deviceID(data)
let available = response.statusCode == 200 && identity != nil
&& (expectedDeviceID == nil || identity == expectedDeviceID)
guard !Task.isCancelled, expectedGeneration == generation else { return false }
let changed = connected != available
connected = available
verifiedDeviceID = available ? identity : nil
status = available ? "Local comma verified." : "Local endpoint unavailable or does not match the paired comma."
if changed { onEvent?(available ? "LAN probe succeeded; comma identity verified" : "LAN probe rejected: HTTP \(response.statusCode), unavailable status or identity mismatch") }
} catch {
if !Task.isCancelled, expectedGeneration == generation {
status = error.localizedDescription
invalidate(reason: "LAN probe failed: \(Self.failureCode(error))")
}
}
return connected
}
static func failureCode(_ error: Error) -> String {
let value = error as NSError
if let url = error as? URLError {
let name: String
switch url.code {
case .timedOut: name = "timeout"
case .networkConnectionLost: name = "connection lost"
case .notConnectedToInternet: name = "no network route"
case .cannotConnectToHost: name = "cannot reach comma"
case .cancelled: name = "cancelled"
default: name = "network error"
}
return "\(name) (\(value.domain) \(value.code))"
}
return "\(value.domain) \(value.code)"
}
private func sessionCookie(endpoint: URL, generation original: String) async throws -> String {
if let cookie { return cookie }
let token: UUID
let pending: Task<String, Error>
if let existing = sessionTask { (token, pending) = existing }
else {
token = UUID()
pending = Task {
var request = URLRequest(url: endpoint.appendingPathComponent("api/auth/session"))
request.timeoutInterval = 8
let (data, response) = try await load(request, 65_536)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let cookies = HTTPCookie.cookies(withResponseHeaderFields: response.allHeaderFields.reduce(into: [String: String]()) {
if let key = $1.key as? String { $0[key] = String(describing: $1.value) }
}, for: endpoint)
guard response.statusCode == 200, json?["authenticated"] as? Bool == true,
json?["localAccess"] as? Bool == true,
let value = cookies.first(where: { $0.name == "galaxy_session" && !$0.value.isEmpty }) else {
throw BridgeError.message("Couldn’t open a local Galaxy session. Connect directly to comma’s Wi-Fi.")
}
return "galaxy_session=\(value.value)"
}
sessionTask = (token, pending)
}
defer { if sessionTask?.0 == token { sessionTask = nil } }
let value = try await pending.value
try Task.checkCancellation()
guard original == generation, self.endpoint == endpoint else { throw CancellationError() }
cookie = value
return value
}
private func authenticated(path: String, method: String, headers: [String: String], body: Data,
limit: Int) async throws -> (Data, HTTPURLResponse) {
guard let endpoint else { throw URLError(.notConnectedToInternet) }
let original = generation
for attempt in 0...1 {
let usedCookie = try await sessionCookie(endpoint: endpoint, generation: original)
guard original == generation else { throw CancellationError() }
var request = URLRequest(url: URL(string: endpoint.absoluteString + path)!)
request.httpMethod = method
request.httpBody = body.isEmpty ? nil : body
request.timeoutInterval = LocalRequest.isPackageUpload(method: method, target: path) ? 120 : 8
for (name, value) in headers where ["accept", "range", "content-type"].contains(name.lowercased()) {
request.setValue(value, forHTTPHeaderField: name)
}
request.setValue(usedCookie, forHTTPHeaderField: "Cookie")
if !["GET", "HEAD"].contains(method) {
request.setValue(endpoint.absoluteString, forHTTPHeaderField: "Origin")
request.setValue(LocalRequest.isPackageUpload(method: method, target: path) ? "application/octet-stream" : "application/json", forHTTPHeaderField: "Content-Type")
}
let result = try await load(request, limit)
try Task.checkCancellation()
guard original == generation, self.endpoint == endpoint else { throw CancellationError() }
let message = (try? JSONSerialization.jsonObject(with: result.0)) as? [String: Any]
if attempt == 0, result.1.statusCode == 401,
message?["error"] as? String == "Refresh the local Galaxy session" {
// The server rejected this operation before execution. Retry only this explicit response.
if cookie == usedCookie { cookie = nil }
continue
}
return result
}
throw BridgeError.message("Galaxy session expired. Reconnect to comma.")
}
func page(path: String) async throws -> BridgeResponse {
guard connected else { throw URLError(.notConnectedToInternet) }
guard SecretGoodGalaxy.validPage(path) else { throw BridgeError.message("Invalid Galaxy page request.") }
let (data, response) = try await authenticated(path: path, method: "GET", headers: [:], body: Data(), limit: 8 * Wire.maxBody)
return bridgeResponse(data, response)
}
func request(path: String, method: String, headers: [String: String], body: Data) async throws -> BridgeResponse {
guard connected else { throw URLError(.notConnectedToInternet) }
guard SecretGoodGalaxy.validTarget(path),
["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"].contains(method), body.count <= LocalRequest.bodyLimit(method: method, target: path) else {
throw BridgeError.message("Invalid Galaxy request.")
}
let (data, response) = try await authenticated(path: path, method: method, headers: headers, body: body, limit: 8 * Wire.maxBody)
return bridgeResponse(data, response)
}
private func bridgeResponse(_ data: Data, _ response: HTTPURLResponse) -> BridgeResponse {
var responseHeaders: [String: String] = [:]
for (key, value) in response.allHeaderFields {
if let name = key as? String, ["content-type", "content-range", "accept-ranges", "content-disposition"].contains(name.lowercased()) {
responseHeaders[name.lowercased()] = String(describing: value)
}
}
return BridgeResponse(id: UUID().uuidString, session: generation, counter: 0, status: response.statusCode,
headers: responseHeaders, body: data.base64EncodedString())
}
}
enum ConnectionMode: String, CaseIterable, Identifiable {
case automatic, lan, bluetooth
var id: String { rawValue }
var title: String { switch self { case .automatic: "Automatic"; case .lan: "Wi-Fi only"; case .bluetooth: "Bluetooth only" } }
}
@MainActor
final class PreferredTransport: ObservableObject, GalaxyRequestTransport {
let lan: any GalaxyRequestTransport
let bluetooth: any GalaxyRequestTransport
var invalidateLAN: (String) -> Void
@Published var mode: ConnectionMode = .automatic
var connected: Bool { (mode != .bluetooth && lan.connected) || (mode != .lan && bluetooth.connected) }
var usesLAN: Bool { mode != .bluetooth && lan.connected }
var label: String { usesLAN ? "Wi-Fi" : bluetooth.connected && mode != .lan ? "Bluetooth" : "Reconnecting…" }
var catalogSHA256: String? { usesLAN ? lan.catalogSHA256 : bluetooth.catalogSHA256 }
init(lan: any GalaxyRequestTransport, bluetooth: any GalaxyRequestTransport, invalidateLAN: @escaping (String) -> Void) {
self.lan = lan; self.bluetooth = bluetooth; self.invalidateLAN = invalidateLAN
}
func page(path: String) async throws -> BridgeResponse {
guard usesLAN else { throw BridgeError.message("Galaxy screens can only be downloaded from comma over Wi-Fi.") }
return try await lan.page(path: path)
}
func request(path: String, method: String, headers: [String: String], body: Data) async throws -> BridgeResponse {
try Task.checkCancellation()
if usesLAN {
do { return try await lan.request(path: path, method: method, headers: headers, body: body) }
catch {
try Task.checkCancellation()
guard error is URLError else { throw error }
invalidateLAN("LAN request failed: \(LANTransport.failureCode(error))")
// Retry reads only; never replay a setting change.
if !["GET", "HEAD"].contains(method) {
throw BridgeError.message("Wi-Fi disconnected. This change may have saved; reload the setting to confirm before trying again.")
}
guard mode == .automatic, bluetooth.connected else { throw error }
}
}
if SecretGoodGalaxy.needsWiFi(path) {
return BridgeResponse(id: UUID().uuidString, session: "wifi-required", counter: 0, status: 501,
headers: ["content-type": "application/json"],
body: Data("{\"error\":\"Connect to comma over local Wi-Fi to use this feature.\"}".utf8).base64EncodedString())
}
guard mode != .lan, bluetooth.connected else { throw BridgeError.message("Your comma is disconnected. Open Connections to reconnect.") }
return try await bluetooth.request(path: path, method: method, headers: headers, body: body)
}
}
@MainActor
enum LANDiscoveryMatch {
static func first(addresses: [String], expectedDeviceID: String?, stillCurrent: () -> Bool,
probe: (String, String) async -> Bool) async -> String? {
guard let identity = expectedDeviceID, !identity.isEmpty else { return nil }
var seen = Set<String>()
for address in addresses.prefix(16) {
guard !Task.isCancelled, stillCurrent() else { return nil }
guard seen.insert(address).inserted, (try? LANTransport.endpoint(address)) != nil else { continue }
let verified = await probe(address, identity)
guard !Task.isCancelled, stillCurrent() else { return nil }
if verified { return address }
}
return nil
}
}
@@ -0,0 +1,13 @@
import Foundation
/// A phone's key from comma and comma's id for it (kept so a re-pair can replace the old entry).
struct PairingCode {
let key: String
let id: String?
static func validID(_ value: String?) -> String? {
guard let value, value.utf8.count == 8,
value.utf8.allSatisfy({ (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }) else { return nil }
return value.lowercased()
}
}
@@ -0,0 +1,73 @@
import Foundation
import Security
struct PairingCredential: Codable, Equatable {
let key: String
let id: String?
var previousId: String?
static func decode(_ data: Data) -> PairingCredential? {
if let stored = try? JSONDecoder().decode(Self.self, from: data) {
return Self(key: stored.key, id: PairingCode.validID(stored.id),
previousId: PairingCode.validID(stored.previousId))
}
guard let key = String(data: data, encoding: .utf8), key.utf8.count == 64,
key.utf8.allSatisfy({ (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) }) else { return nil }
return Self(key: key.lowercased(), id: nil, previousId: nil)
}
static func replacing(_ saved: Self?, with code: PairingCode) -> Self {
let previous = saved?.id == code.id ? saved?.previousId : saved?.id
return Self(key: code.key, id: code.id, previousId: previous)
}
}
enum PairingKeyStore {
// Scanned credentials are committed only after the encrypted handshake succeeds.
private static var pending: PairingCredential?
private static let service = "link.galaxy.bluetooth.pairing"
private static let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "comma"]
static func load() -> String { stored()?.key ?? "" }
private static func stored() -> PairingCredential? {
var request = query
request[kSecReturnData as String] = true
request[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
return PairingCredential.decode(data)
}
static func stage(_ code: PairingCode) {
pending = PairingCredential.replacing(stored(), with: code)
}
static func credential(for key: String) -> PairingCredential? {
if let pending, pending.key == key { return pending }
guard let saved = stored(), saved.key == key else { return nil }
return saved
}
static func phone(for key: String, name: String) -> BridgePhone {
BridgePhone(name: name, replaces: credential(for: key)?.previousId)
}
static func save(_ value: String) throws {
var credential = credential(for: value) ?? PairingCredential(key: value, id: nil, previousId: nil)
credential.previousId = nil
let data = try JSONEncoder().encode(credential)
let status = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly] as CFDictionary)
if status == errSecItemNotFound {
var item = query
item[kSecValueData as String] = data
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { throw BridgeError.message("Could not save pairing key in Keychain.") }
} else if status != errSecSuccess { throw BridgeError.message("Could not update pairing key in Keychain.") }
pending = nil
}
static func remove() { pending = nil; SecItemDelete(query as CFDictionary) }
}
@@ -0,0 +1,94 @@
import CoreLocation
import Foundation
/// Sends this iPhone's GPS to comma about once a second over the current Galaxy connection.
/// Comma uses it for navigation only when its own GPS and the car's GPS have no fix.
@MainActor
final class PhoneLocation: NSObject, ObservableObject, @preconcurrency CLLocationManagerDelegate {
static let path = "/api/companion/location"
nonisolated static let maxAgeMs = 2000.0
@Published private(set) var sharing = UserDefaults.standard.bool(forKey: "galaxy.phoneGps")
@Published private(set) var status = ""
var onSharingChanged: ((Bool) -> Void)?
private let transport: () -> (any GalaxyRequestTransport)?
private let manager: CLLocationManager?
private var sending = false
private var lastSent = Date.distantPast
init(transport: @escaping () -> (any GalaxyRequestTransport)?, manager: CLLocationManager? = CLLocationManager()) {
self.transport = transport
self.manager = manager
super.init()
guard let manager else { return }
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyBestForNavigation
manager.distanceFilter = kCLDistanceFilterNone
#if os(iOS)
manager.activityType = .automotiveNavigation
manager.pausesLocationUpdatesAutomatically = false
manager.showsBackgroundLocationIndicator = true
#endif
if sharing { begin() }
}
func setSharing(_ value: Bool) {
guard value != sharing else { return }
sharing = value
UserDefaults.standard.set(value, forKey: "galaxy.phoneGps")
if value { begin() } else { manager?.stopUpdatingLocation(); status = "" }
onSharingChanged?(value)
}
private func begin() {
guard let manager else { return }
switch manager.authorizationStatus {
case .notDetermined: status = "Allow location to share GPS."; manager.requestWhenInUseAuthorization()
case .denied, .restricted: status = "Location is off for this app. Turn it on in iPhone Settings."
default:
#if os(iOS)
manager.allowsBackgroundLocationUpdates = true
#endif
status = "Waiting for GPS"
manager.startUpdatingLocation()
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
if sharing { begin() }
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let location = locations.last else { return }
Task { await deliver(location) }
}
/// Sends one fix unless another is in flight or one went out under a second ago.
func deliver(_ location: CLLocation, now: Date = Date()) async {
guard sharing, !sending, now.timeIntervalSince(lastSent) >= 0.9 else { return }
guard let body = Self.payload(location, now: now) else { status = "Waiting for GPS"; return }
guard let transport = transport(), transport.connected else { status = "Waiting for comma"; return }
sending = true
defer { sending = false }
do {
let response = try await transport.request(path: Self.path, method: "POST", headers: ["Content-Type": "application/json"], body: body)
lastSent = now
status = response.status == 200 ? "Sharing GPS with comma"
: response.status == 405 ? "This comma doesn't take phone GPS yet." : "Comma didn't accept the location."
} catch { status = "Waiting for comma" }
}
/// iOS marks an unknown value with a negative number; those are left out.
nonisolated static func payload(_ location: CLLocation, now: Date) -> Data? {
let age = now.timeIntervalSince(location.timestamp) * 1000
guard age >= 0, age <= maxAgeMs, location.horizontalAccuracy > 0, CLLocationCoordinate2DIsValid(location.coordinate) else { return nil }
var value: [String: Any] = ["latitude": location.coordinate.latitude, "longitude": location.coordinate.longitude,
"horizontalAccuracy": location.horizontalAccuracy, "ageMs": Int(age.rounded()),
"timestamp": Int64(location.timestamp.timeIntervalSince1970 * 1000)]
if location.verticalAccuracy > 0 { value["altitude"] = location.altitude; value["verticalAccuracy"] = location.verticalAccuracy }
if location.speed >= 0 { value["speed"] = location.speed }
if location.speedAccuracy >= 0 { value["speedAccuracy"] = location.speedAccuracy }
if location.course >= 0, location.courseAccuracy >= 0 { value["bearing"] = location.course; value["bearingAccuracy"] = location.courseAccuracy }
return try? JSONSerialization.data(withJSONObject: value)
}
}
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
</dict>
</plist>
@@ -0,0 +1,185 @@
import Foundation
import Network
/// A local-only HTTP client for the one configured comma endpoint.
@MainActor
final class PrivateLANHTTP {
private let connection: NWConnection
private let limit: Int
private let head: Bool
private var continuation: CheckedContinuation<(Data, HTTPURLResponse), Error>?
private var parser: LANHTTPParser
private var timeout: Task<Void, Never>?
private var finished = false
private var sent = false
private let request: URLRequest
private init(_ request: URLRequest, limit: Int, host: String) {
self.request = request; self.limit = limit; head = request.httpMethod == "HEAD"
parser = LANHTTPParser(limit: limit, headOnly: request.httpMethod == "HEAD")
connection = NWConnection(host: NWEndpoint.Host(host), port: 8082, using: .tcp)
}
static func load(_ request: URLRequest, limit: Int) async throws -> (Data, HTTPURLResponse) {
guard let url = request.url, url.scheme == "http", url.port == 8082, let host = url.host,
url.user == nil, url.password == nil, url.fragment == nil,
(try? LANTransport.endpoint(host)) != nil else { throw BridgeError.message("Invalid local comma endpoint.") }
let client = PrivateLANHTTP(request, limit: limit, host: host)
return try await withTaskCancellationHandler(operation: {
try Task.checkCancellation()
return try await withCheckedThrowingContinuation { client.start($0) }
}, onCancel: { Task { @MainActor in client.finish(.failure(CancellationError())) } })
}
private func start(_ callback: CheckedContinuation<(Data, HTTPURLResponse), Error>) {
guard !finished else { callback.resume(throwing: CancellationError()); return }
continuation = callback
let timeoutInterval = request.timeoutInterval
timeout = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(max(0.1, timeoutInterval) * 1_000_000_000))
guard !Task.isCancelled else { return }
self?.finish(.failure(URLError(.timedOut)))
}
connection.stateUpdateHandler = { [weak self] state in
Task { @MainActor in
guard let self, !self.finished else { return }
switch state {
case .ready: self.send()
case .failed: self.finish(.failure(URLError(.cannotConnectToHost)))
case .cancelled: self.finish(.failure(URLError(.networkConnectionLost)))
default: break
}
}
}
connection.start(queue: .main)
}
private func send() {
guard !sent else { return }
sent = true
guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
finish(.failure(BridgeError.message("Invalid local URL."))); return
}
let target = components.percentEncodedPath + (components.percentEncodedQuery.map { "?" + $0 } ?? "")
let body = request.httpBody ?? Data()
var text = "\(request.httpMethod ?? "GET") \(target) HTTP/1.1\r\nHost: \(url.host!):8082\r\nConnection: close\r\nAccept-Encoding: identity\r\nContent-Length: \(body.count)\r\n"
for (name, value) in request.allHTTPHeaderFields ?? [:] where ["content-type", "accept", "cookie", "origin", "range", "x-companion-nonce", "x-companion-mac"].contains(name.lowercased()) {
guard !value.contains("\r"), !value.contains("\n") else { finish(.failure(BridgeError.message("Invalid request header."))); return }
text += "\(name): \(value)\r\n"
}
guard text.utf8.count <= 16_384 else { finish(.failure(BridgeError.message("HTTP headers are too large."))); return }
text += "\r\n"
connection.send(content: Data(text.utf8), isComplete: false, completion: .idempotent)
connection.send(content: body, completion: .contentProcessed { [weak self] error in
Task { @MainActor in
guard let self, !self.finished else { return }
if error != nil { self.finish(.failure(URLError(.networkConnectionLost))) }
else { self.receive() }
}
})
}
private func receive() {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, ended, error in
Task { @MainActor in
guard let self, !self.finished else { return }
guard error == nil else { self.finish(.failure(URLError(.networkConnectionLost))); return }
do {
if let result = try self.parser.consume(data ?? Data(), ended: ended),
let response = HTTPURLResponse(url: self.request.url!, statusCode: result.status, httpVersion: "HTTP/1.1", headerFields: result.headers) {
self.finish(.success((result.body, response)))
} else { self.receive() }
} catch { self.finish(.failure(URLError(.cannotParseResponse))) }
}
}
}
private func finish(_ result: Result<(Data, HTTPURLResponse), Error>) {
guard !finished else { return }
finished = true; timeout?.cancel(); connection.stateUpdateHandler = nil; connection.cancel()
let callback = continuation; continuation = nil
callback?.resume(with: result)
}
}
/// Incremental HTTP framing with strict bounds, including chunked responses.
struct LANHTTPParser {
let limit: Int
let headOnly: Bool
private var buffer = Data()
private var body = Data()
private var status: Int?
private var headers: [String: String] = [:]
private var length: Int?
private var chunked = false
private var chunkLength: Int?
private var lastChunk = false
private var overhead = 0
init(limit: Int, headOnly: Bool) { self.limit = limit; self.headOnly = headOnly }
mutating func consume(_ data: Data, ended: Bool) throws -> LocalResponse? {
buffer.append(data)
guard buffer.count + body.count <= limit + 16_388 else { throw URLError(.dataLengthExceedsMaximum) }
if status == nil {
guard let range = buffer.range(of: Data("\r\n\r\n".utf8)) else {
guard buffer.count <= 16_384, !ended else { throw URLError(.cannotParseResponse) }; return nil
}
guard range.lowerBound <= 16_384, let text = String(data: buffer[..<range.lowerBound], encoding: .isoLatin1) else { throw URLError(.cannotParseResponse) }
let lines = text.components(separatedBy: "\r\n")
let first = (lines.first ?? "").split(separator: " ")
guard first.count >= 2, ["HTTP/1.0", "HTTP/1.1"].contains(String(first[0])), let code = Int(first[1]), (200...599).contains(code) else { throw URLError(.cannotParseResponse) }
status = code
for line in lines.dropFirst() {
guard let colon = line.firstIndex(of: ":"), colon != line.startIndex else { throw URLError(.cannotParseResponse) }
let name = line[..<colon].lowercased()
guard headers[name] == nil else { throw URLError(.cannotParseResponse) }
headers[name] = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces)
}
if let coding = headers["content-encoding"], coding.lowercased() != "identity" { throw URLError(.cannotDecodeContentData) }
if let encoding = headers["transfer-encoding"] {
guard encoding.lowercased() == "chunked", headers["content-length"] == nil else { throw URLError(.cannotParseResponse) }
chunked = true
}
if let value = headers["content-length"] {
guard let count = Int(value), (0...limit).contains(count) else { throw URLError(.dataLengthExceedsMaximum) }; length = count
}
buffer = Data(buffer[range.upperBound...])
if headOnly || code == 204 || code == 304 { return result(Data()) }
}
if chunked {
while true {
if lastChunk {
// Accept an empty trailer or bounded well-formed trailers.
if buffer.starts(with: Data("\r\n".utf8)) {
guard buffer.count == 2 else { throw URLError(.cannotParseResponse) }; return result(body)
}
if let range = buffer.range(of: Data("\r\n\r\n".utf8)) {
guard range.upperBound == buffer.count, range.lowerBound <= 16_384,
let trailers = String(data: buffer[..<range.lowerBound], encoding: .isoLatin1),
trailers.components(separatedBy: "\r\n").allSatisfy({ $0.contains(":") }) else { throw URLError(.cannotParseResponse) }
return result(body)
}
guard buffer.count <= 16_384 else { throw URLError(.cannotParseResponse) }; break
}
if chunkLength == nil {
guard let range = buffer.range(of: Data("\r\n".utf8)) else {
guard buffer.count <= 1024 else { throw URLError(.cannotParseResponse) }; break
}
guard range.lowerBound <= 1024, let line = String(data: buffer[..<range.lowerBound], encoding: .ascii),
let token = line.split(separator: ";", omittingEmptySubsequences: false).first,
!token.isEmpty, token.allSatisfy(\.isHexDigit), let count = Int(token, radix: 16), count <= limit - body.count else { throw URLError(.cannotParseResponse) }
overhead += range.upperBound
guard overhead <= 65_536 else { throw URLError(.cannotParseResponse) }
buffer = Data(buffer[range.upperBound...]); chunkLength = count
if count == 0 { lastChunk = true; continue }
}
let count = chunkLength!
guard buffer.count >= count + 2 else { break }
guard buffer.subdata(in: count..<count + 2) == Data("\r\n".utf8) else { throw URLError(.cannotParseResponse) }
body.append(buffer.prefix(count)); buffer = Data(buffer.dropFirst(count + 2)); chunkLength = nil
}
} else if let length {
guard buffer.count <= length else { throw URLError(.cannotParseResponse) }
if buffer.count == length { return result(buffer) }
} else {
guard buffer.count <= limit else { throw URLError(.dataLengthExceedsMaximum) }
if ended { return result(buffer) }
}
if ended { throw URLError(.cannotParseResponse) }
return nil
}
private func result(_ body: Data) -> LocalResponse { LocalResponse(status: status!, headers: headers, body: body) }
}
@@ -0,0 +1,14 @@
{
"images": [
{
"filename": "StarPilotIcon.png",
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "Nate",
"version": 1
}
}
@@ -0,0 +1,16 @@
{
"images": [
{
"filename": "bluetooth.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true,
"template-rendering-intent": "template"
}
}
@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m7 7 10 10-5 5V2l5 5L7 17" />
</svg>

After

Width:  |  Height:  |  Size: 249 B

@@ -0,0 +1,16 @@
{
"images": [
{
"filename": "network.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true,
"template-rendering-intent": "template"
}
}
@@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="16" y="16" width="6" height="6" rx="1" />
<rect x="2" y="16" width="6" height="6" rx="1" />
<rect x="9" y="2" width="6" height="6" rx="1" />
<path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" />
<path d="M12 12V8" />
</svg>

After

Width:  |  Height:  |  Size: 445 B

@@ -0,0 +1,16 @@
{
"images": [
{
"filename": "wifi.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true,
"template-rendering-intent": "template"
}
}
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 20h.01" />
<path d="M2 8.82a15 15 0 0 1 20 0" />
<path d="M5 12.859a10 10 0 0 1 14 0" />
<path d="M8.5 16.429a5 5 0 0 1 7 0" />
</svg>

After

Width:  |  Height:  |  Size: 357 B

@@ -0,0 +1,6 @@
{
"info": {
"author": "Nate",
"version": 1
}
}
@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "StarPilot.png",
"idiom": "universal"
}
],
"info": {
"author": "Nate",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "StarPilotMark.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

@@ -0,0 +1,43 @@
ISC License
Copyright (c) 2026 Lucide Icons and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
---
The following Lucide icons are derived from the Feather project:
airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
The MIT License (MIT) (for the icons listed above)
Copyright (c) 2013-present Cole Bemis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,93 @@
Copyright 2019 The Sora Project Authors (https://github.com/sora-xor/sora-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,194 @@
import Foundation
import CryptoKit
import Compression
struct BridgePhone: Codable {
let name: String
let replaces: String?
}
struct BridgeRequest: Codable {
let id: String
let session: String
let counter: UInt64
let path: String
let method: String
let headers: [String: String]
let body: String
var phone: BridgePhone? = nil
var responseCodec: Int? = nil
var responseFlow: String? = nil
}
struct BridgeResponse: Codable {
let id: String
let session: String
let counter: UInt64
let status: Int
let headers: [String: String]
let body: String
var bodyData: Data { Data(base64Encoded: body) ?? Data() }
}
struct BridgeHealth: Decodable {
let `protocol`: Int
let transport: String
let readStream: Bool?
let notificationStream: Bool?
let catalogSHA256: String?
}
enum BridgeError: LocalizedError {
case message(String)
var errorDescription: String? {
if case let .message(value) = self { return value }
return nil
}
}
enum Wire {
static let service = "bd490001-6dc1-4de7-a7d0-6cdb441f7650"
static let rx = "bd490002-6dc1-4de7-a7d0-6cdb441f7650"
static let tx = "bd490003-6dc1-4de7-a7d0-6cdb441f7650"
static let info = "bd490004-6dc1-4de7-a7d0-6cdb441f7650"
static let notify = "bd490005-6dc1-4de7-a7d0-6cdb441f7650"
static let notificationWindow = 8
static let maxFrame = 2 * 1024 * 1024
static let maxBody = 1024 * 1024
static func uint32(_ number: UInt32) -> Data {
var value = number.bigEndian
return withUnsafeBytes(of: &value) { Data($0) }
}
static func number(_ bytes: Data) -> UInt32 {
bytes.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
}
static func seal<T: Encodable>(_ value: T, key: SymmetricKey, direction: String) throws -> Data {
let plaintext = try JSONEncoder().encode(value)
guard plaintext.count <= maxFrame else { throw BridgeError.message("Decoded request exceeds the Bluetooth limit.") }
var compressed = [UInt8](repeating: 0, count: maxFrame)
let written = plaintext.withUnsafeBytes { bytes in
compression_encode_buffer(&compressed, compressed.count, bytes.bindMemory(to: UInt8.self).baseAddress!, plaintext.count, nil, COMPRESSION_ZLIB)
}
let useCompression = written > 0 && written < plaintext.count
let encoded = Data([useCompression ? 1 : 0]) + uint32(UInt32(plaintext.count))
+ (useCompression ? Data(compressed.prefix(written)) : plaintext)
let box = try AES.GCM.seal(encoded, using: key,
authenticating: Data("galaxy-ble-v1/\(direction)".utf8))
guard let combined = box.combined, combined.count <= maxFrame else {
throw BridgeError.message("Request exceeds the Bluetooth frame limit.")
}
return uint32(UInt32(combined.count)) + combined
}
static func open<T: Decodable>(_ data: Data, as: T.Type, key: SymmetricKey, direction: String) throws -> T {
guard (28...maxFrame).contains(data.count) else { throw BridgeError.message("Invalid encrypted message.") }
let encoded = try AES.GCM.open(AES.GCM.SealedBox(combined: data), using: key,
authenticating: Data("galaxy-ble-v1/\(direction)".utf8))
guard encoded.count >= 5 else { throw BridgeError.message("Invalid compressed envelope.") }
let length = Int(number(encoded.subdata(in: 1..<5)))
guard (1...maxFrame).contains(length) else { throw BridgeError.message("Invalid decoded message size.") }
let plaintext: Data
switch encoded.first {
case 0: plaintext = Data(encoded.dropFirst(5))
case 1, 2:
let compressed = Data(encoded.dropFirst(5))
guard !compressed.isEmpty else { throw BridgeError.message("Empty compressed message.") }
var decoded = [UInt8](repeating: 0, count: length + 1)
let written = compressed.withUnsafeBytes { bytes in
compression_decode_buffer(&decoded, decoded.count, bytes.bindMemory(to: UInt8.self).baseAddress!, compressed.count, nil, COMPRESSION_ZLIB)
}
guard written == length else { throw BridgeError.message("Invalid compressed Bluetooth message.") }
plaintext = Data(decoded.prefix(written))
default: throw BridgeError.message("Unknown Bluetooth compression codec.")
}
guard plaintext.count == length else { throw BridgeError.message("Incorrect decoded message size.") }
if encoded.first == 2 {
guard plaintext.count >= 4 else { throw BridgeError.message("Invalid response envelope.") }
let metadataLength = Int(number(plaintext.prefix(4)))
guard metadataLength > 0, metadataLength <= plaintext.count - 4,
plaintext.count - 4 - metadataLength <= maxBody,
var metadata = try JSONSerialization.jsonObject(with: plaintext.subdata(in: 4..<(4 + metadataLength))) as? [String: Any],
metadata["body"] == nil else { throw BridgeError.message("Invalid response metadata.") }
metadata["body"] = plaintext.dropFirst(4 + metadataLength).base64EncodedString()
return try JSONDecoder().decode(T.self, from: JSONSerialization.data(withJSONObject: metadata))
}
return try JSONDecoder().decode(T.self, from: plaintext)
}
static func packets(_ frame: Data, size: Int) -> [Data] {
let payloadSize = max(15, min(512, size) - 5)
return stride(from: 0, to: frame.count, by: payloadSize).enumerated().map { sequence, start in
Data([1]) + uint32(UInt32(sequence)) + frame.subdata(in: start..<min(start + payloadSize, frame.count))
}
}
static func streamTag(session: String, counter: UInt64) throws -> Data {
guard let challenge = Data(hex: session), challenge.count == 16 else {
throw BridgeError.message("Invalid Bluetooth session.")
}
var value = counter.bigEndian
let number = withUnsafeBytes(of: &value) { Data($0) }
return Data(SHA256.hash(data: challenge + number).prefix(8))
}
static func notificationPacket(_ packet: Data, tag: Data) throws -> Data? {
guard packet.count >= 9, packet.first == 3 else { throw BridgeError.message("Invalid Bluetooth notification.") }
guard packet.subdata(in: 1..<9) == tag else { return nil }
guard packet.count >= 14 else { throw BridgeError.message("Truncated Bluetooth notification.") }
// Reuse the bounded, ordered assembler used by the read transport.
return Data([1]) + packet.dropFirst(9)
}
static func notificationAck(tag: Data, sequence: UInt32) -> Data {
Data([4]) + tag + uint32(sequence)
}
}
struct FrameAssembler {
private var data = Data()
private var sequence: UInt32 = 0
private var expectedLength: Int?
mutating func append(_ packet: Data) throws -> Data? {
guard packet.count >= 6, packet.first == 1, Wire.number(packet.subdata(in: 1..<5)) == sequence else {
throw BridgeError.message("Bluetooth fragments arrived out of order.")
}
sequence += 1
data.append(packet.dropFirst(5))
if expectedLength == nil, data.count >= 4 {
let length = Int(Wire.number(data.prefix(4)))
guard (28...Wire.maxFrame).contains(length) else { throw BridgeError.message("Invalid Bluetooth frame size.") }
expectedLength = length
}
guard data.count <= Wire.maxFrame + 4 else { throw BridgeError.message("Bluetooth response is too large.") }
if let length = expectedLength, data.count >= length + 4 {
guard data.count == length + 4 else { throw BridgeError.message("Unexpected trailing response bytes.") }
let result = Data(data.dropFirst(4))
self = FrameAssembler()
return result
}
return nil
}
}
extension Data {
init?(hex: String) {
let text = hex.trimmingCharacters(in: .whitespacesAndNewlines)
guard text.count % 2 == 0 else { return nil }
var bytes = [UInt8]()
var index = text.startIndex
while index < text.endIndex {
let end = text.index(index, offsetBy: 2)
guard let byte = UInt8(text[index..<end], radix: 16) else { return nil }
bytes.append(byte)
index = end
}
self.init(bytes)
}
var hex: String { map { String(format: "%02x", $0) }.joined() }
}
@@ -0,0 +1,35 @@
# iPhone quick start
use the SecretGood version of the app with a comma running the matching Bluetooth bridge. the older Dom app uses different Galaxy APIs.
## install
open your TestFlight invitation on the iPhone and install the build offered there.
to build it yourself, open `openpilot/starpilot/companion/ios/GalaxyBluetooth.xcodeproj` from the repo in Xcode. choose your signing team, connect your iPhone, select it as the destination and press Run. Follow the phone's trust and Developer Mode prompts if shown.
## pair
1. Leave the comma powered and Offroad for setup. it needs internet for the first bridge installation.
2. on comma open Settings → Bluetooth → **Pair phone**. the window stays open for two minutes.
3. on the iPhone, open the app and tap **Pair with comma** (if the phone already reaches comma over Wi-Fi, the app opens comma's pairing window itself and you can skip opening it on comma). allow Bluetooth and Local Network access when asked.
4. when the app says so, open iPhone Settings → Bluetooth and tap your comma once. that pairs phone music; iOS does not let an app do it.
5. open Galaxy and check that settings load.
the app and phone music pair in the same window. **Forget** on a Bluetooth device in comma Settings also removes its app key; it does not remove the car's Bluetooth pairing. tap **Pair with comma** again to reconnect afterwards.
## choose a connection
- **Automatic:** uses reachable local Wi-Fi, with Bluetooth fallback.
- **Bluetooth only:** nearby settings access without a network on either device.
- **Wi-Fi only:** both devices must be able to reach each other on the local network.
bluetooth cannot carry every Galaxy feature. Recordings, replay, cameras, map tiles, phone internet sharing, Android Auto upload/APK download and login/logout show a Wi-Fi message.
## updates and reports
install native app updates through TestFlight. Galaxy's web UI is downloaded from your comma over Wi-Fi/LAN (connect once before using Bluetooth) and refreshed whenever you reconnect; failed downloads keep the saved UI.
for a problem, enable **Developer Mode** in app Settings, reproduce it, then use **Dev / Logs → Copy all reports**. send the report with your phone model, comma version and what you tried. do not share pairing codes or credentials.
the old FFmpeg capture flag and full-display Live View setup belong to the Dom pilot. They are not needed for this branch.
@@ -0,0 +1,24 @@
# starpilot iPhone app
the iPhone app lives here. the comma Bluetooth bridge is in `openpilot/starpilot/galaxy_bluetooth`.
open `GalaxyBluetooth.xcodeproj` in Xcode and choose the `GalaxyBluetooth` scheme. device and TestFlight builds need your Apple team and signing profile. simulator builds do not need signing.
from this folder, run:
```sh
sh ci_scripts/ci_post_clone.sh
xcodebuild -project GalaxyBluetooth.xcodeproj -scheme GalaxyBluetooth -configuration Debug -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO build
```
github Actions runs the app tests, simulator build and bridge tests. bluetooth and CarPlay still need a check on real devices.
see [Quick start](QUICK-START.md) for pairing and [Tester guide](TESTER-GUIDE.md) for connection checks.
app source: `natehuby76/ios-clone`, branch `codex/secretgood-galaxy`, commit `081aa0e`. the Galaxy UI the app runs keeps its upstream license in `UPSTREAM-GALAXY-LICENSE`. app store ownership and signing are separate from this repo.
## Galaxy screens
The app does not bundle Galaxy's web pages. It downloads them from your comma over Wi-Fi/LAN (`GET /api/companion/web-manifest`
lists the files and their SHA-256), verifies them and saves a copy that Bluetooth uses. Connect over Wi-Fi once before using
Bluetooth Galaxy. Custom and unpushed branches work because nothing comes from GitHub. A comma build without that route cannot share its screens: over Wi-Fi the app then reads the pages live from comma, and Bluetooth stays unavailable until the screens have been downloaded.
@@ -0,0 +1,37 @@
# connection testing
start with a powered, Offroad comma and the matching SecretGood phone build. pair once with Settings → Bluetooth → Pair phone on comma, then Pair with comma in the app.
## wi-fi and Bluetooth
1. select **Automatic** and connect both devices to the same local network. confirm the app shows Wi-Fi and Galaxy loads.
2. open a display preference. turn Wi-Fi off in iPhone Settings, leaving Bluetooth on.
3. confirm the app switches to Bluetooth and settings still load. change the display preference, reopen it and check that it saved.
4. restore Wi-Fi and check that Automatic returns to it.
5. repeat with **Bluetooth only** and **Wi-Fi only** selected.
if a setting change loses its response, reload the setting before trying again. the app does not repeat writes automatically after a connection failure.
## hotspots
comma can use a hotspot, Wi-Fi or its SIM for internet. that is separate from the phone's connection to comma. bluetooth settings access needs no internet; model and software downloads still need internet on comma.
some hotspots isolate clients. if direct Wi-Fi access fails, use Bluetooth or another reachable local network. the app does not enable Personal Hotspot or configure comma's Wi-Fi password.
## reconnects
check closing and reopening the app, switching Developer Mode, turning phone Bluetooth off and on, and restarting comma while Offroad. confirm it reconnects without manual setup.
turn comma Bluetooth off and confirm the bridge leaves it off. turn it back on using the normal comma control, then reconnect.
## galaxy UI updates
the app has no bundled Galaxy pages. whenever it connects to comma over Wi-Fi/LAN (or you choose **Check** on the Galaxy screens row) it downloads comma's own web UI, verifies every file against comma's manifest and saves it. a comma without that manifest is too old to share its screens.
an update does not replace an open page. it takes effect when Galaxy next opens. failed or incomplete updates keep the previous copy. a fresh install cannot open Galaxy over Bluetooth until it has downloaded the screens once over Wi-Fi. if comma updated since, Bluetooth still opens the saved copy and says it is from an older comma build.
download the screens over comma's Wi-Fi first, then reopen with phone Wi-Fi and cellular off and Bluetooth on. Galaxy should still load its saved screens. swift changes require a new app build.
## feedback
enable Developer Mode in Settings and use **Dev / Logs → Copy all reports** after a failure. include the connection mode, device versions, what you tried and any error you received. keep pairing codes and passwords out of screenshots.
@@ -0,0 +1,7 @@
Copyright (c) 2018, Comma.ai, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,54 @@
#!/bin/sh
set -eu
cd "$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
test_dir="$(mktemp -d)"
trap 'rm -rf "$test_dir"' EXIT
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library GalaxyBluetooth/Wire.swift GalaxyBluetooth/PairingCode.swift GalaxyBluetooth/PairingKeyStore.swift tests/PairingPhoneRegression.swift -o "$test_dir/pairing-phone-tests"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library GalaxyBluetooth/Wire.swift GalaxyBluetooth/PairingCode.swift GalaxyBluetooth/ClaimPairing.swift tests/ClaimPairingRegression.swift -o "$test_dir/claim-pairing-tests"
"$test_dir/claim-pairing-tests"
python3 tests/extract_claim_transport.py "$test_dir/ClaimTransportProbe.swift"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library GalaxyBluetooth/Wire.swift GalaxyBluetooth/PairingCode.swift "$test_dir/ClaimTransportProbe.swift" -o "$test_dir/claim-transport-tests"
"$test_dir/claim-transport-tests"
"$test_dir/pairing-phone-tests"
python3 tests/extract_carplay_navigation.py "$test_dir/NavigationProbe.swift"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library "$test_dir/NavigationProbe.swift" -o "$test_dir/carplay-navigation-tests"
"$test_dir/carplay-navigation-tests"
python3 tests/extract_connection_monitor.py "$test_dir/MonitorProbe.swift"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library "$test_dir/MonitorProbe.swift" -o "$test_dir/monitor-tests"
"$test_dir/monitor-tests"
python3 tests/extract_bluetooth_reads.py "$test_dir/BluetoothReadProbe.swift"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library "$test_dir/BluetoothReadProbe.swift" -o "$test_dir/bluetooth-read-tests"
"$test_dir/bluetooth-read-tests"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library GalaxyBluetooth/GalaxyRequestTransport.swift tests/GalaxyReadCacheRegression.swift -o "$test_dir/read-cache-tests"
"$test_dir/read-cache-tests"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library GalaxyBluetooth/GalaxyRequestTransport.swift GalaxyBluetooth/AudioPairing.swift tests/AudioPairingRegression.swift -o "$test_dir/audio-pairing-tests"
"$test_dir/audio-pairing-tests"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library GalaxyBluetooth/GalaxyRequestTransport.swift GalaxyBluetooth/PhoneLocation.swift tests/PhoneLocationRegression.swift -o "$test_dir/phone-location-tests"
"$test_dir/phone-location-tests"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library GalaxyBluetooth/Wire.swift GalaxyBluetooth/GalaxyRequestTransport.swift tests/LANPairingRegression.swift -o "$test_dir/lan-pairing-tests"
"$test_dir/lan-pairing-tests"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library GalaxyBluetooth/Wire.swift GalaxyBluetooth/HTTP.swift GalaxyBluetooth/GalaxyRequestTransport.swift GalaxyBluetooth/NetworkTransport.swift GalaxyBluetooth/PrivateLANHTTP.swift tests/SecretGoodLANRegression.swift -o "$test_dir/secretgood-lan-tests"
"$test_dir/secretgood-lan-tests"
python3 tests/check_lan_contract.py
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library GalaxyBluetooth/Wire.swift GalaxyBluetooth/HTTP.swift GalaxyBluetooth/GalaxyRequestTransport.swift GalaxyBluetooth/NetworkTransport.swift GalaxyBluetooth/PrivateLANHTTP.swift GalaxyBluetooth/GalaxyAssetStore.swift tests/SecretGoodAssetsRegression.swift -o "$test_dir/secretgood-assets-tests"
"$test_dir/secretgood-assets-tests"
python3 tests/extract_legacy_link.py "$test_dir/LegacyLinkProbe.swift"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library "$test_dir/LegacyLinkProbe.swift" -o "$test_dir/legacy-link-tests"
"$test_dir/legacy-link-tests"
swiftc -module-cache-path "$test_dir/module-cache" -swift-version 5 -parse-as-library GalaxyBluetooth/Wire.swift GalaxyBluetooth/HTTP.swift GalaxyBluetooth/GalaxyRequestTransport.swift GalaxyBluetooth/NetworkTransport.swift GalaxyBluetooth/PrivateLANHTTP.swift GalaxyBluetooth/LoopbackServer.swift tests/LoopbackRecoveryRegression.swift -o "$test_dir/loopback-recovery-tests"
"$test_dir/loopback-recovery-tests"
if command -v node >/dev/null 2>&1; then
node tests/ToggleSearchRegression.mjs
else
echo "Node unavailable: run tests/ToggleSearchRegression.mjs locally for JavaScript search checks."
fi
@@ -0,0 +1,39 @@
# StarPilot BT privacy policy
Updated October 18, 2026. Changes for version 1.0.1.
This policy covers the StarPilot BT iPhone companion. The publisher responsible for this release is the seller identified on its App Store listing. Public support and privacy requests use the [StarPilot support ticket process](https://wiki.firestar.link/ticketsystem/). Ask for your request to be directed to the StarPilot BT app publisher. Do not post private information in a public channel.
## Connecting your devices
StarPilot BT displays Galaxy from a compatible StarPilot installation and sends your requests to the comma you connect. Local Bluetooth access does not require an internet account or a shared network. Some functions require local Wi-Fi or internet access on comma.
Connection preferences, a remembered Bluetooth identifier and local address are stored on your iPhone. Pairing credentials and the pairing ID supplied by comma are stored in the iOS Keychain. On an encrypted Bluetooth connection, the app sends the iOS-provided phone name to comma so it can label that phone in its pairing list. When replacing a pairing, it also sends the previous pairing ID so comma can remove the old entry. It does not add another device identifier. Galaxy interface files are cached on your iPhone. Bluetooth messages use authenticated encryption. Local Wi-Fi communication currently uses HTTP; use a trusted network.
## Permissions
Bluetooth permission is used to find and connect to comma. Local Network permission is used for local Wi-Fi discovery and requests. Pairing uses the pairing window on comma; version 1.0.1 does not request camera permission or scan QR codes.
The native app does not request access to the iPhone's contacts, microphone or photo library. Optional phone GPS sharing requests location permission only when you enable it. While enabled and connected, it sends recent location fixes, accuracy, timestamps and available altitude, speed and bearing to your comma, about once a second. Sharing may continue in the background with the iOS location indicator. You can turn sharing off in the app or revoke permission in iPhone Settings. This native feature does not upload GPS fixes to the app publisher; comma's navigation features and any providers configured there handle received location under their own settings and policies.
## Network services
The app downloads Galaxy's interface files from your own comma over your local network (or its Wi-Fi hotspot) and saves them on the iPhone so Bluetooth can show them. It does not contact GitHub or any other server for these files.
The native companion does not include advertising or an advertising-tracking SDK. Its diagnostics screen does not automatically upload reports to the publisher.
Opening the legacy Galaxy link opens your device-specific remote website in your browser. That website handles its own login and service requests. Optional services configured through Galaxy, such as maps or a cloud provider, may send information from comma to the selected provider. Their policies apply to their services. This policy does not promise that those separately operated services keep no logs or collect no information.
## Diagnostics and support
The app keeps connection and page events for troubleshooting. Reports can contain app and operating-system versions, connection state, request-path categories, timings and errors. You choose whether to copy and share a report. Review it before sharing, and keep pairing keys, passwords, private device links and location information out of public reports.
If you submit a StarPilot support ticket, the support team and the ticket-service provider can access the information you provide. Ticket history may remain with that service until removed by its operator. To request access to or deletion of a submitted report, use the same support ticket process and identify the ticket without posting additional private information publicly. A fixed deletion deadline is not promised by this policy.
## Removing stored information
Use Forget Bluetooth pairing in the app to remove its saved pairing credential. Revoke app links on comma separately if you want to invalidate the device's pairing key. Deleting the app removes its local app container; iOS Keychain entries may persist until removed through the app. Changes and information stored on comma, in remote Galaxy or with another provider must be managed with that device or service.
## Changes and questions
This policy will be updated when the app's data handling changes. Contact the app publisher through [StarPilot support](https://wiki.firestar.link/ticketsystem/) with questions or privacy requests. Support is not an emergency service.
@@ -0,0 +1,108 @@
import Foundation
struct BridgeResponse { let status: Int; let bodyData: Data }
enum Wire { static let maxBody = 1_048_576 }
enum BridgeError: LocalizedError {
case message(String)
var errorDescription: String? { if case .message(let text) = self { return text }; return nil }
}
@MainActor final class ScriptedComma: GalaxyRequestTransport {
var connected = true
var statuses: [[String: Any]]
var actionStatus = 200
var posts: [[String: Any]] = []
init(_ statuses: [[String: Any]]) { self.statuses = statuses }
func request(path: String, method: String, headers: [String: String], body: Data) async throws -> BridgeResponse {
if method == "POST" {
precondition(path == "/api/bluetooth/action" && headers["Content-Type"] == "application/json")
let payload = try JSONSerialization.jsonObject(with: body) as! [String: Any]
posts.append(payload)
return BridgeResponse(status: payload["operation"] as? String == "accept_pair" ? actionStatus : 200, bodyData: Data("{}".utf8))
}
precondition(path == "/api/bluetooth/status")
let next = statuses.count > 1 ? statuses.removeFirst() : statuses[0]
return BridgeResponse(status: 200, bodyData: try JSONSerialization.data(withJSONObject: next))
}
}
func snapshot(address: String = "AA:BB:CC:DD:EE:FF", pairing: [String: Any]? = nil) -> [String: Any] {
var value: [String: Any] = ["adapterAddress": address, "adapterName": "comma", "pairing": NSNull()]
if let pairing { value["pairing"] = pairing }
return value
}
func prompt(kind: String = "confirmation", displayOnly: Bool = false) -> [String: Any] {
["id": String(repeating: "a", count: 32), "kind": kind, "value": "123456", "displayOnly": displayOnly]
}
@main struct AudioPairingRegression {
@MainActor static func settle(_ model: AudioPairing, until done: (AudioPairing.Phase) -> Bool) async {
for _ in 0..<2000 {
if done(model.phase) { return }
await Task.yield()
}
preconditionFailure("Audio pairing never reached the expected phase: \(model.phase)")
}
@MainActor static func main() async {
// Parsing ignores any pairing that is not the phone window, and rejects bad addresses.
precondition(AudioPairing.parse(Data("{\"adapterAddress\":\"AA:BB:CC:DD:EE:FF\",\"pairing\":{\"state\":\"pairing\",\"prompt\":null}}".utf8))?.state == nil)
precondition(AudioPairing.validAddress("AA:BB:CC:DD:EE:FF") && !AudioPairing.validAddress("AA:BB") && !AudioPairing.validAddress(""))
// Happy path: window opens, code appears, approval is sent, comma reports paired.
let waiting: [String: Any] = ["incoming": true, "state": "pairing", "address": "", "name": "", "prompt": NSNull()]
let asking: [String: Any] = ["incoming": true, "state": "pairing", "address": "11:22:33:44:55:66", "name": "iPhone", "prompt": prompt()]
let paired: [String: Any] = ["incoming": true, "state": "paired", "address": "11:22:33:44:55:66", "name": "iPhone", "prompt": NSNull()]
let comma = ScriptedComma([snapshot(), snapshot(pairing: waiting), snapshot(pairing: asking), snapshot(pairing: asking), snapshot(pairing: paired)])
let model = AudioPairing(transport: { comma }, pause: { await Task.yield() })
model.start()
await settle(model) { if case .confirm = $0 { return true }; return false }
guard case .confirm(let code, _, let name) = model.phase else { preconditionFailure() }
precondition(code == "123456" && name == "comma")
model.respond(accepted: true)
await settle(model) { $0 == .done }
precondition(comma.posts.first?["operation"] as? String == "accept_pair")
precondition(comma.posts.contains { $0["operation"] as? String == "pairing_response" && $0["accepted"] as? Bool == true })
// Older comma: no adapter address means no window and no message.
let old = ScriptedComma([["pairing": NSNull()]])
let oldModel = AudioPairing(transport: { old }, pause: { await Task.yield() })
oldModel.start()
await settle(oldModel) { $0 == .idle }
precondition(old.posts.isEmpty)
let unsupported = ScriptedComma([snapshot()]); unsupported.actionStatus = 400
let unsupportedModel = AudioPairing(transport: { unsupported }, pause: { await Task.yield() })
unsupportedModel.start()
await settle(unsupportedModel) { $0 == .idle && !unsupported.posts.isEmpty }
// Busy comma explains itself; display-only and non-confirmation prompts are never approved.
let busy = ScriptedComma([snapshot()]); busy.actionStatus = 409
let busyModel = AudioPairing(transport: { busy }, pause: { await Task.yield() })
busyModel.start()
await settle(busyModel) { if case .message(let text) = $0 { return text.contains("busy") }; return false }
let quiet: [String: Any] = ["incoming": true, "state": "pairing", "address": "", "name": "", "prompt": prompt(kind: "authorization")]
let failed: [String: Any] = ["incoming": true, "state": "failed", "address": "", "name": "", "prompt": NSNull()]
let careful = ScriptedComma([snapshot(), snapshot(pairing: quiet), snapshot(pairing: failed)])
let carefulModel = AudioPairing(transport: { careful }, pause: { await Task.yield() })
carefulModel.start()
await settle(carefulModel) { if case .message = $0 { return true }; return false }
precondition(!careful.posts.contains { $0["operation"] as? String == "pairing_response" })
// Closing the sheet mid-window cancels it on comma.
let hanging = ScriptedComma([snapshot(), snapshot(pairing: waiting)])
let hangingModel = AudioPairing(transport: { hanging }, attempts: 100000, pause: { await Task.yield() })
hangingModel.start()
await settle(hangingModel) { if case .waiting = $0 { return true }; return false }
hangingModel.dismiss()
await settle(hangingModel) { $0 == .idle && hanging.posts.contains { $0["operation"] as? String == "cancel_pair" } }
let connectedComma = ScriptedComma([snapshot()])
let connectedModel = AudioPairing(transport: { connectedComma }, audioOutput: { $0 == "comma" }, pause: { await Task.yield() })
connectedModel.start()
await settle(connectedModel) { $0 == .connected }
precondition(connectedComma.posts.isEmpty, "An existing audio route must not reopen pairing")
connectedModel.dismiss()
precondition(connectedModel.phase == .idle)
print("audio pairing ok")
}
}
@@ -0,0 +1,67 @@
import Foundation
@MainActor final class UIImage {
init?(systemName: String) {}
}
@MainActor final class CPBarButton {
let action: (CPBarButton) -> Void
init(image: UIImage, handler: @escaping (CPBarButton) -> Void) { action = handler }
}
@MainActor protocol CPBarButtonProviding: AnyObject {
var backButton: CPBarButton? { get set }
}
@MainActor class CPTemplate: NSObject {}
@MainActor final class Page: CPTemplate, CPBarButtonProviding {
var backButton: CPBarButton?
}
@MainActor final class CPInterfaceController {
var templates: [CPTemplate] = [Page()]
var topTemplate: CPTemplate? { templates.last }
var pending: (() -> Void)?
var deferNextPush = false
func pushTemplate(_ page: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)?) {
precondition(templates.count < 4, "Exceeded physical depth")
templates.append(page)
if deferNextPush { deferNextPush = false; pending = { completion?(true, nil) } }
else { completion?(true, nil) }
}
func popTemplate(animated: Bool, completion: ((Bool, Error?) -> Void)?) {
guard templates.count > 1 else { completion?(false, nil); return }
templates.removeLast(); completion?(true, nil)
}
}
@MainActor final class NavigationProbe {
var controller: CPInterfaceController?
var navigating = false
let overflowParents = NSMapTable<CPTemplate, CPTemplate>.weakToStrongObjects()
var notices: [String] = []
func notice(_ message: String) { notices.append(message) }
init(_ controller: CPInterfaceController) { self.controller = controller }
__PRODUCTION_METHODS__
}
@main struct NavigationRegression {
@MainActor static func main() {
let controller = CPInterfaceController(), probe = NavigationProbe(controller)
let pages = (0..<12).map { _ in Page() }
for page in pages {
probe.pushTemplate(page, animated: true, completion: nil)
precondition(controller.topTemplate === page)
precondition(controller.templates.count <= 4)
}
for previous in pages.dropLast().reversed() {
probe.popTemplate(animated: true, completion: nil)
precondition(controller.topTemplate === previous, "Lost parent while returning")
}
probe.popTemplate(animated: true, completion: nil)
precondition(controller.templates.count == 1)
controller.deferNextPush = true
probe.pushTemplate(Page(), animated: true, completion: nil)
let count = controller.templates.count
var accepted = true
probe.pushTemplate(Page(), animated: true) { success, _ in accepted = success }
precondition(!accepted && count == controller.templates.count, "Rapid tap pushed twice")
controller.pending?(); controller.pending = nil
precondition(!probe.navigating)
print("CarPlay navigation: deep menus, Back restoration and rapid taps passed")
}
}
@@ -0,0 +1,45 @@
import Foundation
import CryptoKit
/// The vector comes from comma's own claim.py (galaxy_bluetooth/tests), so both sides agree byte for byte.
@main struct ClaimPairingRegression {
static func hex(_ text: String) -> Data { Data(hex: text)! }
static func main() throws {
let phone = try P256.KeyAgreement.PrivateKey(rawRepresentation: Data(repeating: 0x11, count: 32))
let challenge = Data((0..<16).map(UInt8.init))
let reply = hex("4743523104d65a93977caa3d1b081852ff57a79e465f1660577304baead505dd3a48589cf350185e895372df6221ea3a137557e473fddb6755f05bd507c3c533fce9c912856465666768696a6b6c6d6e6f56e085dc7bc3807c9c7cb045c4c1f175f83b778465883907450039ebf7bae2c056f9ce9975cfb418ec470cffe65a07209b6b0194c4f7858fdac068c0fb2f699f06cc4bfb9a8c6734d68a910c8d5329eadf05462b6c59013892e0522f5056974e80082afbb204b6cde770c4d5ed383ffe6f08909da1a7b716088c91d507bd197a3c8f48e8a53deaa7008f209ac1c78f733f078cd862345f0e2fab00241494bc7bc4be2a4769")
// Comma's reply opens to this phone's key.
let claim = try ClaimCrypto.open(reply, privateKey: phone, challenge: challenge)
precondition(claim.key == String(repeating: "ab", count: 32) && claim.id == "0a1b2c3d")
precondition(claim.adapterAddress == "AA:BB:CC:DD:EE:FF" && claim.adapterName == "comma")
// Both sides derive the same key.
let derived = try ClaimCrypto.derive(phone, peer: reply.subdata(in: 4..<69), challenge: challenge)
precondition(derived.withUnsafeBytes { Data($0) } == hex("3b5c0f6357da2c229949fb3695c3656c009af9b8fa4a7588ce991292c6eb73e0"))
// A wrong session, another phone, a tampered or truncated reply, or a different magic are all refused.
func refused(_ body: () throws -> Void) -> Bool { (try? body()) == nil }
precondition(refused { _ = try ClaimCrypto.open(reply, privateKey: phone, challenge: Data(count: 16)) })
precondition(refused { _ = try ClaimCrypto.open(reply, privateKey: P256.KeyAgreement.PrivateKey(), challenge: challenge) })
var tampered = reply; tampered[tampered.count - 1] ^= 1
precondition(refused { _ = try ClaimCrypto.open(tampered, privateKey: phone, challenge: challenge) })
precondition(refused { _ = try ClaimCrypto.open(reply.prefix(60), privateKey: phone, challenge: challenge) })
var other = reply; other[0] = 0x58
precondition(refused { _ = try ClaimCrypto.open(other, privateKey: phone, challenge: challenge) })
// The request is magic, 65-byte point, then the name (at most 40 characters).
let request = ClaimCrypto.request(publicKey: phone.publicKey, name: "Danny’s iPhone")
precondition(request.prefix(4) == Data("GCL1".utf8) && request.subdata(in: 4..<69) == phone.publicKey.x963Representation)
precondition(String(decoding: request.dropFirst(69), as: UTF8.self) == "Danny’s iPhone")
precondition(ClaimCrypto.request(publicKey: phone.publicKey, name: String(repeating: "x", count: 100)).count == 69 + 40)
// Unexpected contents are refused rather than half used.
precondition(refused { _ = try ClaimCrypto.parse(Data(#"{"id":"0a1b2c3d"}"#.utf8)) })
precondition(refused { _ = try ClaimCrypto.parse(Data(#"{"key":"short"}"#.utf8)) })
let sparse = try ClaimCrypto.parse(Data(#"{"key":"\#(String(repeating: "CD", count: 32))","id":"BAD","adapterAddress":"nope"}"#.utf8))
precondition(sparse.key == String(repeating: "cd", count: 32) && sparse.id == nil && sparse.adapterAddress == "" && sparse.adapterName == "")
print("Claim pairing: comma vector, derive, tamper and malformed cases passed")
}
}
@@ -0,0 +1,79 @@
import Foundation
struct BridgeResponse { let status: Int; let bodyData: Data }
enum Wire { static let maxBody = 1_048_576 }
enum BridgeError: LocalizedError {
case message(String)
var errorDescription: String? { if case .message(let text) = self { return text }; return nil }
}
@MainActor final class FakeTransport: GalaxyRequestTransport {
var connected = true
var session = "first"
var clock = 0.0
var calls = 0
var waiting: [Int: CheckedContinuation<BridgeResponse, Error>] = [:]
func request(path: String, method: String, headers: [String: String], body: Data) async throws -> BridgeResponse {
calls += 1
let index = calls
return try await withCheckedThrowingContinuation { waiting[index] = $0 }
}
func reply(_ index: Int) {
waiting.removeValue(forKey: index)!.resume(returning: BridgeResponse(status: 200, bodyData: Data("reply\(index)".utf8)))
}
func until(_ count: Int) async {
for _ in 0..<1000 {
if calls >= count { return }
await Task.yield()
}
preconditionFailure("Request never reached transport")
}
}
@main struct CacheRegression {
@MainActor static func main() async throws {
let transport = FakeTransport()
let cache = GalaxyReadCache(transport: transport, sessionID: { transport.session }, now: { transport.clock })
func read(_ path: String, method: String = "GET") -> Task<BridgeResponse, Error> {
Task { try await cache.request(path: path, method: method, headers: [:], body: Data()) }
}
let page = "/api/settings/pages/hub"
let first = read(page)
await transport.until(1)
let joined = read(page)
for _ in 0..<10 { await Task.yield() }
precondition(transport.calls == 1, "Concurrent page reads were duplicated")
transport.reply(1)
let a = try await first.value, b = try await joined.value
precondition(a.bodyData == b.bodyData)
let fresh = read(page)
await transport.until(2); transport.reply(2); _ = try await fresh.value
let stale = read(page)
await transport.until(3)
let mutation = read("/api/settings/confirm", method: "POST")
await transport.until(4)
transport.reply(3)
do { _ = try await stale.value; preconditionFailure("Read from before mutation accepted") } catch {}
transport.reply(4); _ = try await mutation.value
let oldSession = read(page)
await transport.until(5)
transport.session = "second"; transport.reply(5)
do { _ = try await oldSession.value; preconditionFailure("Old session read accepted") } catch {}
let cancelled = read(page)
await transport.until(6)
let survivor = read(page)
for _ in 0..<10 { await Task.yield() }
cancelled.cancel(); transport.reply(6)
do { _ = try await cancelled.value; preconditionFailure("Cancelled caller received result") } catch {}
_ = try await survivor.value
precondition(transport.calls == 6)
let background = read("/api/system/monitor")
await transport.until(7)
let duplicate = read("/api/system/monitor")
for _ in 0..<10 { await Task.yield() }
precondition(transport.calls == 7, "Concurrent monitor reads must share one transfer")
transport.reply(7)
_ = try await background.value; _ = try await duplicate.value
let nextSample = read("/api/system/monitor")
await transport.until(8); transport.reply(8); _ = try await nextSample.value
print("SecretGood page reads: coalescing, fresh tokens, writes, reconnects and cancellation passed")
}
}
@@ -0,0 +1,33 @@
import Foundation
@MainActor final class FakeLAN: GalaxyRequestTransport {
var connected = true
var status = 200
var requests: [(path: String, method: String, body: Data)] = []
func request(path: String, method: String, headers: [String: String], body: Data) async throws -> BridgeResponse {
requests.append((path, method, body))
if status == -1 { throw URLError(.notConnectedToInternet) }
return BridgeResponse(id: "", session: "", counter: 0, status: status, headers: [:], body: "")
}
}
@main struct LANPairingRegression {
@MainActor static func main() async throws {
let lan = FakeLAN()
let opened = await LANPairing.openWindow(over: lan)
precondition(opened && lan.requests.count == 1)
precondition(lan.requests[0].path == "/api/bluetooth/action" && lan.requests[0].method == "POST")
precondition(lan.requests[0].body == Data("{\"operation\":\"accept_pair\"}".utf8))
precondition(SecretGoodGalaxy.validTarget(LANPairing.windowPath), "The LAN transport must accept the window request")
lan.status = 409
let busy = await LANPairing.openWindow(over: lan)
precondition(!busy, "A refused window falls back to the manual steps")
lan.status = -1
let offline = await LANPairing.openWindow(over: lan)
precondition(!offline)
lan.connected = false; lan.requests = []
let disconnected = await LANPairing.openWindow(over: lan)
precondition(!disconnected && lan.requests.isEmpty, "No Wi-Fi session means no request")
print("LAN pairing: window opens over an existing Wi-Fi session and falls back to manual pairing otherwise")
}
}
@@ -0,0 +1,102 @@
import Foundation
@MainActor final class OfflineTransport: GalaxyRequestTransport {
var connected = false
var calls = 0
func request(path: String, method: String, headers: [String: String], body: Data) async throws -> BridgeResponse {
calls += 1
throw URLError(.notConnectedToInternet)
}
}
@MainActor final class LiveTransport: GalaxyRequestTransport {
var connected = true
var pages: [String: Data] = ["/js/app.js": Data("live app".utf8), "/index.html": Data("live index".utf8)]
var requested: [String] = []
func request(path: String, method: String, headers: [String: String], body: Data) async throws -> BridgeResponse {
throw URLError(.notConnectedToInternet)
}
func page(path: String) async throws -> BridgeResponse {
requested.append(path)
guard let data = pages[path] else { return BridgeResponse(id: "", session: "", counter: 0, status: 404, headers: [:], body: "") }
return BridgeResponse(id: "", session: "", counter: 0, status: 200, headers: [:], body: data.base64EncodedString())
}
}
@main struct LoopbackRecoveryRegression {
@MainActor static func main() async throws {
let transport = OfflineTransport()
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
try Data("Galaxy test page".utf8).write(to: root.appendingPathComponent("index.html"))
let source = try Data(contentsOf: URL(fileURLWithPath: "../../galaxy/web/js/toggle-search.js"))
try FileManager.default.createDirectory(at: root.appendingPathComponent("js"), withIntermediateDirectories: true)
let searchFile = root.appendingPathComponent("js/toggle-search.js")
try source.write(to: searchFile)
let server = LoopbackServer(transport: transport, webRoot: root)
defer { server.stop() }
let started = await server.ensureAvailable()
precondition(started && server.url != nil)
let first = server.url
let (adaptedSearch, searchResponse) = try await BoundedHTTP.load(URLRequest(url: first!.appendingPathComponent("js/toggle-search.js")), limit: 1_048_576)
precondition(searchResponse.statusCode == 200)
precondition(String(data: adaptedSearch, encoding: .utf8)!.contains("Native Bluetooth search adapter v1"))
let unchangedSearch = try Data(contentsOf: searchFile)
precondition(unchangedSearch == source, "Saved assets must remain verified and unchanged")
let healthy = await server.ensureAvailable()
precondition(healthy && server.url == first, "Healthy foreground checks must retain the page origin")
server.stop()
precondition(server.url == nil && server.listenerState == "stopped")
let restored = await server.ensureAvailable()
precondition(restored && server.url != nil, "Stopped listeners must restart before page reload")
// Let stale cancellation callbacks run; they must not clear the replacement.
try await Task.sleep(nanoseconds: 100_000_000)
precondition(server.url != nil && server.listenerState == "ready")
precondition(transport.calls == 0, "Local recovery must not reconnect, write or probe comma")
var probes = 0
let stale = LoopbackServer(transport: transport, webRoot: root, probe: { _ in
probes += 1
return probes > 1
})
defer { stale.stop() }
let repaired = await stale.ensureAvailable()
precondition(repaired && probes == 2)
precondition(stale.lifecycleEvents.contains(where: { $0.contains("restarting listener") }))
precondition(stale.lifecycleEvents.contains(where: { $0.contains("recovery succeeded") }))
let broken = LoopbackServer(transport: transport, webRoot: root, probe: { _ in false })
defer { broken.stop() }
async let a = broken.ensureAvailable()
async let b = broken.ensureAvailable()
let outcomes = await [a, b]
precondition(outcomes.allSatisfy { !$0 } && broken.url == nil && !broken.error.isEmpty)
precondition(broken.lifecycleEvents.filter { $0.contains("restarting listener") }.count == 1,
"Simultaneous retries must share one bounded recovery")
// No saved copy: over Wi-Fi/LAN pages are read live from comma; otherwise the "not downloaded" message shows.
let live = LiveTransport()
let liveServer = LoopbackServer(transport: live, webRoot: nil)
defer { liveServer.stop() }
let liveStarted = await liveServer.ensureAvailable()
precondition(liveStarted)
let (app, appResponse) = try await BoundedHTTP.load(URLRequest(url: liveServer.url!.appendingPathComponent("js/app.js")), limit: 1_048_576)
precondition(appResponse.statusCode == 200 && app == Data("live app".utf8))
let (_, rootResponse) = try await BoundedHTTP.load(URLRequest(url: liveServer.url!), limit: 1_048_576)
precondition(rootResponse.statusCode == 200 && live.requested.contains("/index.html"))
let (_, missing) = try await BoundedHTTP.load(URLRequest(url: liveServer.url!.appendingPathComponent("js/missing.js")), limit: 1_048_576)
precondition(missing.statusCode == 404)
let (_, escaped) = try await BoundedHTTP.load(URLRequest(url: liveServer.url!.appendingPathComponent("other/secret.js")), limit: 1_048_576)
precondition(escaped.statusCode == 503 && !live.requested.contains("/other/secret.js"), "Only Galaxy page files are read live")
live.connected = false
let (_, offline) = try await BoundedHTTP.load(URLRequest(url: liveServer.url!.appendingPathComponent("js/app.js")), limit: 1_048_576)
precondition(offline.statusCode == 503)
let bluetoothOnly = LoopbackServer(transport: transport, webRoot: nil)
defer { bluetoothOnly.stop() }
_ = await bluetoothOnly.ensureAvailable()
transport.connected = true
let (_, notYet) = try await BoundedHTTP.load(URLRequest(url: bluetoothOnly.url!.appendingPathComponent("js/app.js")), limit: 1_048_576)
precondition(notYet.statusCode == 503, "Over Bluetooth with no saved copy there is nothing to serve")
print("Loopback recovery: real HTTP health, origin preservation, restart, stale callbacks, bounded retries and transport isolation passed")
}
}
@@ -0,0 +1,50 @@
import Foundation
import CryptoKit
@main struct PairingPhoneRegression {
static func main() throws {
let key = String(repeating: "A1", count: 32)
func code(_ id: String? = nil) -> PairingCode { PairingCode(key: key.lowercased(), id: PairingCode.validID(id)) }
let legacy = code()
precondition(legacy.id == nil && legacy.key == key.lowercased())
let first = code("ABCDEF12")
precondition(first.id == "abcdef12")
for bad in ["123", "123456789", "zzzzzzzz", "é1234567"] {
let parsed = code(bad)
precondition(parsed.id == nil && parsed.key == legacy.key)
}
let old = PairingCredential.decode(Data(key.utf8))!
precondition(old.key == legacy.key && old.id == nil && old.previousId == nil)
let saved = PairingCredential.replacing(nil, with: first)
let same = PairingCredential.replacing(saved, with: first)
precondition(same.id == saved.id && same.previousId == nil)
let second = code("12345678")
let replacement = PairingCredential.replacing(saved, with: second)
precondition(replacement.id == "12345678" && replacement.previousId == "abcdef12")
precondition(PairingCredential.replacing(replacement, with: second).previousId == "abcdef12")
let roundTrip = PairingCredential.decode(try JSONEncoder().encode(replacement))
precondition(roundTrip == replacement)
precondition(PairingCredential.decode(Data("broken".utf8)) == nil)
var request = BridgeRequest(id: "test", session: "session", counter: 1,
path: "/_bridge/health", method: "GET", headers: [:], body: "")
func json(_ request: BridgeRequest) throws -> [String: Any] {
try JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as! [String: Any]
}
let initial = try json(request)
precondition(initial["phone"] == nil)
request.phone = BridgePhone(name: "iPhone", replaces: nil)
let noReplacement = try json(request)["phone"] as! [String: Any]
precondition(noReplacement["name"] as? String == "iPhone" && noReplacement["replaces"] == nil)
precondition(Set(noReplacement.keys) == ["name"])
request.phone = BridgePhone(name: "iPhone", replaces: replacement.previousId)
let secret = SymmetricKey(size: .bits256)
let framed = try Wire.seal(request, key: secret, direction: "request")
let opened = try Wire.open(Data(framed.dropFirst(4)),
as: BridgeRequest.self, key: secret, direction: "request")
precondition(opened.phone?.name == "iPhone" && opened.phone?.replaces == "abcdef12")
let final = try json(request)["phone"] as! [String: Any]
precondition(Set(final.keys) == ["name", "replaces"])
print("Phone pairing: old key migration, ID validation, re-scan/replacement and encrypted optional metadata passed")
}
}
@@ -0,0 +1,57 @@
import CoreLocation
import Foundation
struct BridgeResponse { let status: Int; let bodyData: Data }
enum Wire { static let maxBody = 1_048_576 }
enum BridgeError: LocalizedError {
case message(String)
var errorDescription: String? { if case .message(let text) = self { return text }; return nil }
}
@MainActor final class RecordingComma: GalaxyRequestTransport {
var connected = true
var bodies: [[String: Any]] = []
func request(path: String, method: String, headers: [String: String], body: Data) async throws -> BridgeResponse {
precondition(path == PhoneLocation.path && method == "POST" && headers["Content-Type"] == "application/json")
bodies.append(try JSONSerialization.jsonObject(with: body) as! [String: Any])
return BridgeResponse(status: 200, bodyData: Data("{\"ok\":true}".utf8))
}
}
func fix(at time: Date, course: Double = 90, courseAccuracy: Double = 10, speed: Double = 20, vertical: Double = 4) -> CLLocation {
CLLocation(coordinate: CLLocationCoordinate2D(latitude: 40, longitude: -110), altitude: 1200, horizontalAccuracy: 5,
verticalAccuracy: vertical, course: course, courseAccuracy: courseAccuracy, speed: speed, speedAccuracy: -1, timestamp: time)
}
@main
struct PhoneLocationRegression {
@MainActor static func main() async {
let now = Date()
let known = try! JSONSerialization.jsonObject(with: PhoneLocation.payload(fix(at: now.addingTimeInterval(-0.3)), now: now)!) as! [String: Any]
precondition(known["ageMs"] as? Int == 300 && known["bearing"] as? Double == 90 && known["speedAccuracy"] == nil)
// iOS reports unknown values as negative numbers; they are left out.
let unknown = try! JSONSerialization.jsonObject(with: PhoneLocation.payload(fix(at: now, courseAccuracy: -1, speed: -1, vertical: -1), now: now)!) as! [String: Any]
precondition(unknown["bearing"] == nil && unknown["speed"] == nil && unknown["altitude"] == nil)
precondition(PhoneLocation.payload(fix(at: now.addingTimeInterval(-3)), now: now) == nil)
UserDefaults.standard.set(true, forKey: "galaxy.phoneGps")
let comma = RecordingComma()
let sharing = PhoneLocation(transport: { comma }, manager: nil)
await sharing.deliver(fix(at: now), now: now)
await sharing.deliver(fix(at: now.addingTimeInterval(0.5)), now: now.addingTimeInterval(0.5))
precondition(comma.bodies.count == 1 && sharing.status == "Sharing GPS with comma")
await sharing.deliver(fix(at: now.addingTimeInterval(1)), now: now.addingTimeInterval(1))
precondition(comma.bodies.count == 2)
comma.connected = false
await sharing.deliver(fix(at: now.addingTimeInterval(2)), now: now.addingTimeInterval(2))
precondition(comma.bodies.count == 2 && sharing.status == "Waiting for comma")
sharing.setSharing(false)
comma.connected = true
await sharing.deliver(fix(at: now.addingTimeInterval(3)), now: now.addingTimeInterval(3))
precondition(comma.bodies.count == 2)
UserDefaults.standard.removeObject(forKey: "galaxy.phoneGps")
print("phone location ok")
}
}
@@ -0,0 +1,91 @@
import Foundation
import CryptoKit
@main struct AssetRegression {
@MainActor static func main() async throws {
let manager = FileManager.default
let root = manager.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try manager.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? manager.removeItem(at: root) }
let suite = "secretgood-assets-" + UUID().uuidString
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
final class Comma: @unchecked Sendable {
var files = ["index.html": Data("<html>Galaxy</html>".utf8), "js/boot.js": Data("import './app.js'".utf8), "js/app.js": Data("const galaxy = true".utf8), "data/catalog.json": Data("{}".utf8), "sentry-push-worker.js": Data("self.addEventListener('push', () => {})".utf8)]
var commit: String? = String(repeating: "a", count: 40)
private let lock = NSLock()
private var calls = 0
var fileCalls: Int { lock.withLock { calls } }
var corrupt = false
var manifestStatus = 200
var networkFailure = false
func listing() throws -> Data {
let entries = files.map { ["path": $0.key, "size": $0.value.count, "sha256": GalaxyAssetManifest.digest($0.value)] as [String: Any] }
return try JSONSerialization.data(withJSONObject: ["schema": 1, "commit": commit as Any, "files": entries])
}
func fetch(_ path: String, _ limit: Int) async throws -> (status: Int, data: Data) {
if networkFailure { throw URLError(.notConnectedToInternet) }
if path == "/api/companion/web-manifest" { return (manifestStatus, manifestStatus == 200 ? try listing() : Data()) }
lock.withLock { calls += 1 }
return (200, corrupt ? Data("wrong".utf8) : files[String(path.dropFirst())]!)
}
}
let comma = Comma()
let fetch: GalaxyPageFetch = { try await comma.fetch($0, $1) }
let cache = root.appendingPathComponent("cache")
let store = GalaxyAssetStore(cacheRoot: cache, defaults: defaults)
precondition(store.readyRoot == nil && store.status == GalaxyAssetStore.notDownloaded, "No pages are bundled; nothing to open before the first Wi-Fi download")
await store.check(fetch: fetch)
precondition(store.readyRoot != nil && comma.fileCalls == 5)
let savedApp = try Data(contentsOf: store.readyRoot!.appendingPathComponent("js/app.js"))
precondition(savedApp == Data("const galaxy = true".utf8))
let first = store.readyRoot
await store.check(fetch: fetch)
precondition(comma.fileCalls == 5 && store.status == "Galaxy matches comma.", "Unchanged files must not download twice")
comma.files["js/app.js"] = Data("hand patched, same commit".utf8)
await store.check(fetch: fetch)
precondition(comma.fileCalls == 6 && store.readyRoot != first, "A changed file refreshes even when the commit is the same")
let current = store.readyRoot
let restored = GalaxyAssetStore(cacheRoot: cache, defaults: defaults)
precondition(restored.readyRoot == current, "Saved pages must validate after restart so Bluetooth opens offline")
precondition(!restored.olderThan(commit: comma.commit) && restored.olderThan(commit: String(repeating: "b", count: 40)), "Notice when comma moved on")
precondition(!restored.olderThan(commit: nil))
restored.noteCommaBuild(commit: String(repeating: "b", count: 40))
precondition(restored.status.contains("older comma build"))
comma.files["js/app.js"] = Data("changed app".utf8); comma.corrupt = true
await store.check(fetch: fetch)
precondition(store.readyRoot == current, "A failed update keeps the saved pages")
precondition(store.status.contains("verification failed"), "Do not hide verification failures as network failures")
comma.commit = String(repeating: "c", count: 40)
await store.check(fetch: fetch)
precondition(store.olderThan(commit: String(repeating: "c", count: 40)), "A failed download must not hide that comma moved on")
comma.corrupt = false; comma.networkFailure = true
await store.check(fetch: fetch)
precondition(store.status.contains("network error -1009") && store.readyRoot == current)
comma.networkFailure = false; comma.manifestStatus = 404
await store.check(fetch: fetch)
precondition(store.status.contains("too old") && store.readyRoot == current, "Older comma without the inventory route")
comma.manifestStatus = 401
let fresh = GalaxyAssetStore(cacheRoot: root.appendingPathComponent("empty"), defaults: UserDefaults(suiteName: UUID().uuidString)!)
await fresh.check(fetch: fetch)
precondition(fresh.readyRoot == nil && fresh.status.contains("too old") && fresh.status.contains("No Galaxy screens are saved yet."))
comma.manifestStatus = 200; comma.corrupt = true
await fresh.check(fetch: fetch)
precondition(fresh.readyRoot == nil, "Pages that fail their hash are never activated")
try Data("tampered".utf8).write(to: current!.appendingPathComponent("js/app.js"))
let tampered = GalaxyAssetStore(cacheRoot: cache, defaults: defaults)
precondition(tampered.readyRoot == nil, "Reject altered saved UI")
comma.corrupt = false; comma.commit = nil
let custom = GalaxyAssetStore(cacheRoot: root.appendingPathComponent("custom"), defaults: UserDefaults(suiteName: UUID().uuidString)!)
comma.files["js/custom-branch-only.js"] = Data("export default 1".utf8)
await custom.check(fetch: fetch)
precondition(custom.readyRoot != nil, "Custom and unpushed builds work because nothing comes from GitHub")
let switched = GalaxyAssetStore(cacheRoot: root.appendingPathComponent("switched"), defaults: UserDefaults(suiteName: UUID().uuidString)!)
await switched.check(fetch: fetch, stillCurrent: { false })
precondition(switched.readyRoot == nil, "A switched comma must not activate its download")
print("SecretGood assets: comma LAN download, hash verification, atomic update and saved UI validation passed")
}
}
@@ -0,0 +1,137 @@
import Foundation
@MainActor final class Server {
var calls: [URLRequest] = []
var sessions = 0
var expired = false
var failWrite = false
var corruptIdentity = false
func load(_ request: URLRequest, _ limit: Int) async throws -> (Data, HTTPURLResponse) {
calls.append(request)
let path = request.url!.path
var headers: [String: String] = [:]
var status = 200
var json: [String: Any] = [:]
if path == "/api/auth/session" {
sessions += 1
let current = sessions
try await Task.sleep(nanoseconds: 5_000_000)
json = ["authenticated": true, "localAccess": true]
headers["Set-Cookie"] = "galaxy_session=token\(current); HttpOnly; SameSite=Strict; Path=/; Max-Age=1800"
} else {
precondition(request.value(forHTTPHeaderField: "Cookie")?.hasPrefix("galaxy_session=token") == true)
if path == "/data/runtime.json" {
json = ["schemaVersion": 1, "monitor": "local", "instance": corruptIdentity ? "fedcba9876543210" : "0123456789abcdef"]
} else if expired {
expired = false; status = 401; json = ["error": "Refresh the local Galaxy session"]
} else if failWrite { throw URLError(.networkConnectionLost) }
else { json = ["saved": true] }
}
return (try JSONSerialization.data(withJSONObject: json), HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: headers)!)
}
}
@main struct LANRegression {
@MainActor static func main() async throws {
let server = Server()
let lan = LANTransport(load: server.load)
try lan.configure(address: "192.168.1.86")
lan.bind(to: "0123456789abcdef")
let verified = await lan.probe()
precondition(verified && server.sessions == 1)
server.expired = true
let result = try await lan.request(path: "/api/settings/confirm", method: "POST", headers: ["Cookie": "forged", "Origin": "http://evil.invalid"], body: Data("{}".utf8))
precondition(result.status == 200 && server.sessions == 2)
let writes = server.calls.filter { $0.httpMethod == "POST" }
precondition(writes.count == 2, "Explicit session rejection must retry exactly once")
precondition(writes.allSatisfy { $0.value(forHTTPHeaderField: "Origin") == "http://192.168.1.86:8082" && $0.value(forHTTPHeaderField: "Content-Type") == "application/json" })
precondition(writes[0].value(forHTTPHeaderField: "Cookie") != writes[1].value(forHTTPHeaderField: "Cookie"))
server.failWrite = true
let before = server.calls.count
do { _ = try await lan.request(path: "/api/settings/confirm", method: "POST", headers: [:], body: Data("{}".utf8)); preconditionFailure("Expected network error") } catch is URLError {}
precondition(server.calls.count == before + 1, "Never retry an uncertain mutation")
server.failWrite = false
try lan.configure(address: "192.168.1.87")
precondition(!lan.connected)
_ = await lan.probe()
precondition(server.sessions == 3, "A different comma must get a separate session")
server.corruptIdentity = true
let mismatched = await lan.probe()
precondition(!mismatched, "Reject LAN endpoint that differs from authenticated BLE identity")
for invalid in ["http://evil.invalid/api/x", "//evil.invalid/api/x", "/api/../x", "/api/%2e%2e/x", "/api/%252e/x", "/api/x#fragment", "/data/system-monitor.sample.json"] {
precondition(!SecretGoodGalaxy.validTarget(invalid), invalid)
}
precondition(SecretGoodGalaxy.validTarget("/api/settings/pages/hub"))
precondition(SecretGoodGalaxy.validTarget("/data/runtime.json"))
for invalid in ["/api/params", "/js/../x.js", "/js/a b.js", "/js/app.js?x=1", "/data/runtime.json", "/other/x.js", "//evil/js/a.js", "/js//a.js", "js/app.js", "/js/%2e%2e/a.js"] {
precondition(!SecretGoodGalaxy.validPage(invalid), invalid)
}
for valid in ["/index.html", "/js/app.js", "/vendor/vue/vue.esm-browser.js", "/data/catalog.json", "/manifest.webmanifest"] {
precondition(SecretGoodGalaxy.validPage(valid), valid)
}
server.corruptIdentity = false
_ = await lan.probe()
let pageCalls = server.calls.count
let page = try await lan.page(path: "/js/app.js")
precondition(page.status == 200 && server.calls.count == pageCalls + 1)
precondition(server.calls.last!.httpMethod == "GET" && server.calls.last!.url!.path == "/js/app.js")
do { _ = try await lan.page(path: "/api/params"); preconditionFailure("Pages are not API calls") } catch is BridgeError {}
precondition(SecretGoodGalaxy.needsWiFi("/api/cameras/snapshot"))
precondition(!SecretGoodGalaxy.needsWiFi("/api/settings/pages/hub"))
let concurrent = Server()
let shared = LANTransport(load: concurrent.load)
try shared.configure(address: "192.168.1.88")
async let first = shared.probe()
async let second = shared.probe()
_ = await (first, second)
precondition(concurrent.sessions == 1, "Share concurrent session handshakes")
let uploadServer = Server()
let uploader = LANTransport(load: uploadServer.load)
try uploader.configure(address: "192.168.1.89")
_ = await uploader.probe()
let package = Data(repeating: 0x5a, count: 48 * 1024 * 1024)
let header = Data("POST /api/android-auto/upload HTTP/1.1\r\nContent-Length: \(package.count)\r\nContent-Type: application/octet-stream\r\n\r\n".utf8)
let partial = try LocalRequest.parse(header)
precondition(partial == nil)
let parsed = try LocalRequest.parse(header + package)
precondition(parsed?.body == package)
_ = try await uploader.request(path: "/api/android-auto/upload", method: "POST", headers: ["content-type": "application/octet-stream"], body: package)
let uploaded = uploadServer.calls.last!
precondition(uploaded.httpBody == package)
precondition(uploaded.value(forHTTPHeaderField: "Content-Type") == "application/octet-stream")
precondition(uploaded.value(forHTTPHeaderField: "Origin") == "http://192.168.1.89:8082")
precondition(uploaded.timeoutInterval == 120)
for target in ["/api/settings/confirm", "/api/android-auto/upload/extra", "/api/android-auto/upload?x=1"] {
let oversized = Data("POST \(target) HTTP/1.1\r\nContent-Length: 50331648\r\n\r\n".utf8)
do { _ = try LocalRequest.parse(oversized); preconditionFailure("Ordinary requests must remain bounded") } catch {}
}
let tooLarge = Data("POST /api/android-auto/upload HTTP/1.1\r\nContent-Length: 268435457\r\n\r\n".utf8)
do { _ = try LocalRequest.parse(tooLarge); preconditionFailure("Package limit must hold") } catch {}
let discoveryServer = Server()
var probed: [String] = []
let discovered = await LANDiscoveryMatch.first(addresses: ["192.168.1.90", "192.168.1.90", "192.168.1.91"],
expectedDeviceID: "0123456789abcdef", stillCurrent: { true }) { address, identity in
probed.append(address)
discoveryServer.corruptIdentity = address.hasSuffix("90")
let candidate = LANTransport(load: discoveryServer.load)
candidate.bind(to: identity)
try! candidate.configure(address: address)
let verified = await candidate.probe()
precondition(candidate.verifiedDeviceID == (verified ? identity : nil))
return verified
}
precondition(discovered == "192.168.1.91" && probed == ["192.168.1.90", "192.168.1.91"], "Try alternate interfaces, never select a different comma")
let unknown = await LANDiscoveryMatch.first(addresses: ["192.168.1.91"], expectedDeviceID: nil, stillCurrent: { true }) { _, _ in
preconditionFailure("First-time discovery requires an explicit choice")
}
precondition(unknown == nil)
var sameNetwork = true
let stale = await LANDiscoveryMatch.first(addresses: ["192.168.1.91"], expectedDeviceID: "0123456789abcdef", stillCurrent: { sameNetwork }) { _, _ in
await Task.yield()
sameNetwork = false
return true
}
precondition(stale == nil, "Reject verification completed after the network or selected comma changes")
print("SecretGood LAN: cookies, refresh, mutation safety, identity and isolation passed")
}
}
@@ -0,0 +1,71 @@
import fs from 'node:fs'
import vm from 'node:vm'
import assert from 'node:assert/strict'
const source = fs.readFileSync('../../galaxy/web/js/toggle-search.js', 'utf8').replace(/^import .*\n/gm, '').replaceAll('export ', '')
const settings = fs.readFileSync('../../galaxy/web/js/settings.js', 'utf8')
const sections = settings.slice(settings.indexOf('export const SETTINGS_SECTIONS'), settings.indexOf('\n\n// Crop')).replace('export ', '')
const adapter = fs.readFileSync('GalaxyBluetooth/LoopbackServer.swift', 'utf8').match(/static let nativeSearchScript = #"""\n([\s\S]*?) """#/)[1]
function probe(timeout = 20000) {
const context = vm.createContext({ window: { __galaxyNative: true }, AbortController, DOMException,
setTimeout: (fn, ms) => setTimeout(fn, ms === 20000 ? timeout : ms), clearTimeout })
vm.runInContext(sections + '\nconst isPipCropRow = () => false;\n' + source + '\n' + adapter + '\nglobalThis.api = {SettingsSearchIndex, ToggleSearch, searchSettings};', context)
return context.api
}
const tick = () => new Promise(resolve => setImmediate(resolve))
async function until(condition) { for (let i = 0; i < 100; i++) { if (condition()) return; await tick() }; assert.fail('Expected search progress') }
const response = (page, rows = []) => ({ ok: true, status: 200, json: async () => ({ page, title: page, rows }) })
{
const api = probe(), updates = []
let active = 0, maximum = 0, release
const index = new api.SettingsSearchIndex({ publish: value => updates.push(value), fetcher: async (url, options) => {
assert.equal(options.method, undefined, 'Search must only read settings')
active++; maximum = Math.max(maximum, active)
const page = decodeURIComponent(url.split('/').at(-1))
if (page === 'lane_change') await new Promise(resolve => { release = resolve })
active--
return response(page, page === 'aol' ? [{ label: 'Always On Lateral', value: 'On' }]
: page === 'lane_change' ? [{label: 'Nested', page: 'lane_change/nested'}]
: page === 'lane_change/nested' ? [{label: 'Lane change delay', value: '2 seconds'}] : [])
}})
const loading = index.load()
await until(() => typeof release === 'function')
assert(updates.some(update => update.status === 'loading' && update.entries.some(row => row.label === 'Always On Lateral')), 'Show matches before the whole scan finishes')
release(); await loading
assert.equal(maximum, 1, 'Bluetooth search must not enqueue four parallel page reads')
assert.equal(index.status, 'ready')
assert.equal(api.searchSettings(index.entries, 'always lateral').length, 1)
assert(index.entries.some(row => row.label === 'Lane change delay'), 'Include nested settings')
}
{
const api = probe(); let calls = 0
const index = new api.SettingsSearchIndex({ fetcher: (url, options) => {
calls++
return new Promise((resolve, reject) => options.signal.addEventListener('abort', () => reject(new DOMException('Closed', 'AbortError'))))
}})
const loading = index.load()
await until(() => calls === 1)
api.ToggleSearch.methods.close.call({ index, state: {} })
await loading
assert.equal(calls, 1, 'Closing must cancel the rest of the index scan')
assert.equal(index.status, 'idle')
assert.equal(index.entries.length, 0)
}
{
const api = probe(5); let calls = 0
const index = new api.SettingsSearchIndex({ fetcher: async url => {
calls++
const page = decodeURIComponent(url.split('/').at(-1))
if (calls === 1) return new Promise(() => {}) // Simulate a response that ignores cancellation.
return response(page, [{label: 'Working later setting'}])
}})
await index.load()
assert.equal(index.status, 'partial')
assert(index.entries.length > 0, 'One timed-out page must not prevent later results')
}
{
const api = probe(); let unauthorized = 0, calls = 0
const index = new api.SettingsSearchIndex({ unauthorized: () => unauthorized++, fetcher: async () => { calls++; return {status: 401} } })
await index.load()
assert.equal(unauthorized, 1); assert.equal(calls, 1); assert.equal(index.status, 'idle')
}
console.log('Toggle search: incremental matches, serial reads, nested pages, close cancellation, bounded timeout and expired sessions passed')
@@ -0,0 +1,13 @@
from pathlib import Path
for name in ['LANDiscovery.swift', 'Info.plist']:
value = (Path('GalaxyBluetooth') / name).read_text()
assert '_sp-galaxy._tcp' in value and '_starpilot-galaxy._tcp' not in value
for name in ['CarPlaySceneDelegate.swift', 'GalaxyBluetoothApp.swift', 'NetworkTransport.swift']:
value = (Path('GalaxyBluetooth') / name).read_text()
for old in ['/api/params', '/api/stats', '/api/device/status', '/api/personality_profiles', '/api/longitudinal_mode']:
assert old not in value, (name, old)
for name in ['GalaxyAssetStore.swift', 'LoopbackServer.swift', 'GalaxyBluetoothApp.swift']:
value = (Path('GalaxyBluetooth') / name).read_text()
assert 'bundledRoot' not in value and 'Resources/Web' not in value and 'raw.githubusercontent.com' not in value, name
assert not Path('GalaxyBluetooth/Resources/Web').exists(), 'Galaxy pages come from comma, never from the app bundle'
print('LAN contract: discovery service type, endpoint migration and no bundled Galaxy pages passed')
@@ -0,0 +1,56 @@
from pathlib import Path
import sys
def method(path, name):
source = Path(path).read_text()
start = source.index(" static func " + name + "(")
end = source.index("{", start) + 1
depth = 1
while depth:
if source[end] == "{":
depth += 1
if source[end] == "}":
depth -= 1
end += 1
return source[start:end]
methods = method("GalaxyBluetooth/BluetoothTransport.swift", "useNotifications")
methods += "\n" + method("GalaxyBluetooth/BluetoothTransport.swift", "backgroundRead")
methods += "\n" + method("GalaxyBluetooth/BluetoothTransport.swift", "nextRequestIndex")
methods += "\n" + method("GalaxyBluetooth/BluetoothTransport.swift", "shouldPauseFastReads")
methods += "\n" + method("GalaxyBluetooth/BluetoothTransport.swift", "canDrainCancellation")
tests = r'''
@main struct BluetoothReadRegression {
static func main() {
for path in ["/api/settings/pages/hub", "/api/device/state", "/api/settings/pages/appearance"] {
precondition(!Probe.useNotifications(method: "GET", path: path, supported: true), "Read used stalled notification window")
}
precondition(Probe.useNotifications(method: "PUT", path: "/api/settings/confirm", supported: true))
precondition(!Probe.useNotifications(method: "PUT", path: "/api/settings/confirm", supported: false))
precondition(!Probe.useNotifications(method: "GET", path: "/_bridge/health", supported: true))
precondition(Probe.nextRequestIndex(methods: ["GET", "GET"], paths: ["/api/system/monitor", "/api/settings/pages/hub"], priorityStreak: 0) == 1)
precondition(Probe.nextRequestIndex(methods: ["GET", "PUT", "GET"], paths: ["/api/system/monitor", "/api/settings/confirm", "/api/settings/pages/hub"], priorityStreak: 0) == 0)
precondition(Probe.nextRequestIndex(methods: ["GET", "GET"], paths: ["/api/system/monitor", "/api/settings/pages/hub"], priorityStreak: 3) == 0)
precondition(Probe.nextRequestIndex(methods: ["PUT", "GET"], paths: ["/api/settings/confirm", "/api/settings/pages/hub"], priorityStreak: 0) == 0)
precondition(Probe.backgroundRead("/api/device/state?refresh=1"))
precondition(Probe.nextRequestIndex(methods: ["GET", "GET", "GET"], paths: ["/api/models/status", "/api/drives/stats?timezone=UTC", "/api/settings/pages/hub"], priorityStreak: 0) == 2)
precondition(Probe.backgroundRead("/api/software/status?history=0"))
precondition(Probe.useNotifications(method: "GET", path: "/api/settings/pages/hub", supported: true, fastReads: true))
precondition(!Probe.useNotifications(method: "GET", path: "/api/settings/pages/hub", supported: false, fastReads: true))
precondition(!Probe.useNotifications(method: "GET", path: "/_bridge/health", supported: true, fastReads: true))
precondition(!Probe.useNotifications(method: "GET", path: "/api/settings/pages/hub", supported: true, fastReads: false))
precondition(Probe.shouldPauseFastReads(method: "GET", notifications: true, cancelled: false))
precondition(!Probe.shouldPauseFastReads(method: "GET", notifications: false, cancelled: false))
precondition(!Probe.shouldPauseFastReads(method: "GET", notifications: true, cancelled: true))
precondition(!Probe.shouldPauseFastReads(method: "PUT", notifications: true, cancelled: false))
precondition(!Probe.shouldPauseFastReads(method: nil, notifications: true, cancelled: false))
precondition(Probe.canDrainCancellation(method: "GET", requestSent: false))
precondition(Probe.canDrainCancellation(method: "GET", requestSent: true))
precondition(!Probe.canDrainCancellation(method: "PUT", requestSent: false))
precondition(!Probe.canDrainCancellation(method: "POST", requestSent: false))
precondition(Probe.canDrainCancellation(method: "PUT", requestSent: true))
print("Bluetooth read policy, cancellation and priority passed")
}
}
'''
Path(sys.argv[1]).write_text("import Foundation\nstruct Probe {\n" + methods + "\n}\n" + tests)
@@ -0,0 +1,19 @@
from pathlib import Path
import sys
source = Path("GalaxyBluetooth/CarPlaySceneDelegate.swift").read_text()
def method(name):
start = source.index(" private func " + name + "(")
opening = source.index("{", start)
depth = 1
end = opening + 1
while depth:
if source[end] == "{":
depth += 1
if source[end] == "}":
depth -= 1
end += 1
return source[start:end].replace("private func", "func", 1)
stub = Path("tests/CarPlayNavigationRegression.swift").read_text()
Path(sys.argv[1]).write_text(stub.replace("__PRODUCTION_METHODS__", method("pushTemplate") + "\n" + method("popTemplate")))
@@ -0,0 +1,121 @@
from pathlib import Path
import sys
source = Path('GalaxyBluetooth/ClaimPairing.swift').read_text().replace('import CoreBluetooth\n', '').replace('@preconcurrency ', '')
bluetooth = r'''
protocol CBCentralManagerDelegate: AnyObject {}
protocol CBPeripheralDelegate: AnyObject {}
enum CBManagerState { case poweredOn, unauthorized, poweredOff, unknown, resetting, unsupported }
enum CBPeripheralState { case connected, disconnected }
enum CBCharacteristicWriteType { case withResponse, withoutResponse }
final class CBUUID: NSObject {
let value: String
init(string: String) { value = string }
override func isEqual(_ other: Any?) -> Bool { (other as? CBUUID)?.value == value }
}
@MainActor final class CBCharacteristic {
let uuid: CBUUID
var value: Data?
init(_ uuid: String) { self.uuid = CBUUID(string: uuid) }
}
@MainActor final class CBService {
let uuid = CBUUID(string: Wire.service)
var characteristics: [CBCharacteristic]?
}
@MainActor final class CBPeripheral: NSObject {
let identifier = UUID()
var delegate: CBPeripheralDelegate?
var services: [CBService]?
var state: CBPeripheralState = .connected
var writes: [Data] = []
func discoverServices(_ uuids: [CBUUID]) {}
func discoverCharacteristics(_ uuids: [CBUUID], for service: CBService) {}
func readValue(for characteristic: CBCharacteristic) {}
func maximumWriteValueLength(for type: CBCharacteristicWriteType) -> Int { 180 }
func writeValue(_ data: Data, for characteristic: CBCharacteristic, type: CBCharacteristicWriteType) { writes.append(data) }
}
@MainActor final class CBCentralManager {
static var instances: [CBCentralManager] = []
var state = CBManagerState.poweredOn
var cancellations = 0
init(delegate: CBCentralManagerDelegate, queue: DispatchQueue) { Self.instances.append(self) }
func scanForPeripherals(withServices services: [CBUUID], options: [String: Any]?) {}
func stopScan() {}
func connect(_ peripheral: CBPeripheral, options: [String: Any]?) {}
func cancelPeripheralConnection(_ peripheral: CBPeripheral) { cancellations += 1 }
}
'''
tests = r'''
@main struct ClaimTransportRegression {
@MainActor static func main() async throws {
let transport = ClaimTransport()
var events: [String] = []
transport.onEvent = { events.append($0) }
var previous: CBCentralManager?
for attempt in 0..<2 {
let count = CBCentralManager.instances.count
let task = Task { try await transport.claim(name: "iPhone") }
for _ in 0..<100 where CBCentralManager.instances.count == count { await Task.yield() }
precondition(CBCentralManager.instances.count == count + 1)
let central = CBCentralManager.instances.last!
if let previous {
previous.state = .poweredOff
transport.centralManagerDidUpdateState(previous)
}
let peripheral = CBPeripheral()
transport.centralManagerDidUpdateState(central)
transport.centralManager(central, didDiscover: peripheral, advertisementData: [:], rssi: 0)
transport.centralManager(central, didConnect: peripheral)
let rx = CBCharacteristic(Wire.rx), tx = CBCharacteristic(Wire.tx), info = CBCharacteristic(Wire.info)
let service = CBService()
service.characteristics = [rx, tx, info]
peripheral.services = [service]
transport.peripheral(peripheral, didDiscoverServices: nil)
transport.peripheral(peripheral, didDiscoverCharacteristicsFor: service, error: nil)
let challenge = Data(repeating: UInt8(attempt + 1), count: 16)
info.value = Data([1]) + challenge
transport.peripheral(peripheral, didUpdateValueFor: info, error: nil)
precondition(peripheral.writes.count == 1)
var request = FrameAssembler()
let body = try request.append(peripheral.writes[0])!
transport.peripheral(peripheral, didWriteValueFor: rx, error: nil)
precondition(central.cancellations == 0, "A retry must not complete using the previous phone key")
let server = P256.KeyAgreement.PrivateKey()
let key = try ClaimCrypto.derive(server, peer: body.subdata(in: 4..<69), challenge: challenge)
let id = attempt == 0 ? "11223344" : "55667788"
let credential = String(repeating: attempt == 0 ? "ab" : "cd", count: 32)
let json = try JSONSerialization.data(withJSONObject: ["key": credential, "id": id])
let box = try AES.GCM.seal(json, using: key, authenticating: ClaimCrypto.aad)
let reply = ClaimCrypto.replyMagic + server.publicKey.x963Representation + box.combined!
for packet in Wire.packets(Wire.uint32(UInt32(reply.count)) + reply, size: 180) {
tx.value = packet
transport.peripheral(peripheral, didUpdateValueFor: tx, error: nil)
}
let completions = events.filter { $0.contains("pairing complete") }.count
transport.peripheral(peripheral, didWriteValueFor: rx, error: nil)
precondition(central.cancellations == 1)
precondition(events.filter { $0.contains("pairing complete") }.count == completions,
"Setup must disconnect before the encrypted transport starts")
peripheral.state = .disconnected
transport.centralManager(central, didDisconnectPeripheral: peripheral, error: nil)
let result = try await task.value
precondition(result.result.key == credential && result.result.id == id)
previous = central
if attempt == 0 {
let count = CBCentralManager.instances.count
let cancelled = Task { try await transport.claim(name: "iPhone") }
for _ in 0..<100 where CBCentralManager.instances.count == count { await Task.yield() }
let abandoned = CBCentralManager.instances.last!
transport.cancel()
abandoned.state = .poweredOff
transport.centralManagerDidUpdateState(abandoned)
do { _ = try await cancelled.value; preconditionFailure("Cancelled pairing returned a credential") }
catch is CancellationError { }
}
}
precondition(events.filter { $0.contains("pairing complete") }.count == 2)
print("Repeated pairing, stale callbacks and setup handoff passed")
}
}
'''
Path(sys.argv[1]).write_text(source + '\n' + bluetooth + '\n' + tests)
@@ -0,0 +1,79 @@
from pathlib import Path
import sys
source = Path('GalaxyBluetooth/GalaxyBluetoothApp.swift').read_text()
def method(signature):
start = source.index(signature)
opening = source.index('{', start)
depth = 1
end = opening + 1
while depth:
depth += (source[end] == '{') - (source[end] == '}')
end += 1
return source[start:end]
stubs = r'''
import Foundation
enum ConnectionMode { case automatic, bluetooth, lan }
@MainActor final class Bluetooth {
var connected = false, connecting = false, isScanning = false
var attempts = 0
func reconnectSaved(pairingKey: String) { attempts += 1 }
}
@MainActor final class LAN {
var endpoint: URL?
var connected = false
var probes = 0
func configure(address: String) throws {}
func probe() async -> Bool {
probes += 1
try? await Task.sleep(nanoseconds: 60_000_000_000)
return false
}
}
@MainActor final class Transport {
let label = "Reconnecting…"
let connected = false
}
@MainActor final class Discovery { func stop() {} }
@MainActor final class Assets { var readyRoot: URL?; var status = "" }
@MainActor final class MonitorProbe {
let bluetooth = Bluetooth(), lan = LAN(), transport = Transport(), discovery = Discovery()
let assets = Assets()
var opened = false
var wantsConnection = true, pairingInProgress = false, openingGalaxy = false, offerAudioPairing = false
var mode = ConnectionMode.automatic
var pairingKey = "saved", address = "", connectionMessage = "", lastRoute = ""
var lastReconnect = Date.distantPast, lastGalaxyMatch = Date.distantPast
var lanProbeTask: Task<Void, Never>?
func learnAddress() async {}
func discoverAutomaticLAN() {}
func logConnection(_ reason: String) {}
func openIfReady() {}
func checkGalaxyUpdates() async {}
func startAudioPairing() {}
'''
tests = r'''
}
@main struct MonitorRegression {
@MainActor static func main() async throws {
let model = MonitorProbe()
let monitor = Task { await model.monitor() }
for _ in 0..<100 where model.lan.probes == 0 {
try await Task.sleep(nanoseconds: 20_000_000)
}
precondition(model.lan.probes == 1 && model.bluetooth.attempts == 1)
model.lastReconnect = .distantPast
try await Task.sleep(nanoseconds: 3_200_000_000)
precondition(model.bluetooth.attempts >= 2, "A stalled Wi-Fi request must not block Bluetooth reconnects")
precondition(model.lan.probes == 1, "Do not pile up Wi-Fi probes while one is still running")
monitor.cancel()
model.lanProbeTask?.cancel()
await monitor.value
print("Slow Wi-Fi leaves Bluetooth reconnects running; only one LAN probe in flight")
}
}
'''
Path(sys.argv[1]).write_text(stubs + '\n' + method(' private func probeLocalConnection()') + '\n' + method(' func monitor() async') + tests)
@@ -0,0 +1,21 @@
from pathlib import Path
import sys
source = Path("GalaxyBluetooth/GalaxyBluetoothApp.swift").read_text()
start = source.index("struct LegacyGalaxyStatus:")
end = source.index("\n@MainActor", start)
probe = source[start:end]
tests = r'''
@main struct LegacyLinkRegression {
static func main() {
let first = "https://galaxy.firestar.link/AbCdEf0123456789"
let second = "https://galaxy.firestar.link/ZyXwVu9876543210"
precondition(LegacyGalaxyStatus(paired: true, url: first).browserURL?.absoluteString == first)
precondition(LegacyGalaxyStatus(paired: true, url: second).browserURL?.absoluteString == second)
for bad in ["https://galaxy.firestar.link", first + "?key=private", first + "#fragment", "http://galaxy.firestar.link/AbCdEf0123456789", "https://evil.example/AbCdEf0123456789", "https://galaxy.firestar.link:444/AbCdEf0123456789"] {
precondition(LegacyGalaxyStatus(paired: true, url: bad).browserURL == nil)
}
print("Legacy Galaxy links: device-specific address preserved; general and unsafe links rejected")
}
}
'''
Path(sys.argv[1]).write_text("import Foundation\n" + probe + tests)
@@ -0,0 +1,75 @@
"""Host-only companion tests: stub native Params/IPC/solver imports, never devices.
Run from the repository root with uv --extra testing --noconftest via this script.
The Params stub is in-memory and does not establish native storage/ABI coverage.
"""
import sys
import types
from unittest.mock import MagicMock
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
msgq = types.ModuleType('msgq')
for name in (
'fake_event_handle',
'drain_sock_raw',
'MultiplePublishersError',
'IpcError',
'Context',
'Poller',
'SubSocket',
'PubSocket',
'SocketEventHandle',
'toggle_fake_events',
'set_fake_prefix',
'get_fake_prefix',
'delete_fake_prefix',
'wait_for_one_event',
):
setattr(msgq, name, MagicMock())
sys.modules['msgq'] = msgq
params = types.ModuleType('openpilot.common.params')
class Params:
values = {}
def __init__(self, path='default'):
self.data = self.values.setdefault(str(path), {})
def get(self, key, *args, **kwargs):
return self.data.get(key)
def get_bool(self, key):
return bool(self.get(key))
def put(self, key, value, **kwargs):
self.data[key] = value
def put_bool(self, key, value, **kwargs):
self.put(key, value)
def remove(self, key):
self.data.pop(key, None)
params.Params = Params
params.ParamKeyType = MagicMock()
params.ParamKeyFlag = MagicMock()
params.UnknownKeyName = KeyError
sys.modules['openpilot.common.params'] = params
native = types.ModuleType('openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.c_generated_code.acados_ocp_solver_pyx')
native.AcadosOcpSolverCython = MagicMock()
sys.modules[native.__name__] = native
import pytest
defaults = [
'openpilot/starpilot/companion/android/tests/test_android_manifest.py',
'openpilot/starpilot/companion/android/tests/test_ble_wire_vectors.py',
'openpilot/starpilot/companion/android/tests/test_lan_discovery.py',
'openpilot/starpilot/galaxy/tests/test_companion.py',
'openpilot/starpilot/galaxy/tests/test_companion_http.py',
'openpilot/starpilot/galaxy/tests/test_companion_data_guards.py',
]
sys.exit(pytest.main(['--noconftest', '-n0', '-q', *(sys.argv[1:] or defaults)]))
+1 -1
View File
@@ -124,7 +124,7 @@ quiet-level = 3
# if you've got a short variable name that's getting flagged, add it here
ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite,ser,assertIn,HomeState,cancelled,CAS,acknowledgement,writeable,noice,FoF,waitFor,centre,centres,KeyPair,aPoint"
builtin = "clear,rare,informal,code,names,en-GB_to_en-US"
skip = "openpilot/cereal/gen/cpp/*.capnp.c++, openpilot/cereal/gen/cpp/*.capnp.h, *.po, uv.lock, *.onnx, *.pem, */c_generated_code/*, docs/assets/*, openpilot/tools/plotjuggler/layouts/*, openpilot/selfdrive/assets/offroad/mici_fcc.html, openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js, openpilot/starpilot/ui/assets/fonts/notices/Unifont-COPYING.txt, openpilot/starpilot/companion/ios/GalaxyBluetooth/Resources/Web/vendor/vue/vue.esm-browser.js, openpilot/starpilot/galaxy_bluetooth/vendor/*, openpilot/starpilot/soloist/pulseaudio/licenses/*, openpilot/starpilot/companion/android/LICENSE.gradle"
skip = "openpilot/cereal/gen/cpp/*.capnp.c++, openpilot/cereal/gen/cpp/*.capnp.h, *.po, uv.lock, *.onnx, *.pem, */c_generated_code/*, docs/assets/*, openpilot/tools/plotjuggler/layouts/*, openpilot/selfdrive/assets/offroad/mici_fcc.html, openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js, openpilot/starpilot/ui/assets/fonts/notices/Unifont-COPYING.txt, openpilot/starpilot/galaxy_bluetooth/vendor/*, openpilot/starpilot/soloist/pulseaudio/licenses/*, openpilot/starpilot/companion/android/LICENSE.gradle"
ignore-regex = "scale\\.numer" # codespell:ignore numer
# https://docs.astral.sh/ruff/configuration/#using-pyprojecttoml
-1
View File
@@ -10,7 +10,6 @@ LARGE_TEXT_FILES = frozenset((
'openpilot/selfdrive/selfdrived/selfdrived.py',
'openpilot/starpilot/galaxy/server.py',
'openpilot/starpilot/galaxy/web/js/onroad-layout.js',
'openpilot/starpilot/companion/ios/GalaxyBluetooth/Resources/Web/js/onroad-layout.js',
'openpilot/cereal/gen/cpp/car.capnp.c++',
'openpilot/cereal/gen/cpp/car.capnp.h',
'openpilot/cereal/gen/cpp/custom.capnp.c++',