Overview
The general architecture of the daemon along with the other pieces it forms a complete system with.
Client Orchestration
The daemon
/usr/libexec/SkyglowNotificationsDaemon is the
core process, started at boot as com.skyglow.snd.
It is the primary orchestrator of the whole operation chain,
it holds and manages the TCP connection, and acts as an
intermediary between database, the
SGPlatform implementation and its own operations,
by exposing its services through a protected
control channel.
The native receiver. It intercepts push
registration, is the final actor in the delivery process, and
reports application lifecycle changes back to the daemon (see
SGPlatform).
The preference bundle is a PreferenceLoader pane inside Settings. It holds no administrative logic of its own, but is one of the clients allowed to talk to the daemon over the control channel to perform actions through its interface such as registration, profile management, and global on/off. Most of its actions require the daemon to be running, it itself just being a frontend to you, in order to be able to control SGN. A PreferencePane is chosen for simplicity, but any other app/process could be implemented to do the same.
Platform abstraction
The daemon does not inherently know
SpringBoard or
usernotificationd exists. Deliveries, key
storage, network state, power management, and any other system
that behaves different on each platform is all reached through
SGPlatform, the host abstraction class. This allows the implementation
of certain systems to become OS specific, while keeping the
caller and rest of the code unaffected, without giving up
version determined implementations or features. Bringing the
possibility of multi version or platform support clean and
easy.
This page covers how SGN works on iOS 4 through 17, and OSX 10.8 through 26 respectively. For the wire protocol itself, see the Protocol Reference.
The Control Channel
The IPC foundation that powers every message and coordination between the daemon and related parties.
Transport
With a decently complex daemon such as this one, a solid IPC system is needed in order to coordinate with the system appropiately. For this, the control channel is built directly on Mach messages. The whole primitive is, a server registers a named service port with the bootstrap server, clients look the name up and send fixed layout messages to it, allowing bidirectional communication. This is needed on systems where the execution space of the daemon cannot trigger system actions, and needs to communicate which an external helper such as SpringBoard delivery node:
iOS
| Service | Server | Clients |
|---|---|---|
com.skyglow.sgn.control.daemon |
Daemon | SpringBoard, Preference Pane |
com.skyglow.sgn.control.springboard |
SpringBoard | Daemon |
macOS
| Service | Server | Clients |
|---|---|---|
com.skyglow.sgn.control.daemon |
Daemon | sgnctl |
com.apple.usernoted.client |
Apple, external | Daemon calls out to it for delivery, via SGPlatform |
OSX does not have a SpringBoard equivalent, so the daemon directly talks to the system's notification process, via its own mechanism unrelated to SGN.
All communication here is internal, the use of "server" or "client" do not imply external communication.
Message format
Every message is one fixed structure appended to the Mach header, validated on receipt:
| Field | Size | Description |
|---|---|---|
| Magic | 1 B | Always 0x43 |
| Version | 1 B |
Channel protocol version, currently 0x01
|
| Flags / Type | 1 B + 1 B | Message flags and the request or response type |
| Event type | 2 B | Set on event deliveries |
| Error code | 2 B | One of nine typed error codes on failure responses |
| Request ID | 8 B | Correlates a response to its request |
| Subscription ID | 8 B | Identifies an event subscription |
| Payload length + payload | 4 B + ≤4096 B | Typed payload struct that hard size caps per field |
Payloads are fixed C structs per message type, bundle identifiers are capped at 256 bytes, tokens at 48, push payloads at 3072, error detail strings at 256 and identifier strings are restricted to a safe character set before any handler sees them.
Requests, responses, events
A client tags each request with a sequential request ID and a
timeout, the completion fires exactly once with either the
peer's response or a typed error. Independently of
request/response, a client may subscribe to an event type and
receive deliveries until it unsubscribes, the daemon publishes
a STATE_CHANGED event carrying a full status
snapshot on every state machine transition, which is how a
subscribed client such as the Preference Pane stays live
without polling.
Lifespan and recovery
Peer hangup is detected by the kernel, each side requests a "death" notification on the port it holds, so when a process exits the other side learns immediately. A client that finds its peer gone keeps accepting requests, queues them, and replays the queue once the service reappears, stopping a channel cancels everything pending with an unreachable error rather than leaving callers hanging.
Authorization
Every inbound message carries the kernel's UID and PID for the process, values a sender cannot fake. The PID is resolved to its executable path and checked against the allowlist of trusted binaries. This ensures that only UID 0 (root) or processes in the pre aprooved list of allowed paths can connect to the control channel.
The State Machine
Every part of the external connection process is one explicit transition, every possible state the daemon can encounter itself in is pre aprooved.
Transitions
Transitions pass through a static legal transition table before they execute so an illegal transition is rejected and logged instead of corrupting state.
States
| State | Meaning |
|---|---|
| Starting | Process initialising and may transition anywhere |
| Disabled |
Process is disabled, only local operations such as
SGControlChannel
are executed, no network activity occurs
|
| IdleUnregistered | No profile configured, same as Disabled until profile is registered and selected |
| ErrorBadConfig | Profile missing its server address or certificate, same as Disabled until fixed |
| ResolvingDNS | Looking up the server endpoint |
| Connecting | Opening the pinned TLS connection |
| Registering | First time registration in progress |
| Authenticating | Challenge response login in progress |
| Connected | Live, the daemon is sucesfully connected to the server |
| BackingOff | Waiting out the retry delay after a connection failure |
| IdleNoNetwork | No usable network, waiting on reachability, same as Disabled until connection is established |
| IdleCircuitOpen | Circuit breaker tripped after eight consecutive connection failures, holds for an hour, or until an event occurs that resets it |
| ErrorAuth | Server rejected credentials, re registration required, same as Disabled until restarted or profile is re added |
| ErrorVersionMismatch | Server requires a newer client, same as Disabled until restart |
Generation Counter
Every transition increments a counter, and every asynchronous continuation captures the generation it was started under and checks before acting. A timer from a previous connection attempt that fires late is disregarded. This makes the runnable from many sources at the same time, reachability callbacks, control channel commands, protocol events, and power events all fall into the same serialized event handler.
Watchdogs and entry actions
States that wait for the network start a deadline when entered. If the deadline fires in the same generation, it injects the matching failure event, so no state can hang forever. Entry actions run on a serial queue after the transition finished. The machine also gates itself, states that are working or waiting on the network needed timers running. The states with nothing to wait for shut all of that down, so an inactive daemon does not use resources. Reachability monitoring is the exception, as it runs continuously in every state, so the daemon always learns the moment the network changes and leaves the no network state on its own.
Connection & Keepalive
The whole connection process from finding the server to checking for and remaining connected.
DNS resolution
The DNS system is simple, a result newer than an hour is used directly, an older one is still used immediately, but a one time refresh runs in the background, ensuring instantanious connection and new data. Only with no cache at all a normal DNS request is waited for before connecting. Resolved IP runs through standard checks. The cache is refreshed after every authentication and every failed connect, and deleted when a connection failure indicates the cached endpoint is wrong, forcing new data on the next attempt.
Reconnection Backoff
Consecutive failures double the retry delay from 2 s to
600 s, plus 0 to 5 s of random jitter. A
retry_after value in the server's disconnect
frame is used when it is longer than the computed delay, not
past the max though. After eight consecutive failures a
circuit breaker goes up and the daemon stops the fast retry
loop and now waits for an hour, or until the
network returns or the
device wakes, which ignore the wait, retry
immediately and reset the failure count.
The Keepalive growth algorithm
Since every ping-pong wakes the AP, the interval between pings should be as long as the current network path will allow without shutting it down. Since the value cannot be known in advance, the daemon "learns" it with a four stage algorithm, bounded between 600 s and 3600 s on Wi-Fi or 1680 s on cellular, starting at 900 s.
| Stage | Behavior |
|---|---|
| Initial growth | Every acknowledged ping grows the interval by 300s, each failure reverts to the last good value and drops to refined growth. |
| Refined growth | Moves upward in 120s chunks, reaching the value that previously failed moves back to initial growth, which means a failure reverts and into a steady state. |
| Steady state | Holds the working interval, changing only when the path also changes, later success below the proven best restarts growth. |
| Backoff | Each failure halves the interval until a ping succeeds again, then growth restarts. |
Every growth chunk carries ±20s of random variance so probes never synchronize with the NAT timeout boundary, and a high growth remembers the best interval the path has proven, if steady state is more than 330s below the mark, the network has changed and growth starts over. The learned interval is persisted per network type, so a daemon restart resumes from what the path already has saved rather than relearning from scratch. Switching between Wi-Fi and cellular swaps in that network's own saved algorithm state.
Sleep & Persistent Connection
Pings use a timer, which does not fire while the device is suspended, so before the device sleeps the daemon also uses a scheduled RTC wake sized to the current interval so the connection is still maintained. On wake or on a network change while connected, the daemon does not think the socket is still real, but rather it sends a ping, and if no pong arrives within the timeout it records the failure into the growth algorithm and stops the connection so the state machine can rebuild it. Probes are coalesced so only one can be in flight and a stale unanswered ping discovered by the regular timer triggers the same probe path.
Registration & Tokens
The whole app side token registration implementation and similar.
Device registration
A profile with no device address is unregistered, and registers the first time it connects. The keypair is generated ahead of time, then the daemon runs the exchange from the Protocol Reference. On successful register, it saves the address the server returns to the profile and the private key to the keychain, then clears its working copy of the key. The process contains proper fallbacks in order to not leave the profile half done.
A profile can have an optional registration identity, which is a client certificate needed for servers that gate registration. It is only used during registration as an attestation of identity. Registration rejections carry a server reason code. An authorization rejection stops the FSM, and anything else backs off and retries.
App tokens
Each app using SGN gets its own token generated locally. It is built from the profile's server address and a random secret, so it needs a configured profile, not a registered device. When a control channel client requests one for a bundle identifier, the daemon returns it and records the app in its registry. With no profile there is no server address to build from, meaning a token request fails, the daemon returns an appropiate error, and the requester handles it appropiately. Any change rebuilds the routing filter sent to the server.
iOS Specific Integration
On iOS, SpringBoard tweak hooks the system's push registration path so that when an app registers, the user is asked: Use Apple Push lets the original registration proceed unchanged, while Use Skyglow, enables the app with the daemon, and returns the token through the means dicussed. This alert only appears if the app is not registered in the system's database (not SGN), once registered via either path, the system believes it is registered normally, as this is needed for standard delivery.
Delivery & Persistence
A notification is only acknowledged once it is safely handled, and everything in between is persisted.
The processing pipeline
Each incoming notification is deduplicated first, against a persistent seen set in the database, a 200 entry in memory set, and anything already waiting in the pending queue, so a server redelivery is acknowledged without delivering it twice. Expired messages are acknowledged as expired, and messages for apps the user muted are acknowledged and suppressed. The payload is then decrypted and decompressed as its flags require and parsed by content type, with each failure mode acknowledged on its own, decrypt failure and parse failure separately, so the server knows why a message failed. Only a fully parsed payload is dispatched to the platform layer.
The local pending queue
If the platform hand off fails, and on iOS SpringBoard caught mid respring is the common case, the parsed payload is written to a pending queue in the database instead of being dropped, and no acknowledgement is sent. A retry timer drains the queue every five minutes, and extra drains run at every moment delivery may have become possible again, such as a successful authentication, the platform peer reconnecting, a profile switch, or daemon startup. Entries expire with the message's own expiry, or a 24 hour fallback, and are then acknowledged as expired. If even the enqueue fails, the daemon aborts the connection on purpose so the server redelivers later.
The durable event inbox
Traffic in the other direction has its own on disk inbox. When the platform must tell the daemon something that cannot be lost, on iOS that means an app was uninstalled, it writes the event as a file before attempting the live message, and the file is deleted only once the daemon acknowledges having applied it. The daemon drains the inbox at startup and on every configuration change. Structurally invalid or expired event files are discarded rather than retried forever, and enabling an app again purges any stale uninstall events it left behind.
Storage layout
| Path | Contents |
|---|---|
.../Preferences/com.skyglow.sndp.plist
|
Global switch, active profile, per-app status |
.../Preferences/com.skyglow.sndp-profileN.plist
|
Per profile (1-5): device and server address, plus the pinned certificate and registration identity as referenced .pem files |
| System keychain | Device private key, one per profile |
.../SkyglowNotifications/sqlite.db |
App/token registry, DNS cache, seen messages, pending deliveries, learned keep-alive intervals |
.../SkyglowNotifications/inbox |
Durable event files awaiting acknowledgement |
/var/log/sgn.log |
Structured daemon log, rotated at 1 MB |
All paths are transparently prefixed with
/var/jb on rootless jailbreaks, and rooted under
/usr/local/var/skyglow on macOS. Profile writes
are atomic, and invalidating a profile's credentials or
changing its server address resets that profile's whole
operational state, its tokens, cached DNS, learned intervals,
and queued items alike. On macOS the device key lives in the
System keychain under an open access list, readable by any
local process without a prompt. That is a deliberate tradeoff,
and the one keychain model that behaves consistently across
the full range of macOS releases SGN targets.
The Platform Layer
How the daemon stays platform neutral behind a host abstraction, and what each supported platform does to fulfil it.
The abstraction
Everything the daemon does that depends on the host operating
system, putting a notification on screen, storing the device
key, watching the network, holding the hardware awake, is
reached through one object,
SGPlatform. It is a per process singleton ([SGPlatform currentPlatform]) that carries no logic of its own, and works as a facade
over four independent capability nodes, each one its own small
protocol:
| Node | Interface | Responsibility |
|---|---|---|
delivery |
SGNotificationDelivery |
Start and stop, deliver a notification for a bundle identifier, reset an app's registration |
keyStore |
SGKeyStore |
Store, read, and delete the device private key, one per profile |
network |
SGNetworkInfo |
Reachability, and whether the active path is cellular |
system.power |
SGSystemPower |
Wake and sleep notifications, timed power assertions, scheduled RTC wakes |
A single factory builds each node at launch, choosing the iOS
or macOS concrete at compile time, and the daemon never sees
which one it got. Two of them, the reachability monitor and
the power and wake machinery, are identical on both systems
and share one common implementation, so only delivery, the key
store, and the cellular test really diverge. Alongside the
nodes the platform answers a capability query,
hasCapability:, over a small fixed set of power
assertions, scheduled wake, keep-alive offload, and cellular,
so the daemon can ask whether this host can schedule an RTC
wake instead of hard coding an OS check. Nothing in the core
process names SpringBoard or usernoted.
Some operations only make sense where a real registration
system exists, listing natively registered apps, registering
one, activating it, running its permission prompt. Those live
in an extended delivery protocol,
SGNativePushDelivery. The
control channel router asks the
running delivery node whether it conforms to that protocol
before routing such a request, and if it does not, the request
is answered with UNSUPPORTED rather than failing
silently. iOS's delivery node conforms, and macOS's does not.
That single conformsToProtocol: check is the
whole of the platform difference as the rest of the daemon
sees it.
iOS
This is the platform SGN was built for, and the one where every node is fully implemented. Delivery is deliberately thin. The iOS delivery node is itself a control channel client, and every call it receives is forwarded as a request to the SpringBoard tweak, which runs the matching server. The daemon does the networking and the bookkeeping, and SpringBoard does the part that can only be done from inside SpringBoard.
Inbound: push delivery
A delivery arrives at the tweak as a bundle identifier and the
notification dictionary serialized as a property list, and
both are validated before use, the identifier character set,
the payload cap, and the plist structure. Payloads not already
in APNS form are wrapped into a standard
aps dictionary, with title, body, sound, badge,
category, content-available, and the remaining keys carried
alongside, and handed to the native push pipeline, so the
receiving app sees a notification identical to one from
Apple's service. The reply to the daemon is the real result,
and the daemon's acknowledgement to the server matches it, so
a push is either delivered into iOS or stays queued, never
lost.
Outbound: commands
The tweak is also a client of the daemon. It sends token requests when an app registers, enable and clear intent commands when the user answers the provider choice, and delete commands when an app is uninstalled. These queue and replay across daemon restarts like any control channel client, so a command issued while the daemon is down is not lost.
Lifecycle watching, three layers
Uninstalls must reach the daemon even if it never got the message, so there are three layers. The live hook on SpringBoard's uninstallation path fires first. The event is at the same time written to the durable inbox, so a dead daemon finds it later. And as a final backstop, ten seconds after SpringBoard starts, the persisted app registry is compared against the apps actually installed, and anything missing is checked once more ten seconds later before its uninstall is recovered. The double pass avoids false positives while the app list is still settling after boot.
Bookkeeping
For apps the user assigns to Skyglow, the tweak keeps exactly the records SpringBoard would have kept for an Apple push app, the per app remote notification client, its environment and enabled types, all persisted across restarts, and it still presents the stock permission alert for alerts and sounds. Deleting an app's row in the preference pane deregisters it natively and clears that state, so its next cold launch registers from scratch.
Key store, network, and power
The other nodes are simpler. The key store is the iOS data protection keychain. The device key lives in a per profile generic password item marked for pre-unlock, device bound access, so the daemon can read it and decrypt traffic before you first unlock after a reboot, and it is never migrated off the device. The network node is the shared reachability monitor with one iOS specific answer added, whether the active path is cellular, read from the WWAN flag, which is what lets the keep-alive learn Wi-Fi and cellular separately. Power assertions, sleep and wake notifications, and scheduled wake all come from the common layer, and scheduled wake is reported as available only when the running system actually exports the IOKit call for it.
macOS
macOS support is experimental, but within its scope it fills
in the same nodes by different means. There is no SpringBoard
and no separate tweak process, so the macOS delivery node
delivers notifications itself, inside the daemon, by talking
to Apple's own notification service,
usernoted, over XPC.
Delivery
To deliver, the platform first finds the console user, the
person actually logged in, and refuses to deliver to the login
window or to root. It encodes the notification the way the
system expects, a keyed archive standing in for a private
_NSConcreteUserNotification, filling title,
subtitle, body, and sound out of the same
aps dictionary iOS reads. It then opens an XPC
connection to that user's usernoted instance,
sends a handshake that presents the notification as coming
from the target app's bundle identifier, and sends the encoded
notification for immediate display. Delivery is confirmed with
a send barrier bounded by a two second timeout, and only a
clean barrier with no transport error is reported as success,
so the outcome flows back to the server as an acknowledgement
exactly as it does on iOS.
Connection cache
Connections are cached per bundle identifier and keyed to the current console user, so a fast user switch or a logout drops the whole cache and rebuilds it for the new session. A connection that reports an error is marked invalid and dropped, so the next delivery reconnects rather than writing into a dead channel.
Key store, network, and power
The remaining nodes are filled in too. The key store writes the device key into the machine wide System keychain under an open access list, and the pre-unlock rewrap step is a deliberate no-op here, because that keychain is already readable before login (see Storage layout). The network node is the same reachability monitor iOS uses, with the cellular test left at its default of always false, since a Mac has no radio, and power, assertions, and scheduled wake come from the very same common layer, so a Mac schedules an RTC wake for its keep-alive exactly as an iPhone does.
What is not here
macOS offers no equivalent of SpringBoard's registration and
installation hooks, so its delivery node conforms only to the
base delivery protocol, not
SGNativePushDelivery. Native registration
requests return UNSUPPORTED, and resetting a
registration is accepted but does nothing beyond validating
the bundle identifier. There is no permission interception and
no lifecycle detection. The end result each platform reaches
is the same, a native notification, delivered and accounted
for, but iOS reaches it by hooking the system and macOS by
driving the system's own service directly.
Client sections