Add CloudKit transport and hotspot suggestion, both opt-in - #12
Open
ARamy23 wants to merge 19 commits into
Open
Conversation
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
- Tests/PhoneTests: snapshot tests pinning the companion screen in each state a user can land in (kept awake, idle, waiting for the Mac, transport failure), recorded on a fixed device so a different simulator cannot silently rewrite them. - .maestro/companion-status.yaml: an end-to-end flow asserting the app shows what the Mac is doing, labels a paused app as paused, and that pausing from the phone moves the Mac to "Idle. Sleep allowed". - CI gains two jobs: one building both companion apps and running the snapshot tests, one booting a simulator and running the Maestro flows. The existing macOS job now also enforces the domain coverage gate. Local execution status, honestly: - The 24 macOS scenarios pass and the coverage gate passes. - The snapshot tests compile and the target builds, but Xcode reports "No result" for all four with zero recorded issues, so they are NOT passing locally. TEST_HOST and BUNDLE_LOADER are set correctly and the bundle builds; the cause is not yet identified. They are wired into CI so the next run will say whether this is local-only. - The Maestro flows have NOT been run. Maestro requires Java, which is not installed on this machine. The same assertions were performed by hand against the running app via the accessibility tree, so the flow content is known-good, but the flow file itself is unexecuted. Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
Completes the companion story, with the networking kept behind an explicit switch. CloudKit transport (NightcapCloudTransport): - CloudKitMacStateStore moves a MacState between the Mac and its companions through the user's own private CloudKit database. No server we operate, nothing shared between users. - The Mac is the only writer, so a serverRecordChanged conflict means a stale local copy; the store re-applies onto the server record rather than dropping the update. - Companions get push via CKQuerySubscription, with a 30s poll as a floor so a missed notification cannot leave a companion showing a stale Mac forever. - A companion cannot mutate the Mac directly. It writes an ObservationRequest record the Mac picks up. Hotspot suggestion: - NetworkPathClient reports whether the Mac has a usable network path. The interface lives in the domain; the NWPathMonitor implementation lives in NightcapClients. - MacState gains hasLostNetwork, and shouldSuggestHotspot is true only when the Mac is BOTH offline and actually being kept awake. A sleeping Mac losing Wi-Fi is not worth a notification. - Duplicate path reports are ignored, since NWPathMonitor fires on every interface change. - MacState decodes snapshots written before this field existed, defaulting to "network fine" rather than showing a false alarm. Networking is opt-in, deliberately: - project.yml is unchanged in what it ships: sandbox plus user-selected read-only, no network entitlement. - project.cloud.yml is a separate overlay that adds the iCloud entitlements and links the transport. It needs a real DEVELOPMENT_TEAM and a provisioned container, so it cannot be built with ad-hoc signing. - MacStatePublisherClient.liveValue is a no-op and MacStateTransportClient.liveValue is still the stub, so a default build behaves exactly as before. PRIVACY.md rewritten to describe the feature honestly: the data goes to the user's own iCloud account rather than to us, and enabling it does mean the Mac app is no longer network-free. 29 scenarios pass, including 5 new ones for network loss and legacy snapshot decoding. NOT verified: no CloudKit code path has been exercised at runtime. That needs a real team and a provisioned iCloud container, neither of which is available here. What is verified is that it compiles and that the domain logic around it is covered. Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
Three verification gaps closed, with the root causes rather than workarounds. Snapshot tests now run and pass (5/5). Three separate causes: - The iOS test bundle was never built. Xcode's autocreated schemes did not include it, so tests were listed but reported "No result" with no errors. Schemes are now defined explicitly in project.yml. - TEST_HOST pointed at $(BUILT_PRODUCTS_DIR)/NightcapPhone.app while PRODUCT_NAME: Nightcap made the real product Nightcap.app, so the host never resolved. The PRODUCT_NAME override is gone; the user-visible name comes from CFBundleDisplayName. - The test runner segfaulted in objc_copyClassList during +[XCTestCase _allSubclasses], faulting on preview thunk metadata. This happens on a simulator runtime older than the SDK (iOS 26.5 runtime, iOS 27 SDK). ENABLE_PREVIEWS is off for the modules, the test bundle and the apps, and snapshot tests run on an iOS 27 simulator. The snapshot tests were also weak as written: the view sends .onAppear, which hit the unimplemented test dependency and recorded an issue. They passed only by timing luck. Each store now gets a stubbed transport. The watch app launches and renders. It declared neither WKWatchOnly nor WKCompanionAppBundleIdentifier, so installation was rejected. It is genuinely watch-only: its counterpart is a Mac, not the iPhone app. Snapshot tests are hosted-app-free on purpose. Hosting them in NightcapPhone crashed during test bootstrap, and rendering a view needs no running app. Maestro flow updated: the first assertion matched the springboard app icon labelled "Nightcap" and so passed even when the app had died. It now waits on app-specific content instead. Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
Two subagents audited the suite: one applied mutation operators to the production code to find tests that would not fail, one audited the tier distribution against the test pyramid. Both reports drove this commit. Surviving mutants, now killed: - Resuming a watched app that has since QUIT re-acquired nothing, but no scenario covered it. Mutating the guard to always insert survived the suite. The real bug that hides: pause Ghostty, quit Ghostty, resume from the menu, and the Mac is wake-locked for an app that is not running. - Quitting was only ever tested from an idle state, so removing `state.assertionHeld = false` was invisible, and no scenario asserted that release happens BEFORE terminate. Both mutants survived. A quit that terminates first can leave a kernel assertion alive past process exit. TestEnv now records effect ordering so the invariant is asserted directly rather than inferred from a count. - launch-at-login had exactly one scenario. .requiresApproval being reported as enabled, and the unknown/error fallback path, both survived. Added. - CompanionFeature never checked that a stale failure banner clears when the Mac becomes reachable again. - LaunchAtLoginStatus.isOn had no direct test; .requiresApproval returning true from it would have gone unnoticed. CloudKit is now testable without a provisioned container. The payload format and the conflict decision are extracted into MacStateRecordCoding, a pure type living in the domain rather than the transport, since it describes the wire format of a MacState. Six scenarios cover round-tripping, the stale-copy re-apply, the no-server-record failure path, "no Mac has published" versus an idle Mac, and a corrupt payload failing loudly instead of rendering a wrong Mac. The unit suite moved into the domain package and runs through SwiftPM: 42 scenarios in 0.19s with no Xcode, no simulator and no scheme. The coverage gate follows it, reading SwiftPM's codecov JSON, and CI gains a fast unit job that does not need a simulator. Domain coverage 94.48% (479/507), up from 93.11%. Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
Author
|
Update: the CloudKit logic is now tested, without a provisioned container. The description says no CloudKit code path was exercised. The parts that are ordinary logic no longer need one. Six scenarios now cover:
Still unverified: the actual CKDatabase calls, subscription registration, and the entitlement path. Those need a real team and a provisioned iCloud container. |
Closes the two gaps the pyramid audit ranked highest after the CloudKit work. NightcapUI had zero coverage across ~350 lines of SwiftUI, despite the menu bar being Nightcap's entire interface. Nine snapshot tests now pin the branches the reducer tests already exercise but cannot see rendered: the Mac held by one app versus several, idle, an empty watched list, running/idle/ paused apps shown distinctly, candidate lists with and without an already-watched app, and launch-at-login both enabled and awaiting approval. They render through NSHostingView rather than a running app, so they need no Xcode scheme and no simulator, and run with `swift test` alongside the domain suite. Note they pin shape and layout rather than colour: outside a real Menu context the status tint renders monochrome, so the three app states are distinguished by icon shape. MacState.shouldSuggestHotspot was only covered transitively through CompanionFeature. It now has a direct parameterised test over all four awake/offline combinations, since a two-boolean AND is exactly where an inverted condition hides, plus a scenario checking an offline Mac still reports which app is keeping it awake so the nudge carries context. 44 domain scenarios and 9 menu snapshots, all through SwiftPM. CI runs both in the simulator-free unit job. Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
…linking
Adds a fastlane setup, adapted from the Al-Najd project, that provisions
Nightcap for CloudKit under a personal Apple developer account without a
human in the loop for anything the Apple APIs allow to be automated.
Lanes:
register_this_mac registers this Mac (reads the provisioning UDID)
ensure_icloud_container creates the container and associates it with the
iOS/watchOS App IDs
enable_icloud_capabilities flips ICLOUD + PUSH on all three App IDs
fetch_dev_profile mints the development profile
generate_cloud_project regenerates the project with the entitlement
build_mac_with_cloudkit builds signed
provision_icloud the whole chain
Almost everything runs on the App Store Connect API key, which needs no 2FA.
Two exceptions, both Apple limitations rather than choices:
- iCloud containers are not exposed by the App Store Connect API at all. They
live in the legacy portal, which only accepts an Apple ID session, so
container creation needs FASTLANE_SESSION from `fastlane spaceauth`.
- Spaceship refuses `associate_cloud_containers` for Mac App IDs outright, so
the macOS App ID must be associated in Xcode or on the portal website. The
iOS and watchOS App IDs are associated automatically.
Verified: the container exists, is associated with the iPhone and Watch App
IDs, and the minted iPhone development profile carries
iCloud.com.ahmedramy.nightcap.
Nothing personal or secret is committed. Identifiers and credentials live in
fastlane/.env (gitignored, chmod 600), the generated signing overlay is
project.cloud.local.yml (gitignored), and .gitignore now also covers
*.mobileprovision, which sigh drops into the repo root.
Also fixes a latent linking bug the signed build exposed: the app linked the
SwiftPM packages directly AND linked NightcapDomain, a static framework that
already contains those objects, producing thousands of duplicate symbols. The
packages are now link: false wherever a static framework already carries them.
44 domain scenarios and 9 menu snapshots pass; domain coverage 94.48%.
Claude-Session: https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw
The domain was already gated at 80%, but the SwiftUI layer had no threshold and no measurement. This adds both, and the tests to clear them: UI coverage is now 90.25% and the share of it reached by the snapshot suites alone is 80.81%, against targets of 80% and 60%. Most of the work was making presentation logic reachable at all. Three kinds of code were structurally untestable: * NSOpenPanel/NSAlert plumbing in MenuAppPicker, which blocks on a real window server. Every decision it made moved to MenuAppPickerLogic; the file that remains is pure plumbing and is excluded by the gate, the same way the domain gate excludes the IOKit and NSWorkspace adapters. * Action closures nested inside SwiftUI `Menu` content, which SwiftUI does not evaluate until the menu is opened. These became named methods, so a test can invoke the intent and assert the reducer saw the right action for the right app. * Private computed properties holding the companion's wording. Extracted into MacStatusPresentation and WatchedAppRowPresentation, which is where that logic belonged anyway: it is the only channel telling you whether your Mac is awake. NightcapCompanionUI had no test target at all and now has one, running on the host so it needs no simulator. Also adds a fastlane lane to register attached iPhones and Watches from devicectl, and pins .ruby-version to the Ruby fastlane is installed under.
The container, entitlements and profiles were all provisioned, but nothing was wired to them, so a device build would have looked like it worked while being entirely fake: * MacStateTransportClient.liveValue is the stub, so the phone and watch would have rendered the canned sample apps (Ghostty, Xcode, zoom) as if they were the real Mac. * MacStatePublisherClient.liveValue is a no-op, so the Mac published nothing. Even with the companions reading correctly, they would have sat on "Waiting for your Mac" forever. * Only the Mac target carried the iCloud entitlement. The companions would have signed fine and then found no container at runtime — which presents identically to the Mac never having published. All three are now fixed behind NIGHTCAP_CLOUDKIT, set only by the CloudKit overlay. The default build is untouched and stays offline, which is what the current App Store listing and PRIVACY.md describe. Adds MacStatePublisherClient.cloudKit, the missing Mac-side adapter: the store could already publish, but nothing exposed it as the client the reducer depends on. Also adds a provision_devices lane that registers attached devices and then re-mints the iPhone and Watch profiles, failing if a profile does not carry every attached device. Registering a device does not retro-fit it into profiles minted earlier, so the order matters.
Publishing was wired to a single reducer case — the network change — so it only ever fired when connectivity flipped. Adding an app, pausing one, an app launching or quitting all change what the phone and watch display and none of them reached the companions. In practice a companion showed whatever the Mac was doing when it connected and then froze, which is indistinguishable from a broken transport. Publishing now hangs off the state instead of off individual cases, via onChange on a companion-visible snapshot, so nothing has to remember to ask. The snapshot deliberately excludes macState.lastUpdated, which is Date() computed on every read and would make any equality check meaningless — and would also publish on every action, burning CloudKit quota on updates no one can see. Five scenarios pin this; the first three fail against the previous code, verified by reverting the fix and re-running. Scenario 5 pins the other direction: a change only the Mac's own menu renders must NOT publish. Also stops swallowing publish failures. `try?` meant a completely broken companion link produced no error, no log, and no visible difference from a working one — during this work that made it impossible to tell whether CloudKit was doing anything at all. Failures now log; they stay out of the menu bar, which is not the place to report them.
The CloudKit container identifier was hardcoded to one specific Apple
account's container. Since a container belongs to the account that created
it, anyone building with their own team — which is the only way to run this,
because you cannot sign for someone else's container — asked the server for
a container they had no entitlement to and got:
Couldn't get container configuration from the server for container
"iCloud.com.abdocodes.nightcap"
This was invisible until the previous commit stopped swallowing publish
errors; it is the first thing the new log printed on a real device.
The identifier is now derived from the bundle identifier, so it follows
whoever signed the build with no configuration. The companions' .phone and
.watch suffixes are stripped, because all three apps share one container —
which is also why CKContainer.default() cannot be used: it would look for
iCloud.<bundleID> and send the companions to a container that does not
exist.
Reading the signed entitlement would be more direct, and was tried first,
but SecTaskCopyValueForEntitlement is macOS-only: it compiled on the host
via SwiftPM and failed the iOS build.
Also logs a successful fetch on the companion side. Success and failure were
previously both silent, which is precisely how the container mismatch
survived being "verified".
…fline Two gaps behind the hotspot nudge. The Mac only knew whether it had a network, not what kind. So a Mac already tethered to a phone reads as "connected" while having no network of its own, and the moment it dropped, the companion would tell the user to enable the Personal Hotspot they were already using. NetworkPathClient now reports a NetworkConnection — none, wifi, wired, hotspot, other — and the nudge is suppressed only for hotspot. The classification uses NWPath.isExpensive rather than the interface type: tethering over Wi-Fi still reports .wifi, so interface type alone cannot tell a hotspot from an ordinary network. Apple sets isExpensive for both cellular and Personal Hotspot, which is exactly the set worth suppressing. wouldBenefitFromHotspot is deliberately "not already tethered" rather than "is offline". The two differ for .other, which means unknown — a Mac that has not reported a type yet, or an older one that never will. Treating unknown as "no hotspot needed" would silently swallow the nudge for exactly those users. The first version got this wrong and the existing suite caught it. Second gap: the companion only showed an in-app banner, which requires the app to already be open — precisely when the user least needs telling. It now posts a local notification, edge-triggered on the transition into needing a hotspot. Level triggering would fire every 30 seconds for as long as the Mac stayed offline, since the transport re-delivers the same snapshot on each poll. MacState gains `connection`, defaulted from `hasLostNetwork` in both the initialiser and the decoder, so older Macs and existing callers behave exactly as before.
Closing the lid sleeps the Mac regardless of any assertion Nightcap holds — PreventUserIdleSystemSleep covers idle sleep only, and macOS exposes no way to veto lid close. So the case the companion most needs to report is exactly the one the Mac cannot report itself. Worse, it did not look like a failure from the phone: a sleeping Mac leaves its last record in CloudKit, so fetches kept succeeding and returning the same snapshot. The companion showed a confident, minutes-old picture of a Mac that was no longer there. Silence is the only signal such a Mac can send, so the companion now judges contact from the Mac's own lastUpdated rather than from whether a fetch succeeded, and alerts once on crossing the timeout. Three minutes is several missed 30-second poll cycles: long enough not to fire on a hiccup, short enough to catch a closed lid. Contact loss deliberately outranks the hotspot nudge. If the Mac has gone quiet its network state is unknown and the snapshot is stale, so suggesting a hotspot would be a guess — and two notifications for one event. Nothing is claimed before the first report: a fresh state's lastUpdated is distantPast, which is stale by any measure, but "lost contact" before ever making contact would be a lie.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #7, #8, #9, #10 and #11. This is the one that needs a real decision from you, not just a code review.
Completes the companion story — with the networking kept behind an explicit switch.
Networking is opt-in, deliberately
I flagged repeatedly that turning Nightcap into a networked app contradicts its App Store listing. So the default build is unchanged:
project.ymlships exactly what it shipped before: sandbox + user-selected read-only. No network entitlement.project.cloud.ymlis a separate overlay adding the iCloud entitlements and linking the transport. It needs a realDEVELOPMENT_TEAMand a provisioned container.MacStatePublisherClient.liveValueis a no-op andMacStateTransportClient.liveValueis still the stub — a default build behaves identically to today.Merging this does not make Nightcap networked. Running
xcodegen --spec project.cloud.ymldoes.CloudKit transport
CloudKitMacStateStoremoves aMacStatethrough the user's own private database. No server we operate, nothing shared between users.serverRecordChangedconflict means a stale local copy — the store re-applies onto the server record rather than dropping the update.CKQuerySubscription, with a 30s poll as a floor so a missed notification can't leave a companion showing a stale Mac forever.ObservationRequestrecord the Mac picks up.Hotspot suggestion
Answers the original question that started all this — can the Mac tell your phone it needs a hotspot.
NetworkPathClientinterface in the domain,NWPathMonitorimplementation inNightcapClients.shouldSuggestHotspotis true only when the Mac is both offline and actually being kept awake. A sleeping Mac losing Wi-Fi isn't worth a notification.NWPathMonitorfires on every interface change.MacStatedecodes snapshots written before this field existed, defaulting to "network fine" rather than a false alarm.PRIVACY.md
Rewritten to describe this honestly, including the part that isn't flattering:
Verification
29 scenarios pass (5 new: network loss while held, while idle, duplicate reports, recovery, legacy snapshot decode).
Not verified: no CloudKit code path has been exercised at runtime. That needs a real team and a provisioned iCloud container, neither available here. What's verified is that it compiles and that the domain logic around it is covered.
https://claude.ai/code/session_01WGsVLm7Vs83QN4o1tCaCLw