Skip to content

Add watchOS companion app - #10

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

Add watchOS companion app#10
ARamy23 wants to merge 5 commits into
Abdo-codes:mainfrom
ARamy23:feature/watch-companion-app

Conversation

@ARamy23

@ARamy23 ARamy23 commented Jul 28, 2026

Copy link
Copy Markdown

Stacked on #7, #8 and #9.

The watch app needed no new logic and no new views. CompanionFeature and NightcapCompanionUI were already built for iOS and watchOS in #9, so this is an entry point plus target configuration — which is the payoff from the domain-purity work in #8.

Runs independently of a companion iPhone app (WKRunsIndependentlyOfCompanionApp), since the companion talks to a Mac, not to the phone.

Verification — read this part

Verified: builds and links cleanly against the watchOS simulator SDK.

Not verified at runtime. This machine has no watchOS simulator runtime installed, so no watch simulator can be created and the app has never been launched. Installing a runtime is a multi-gigabyte download, so I left that as a deliberate decision rather than starting it.

The screen itself is the same CompanionView that was driven end to end on the iPhone 17 Pro simulator in #9, so what's untested here is the watchOS app shell, not the UI. Still — treat "watch app works" as unproven until someone runs it.

ARamy23 added 5 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
Adds the watchOS app target. It needed no new logic and no new views:
CompanionFeature and NightcapCompanionUI were already built for both iOS
and watchOS, so this is an entry point plus target configuration.

Runs independently of a companion iPhone app
(WKRunsIndependentlyOfCompanionApp), since the companion talks to a Mac
rather than to the phone.

Verified: builds and links cleanly against the watchOS simulator SDK.

NOT verified at runtime: this machine has no watchOS simulator runtime
installed, so no watch simulator can be created and the app has not been
launched. Installing a runtime is a multi-gigabyte download and is left as
a deliberate decision. The UI is shared with the iPhone app, which has been
driven end to end on a simulator, so the untested surface is the watchOS
shell rather than the screen itself.

Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
@ARamy23

ARamy23 commented Jul 28, 2026

Copy link
Copy Markdown
Author

Update: the watch app now runs.

It previously only built. Root cause of the install failure: the app declared neither WKWatchOnly nor WKCompanionAppBundleIdentifier, so watchOS rejected it. It is genuinely watch-only — its counterpart is a Mac, not the iPhone app — so INFOPLIST_KEY_WKWatchOnly is now set.

Verified on an Apple Watch Series 11 (46mm) simulator, watchOS 27: launches and renders "Keeping Mac Awake / 1 app active" with Ghostty listed and its toggle on.

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