Skip to content

espurr Apple apps

Native SwiftUI controllers for iPhone/iPad and Mac Catalyst (iOS/iPadOS 26+, macOS 26+ for Catalyst), with a watchOS 26+ companion. The phone provides detailed configuration, diagnostics and firmware updates; the Watch is a compact remote. The ears work independently.

Both apps require application protocol 2 and the authenticated firmware catalogue. Current firmware uses bonded LE Secure Connections. Older Security 2 firmware is unsupported; update it by USB before connecting.

Optional analytics and crash reports

The phone/iPad and Mac Catalyst target links Firebase Apple SDK 13.1.1 through SwiftPM: FirebaseAnalyticsCore and FirebaseCrashlytics. The old FirebaseAnalyticsWithoutAdId product was removed upstream; Core is the current product without the separate IdentitySupport/IDFA library. Watch and its widget do not link Firebase. IDFV collection, advertising consent/personalisation, automatic screen reporting, on-device conversion events and Firebase Sessions are disabled.

The first-open Allow analytics and crash reports? sheet defaults to no collection. No FirebaseApp.configure() happens without a saved explicit opt-in. Share analytics and crash reports in Settings or Support changes the choice. There are only two allowlisted usage events: connection and firmware outcomes with duration buckets. There are no user IDs, accessory identities, recordings or logs in these payloads. Generated SDK identifiers and crash/system metadata remain pseudonymous. This app-specific consent is independent of Apple's system analytics sharing choices.

Crashlytics automatic uploading stays disabled permanently. A continuously opted-in cold launch authorises sending prior reports; first opt-in or a new consent period discards old pending data. Turning off disables/resets Analytics and prevents future crash-send calls, and the next cold launch clears SDK-owned pending stores before any later configuration. The SDK can retain a local current-session crash file until then. Already authorised/in-flight network requests cannot be recalled. iOS never force-quits the app when consent changes.

The checked-in ios/GoogleService-Info.plist configures local, Xcode Cloud and GitHub Actions builds. Alternatively, set FIREBASE_CONFIG_FILE to a private build-time path. It must match the phone's bundle identity. A missing file (or FIREBASE_ENABLED=NO) leaves source/CI builds usable without Firebase configuration. The Downloads original is preserved; ~/Downloads is not a build dependency. The resource phase copies the configuration and separately generated, pin/hash-checked offline notices into phone/Mac products. The phone target's script sandbox is disabled for this optional-resource staging and Crashlytics tooling; the Catalyst runtime app sandbox remains enabled.

Ordinary builds do not upload symbols. For a configured Release archive, set FIREBASE_UPLOAD_SYMBOLS=YES; the final build phase invokes Firebase's own Crashlytics/run with the bundled configuration. Keep exact matching dSYMs for each distributed build. Console receipt/symbolication from real crashes is a separate release check. UI tests force a fake backend and never upload to the local project.

The Debug-only live probes and the completed synthetic event/crash checks are documented in live telemetry acceptance. They require normal saved opt-in; Release builds exclude them.

Connect

  1. Put the ears in BLE mode and choose Find ears. iPhone/iPad use the system AccessorySetupKit picker; Catalyst uses native BLE discovery. Normal firmware starts in BLE mode; see button gestures to switch radios.
  2. Select the ears, confirm the OS numeric-comparison prompt and make a fresh headset click within 30 seconds. Compare the serial number with the OS prompt if you need human-verified numeric comparison. There is no fixed PIN.
  3. Wait for fresh settings before editing. The OS remembers the bond for reconnects.

A remembered row connects directly; its information button opens connection settings. Saved associations contain only device identifiers/names/preferences; OS-managed bonds supply authentication. No app password or shared phone/Watch Keychain group is needed. Existing iPhone/iPad users choose Continue setup once to migrate their saved UUID into ASK before any CoreBluetooth central starts. Cancellation leaves migration pending. Authorisation is reconciled before reconnect; revocation/removal disconnects the client and clears the matching Watch association. Forget device removes ASK authorisation on iPhone/iPad, or the saved app association on Catalyst. It does not erase all bonds on the ears or the Watch. Manage independent OS bonds separately.

If accessory setup becomes unavailable, the app clears its session authorization and pending selection; Find ears can retry. A framework invalidation event discards that session without cancelling it a second time. See the startup regression evidence.

ASK matches the local-control service plus the espurr- name and requests LE pairing. Its bundled cat artwork and friendly display names do not authenticate the accessory. The raw hardware name is kept separately for Watch discovery. No unrestricted scan fallback is used on iPhone/iPad. Bluetooth restoration/background relaunch is deferred. New iOS 26.1+ picker results use the advertised name. The app also substitutes the known hardware name for older generic saved labels, while preserving custom names. Existing names saved by the system picker itself remain OS-managed.

The phone makes one automatic reconnect attempt per opening when remembered setup is available. An explicit device choice is saved before connection so a failed attempt doesn't lose the target. If ASK has exactly one authorised accessory but the app has no saved target, it restores that selection; multiple accessories still need a choice. Migration and authorisation checks precede CoreBluetooth. It retains an authenticated ordinary BLE connection through screen lock/background while iOS preserves the process and link. Discovery, polling and edits pause; unsent edits are discarded, and fresh state is read before editing resumes. Connecting or uploading in the background disconnects. Explicit Disconnect and Watch release also end ownership; OTA refuses handoff. OS termination/link loss needs reconnect.

Cancel connection ends pending Bluetooth, Watch-release and accessory-setup work, immediately restoring the welcome controls. It suppresses automatic reconnect for the current visit. Reopening after backgrounding, or explicitly choosing Find ears or a device, permits another attempt. Temporary system-prompt interruptions don't retry a failed or cancelled connection. Saved setup and OS bonds are retained. See the cancellation checks.

Apple interface

The Apple app uses one window on iPhone, iPad and Mac Catalyst. Its Bluetooth session, accessory setup and Watch ownership are shared app resources; a second window must not independently pause or replace them. The window can still resize and participate in system multitasking. Native toolbar actions provide titles and symbols so the system can adapt them to horizontal or vertical bars. Connection status retains distinct demo, refreshing, applying, saving and save-failed accessible values. Demo also remains visible as a native navigation subtitle when the toolbar uses only a symbol.

  • Control: power, brightness, temporary flashlight when supported, catalogue effects and their parameters, palettes and motion switches. Firmware confirms application; a successful Bluetooth write alone is insufficient.
  • Colours: palette editing, Smooth/Bands, hue cycling, background glow and device-saved palettes. Replacement/deletion/restoration require confirmation; naming refusals retain the draft. Artistic RGB stays unscaled.
  • Settings: calibration, Watch setup, LED output, ear wiring, Wi-Fi, device information, connection, diagnostics and updates. Firmware capabilities govern availability. Missing sensors don't disable unrelated controls.

Pending, applied, saving and save-failed states stay distinct. Polls don't reset the effect picker. Calibration needs two seconds of continuous stillness within a five-second capture window; the app waits for the confirmed result. See calibration and motion.

Control's optional local ear simulation uses the shared C renderer and phone/manual motion. Audition stays local; disconnect freezes the last frame. It isn't headset telemetry. The Watch has no simulation.

Settings → Audio diagnostics can request temporary capture and, on supported hardware, a separate five-second recording. Native Save/Share exports WAV plus a JSON sidecar. The app doesn't use the phone microphone. See the audio contract and recording wire format.

Support works from welcome, Settings and recovery, including disconnected/demo use. It exposes offline privacy/notices and explicit report/export choices. Local reliability counts default off; see support reporting.

Firmware updates and recovery

Use Check for firmware, optionally include release candidates, and download a matching release for review. Choose firmware imports a local application .bin. Install firmware is a separate action. Local files and BLE work without Pages. The picker sends no accessory identifiers or credentials to the download host.

Preflight checks the image family/chip and the actual inactive-slot capacity: 1700 KiB on ESP32, 3072 KiB on legacy S3/v2 8 MB, and 4096 KiB on v2 16 MB. Firmware validates SHA-256 and the native image before selecting it. App OTA can't change partition layouts; use the update guide.

Keep the app foreground and ears powered/in range. Normal polling/edits pause and auto-lock is disabled during upload. Cancel waits for the active exchange and aborts; there is no partial resume. After a lost final reply, reconnect to inspect the running firmware before retrying. Selection is not proof of a healthy boot.

BLE recovery

Dedicated recovery opens the updater directly, using the firmware-reported normal upload family. Wearable settings/calibration are unavailable. Watch directs users to phone and can release its connection. Bonded phone clients can set the AP password from Set up recovery Wi-Fi, or normal Settings → Wi-Fi. Password fields are transient and never read back. See recovery and storage repair.

Apple Watch companion

The dependent companion reuses EspurrControl and communicates directly with the ears. Swipe between Control, Appearance, Motion and Connection. It supports power/brightness with Crown and touch, catalogue effects, builtin/saved palettes, overlay switches/shared offset and confirmed neutral recenter. Detailed editors, full mounting calibration, recordings, reporting and OTA stay on phone.

Setup and ownership

  1. Install the phone app and its embedded Watch companion.
  2. Connect the phone to the ears. A versioned, non-secret WatchConnectivity application context automatically carries the selected ears' setup ID, hardware discovery name and display name. Delivery is opportunistic; both apps needn't be reachable at once. Settings → Apple Watch → Retry sync retries queueing the current selection.
  3. Open the Watch app. It requests phone release, then connects to its remembered peripheral or makes one bounded discovery attempt for the hardware name. It discovers its own UUID; the phone UUID identifies setup, never the Watch's Bluetooth peripheral. Confirm the Watch's own OS pairing and click the headset button the first time.
  4. Reopen phone to request reciprocal release and reconnect. If the other controller is unreachable and still holds BLE, explicitly leave/disconnect it and retry.

The Watch follows the phone's selected ears. Duplicate/stale contexts are ignored; renaming preserves its own peripheral ID. Replacement/forget clears the old target and connection. If setup arrives after opening the Watch app, it requests release and connects while active; background receipt only saves setup for the next opening. Setup received while dimmed waits until active. Phone and Watch maintain independent bonds. Android can control the same bonded firmware, with system companion-device setup/direct scan fallback and explicit ownership release.

Release requests match setup account/request ID and a three-second expiry, never credentials. OTA refuses release. Unavailable messaging permits one direct attempt; explicit refusal stops it. No queued retry loop or stale callback may acquire BLE.

Watch wrist-down/frontmost inactivity retains BLE and pauses polls/edits. Raising it resumes the same session. Actual backgrounding releases BLE and unsent work. After release, dimming/reactivation alone doesn't reconnect. Cached controls stay disabled until a new authoritative read. Active Watch polling is every three seconds.

espurrWatchLauncher is a static circular WidgetKit complication that opens the app. It has no device state, credentials, Bluetooth or scheduled refresh.

Build and install

Use Xcode 27.0 or later. The app uses SDK APIs introduced in Xcode 27 while retaining its iOS/macOS/watchOS 26 deployment targets. Each app/extension target runs the toolchain check before its sources; GitHub CI and the Cloud pre-build hook run the same check. For command-line preflight:

python3 tools/check_xcode.py

GitHub's Apple job uses the xcode-27 runner and explicitly selects Xcode 27.2, matching Cloud's validated toolchain. A runtime #available check cannot make an older build SDK recognise a newer API.

Open ios/espurr.xcodeproj and select espurr or espurrWatch. Signing overrides belong in ignored ios/Local.xcconfig:

DEVELOPMENT_TEAM = YOURTEAMID

Enable Developer Mode and pair intended physical devices in Xcode. ASK is included only in the iPhone/iPad plist; Catalyst retains native discovery. The app doesn't request Keychain sharing. Signing, physical ASK discovery and pairing need device checks. All Apple targets share ReleaseVersion.xcconfig; keep their release versions aligned. The ios/vX.Y.Z tag covers iPhone/iPad, Watch and macOS Catalyst together; there is no separate macos/ track. Its base must match MARKETING_VERSION. Xcode Cloud assigns distribution build numbers; seed its counter above previous uploads and at least as high as committed CURRENT_PROJECT_VERSION. Local/manual store uploads must also use a fresh build number. App versions are independent of firmware. The Apple release setup uses Xcode Cloud for signing and TestFlight, including the embedded Watch and complication. Mac Catalyst uses its own App Sandbox entitlements for Bluetooth, outgoing network connections and files selected through the system picker, as required for Mac App Store distribution. GitHub retains unsigned CI archives and a draft as source/build evidence; those build numbers do not identify the separately built Cloud distribution archives. Public store release and physical acceptance remain separate steps.

From the repository root:

swift test --package-path ios/EspurrControl
xcodebuild -project ios/espurr.xcodeproj -scheme espurr \
  -destination 'generic/platform=iOS' -derivedDataPath ios/DerivedData \
  CODE_SIGNING_ALLOWED=NO build
xcodebuild -project ios/espurr.xcodeproj -scheme espurrWatch \
  -destination 'generic/platform=watchOS' -derivedDataPath ios/DerivedData \
  CODE_SIGNING_ALLOWED=NO build

For a signed Catalyst build, use destination platform=macOS,variant=Mac Catalyst and omit CODE_SIGNING_ALLOWED=NO. Build output stays in ignored ios/DerivedData/; package output stays in ios/EspurrControl/.build/.

To build signed Release phone/Watch apps without installing:

./ios/deploy-release.sh --build-only

Without --build-only, the helper installs both apps without launching/debugging. It prompts for targets from xcrun devicectl list devices, saves shell-escaped Bash assignments in ignored ios/deploy-release.conf, and sources that local file. Optional phone/Watch positional arguments override saved targets. Noninteractive installation requires existing configuration. Check destinations before installing. Full build output goes to ios/DerivedData/deploy-release.log.

Validation

Reuse the named project destinations in the simulator policy. ESPurr Duo is the persistent iPhone Duo profile for vertical bars and folding transitions. Resolve its UUID together with the device type and compatible Duo-specific runtime; the ordinary iOS runtime with the same version is not interchangeable. Use Device Hub's Closed, Partially Open, Open and rotation controls for manual transition checks, preserving the current navigation, sheets and drafts.

Choose available simulators with xcrun simctl list devices available and set ESPURR_PHONE_SIMULATOR / ESPURR_WATCH_SIMULATOR to the intended UUIDs:

xcodebuild -project ios/espurr.xcodeproj -scheme espurr \
  -destination "platform=iOS Simulator,id=$ESPURR_PHONE_SIMULATOR" \
  -derivedDataPath ios/DerivedData CODE_SIGNING_ALLOWED=NO ENABLE_DEBUG_DYLIB=NO \
  -collect-test-diagnostics never test
xcodebuild -project ios/espurr.xcodeproj -scheme espurrWatch \
  -destination "platform=watchOS Simulator,id=$ESPURR_WATCH_SIMULATOR" \
  -derivedDataPath ios/DerivedData CODE_SIGNING_ALLOWED=NO test

ENABLE_DEBUG_DYLIB=NO avoids a preview-launcher link failure on the tested Xcode 27 installation; Release doesn't need it. Disabling test diagnostics avoids slow post-failure simulator collection. Tests exercise real views with explicit mocks, not real BLE, system ASK UI, WatchConnectivity delivery or mounted sensors.

After a Debug Catalyst build, run the relevant host regression helpers:

tools/test_phone_lifecycle.sh
tools/test_watch_release.sh
tools/test_watch_foreground.sh
tools/test_watch_setup_callbacks.sh

tools/test_effect_picker.sh <booted-phone-simulator-uuid> checks wheel stability through synthetic polls. Other Debug fixtures are selected by launch arguments:

Arguments Purpose
--ui-preview, optionally --ui-v2, --ui-colours, --ui-settings Labelled in-memory phone UI; no Bluetooth or credentials
--ui-polls, --ui-refuse-save, --ui-no-motion Polling, palette refusal and missing-motion cases
--motion-preview, --motion-sflp-preview, --motion-off-preview Host/SFLP/unavailable motion filtering
--ear-preview, --audio-preview, --hardware-preview Diagnostic/capability routes without invented readings
--firmware-preview, --firmware-catalogue-preview Local picker / synthetic release downloads
--firmware-catalogue-failure, --firmware-catalogue-slow Offline and cancellation fixtures
--watch-setup-preview Automatic Watch setup copy; sync/release disabled
--recovery-preview Phone-only recovery route with installation disabled
--watch-preview, optionally --watch-recovery, --watch-no-motion Labelled Watch preview variants
--watch-disconnected-preview, --watch-unsupported, --watch-calibrated-preview Cached, unsupported and synthetic recenter cases

For an explicitly authorised hardware smoke test, Debug --ble-acceptance connects to the remembered ears, checks reconnect/polling, changes brightness by one step, restores it and waits for save completion. --connection-timing instead performs one read-only connection. iPhone/iPad must already have ASK authorisation from normal setup. Neither proves optical output, reboot persistence or rollback. These modes use remembered associations and OS bonds without exporting them and print ESPURR_ACCEPTANCE results.

Historical measurements and installations are in Apple acceptance, early firmware evidence and mobile release checks. Real pairing/bonds, phone lock retention, Watch handoff/Crown, provider exports, mounted calibration, interrupted OTA and rollback still need physical acceptance.

Protocol dependencies and notices

SwiftProtobuf is pinned to 1.38.1. Canonical ESP-IDF v6.1 protobuf inputs live in EspurrControl/Protocol/; regenerate with tools/generate_control_protos.sh. Legacy Espressif Security 2/SRP/BigInt source, adapter, target and schemas were removed. Its historical provenance remains for old releases. Current firmware uses OS bonding.

Run python3 tools/generate_mobile_notices.py after reviewed dependency changes; --check verifies generated notices. Shared/PrivacyPolicy.txt is an imported artifact from the web-owned policy; do not edit it directly. Its sibling PrivacyPolicy.source.json pins the reviewed revision and hashes. Normal CI runs python3 tools/import_privacy_policy.py --check offline; use the policy import workflow for updates. The imported draft still needs its publication/provider checks before submission. Store operations remain in the submission checklist. Icon provenance is in app-icon.md.