The demo app for the series iOS Notifications, End to End on Medium. Start with Part 0, the map of the whole series. One SwiftUI app with five tabs, each a real product scenario, and two notification extensions.
| Tab | Scenario | Shows |
|---|---|---|
| Status | Settings inspector | Permission, provisional, every UNNotificationSettings value, Low Power Mode, Background App Refresh, a shared event log |
| Habits | Habit tracker | Local notifications, Done / Snooze / Note actions, the 64-pending limit (rolling window), interruption levels |
| Orders | E-commerce | APNs + FCM tokens, order updates by push, deep links, Service Extension image, Content Extension card, a driver message as a communication notification (sender photo + Reply) |
| Sync | Silent update | content-available background pushes |
| Calls | Calling app | PushKit VoIP token, CallKit incoming call |
Targets: NotifyLab (iOS 17+, Swift 6), NotifyLabService (Notification Service Extension), NotifyLabContent (Notification Content Extension). They share data through the App Group group.<prefix>.notifylab. Every ID comes from one setting, BUNDLE_ID_PREFIX in project.yml.
git clone https://github.com/Sa3doola/NotifyLab.git
cd NotifyLab
open NotifyLab.xcodeproj # run the NotifyLab scheme on any iOS 17+ SimulatorThen send a push to the Simulator without any server:
xcrun simctl push booted payloads/order-shipped.apnsOr drag any .apns file from payloads/ onto the Simulator window.
The project is generated from
project.yml. After editing it, runxcodegen generate.
- IDs. One command switches every ID (bundle IDs, App Group, payloads, tool configs) and regenerates the project:
Then set
./tools/set-bundle-prefix.sh com.yourname
DEVELOPMENT_TEAMinproject.ymlto your Team ID and runxcodegen generate. (Picking your team in Xcode also works, until the nextxcodegen generateresets it.) - APNs key. Go to developer.apple.com ▸ Keys ▸ + ▸ Apple Push Notifications service. Download the
.p8(you can only do this once) intosecrets/. - Env file.
cp tools/.env.example tools/.envand fill inTEAM_ID,KEY_ID,KEY_PATH,BUNDLE_ID, andDEVICE_TOKEN(copy it from the Orders tab). - Check the key (no device needed):
node tools/check-key.mjsshould print400 BadDeviceToken ✓ key acceptedfor sandbox and production.403 InvalidProviderTokenmeansKEY_ID,TEAM_IDorKEY_PATHis wrong. - Send:
./tools/apns.sh payloads/order-shipped.apns # alert → Service Extension adds the image
./tools/apns.sh payloads/driver-message.apns # communication notification: driver's photo + Reply
./tools/apns.sh payloads/marketing-passive.apns marketing # passive, priority 5
./tools/apns.sh payloads/silent-sync.apns background # silent push → Sync tab
./tools/apns.sh payloads/voip-call.json voip # PushKit → CallKit call screen (device)The Simulator gets a real sandbox token (Xcode 14+ on Apple silicon or T2 Macs), so apns.sh works there too.
Shell variables override tools/.env, so you can change one thing per send:
COLLAPSE_ID=order-1042 ./tools/apns.sh payloads/order-out-for-delivery.apns # replaces the last order update
EXPIRATION=0 ./tools/apns.sh payloads/order-shipped.apns # try once, don't storeproject.ymlalready links FirebaseMessaging. DownloadGoogleService-Info.plistfrom Firebase ▸ Project settings ▸ Your apps, put it insecrets/, and build again (a build step copies it into the app). Without it, the app still builds and FCM stays off.- Upload your
.p8in Firebase ▸ Project settings ▸ Cloud Messaging ▸ Apple app configuration. - Put the service-account JSON in
secrets/and setFCM_SERVICE_ACCOUNTandFCM_TOKENintools/.env, then:
node tools/fcm.mjs payloads/order-shipped.apnsFirebaseBridge.swift switches FCM on when the plist is in the app, and logs a message to the Status tab when it isn't.
node tools/registry-server.mjs # POST /devices, GET /devices, POST /sendIn the app, go to Orders ▸ "Your server" and enter http://localhost:8080 (Simulator) or your Mac's IP (device).
POST /send {"file":"payloads/order-shipped.apns"} sends to every stored device. Before sending it prunes devices that haven't checked in for 60 days; after, it deletes tokens APNs answers with 410 (unless the device re-uploaded the token after APNs' timestamp).
Import tools/NotifyLab.postman_collection.json. Set Settings ▸ HTTP version ▸ HTTP/2, because APNs refuses HTTP/1.1. Paste the output of node tools/jwt.mjs into the jwt variable.
| Scenario | simctl push / drag & drop |
Real sandbox push to Simulator | iPhone |
|---|---|---|---|
| Local habit reminders + actions | n/a (scheduled in-app) | n/a | ✅ |
| Alert push, deep link | ✅ | ✅ | ✅ |
| Content Extension (long-press card, Call driver) | ✅ | ✅ | ✅ |
| Service Extension (image) | ❌ doesn't run | ✅ | |
| Communication notification (sender photo, Reply) | ❌ doesn't run (needs the Service Extension) | not tested | ✅ |
| Silent push | ❌ rejected: "no user visible content" | ✅ | |
| VoIP (PushKit) | ❌ | ✅ | |
| Critical alerts | ❌ | ❌ | needs Apple's entitlement |
Also worth knowing: the Simulator's APNs token was 80 bytes (160 hex), the iPhone's 32 bytes (64 hex). Never assume a length.
Verified with Xcode 27, the iOS 27 Simulator and an iPhone on iOS 27.
NotifyLab/App/ AppDelegate (token callbacks, silent push), AppModel
NotifyLab/Notifications/ Permission, HabitScheduler, Categories, Router, PushTokenStore,
FirebaseBridge, VoIPService, OrderStore (+ SyncStore)
NotifyLab/Features/ The five tabs
NotifyLabService/ Notification Service Extension
NotifyLabContent/ Notification Content Extension (SwiftUI card)
Shared/ Identifiers + App Group store, compiled into all three targets
payloads/ .apns files
tools/ apns.sh, check-key.mjs, jwt.mjs, fcm.mjs, registry-server.mjs, Postman collection
articles/ The tutorial drafts, figures and screenshots
The code is under the MIT License. The articles, figures, tables and screenshots in articles/ are under CC BY 4.0: share and adapt them freely, with credit.