Skip to content

fix(macos): restore CloudKit pull sync and stop CLI SIGABRT on exit#17

Merged
pseudobun merged 1 commit intomainfrom
fix/cli-cloudkit
Apr 12, 2026
Merged

fix(macos): restore CloudKit pull sync and stop CLI SIGABRT on exit#17
pseudobun merged 1 commit intomainfrom
fix/cli-cloudkit

Conversation

@pseudobun
Copy link
Copy Markdown
Owner

Summary

Two related production bugs surfacing as one user-visible problem: the CLI is unusable across machines.

Bug 1: Cross-machine data divergence

The Mac mini and MacBook show completely different sets of tosses despite both being signed in to the same iCloud account, both running the same release, and both pointing at the same `iCloud.lutra-labs.toss` container.

`release.yml:173-177` strips both `aps-environment` and `com.apple.developer.aps-environment` from the macOS app's signed entitlements before codesigning, with a comment claiming "we don't use APNs on macOS." That's wrong on a load-bearing point: `NSPersistentCloudKitContainer` (the engine SwiftData uses for `cloudKitDatabase: .private(...)` mode) needs `aps-environment` to receive CloudKit silent push notifications. Without it the OS never wakes the app for "remote changes happened" pushes, so the local SwiftData store never pulls from CloudKit. Each Mac becomes a write-only island whose store reflects only what that machine itself wrote.

I verified the parent app's embedded provisioning profile (`/Applications/Tossinger.app/Contents/embedded.provisionprofile`) authorizes `aps-environment: production`, so the entitlement can be present — the workflow just deletes it. The fix substitutes the value (development → production) instead of stripping the keys.

Bug 2: CLI SIGABRTs at process exit

`toss ls` consistently crashes after printing results with:

```
NSInternalInconsistencyException 'Invalid parameter not satisfying: bundleIdentifier...'
-[PKUserNotificationsRemoteNotificationServiceConnection initWithBundleIdentifier:]
-[PKPushRegistry _registerForPushType:]
-[NSCloudKitMirroringDelegate dealloc]
-[NSPersistentStore dealloc]
TossKit Container.deinit
ListCommand.run
```

Read bottom-up: the command finishes, prints results, exits → SwiftData tears down → `NSPersistentCloudKitContainer` deallocs → CloudKit cleanup tries to register for push types via `PKPushRegistry` → PushKit looks up the bundle identifier through some path that doesn't work for a sub-bundled `.app` helper invoked via `/opt/homebrew/bin/toss → /Applications/Tossinger.app/Contents/Helpers/toss.app/Contents/MacOS/toss` → `bundleIdentifier` resolves to nil → assertion fails → SIGABRT.

The CLI doesn't actually need CloudKit. It opens the same SQLite store as the main app via the app group, performs a single read or write, and exits. CloudKit sync is the main app's job.

Changes

`.github/workflows/release.yml` (`Prepare sanitized entitlements` step) — replace the two `Delete :aps-environment` PlistBuddy lines with `Set ... production`. Updated the misleading comment to explain why APNs is actually load-bearing for CloudKit silent push.

`Packages/TossKit/Sources/TossKit/Persistence/PersistenceStack.swift` — added a public `CloudKitMode` enum with two cases:

  • `.automatic` — wires the store to the private CloudKit database (current behavior)
  • `.disabled` — uses `ModelConfiguration.CloudKitDatabase.none`, preventing `NSPersistentCloudKitContainer` from being instantiated at all

`makeContainer` now takes `cloudKit: CloudKitMode = .automatic`, so the app and share extension are bytewise-equivalent at runtime through the default.

`tossinger/Support/TossEnvironment.swift` — passes `cloudKit: .disabled`. Doc comment explains the rationale (CLI doesn't await async push delivery anyway, and instantiating the CloudKit container in a sub-bundled helper SIGABRTs).

Same store file, same data; only the transport layer for the CLI changes.

Test plan

  • Build the CLI helper in Xcode after the TossKit + TossEnvironment edits — should compile cleanly
  • Run `toss ls` against the existing populated store — results print, process exits cleanly with no SIGABRT and no PushKit assertion in the logs
  • Run `toss add "https://example.com"\` and `toss delete ` — same expectation, no crashes
  • Run `toss ls` 10× in a row on the macOS box where the crash was reproducible — expect zero crashes
  • Build and run the main macOS app — behaves identically to before (default `.automatic` mode → same `.private(...)` configuration)
  • Cut a release with this branch and inspect the workflow log for the `Prepare sanitized entitlements` step — the dumped plist (`cat "${SANITIZED}"` already in the step) should now show `aps-environment: production` and `com.apple.developer.aps-environment: production`
  • Confirm `codesign --verify --strict --verbose=2` (already in the workflow at line 205) still passes — entitlements remain a subset of what the Developer ID profile authorizes
  • Install the new release on both Mac mini and MacBook, add a toss on one, wait ~30s, verify it appears on the other without manual refresh
  • Run `toss ls` on both machines after sync settles — they should converge

Notes

  • The two open PRs (this one + fix(ci): pin iOS Release to manual signing, stop minting dev certs #16 cert spam) touch different regions of `release.yml` (this one edits ~line 176, fix(ci): pin iOS Release to manual signing, stop minting dev certs #16 edits ~lines 437/450) so they shouldn't conflict whichever order they merge in.
  • The share extension stays on the default `.automatic` mode — iOS extensions don't have the CLI's PushKit instantiation problem since they run in the parent app's bundle context. Could be revisited if it ever proves problematic.
  • Existing `~/Library/Group Containers/group.lutra-labs.toss/default.store` files (which carry CloudKit sync metadata from prior `.private(...)` use) open cleanly with `.none` — opening a CloudKit-mirrored store from a non-CloudKit context is a supported pattern and a no-op on the metadata side. The next time the app runs with full CloudKit it picks up CLI-written rows via NSPersistentHistoryTracker and pushes them.

🤖 Generated with Claude Code

Two related production bugs that surface as one user-visible problem:
the CLI is unusable across machines.

1. Cross-machine data divergence. release.yml stripped both
   aps-environment keys from the macOS app's signed entitlements before
   codesigning, with a comment claiming "we don't use APNs on macOS."
   That's wrong on a load-bearing point: NSPersistentCloudKitContainer
   needs aps-environment to receive CloudKit silent push notifications.
   Without it, the OS never wakes the app for "remote changes happened"
   pushes, so the local SwiftData store never pulls from CloudKit. Each
   Mac became a write-only island whose store reflected only what that
   machine itself wrote, which is exactly why the Mac mini and MacBook
   showed completely different sets of tosses.

   The Developer ID provisioning profile already authorizes
   aps-environment: production, so substitute the value (development →
   production) instead of stripping the keys.

2. CLI SIGABRT on process exit. `toss ls` consistently crashed after
   printing results with a PushKit assertion raised inside
   NSCloudKitMirroringDelegate.dealloc → NSPersistentStore.dealloc when
   SwiftData tore down its CloudKit-backed model container at process
   exit. PushKit can't resolve a bundle identifier for a sub-bundled
   `.app` helper invoked via the brew cask symlink path.

   The CLI doesn't actually need CloudKit — it does one read or write
   against the local SQLite store and exits; sync is the main app's
   job. Add a CloudKitMode enum to TossPersistenceStack.makeContainer
   with .automatic (default, current behavior) and .disabled (uses
   ModelConfiguration.CloudKitDatabase.none). The CLI's TossEnvironment
   now passes .disabled, which prevents NSPersistentCloudKitContainer
   from being instantiated at all and removes the PushKit init from the
   dealloc path.

The main app and share extension are unaffected — they keep the
default .automatic mode and continue to own CloudKit sync. Same store
file, same data; only the transport layer for the CLI changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@pseudobun pseudobun merged commit 2e447ef into main Apr 12, 2026
3 checks passed
@pseudobun pseudobun deleted the fix/cli-cloudkit branch April 12, 2026 09:10
pseudobun added a commit that referenced this pull request Apr 12, 2026
🤖 I have created a release *beep* *boop*
---


##
[1.3.1](v1.3.0...v1.3.1)
(2026-04-12)


### Bug Fixes

* **ci:** pin iOS Release to manual signing, stop minting dev certs
([#16](#16))
([cd4df60](cd4df60))
* **macos:** restore CloudKit pull sync and stop CLI SIGABRT on exit
([#17](#17))
([2e447ef](2e447ef))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
pseudobun added a commit that referenced this pull request Apr 12, 2026
…19)

## Summary

v1.3.1 is dead on arrival: every launch SIGKILLs with \`Termination
Reason: Namespace CODESIGNING, Code 1, Taskgated Invalid Signature\`.
Diagnosed via side-by-side of the shipped binary's signed entitlements
vs. its embedded provisioning profile:

| Signed in v1.3.1 binary | Authorized by profile |
|---|---|
| \`aps-environment: production\` | **NOT PRESENT** |
| \`com.apple.developer.aps-environment: production\` | ✅ present |

The bare \`aps-environment\` key is the iOS convention. macOS uses only
the \`com.apple.developer.*\` namespace for developer entitlements, and
the Developer ID provisioning profile authorizes
\`com.apple.developer.aps-environment\` but not the bare iOS-shaped key.
amfi enforces "every restricted entitlement must be profile-backed" at
load time, so the bare key — which #17 started adding alongside the
correct one — causes amfi to SIGKILL the binary on exec.

\`codesign --verify --strict\` in CI didn't catch this because it only
validates structural integrity, not profile subset. That's amfi's job
and it only runs at load time on the end-user's machine.

## Changes

**\`.github/workflows/release.yml\`** — in the \`Prepare sanitized
entitlements\` step, **delete** the bare \`aps-environment\` key on
macOS releases instead of substituting it. Keep the \`Set
:com.apple.developer.aps-environment production\` line — that's the
macOS runtime key for CloudKit silent push delivery, and it IS in the
profile's authorized list.

## Test plan

- [ ] Merge this PR and let release-please open the v1.3.2 PR
- [ ] Merge v1.3.2 release PR
- [ ] Re-verify the \`Prepare sanitized entitlements\` log in the v1.3.2
release workflow — the dumped plist should show
\`com.apple.developer.aps-environment: production\` and NO bare
\`aps-environment\` key
- [ ] After release ships: \`brew upgrade --cask tossinger\` on both Mac
mini and MacBook
- [ ] Launch the app — should no longer crash with SIGKILL
- [ ] Add a toss on one machine, wait ~30s, verify it appears on the
other (cross-machine CloudKit push sync should work since
\`com.apple.developer.aps-environment\` is present)
- [ ] Run \`toss ls\` — should exit cleanly (CLI opt-out from #17 still
applies)

## Notes

- This PR reverses **only the iOS-shaped half** of the APNs entitlement
work from #17. The CLI CloudKit opt-out and the iOS cert-spam fix from
#16 are untouched.
- #17's \`toss/toss.entitlements\` source file still declares both keys
with value \`development\` — that's fine for local Debug Xcode builds
(Xcode automatic signing handles the iOS/macOS key split per-platform).
The release workflow is the only place we need to strip the iOS shape
for macOS distribution.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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