Skip to content

Add CloudKit transport and hotspot suggestion, both opt-in - #12

Open
ARamy23 wants to merge 19 commits into
Abdo-codes:mainfrom
ARamy23:feature/cloudkit-transport
Open

Add CloudKit transport and hotspot suggestion, both opt-in#12
ARamy23 wants to merge 19 commits into
Abdo-codes:mainfrom
ARamy23:feature/cloudkit-transport

Conversation

@ARamy23

@ARamy23 ARamy23 commented Jul 28, 2026

Copy link
Copy Markdown

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.yml ships exactly what it shipped before: sandbox + user-selected read-only. No network entitlement.
  • project.cloud.yml is a separate overlay adding the iCloud entitlements and linking the transport. It needs a real DEVELOPMENT_TEAM and a provisioned container.
  • MacStatePublisherClient.liveValue is a no-op and MacStateTransportClient.liveValue is still the stub — a default build behaves identically to today.

Merging this does not make Nightcap networked. Running xcodegen --spec project.cloud.yml does.

CloudKit transport

CloudKitMacStateStore moves a MacState through the user's own private 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 can't leave a companion showing a stale Mac forever.
  • A companion can't mutate the Mac directly; it writes an ObservationRequest record the Mac picks up.

Hotspot suggestion

Answers the original question that started all this — can the Mac tell your phone it needs a hotspot.

  • NetworkPathClient interface in the domain, NWPathMonitor implementation in NightcapClients.
  • shouldSuggestHotspot is true only when the Mac is both offline and actually being kept awake. A sleeping Mac losing Wi-Fi isn't worth a notification.
  • Duplicate path reports are ignored — NWPathMonitor fires on every interface change.
  • MacState decodes 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:

It is still a network connection. Enabling the companion apps means the Mac app is no longer network-free.

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

ARamy23 added 9 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
- 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
@ARamy23

ARamy23 commented Jul 28, 2026

Copy link
Copy Markdown
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. MacStateRecordCoding extracts the payload format and the conflict decision out of CloudKitMacStateStore, and lives in the domain rather than the transport, since it describes the wire format of a MacState.

Six scenarios now cover:

  • a published snapshot round-tripping without loss
  • a stale local copy re-applying onto the server's record (the Mac is the only writer, so a conflict means our copy was stale — not a competing edit)
  • a conflict with no server record surfacing the failure rather than being swallowed
  • "no Mac has published" being distinct from "the Mac is idle"
  • a corrupt payload throwing instead of rendering a default state that would wrongly claim the Mac is asleep

Still unverified: the actual CKDatabase calls, subscription registration, and the entitlement path. Those need a real team and a provisioned iCloud container.

ARamy23 added 10 commits July 29, 2026 01:58
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.
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