Skip to content

Harden shared services for headless (CarPlay-only) launches#114

Open
einsteinx2 wants to merge 9 commits into
new-swift-portfrom
claude/carplay-support-rdtd71
Open

Harden shared services for headless (CarPlay-only) launches#114
einsteinx2 wants to merge 9 commits into
new-swift-portfrom
claude/carplay-support-rdtd71

Conversation

@einsteinx2

Copy link
Copy Markdown
Owner

CarPlay adds a second scene that can connect before - or without - the
phone's window scene, so the shared layer must not assume one exists:

  • AppBootstrap.sceneDidConnect() is idempotent and runs for whichever
    scene connects first (download-lane restore + playback resume)
  • SceneDelegate.shared is optional and filters for the window scene
    instead of force-casting connectedScenes.first
  • ServerSwitcher takes NetworkMonitor via its initializer instead of
    reaching up through SceneDelegate.shared, and optional-chains its
    remaining UI cleanup
  • HUD.show skips when there is no key window, and a new HUD.banner
    wraps the ProgressHUD banners reachable from headless code paths
    (NetworkMonitor, PlaybackCoordinator, DownloadEngine)
  • NowPlayingService reports playbackRate 0 while paused (the CarPlay/
    lock-screen progress bar no longer advances when paused), renders
    artwork at the requested size, and exposes currentNowPlayingInfo()
    for tests

Co-Authored-By: Claude Fable 5 [email protected]
Claude-Session: https://claude.ai/code/session_01JCT7pNgGdjGuwFehDWnVfS

claude added 9 commits July 18, 2026 02:33
CarPlay adds a second scene that can connect before - or without - the
phone's window scene, so the shared layer must not assume one exists:

- AppBootstrap.sceneDidConnect() is idempotent and runs for whichever
  scene connects first (download-lane restore + playback resume)
- SceneDelegate.shared is optional and filters for the window scene
  instead of force-casting connectedScenes.first
- ServerSwitcher takes NetworkMonitor via its initializer instead of
  reaching up through SceneDelegate.shared, and optional-chains its
  remaining UI cleanup
- HUD.show skips when there is no key window, and a new HUD.banner
  wraps the ProgressHUD banners reachable from headless code paths
  (NetworkMonitor, PlaybackCoordinator, DownloadEngine)
- NowPlayingService reports playbackRate 0 while paused (the CarPlay/
  lock-screen progress bar no longer advances when paused), renders
  artwork at the requested size, and exposes currentNowPlayingInfo()
  for tests

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JCT7pNgGdjGuwFehDWnVfS
New CPTemplateApplicationScene role wired into both targets:

- com.apple.developer.carplay-audio in both entitlements files, now
  referenced via CODE_SIGN_ENTITLEMENTS in the app build configs
- CarPlay scene config in both Info.plists; multiple scenes enabled
  (required so the phone scene survives the car connecting); AppDelegate
  routes the .carTemplateApplication role to CarPlaySceneDelegate

The car UI is a template layer over the existing Store reads and
PlaybackCoordinator calls the phone uses - no phone UI changes:

- Tabs: Library (Folders/Artists with A-Z index and media-folder picker,
  Bookmarks), Playlists (Play Queue/Local/Server), Downloads (Folders/
  Artists/Albums/Songs), Discover (quick album lists, Shuffle All)
- Now Playing: CPNowPlayingTemplate with repeat/shuffle buttons and an
  Up Next queue screen; metadata/transport ride the existing
  NowPlayingService surfaces (single MPNowPlayingInfoCenter writer)
- Screens are CP-free section builders (cache-first, async loader
  refresh, offline row disabling via isAvailableOffline); a factory maps
  them to CPListItems, re-rendering art for the car display scale and
  clamping to the head unit's runtime list limits
- CarPlayManager routes row taps to PlaybackCoordinator, refreshes live
  templates on playback/queue/download notifications, rebuilds on server
  switch and offline transitions (Downloads-first tab order offline),
  and auto-disables jukebox mode on connect (the car would be silent)
- CPInterfaceController is wrapped in a protocol seam so the manager and
  screens are unit-testable without a car session

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JCT7pNgGdjGuwFehDWnVfS
…phone scene

The launch offline check and the goOnline/goOffline transition handling
lived in the phone SceneDelegate, so a CarPlay-only launch never entered
offline mode and never ran the periodic server check. They now live in
OfflineModeCoordinator, constructed with the service graph and invoked
by whichever scene appears (phone sceneDidBecomeActive or
CarPlayManager.connect):

- performLaunchOfflineCheckIfNeeded() runs once per process and stashes
  the explanatory alert text; the phone scene presents it whenever it
  appears (possibly long after a car-first launch)
- ServerChecker moves behind the coordinator (created lazily because the
  coordinator itself is built before container registrations exist)
- SceneDelegate keeps only alert presentation and the static
  launchOfflineAlertMessage truth table the tests reference; its unused
  service injections are dropped

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JCT7pNgGdjGuwFehDWnVfS
CarPlay audio apps can't show keyboard search (CPSearchTemplate is
navigation-only), so search in the car is voice-driven: 'Hey Siri, play
<song/album/artist/playlist> in iSub'. Works anywhere Siri does, not
just in the car.

- PlayMediaIntentHandler resolves INMediaSearch against the server
  (search/search2/search3 chosen by the same capability flags the phone
  search screen uses) with a local fallback over downloaded songs/
  albums/artists and playlists when offline; matched songs are persisted
  so the play-queue JOIN can hydrate them
- handle() maps the match onto the existing play paths (single song,
  tag album, recursive folder/tag-artist gather, local/server playlist),
  honoring playShuffled and supporting bare resume requests
- AppDelegate returns the handler from application(_:handlerFor:);
  INIntentsSupported declared in both Info.plists (in-app handling also
  requires multi-scene support, already enabled for CarPlay)
- Siri entitlement added to both entitlements files
- Library tab shows the 'Hey Siri' assistant cell
  (CPAssistantCellConfiguration, .playMedia)

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JCT7pNgGdjGuwFehDWnVfS
- FakeCarPlayInterface records the template stack so CarPlayManagerTests
  can drive a real manager: tab order online vs offline (Downloads-first),
  live reorder on the offline transition, jukebox auto-disable on connect,
  play actions filling the queue and landing on Now Playing, back-
  navigation pruning, server-switch pop-to-root, the template depth
  guard, and the Up Next pop-back-to-Now-Playing path
- CarPlayScreenTests cover the CP-free section builders against a seeded
  in-memory store: row content and tap payloads, video filtering with
  position alignment, offline row disabling, internal queues excluded
  from local playlists, downloads root states, and the list-clamping math
- OfflineModeCoordinatorTests cover the once-per-process launch check,
  the pending-alert handoff to the phone scene, and the goOffline
  jukebox side effect; OfflineModeCoordinator now takes the
  NetworkStatus seam instead of the concrete NetworkMonitor so tests
  inject FakeNetworkStatus
- NowPlayingServiceTests assert currentNowPlayingInfo()'s paused rate 0
- docs/carplay.md describes the architecture, non-goals, dev/test flow,
  and gotchas

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JCT7pNgGdjGuwFehDWnVfS
The depth-guard pop inside push(screen:) fires the stack-changed prune;
recording the (screen, template) pair before the template is actually in
the stack let that prune sweep the new entry away, cancelling its load
and dropping its refresh wiring while its template stayed visible.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JCT7pNgGdjGuwFehDWnVfS
A song queued twice lit up both rows; the queue screen knows each row's
exact queue position, so pass it through as an override.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JCT7pNgGdjGuwFehDWnVfS
XCUITest cannot drive the real car screen: CarPlay templates render
out-of-process in the system's CarPlay host, there are no public
automation APIs for it, and headless CI simulators cannot attach the
CarPlay display at all. The testable boundary is everything up to that
hand-off, so a -CARPLAY launch argument (UI test mode only) now runs the
real CarPlayManager in the app process against the same interface seam
the unit tests use and renders its live template stack in a native
mirror window (CarPlayUITestMirror.swift). The mirror shows the tab
strip, real CPListItem rows (titles, details, disabled state, playing
indicator, empty-state variants) and a Now Playing panel; tapping a row
invokes the actual CPListItem handler, driving the real coordinator,
stores, loaders, and audio engine. Phone/Car toggle buttons let a test
hop between both UIs in one launch. Dormant outside UI test mode.

CarPlayUITests (9 tests) covers: online tab order and Library rows;
Folders drill-down to song playback with Up Next queue jumps and
phone-player agreement; the media-folder picker; tag artist/album play
all; server playlist playback plus the Play Queue screen; Discover quick
albums with Load More and Shuffle All; offline Downloads-first reorder
and empty states; jukebox auto-disable with a request-log proof that
playback streams locally; and bookmark playback from the car.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JCT7pNgGdjGuwFehDWnVfS
The mirror's 0.5s poll called reloadData() unconditionally, so cells were
destroyed and recreated continuously; XCUITest taps raced the swaps and
failed intermittently ('activation point invalid', eaten taps) in 6 of
the 9 CarPlay tests. The poll now renders only when a content signature
(template identity, rows' text/detail/enabled/playing, tabs, depth,
now-playing title — images excluded on purpose) actually changes, so the
table is static between real updates.

Belt-and-braces on the test side: car row taps retry once if the row
stays front-and-center (every car tap navigates or starts playback), with
a no-retry variant for the media-folder picker flow where the tapped text
legitimately recurs on the destination screen.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JCT7pNgGdjGuwFehDWnVfS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants