Skip to content

Add iPhone companion app over a stubbed Mac transport - #9

Open
ARamy23 wants to merge 4 commits into
Abdo-codes:mainfrom
ARamy23:feature/ios-companion-app
Open

Add iPhone companion app over a stubbed Mac transport#9
ARamy23 wants to merge 4 commits into
Abdo-codes:mainfrom
ARamy23:feature/ios-companion-app

Conversation

@ARamy23

@ARamy23 ARamy23 commented Jul 28, 2026

Copy link
Copy Markdown

Stacked on #7 and #8.

Builds the companion surface inside-out from the domain, so nothing platform-specific leaked into the logic.

Type Role
MacState The entire contract between Mac and companion. Plain value type.
MacStateTransportClient Transport-agnostic interface, with a working in-memory stub as its liveValue.
CompanionFeature One reducer driving both iPhone and Watch.
NightcapCompanionUI Shared SwiftUI for iOS + watchOS. NightcapUI stays macOS-only (AppKit menu-bar code).
NightcapPhone The iOS app target.

NightcapDomain is now multi-platform (macOS/iOS/watchOS) — which is exactly what the purity work in #8 was for. It needed no changes to compile for iOS and watchOS.

Why the transport is stubbed

A real transport means adding a network entitlement to a sandboxed App Store app whose listing promises zero network calls, plus a privacy policy rewrite and re-review. That's a separate decision, so it's deliberately deferred. The stub behaves plausibly enough to build, demo, and test the whole companion end to end.

Design points worth reviewing

  • "Waiting" is not "idle." isWaitingForMac is distinct from isAwakeHeld == false, so the UI never claims your Mac is asleep before any snapshot has arrived. There's a scenario covering this.
  • Failures surface. A transport error produces a visible message rather than a silent no-op.
  • onDisappear cancels the subscription so a watch app isn't holding a stream open in the background.

One build note

ComposableArchitecture is linked exactly once, via NightcapDomain. The companion UI and phone app take it with link: false — linking it into each static framework produced 7674 duplicate symbols.

Verification

24 scenarios pass (4 new).

Driven on the iPhone 17 Pro simulator and read back from the accessibility tree:

staticText 'Keeping Mac Awake. 1 app active'
checkBox   'Watch Ghostty' value=1
     ↓ tap
staticText 'Idle. Sleep allowed'
staticText 'Paused'
checkBox   'Watch Ghostty' value=0

https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw

ARamy23 added 4 commits July 29, 2026 00:12
Rewrites NightcapAppTests from XCTest to Swift Testing, organised as one
@suite per feature with numbered Given/When/Then scenarios. Behaviour is
characterised rather than changed: no production code is touched.

Notable additions beyond a straight translation:

- removeAppRequested had no coverage at all. Three scenarios now cover
  removing a running app, removing an idle one, and removing one of two
  running apps (the assertion must survive for the other).
- The assertion reason string is asserted when a second watched app
  launches, so the pmset-visible reason stays truthful.
- A scenario covers IOKit refusing the assertion, where the app must not
  claim the Mac is being kept awake.
- test_terminate_event_keeps_assertion_when_another_instance_still_running
  previously asserted nothing. It now checks that release() is not called.

Tests are given an in-memory file storage dependency. Swift Testing runs
suites in parallel, and @shared(.fileStorage) would otherwise be shared
mutable state across scenarios.

scripts/check-domain-coverage.sh enforces a floor on pure-domain coverage
from an .xcresult bundle. Live adapters (NSWorkspace, IOKit, SMAppService,
StoreKit) and SwiftUI views are excluded by design; covering those means
integration tests, not characterisation.

20 scenarios, all passing. Domain coverage 96.04% (291/303), up from
93.70% (284/303).

Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
Splits the app into three modules inside one local SwiftPM package:

- NightcapDomain: WatchedApp, LaunchAtLoginStatus, AppFeature, and all
  dependency-client interfaces. No AppKit, IOKit, ServiceManagement or
  StoreKit, so it can build for iOS and watchOS.
- NightcapClients: the live DependencyKey conformances. The only module
  that touches platform frameworks.
- NightcapUI: the SwiftUI menu views.

AppFeature.swift previously imported AppKit without using a single AppKit
symbol; the reducer was already pure and that import is now gone.

The package builds standalone (`swift build` in Packages/NightcapKit
succeeds). The generated Xcode project does NOT yet consume it: Xcode never
registers the XCLocalSwiftPackageReference, and NightcapKit is absent from
SourcePackages/workspace-state.json, so all three products report as
"Missing package product". Committed as WIP so the extraction is not lost
while that is resolved.

Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
Splits the single app target into three modules with compiler-enforced
boundaries:

- NightcapDomain: WatchedApp, LaunchAtLoginStatus, AppFeature, and the
  dependency-client interfaces. Imports no platform frameworks, so it can
  be reused by iOS and watchOS targets later.
- NightcapClients: the live DependencyKey conformances. The only module
  that touches AppKit, IOKit, ServiceManagement and StoreKit.
- NightcapUI: the SwiftUI menu views.

Client interfaces are separated from implementations using the standard
swift-dependencies split: the @DependencyClient struct and its
TestDependencyKey live in the domain, the liveValue conformance lives in
NightcapClients.

AppFeature.swift imported AppKit without using a single AppKit symbol. The
reducer was already pure, so the extraction started by deleting that import.

Modules are built as static framework targets rather than local SwiftPM
packages. Xcode would not register a local package reference for this
project: NightcapKit never appeared in SourcePackages/workspace-state.json
and all products reported "Missing package product", despite a manifest
that builds fine under `swift build` and matches a working setup elsewhere.
Static linking also avoids embedding and signing three extra dynamic
frameworks. Package.swift manifests are kept alongside so the modules
remain consumable by SwiftPM directly.

Shipping parity verified on the built app: LSUIElement true, category
unchanged, app-sandbox and files.user-selected.read-only intact, and no
Nightcap frameworks embedded (statically linked).

All 20 scenarios still pass. Domain coverage is 93.11%, down from 96.04%,
entirely because LaunchAtLoginStatus.init(SMAppService.Status) was
previously counted as covered by the test host app exercising the live code
path at launch, not by any test. That code now lives in NightcapClients, so
the number reflects real test coverage.

Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
Introduces the companion surface, built inside-out from the domain:

- MacState: the entire contract between the Mac and a companion. A plain
  value type, so companions can be built and tested long before a real
  transport exists.
- MacStateTransportClient: transport-agnostic interface with a working
  in-memory stub as its live value. Shipping a real transport means adding
  a network entitlement to a sandboxed App Store app whose listing promises
  zero network calls, so that stays a separate decision.
- CompanionFeature: one reducer driving both iPhone and Watch. Neither
  platform holds logic of its own.
- NightcapCompanionUI: shared SwiftUI for iOS and watchOS. The existing
  NightcapUI stays macOS-only because it is AppKit menu-bar code.
- NightcapPhone: the iOS app target.

NightcapDomain is now a multi-platform target (macOS, iOS, watchOS), which
is what the earlier purity work was for.

CompanionFeature.State distinguishes "waiting for the Mac" from "the Mac is
idle", so the UI never claims the Mac is asleep before any snapshot has
arrived. Transport failures surface a message rather than failing silently.
onDisappear cancels the subscription so a watch app is not holding a stream
open in the background.

ComposableArchitecture is linked once, via NightcapDomain. The companion UI
and phone app take it with link: false; linking it into each static
framework produced 7674 duplicate symbols.

Verified on the iPhone 17 Pro simulator via the accessibility tree: the app
shows "Keeping Mac Awake. 1 app active", and tapping Ghostty's toggle moves
it to "Idle. Sleep allowed" with Ghostty marked Paused.

24 scenarios pass, including 4 new companion scenarios.

Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
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.

1 participant