Documentation
¶
Overview ¶
Package apns sends push notifications directly from the daemon to Apple Push Notification service (APNs).
It serves two jobs:
- Alert pushes: the background "turn done / needs input / agent exited" notifications that wake the device even when the app is killed.
- Live Activity pushes: content-state updates (and end) that keep the single global Live Activity current after the app is force-quit (the app-driven path only works while the WS is alive).
Authentication is token-based (Apple provider JWT, ES256) signed from a .p8 key — no per-year cert, one key for all devices on the bundle. The JWT is hand rolled with the Go stdlib (crypto/ecdsa); no third-party JWT dependency.
All secrets live under ~/.rmote/apns/ (0600): the .p8 key and a small JSON registry of device + Live Activity push tokens. The 0600 permission is the security boundary, mirroring the agent secret.
Index ¶
- Constants
- Variables
- func LoadPrivateKey(path string) (*ecdsa.PrivateKey, error)
- type Alert
- type Client
- func (c *Client) SendAlert(ctx context.Context, deviceToken, deepLink string, a Alert, opts SendOpts) error
- func (c *Client) SendLiveActivity(ctx context.Context, laToken string, u LiveActivityUpdate) error
- func (c *Client) SendRaw(ctx context.Context, pushType, topic, token string, payload map[string]any, ...) error
- type Device
- type DeviceEntry
- type Env
- type LiveActivityEvent
- type LiveActivityUpdate
- type SendOpts
- type Settings
- type Signer
- type Store
- func (s *Store) Configured() bool
- func (s *Store) Devices() []DeviceEntry
- func (s *Store) DropDevice(token string) error
- func (s *Store) DropLAToken(deviceToken string) error
- func (s *Store) KeyInfo() (keyID, teamID, bundleID string, env Env, ok bool)
- func (s *Store) KeyPath() string
- func (s *Store) RegisterDevice(token string) error
- func (s *Store) SaveSettings(in Settings) error
- func (s *Store) SetDeviceInstallation(deviceToken, installationID string, e2ePubKey []byte, keyID string) error
- func (s *Store) SetKey(keyBytes []byte, keyID, teamID, bundleID string, env Env) error
- func (s *Store) SetLAToken(deviceToken, laToken string) error
- func (s *Store) Settings() Settings
Constants ¶
const ( // Live Activity pushes require the topic suffixed with this; alert pushes // use the bare bundle id. Getting this backwards silently drops LA pushes. // Exported so the relay (a separate package) reuses the exact suffix. LATopicSuffix = ".push-type.liveactivity" )
Verified APNs constants (Apple docs, Aug 2026). APNs is a shifting surface; if a push silently fails, re-check these against current docs first.
Variables ¶
var ErrTokenUnregistered = fmt.Errorf("apns: push token no longer valid")
ErrTokenUnregistered means APNs reports the push token is no longer valid (Unregistered / BadDeviceToken / DeviceTokenNotForTopic). The caller MUST drop the token from its registry — the reliable backstop when the app is reinstalled or the user dismissed the Live Activity.
Functions ¶
func LoadPrivateKey ¶
func LoadPrivateKey(path string) (*ecdsa.PrivateKey, error)
LoadPrivateKey reads and parses an Apple APNs provider .p8 key (a PKCS#8 EC P-256 private key) from path. It refuses a key file that is readable by group or other (mode bits 0o077 set): the .p8 can push to every device on the bundle, so a world-readable source is rejected before the bytes are trusted. The caller (the set-key CLI path) writes its own 0600 copy.
Types ¶
type Alert ¶
type Alert struct {
Title string // empty → no title
Body string
Sound string // empty → default; "default" for the standard sound
Silent bool // omit sound; iOS consequently suppresses notification vibration
}
Alert is the user-visible content of an alert push.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client posts to APNs over HTTP/2 (negotiated automatically by net/http over TLS via ALPN) using a cached provider JWT.
func NewClient ¶
func NewClient(key *ecdsa.PrivateKey, keyID, teamID, bundleID string, env Env) *Client
NewClient builds an APNs client. bundleID is the app bundle (e.g. "dev.8ugust.rmt"); alert pushes use it as the topic verbatim, Live Activity pushes append laTopicSuffix.
func (*Client) SendAlert ¶
func (c *Client) SendAlert(ctx context.Context, deviceToken, deepLink string, a Alert, opts SendOpts) error
SendAlert delivers a standard alert push to deviceToken. deepLink is the rmote:// URL placed at the payload root so tapping the notification opens the session.
func (*Client) SendLiveActivity ¶
SendLiveActivity pushes a content-state update/end to the activity's push token. All fields land INSIDE aps (timestamp/event/content-state/stale-date/ dismissal-date) — that is where ActivityKit looks.
func (*Client) SendRaw ¶
func (c *Client) SendRaw(ctx context.Context, pushType, topic, token string, payload map[string]any, opts SendOpts) error
SendRaw is the single HTTP/2 POST path. It sets the topic per push-type, the cached bearer JWT, and maps APNs error reasons to ErrTokenUnregistered. It takes an already-built payload so callers that manage their own payload (notably the relay posting an E2E ciphertext envelope with a static fallback body, never plaintext) can send without going through SendAlert/SendLiveActivity.
type Device ¶
type Device struct {
LAToken string `json:"la_token,omitempty"` // Live Activity push token (one global LA per device)
LastSeen int64 `json:"last_seen"` // unix seconds of last register/refresh
InstallationID string `json:"installation_id,omitempty"` // relay E2E: iPhone-owned install id this device belongs to
E2EPubKey []byte `json:"e2e_pubkey,omitempty"` // relay E2E: per-install X25519 HPKE public key (daemons seal to this)
KeyID string `json:"key_id,omitempty"` // relay E2E: the install's key label (rotation/revocation)
}
Device is the per-device state the daemon holds.
type DeviceEntry ¶
type DeviceEntry struct {
Token string
LAToken string
LastSeen int64
InstallationID string // relay material; populated when the device paired for E2E
E2EPubKey []byte
KeyID string
}
DeviceEntry is a Devices() snapshot entry.
type Env ¶
type Env string
Env selects the APNs endpoint. Development builds (Xcode + sandbox device tokens) talk to api.development; distribution builds talk to api. A mismatch between build env and token env is the classic silent-no-push failure.
type LiveActivityEvent ¶
type LiveActivityEvent string
LiveActivityEvent is the aps.event value for a Live Activity push.
const ( LAUpdate LiveActivityEvent = "update" LAEnd LiveActivityEvent = "end" )
type LiveActivityUpdate ¶
type LiveActivityUpdate struct {
Event LiveActivityEvent
ContentState json.RawMessage
Timestamp int64 // aps.timestamp (when this update occurred)
StaleDate int64 // aps.stale-date (update); 0 omits
DismissalDate int64 // aps.dismissal-date (end); 0 omits
}
LiveActivityUpdate is a content-state push for the single global Live Activity. ContentState is the app's SessionActivityAttributes.ContentState JSON (field names must match the iOS Codable struct exactly). StaleDate / DismissalDate are unix seconds; DismissalDate is only meaningful for LAEnd.
type SendOpts ¶
type SendOpts struct {
CollapseID string // apns-collapse-id: coalesce a stream into one slot
Expiration int64 // apns-expiration (unix seconds); 0 = don't store if offline
Priority int // apns-priority (10 immediate, 5 conserve); 0 → 10
}
SendOpts tunes delivery. Zero values are fine; sensible defaults apply.
type Settings ¶
type Settings struct {
NotifyTurnDone bool `json:"notify_turn_done"`
NotifyWaiting bool `json:"notify_waiting"`
NotifyErrored bool `json:"notify_errored"`
VibrationEnabled bool `json:"vibration_enabled"`
CooldownSeconds int `json:"cooldown_seconds"`
}
Settings is the unified per-event push-toggle set. One set drives BOTH the iOS foreground local-notification check and the daemon background APNs check (validation decision #2). cmd_done is deliberately absent — it drives the Live Activity `done` phase but never fires an alert.
Persisted at <dir>/apns_settings.json (sibling of apns.json), 0600, atomic. Phase 4's fireAPNsForHook reads this; Phase 2's iOS app writes it.
func DefaultSettings ¶
func DefaultSettings() Settings
DefaultSettings returns sensible defaults (all alerts on, 5s cooldown), used when no settings file exists yet.
type Signer ¶
type Signer struct {
// contains filtered or unexported fields
}
Signer mints APNs provider JWTs (ES256) from an Apple .p8 key, caching a signed token for jwtTTL and re-signing under a lock. APNs accepts the same token across many requests, so signing once per ~50 min is correct and cheap.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store owns the ~/.rmote/apns/ directory: the .p8 provider key and a JSON registry of device + Live Activity push tokens. The registry is the daemon-side source of truth for "who gets pushes."
All files are 0600 (the dir 0700); that permission is the security boundary, matching the agent secret. Writes are atomic (temp + rename) so a crash never leaves a partial registry.
func NewStore ¶
NewStore loads the registry from dir (creating it if needed). A missing registry is not an error — it means APNs isn't configured yet.
func (*Store) Configured ¶
Configured reports whether a provider key + team are set.
func (*Store) Devices ¶
func (s *Store) Devices() []DeviceEntry
Devices returns a snapshot of all registered devices.
func (*Store) DropDevice ¶
DropDevice removes a device and its LA token entirely.
func (*Store) DropLAToken ¶
DropLAToken clears only the LA token for a device (e.g. after dismissal).
func (*Store) RegisterDevice ¶
RegisterDevice records (or refreshes) a device token's last-seen time.
func (*Store) SaveSettings ¶
SaveSettings persists the push settings atomically (temp + rename, 0600), mirroring the registry write.
func (*Store) SetDeviceInstallation ¶
func (s *Store) SetDeviceInstallation(deviceToken, installationID string, e2ePubKey []byte, keyID string) error
SetDeviceInstallation attaches relay E2E installation material (the iPhone-owned installation_id + its per-install HPKE public key + key label) to an already-registered device. The device must exist (call RegisterDevice first); unknown devices are rejected so orphan material can't accumulate. This is what enables relay-mode fanout for a device.
func (*Store) SetKey ¶
SetKey stores the provider key bytes (0600) and key/team/env/bundle metadata. If the key id changed, the previous .p8 file is removed. It does NOT relax permissions on an existing file.
func (*Store) SetLAToken ¶
SetLAToken attaches a Live Activity push token to a device. The device must already be registered (call RegisterDevice first); unknown devices are rejected so an orphan LA token can't accumulate.