apns

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 13, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

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

View Source
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

View Source
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

func (c *Client) SendLiveActivity(ctx context.Context, laToken string, u LiveActivityUpdate) error

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.

const (
	EnvDevelopment Env = "development"
	EnvProduction  Env = "production"
)

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.

func NewSigner

func NewSigner(key *ecdsa.PrivateKey, keyID, teamID string) *Signer

NewSigner builds a signer. keyID is the 10-char Key ID from the Developer Portal; teamID is the 10-char Team ID that owns the bundle.

func (*Signer) Token

func (s *Signer) Token() (string, error)

Token returns a valid APNs provider JWT, reusing the cached one if fresh.

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

func NewStore(dir string) *Store

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

func (s *Store) Configured() bool

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

func (s *Store) DropDevice(token string) error

DropDevice removes a device and its LA token entirely.

func (*Store) DropLAToken

func (s *Store) DropLAToken(deviceToken string) error

DropLAToken clears only the LA token for a device (e.g. after dismissal).

func (*Store) KeyInfo

func (s *Store) KeyInfo() (keyID, teamID, bundleID string, env Env, ok bool)

KeyInfo returns the stored provider-key metadata. ok=false if unconfigured.

func (*Store) KeyPath

func (s *Store) KeyPath() string

KeyPath returns the on-disk path of the stored .p8 for the current key id.

func (*Store) RegisterDevice

func (s *Store) RegisterDevice(token string) error

RegisterDevice records (or refreshes) a device token's last-seen time.

func (*Store) SaveSettings

func (s *Store) SaveSettings(in Settings) error

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

func (s *Store) SetKey(keyBytes []byte, keyID, teamID, bundleID string, env Env) error

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

func (s *Store) SetLAToken(deviceToken, laToken string) error

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.

func (*Store) Settings

func (s *Store) Settings() Settings

Settings returns the stored push settings, or DefaultSettings if none saved.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL