mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 */;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
+77
@@ -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) }
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "StarPilotIcon.png",
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "Nate",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 530 KiB |
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "bluetooth.svg",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
},
|
||||
"properties": {
|
||||
"preserves-vector-representation": true,
|
||||
"template-rendering-intent": "template"
|
||||
}
|
||||
}
|
||||
+13
@@ -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 |
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "network.svg",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
},
|
||||
"properties": {
|
||||
"preserves-vector-representation": true,
|
||||
"template-rendering-intent": "template"
|
||||
}
|
||||
}
|
||||
+17
@@ -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 |
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "wifi.svg",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
},
|
||||
"properties": {
|
||||
"preserves-vector-representation": true,
|
||||
"template-rendering-intent": "template"
|
||||
}
|
||||
}
|
||||
+16
@@ -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 |
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "Nate",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "StarPilot.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "Nate",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 461 KiB |
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "StarPilotMark.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
BIN
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.
|
||||
Binary file not shown.
@@ -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
@@ -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
|
||||
|
||||
@@ -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++',
|
||||
|
||||
Reference in New Issue
Block a user