iOS SDK

Beta · 0.1.0-beta.1 · iOS 15+ · SwiftUI and UIKit

The Swift SDK owns installation bootstrap, APNs token updates, Keychain credentials, bounded offline event/receipt retry, consent, identity, actions, receipts, and deep-link extraction.

Prerequisites

  1. Create an iOS app in PushHub and configure its APNs .p8 key, team ID, key ID, and bundle ID.
  2. Enable Push Notifications and required background modes in Xcode.
  3. In App → Integration → Client SDK access, create a publishable ph_pub_ key. Choose its environment: use a Development key in debug builds and a Production key in release builds. Devices take the environment of the key they register with.
  4. Never place a server ph_live_ key in an app bundle.

Install

In Xcode, select File → Add Package Dependencies, enter the repository URL, and select the PushHub product from sdk/ios:

https://github.com/kemegames-studio/pushhub.git

The Beta source artifact is also available as pushhub-ios-0.1.0-beta.1.zip.

CocoaPods. The pod isn't on the CocoaPods trunk. Add it to your Podfile pinned to a commit that contains PushHub.podspec at the repository root:

pod 'PushHub', :git => 'https://github.com/kemegames-studio/pushhub.git', :commit => 'COMMIT_SHA'

Use the full 40-character SHA. Don't pin a tag: no release tag contains the podspec yet (the 0.1.0-beta.1 tag predates it), so :tag fails with "Unable to find a specification". The Flutter and React Native iOS setup uses the same line.

Device context and remote config are available in the next iOS release, which is pending a macOS build. The published 0.1.0-beta.1 does not include them; the sections below describe that next release.

Configure

let configuration = try PushHubConfiguration(
    appId: UUID(uuidString: "YOUR_APP_ID")!,
    publishableKey: "ph_pub_YOUR_PUBLISHABLE_KEY"
)
try await PushHub.shared.configure(configuration)

Configuration is idempotent and does not show UI or ask for permission.

Permission and APNs registration

let granted = try await PushHubNotifications.requestAuthorization()
if granted {
    await MainActor.run { UIApplication.shared.registerForRemoteNotifications() }
}

func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken token: Data) {
    Task { try await PushHub.shared.start(deviceToken: token) }
}

The SDK stores its installation credential with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. The provider token is converted to lowercase hexadecimal and never exposed by diagnostics or logs.

Device context

Available in the next iOS release (pending a macOS build). 0.1.0-beta.1 does not report device context, so segments fall back to a metadata key of the same name.

Every start(deviceToken:) also reports the device context that segments target as built-in fields, so it follows app updates and language changes:

Field Source
appVersion CFBundleShortVersionString, e.g. 2.4.1
appBuild CFBundleVersion, e.g. 241
osVersion ProcessInfo.operatingSystemVersion, e.g. 17.5
locale Locale.current, as a BCP-47 tag such as ar-SA (the server derives language, e.g. ar)
country the region of Locale.current, e.g. SA
timezone TimeZone.current.identifier, e.g. Asia/Riyadh

Values the server would reject, such as a numeric region like 419, are left out rather than failing the call.

Notification callbacks

func userNotificationCenter(_ center: UNUserNotificationCenter,
    willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
    await PushHubNotifications.handleForeground(notification)
    return [.banner, .sound, .badge]
}

func userNotificationCenter(_ center: UNUserNotificationCenter,
    didReceive response: UNNotificationResponse) async {
    if let url = await PushHubNotifications.handleResponse(response) {
        // Validate the allowed scheme/host, then route inside the application.
    }
}

The helpers report signed received/opened/action receipts. They do not claim delivery when iOS does not execute the application and do not navigate automatically.

try await PushHub.shared.identify("player-1842", attributes: ["tier": "gold", "region": "mena"])
try await PushHub.shared.setConsent(.granted)
try await PushHub.shared.track("level_completed", properties: ["level": "20"])
try await PushHub.shared.unlinkUser() // on logout

identify attributes merge into the traits already stored for the device: keys you pass are added or overwritten and keys you leave out are kept. Calling identify(_:) without attributes no longer clears earlier traits. The server does the merging, so it applies to 0.1.0-beta.1 as well.

Retryable writes are stored in Keychain as a bounded queue of at most 100 operations for seven days. Call flushPending() on a foreground transition for immediate recovery; start() also flushes it.

Remote config

Available in the next iOS release (pending a macOS build). 0.1.0-beta.1 does not include PushHub.config.

Remote config delivers values you change from the dashboard (LiveOps → Remote config) without shipping an app update. Values can differ by condition, such as country, app version or a percentage of players. Use the shared instance, PushHub.config.

Set defaults and fetch on launch

Set in-app defaults first, so every getter has a value before the first fetch. Then fetch once the installation is started:

PushHub.config.setDefaults([
    "starter_coins": 100,
    "maintenance_mode": false,
    "welcome_text": "Welcome!",
    "store_layout": ["tabs": ["offers", "coins"]]
])

// After configure(_:) and start(deviceToken:):
Task {
    do {
        let changed = try await PushHub.config.fetchAndActivate()
        if changed { /* refresh UI that reads config */ }
    } catch {
        // Offline or timed out: the getters keep returning the last active values.
    }
}

The callback form, PushHub.config.fetchAndActivate(force:completion:), runs completion on the main queue. Before fetching, the SDK reports the device context again if it changed since the last report (for example after an app update), so conditions see the current app version, locale and country.

Read values

Getters are synchronous and safe to call from any thread:

let coins = PushHub.config.int(forKey: "starter_coins")
let maintenance = PushHub.config.bool(forKey: "maintenance_mode")
let text = PushHub.config.string(forKey: "welcome_text")
let ratio = PushHub.config.double(forKey: "drop_rate")
let layoutJSON = PushHub.config.json(forKey: "store_layout") // raw JSON text, e.g. {"tabs":["offers","coins"]}
let everything = PushHub.config.all()                          // defaults overlaid with remote values

let value = PushHub.config.value(forKey: "starter_coins")
value.source // .remote, .default or .static

Each getter returns the active remote value, then your default, then the type's zero value ("", false, 0; json(forKey:) returns nil). value(forKey:).source tells you which one you got.

React to updates

let token = PushHub.config.onUpdated { changedKeys in
    // Runs on the main queue after an activate that changed values.
    if changedKeys.contains("maintenance_mode") { showMaintenanceBannerIfNeeded() }
}
// Later: token.cancel()

Fetch now, apply later

To apply new values only at a safe moment, such as a loading screen, split the two steps:

try await PushHub.config.fetch()         // download; the getters still return the old values
let changed = try PushHub.config.activate() // apply, then notify the onUpdated handlers

activate() throws only when signing keys are set and the fetched config fails its signature check (see Signed config).

Offline behaviour and fetch interval

  • The SDK saves the active values, their version, the ETag, the exact response they came from, the time of the last fetch and any trusted signing keys in Application Support. At the next launch the getters return them right away, before any network call (with signing keys set, after checking them again; see Signed config).
  • Each fetch sends the stored ETag. When nothing changed, the server answers 304 Not Modified and the values stay as they are.
  • A failed or timed-out fetch throws (or completes with .failure) and leaves the active values unchanged. It never crashes the app.
  • Fetches are throttled to the larger of the server's minimum interval (900 seconds by default) and PushHub.config.minimumFetchIntervalSeconds (default 0). Within that interval, fetchAndActivate() makes no request. fetchAndActivate(force: true) always fetches, so use it for a debug menu, not on every launch.
  • PushHub.config.fetchTimeoutSeconds sets the timeout, 10 seconds by default.
  • With nothing published yet, the server returns version 0 with no parameters, and the getters use your defaults. PushHub.config.version is the active version.

Instant refresh

Available in the next iOS release, 0.1.0-beta.2 (pending a macOS build).

PushHub sends refreshes only to iOS installations that report sdkVersion 0.1.0-beta.2 or later. 0.1.0-beta.1 has no refresh handling, so its devices aren't sent one and pick up the new config on their next normal fetch. The SDK reports PushHub.version, so every iOS release must bump it. A build that still says 0.1.0-beta.1 gets no refreshes.

When you publish, roll back or schedule a publish with Notify devices set to Test devices only or All players, PushHub sends the environment's devices a silent background notification (content-available, with no alert, sound or badge). To act on it:

  1. In Xcode, under Signing & Capabilities, add Background Modes and turn on Remote notifications.
  2. Forward background notifications from your app delegate to PushHub first:
func application(_ application: UIApplication,
                 didReceiveRemoteNotification userInfo: [AnyHashable: Any],
                 fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
    if PushHub.handleBackgroundNotification(userInfo, completionHandler: completionHandler) { return }
    completionHandler(.noData) // or hand the notification to your other libraries
}

In a SwiftUI app, add an app delegate with @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate and put the method there.

handleBackgroundNotification returns true for a PushHub config refresh and false, without calling completionHandler, for anything else. For a refresh, when the announced version is newer than PushHub.config.version, or can't be read, it calls fetchAndActivate(force: true), so your onUpdated handlers run. It then calls completionHandler on the main queue with .newData, .noData or .failed. If iOS launches the app in the background to deliver the refresh, the SDK waits up to 5 seconds for your configure(_:) call.

Objective-C and C++ app delegates can't call this Swift method, so the SDK also exports it as a C function:

extern "C" BOOL PushHubHandleBackgroundNotification(NSDictionary *userInfo, void (^completionHandler)(UIBackgroundFetchResult)); // drop "C" in a .m file

- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo
    fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
{
  if (PushHubHandleBackgroundNotification(userInfo, completionHandler)) return;
  completionHandler(UIBackgroundFetchResultNoData);
}

The new values can arrive mid-session. If part of your app must change only at a safe moment, read those values at that moment. Instant refresh is best effort: iOS delivers background notifications at its own pace, may hold them in Low Power Mode, and doesn't deliver them to an app the user force-quit. PushHub also rate-limits refreshes to once every 10 minutes for all players (once a minute for test devices). Keep fetching on launch, so a missed refresh is picked up then.

If a refresh arrives but can't be fetched, for example while the device is offline or because your app wasn't configured within the 5 seconds, the SDK remembers the announced version. Your next fetch, also after a relaunch, then skips the minimum fetch interval and goes to the server. Without a missed refresh, the interval applies as usual.

Signed config

Available in the next iOS release (pending a macOS build).

When an environment has a signing key (App setup → Config signing), PushHub signs every config response with it: ECDSA P-256 with SHA-256 (ES256) over the exact response bytes, sent in the X-PushHub-Signature header. The SDK checks the signature, using CryptoKit, once you give it the public keys you trust, keyed by key ID:

// At launch, before the first fetch.
try PushHub.config.setTrustedSigningKeys([
    "KEY_ID": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..." // the public key (base64 SPKI) from App setup → Config signing
])

Once keys are set, verification is required everywhere, not only for a fresh fetch:

  • Fetch: a config is used only when a listed key signed those exact bytes. An unsigned response, an unknown key ID or a wrong signature fails the fetch with PushHubSignatureError, whose code is "signature", and the active values stay as they were. A 304 Not Modified carries no signature and keeps the response you already verified.
  • Activate: activate() checks the fetched config again against the keys you trust now, and throws PushHubSignatureError if it fails. The fetched config is then discarded and the active values stay, so a config fetched before you set the keys is never activated unchecked.
  • Saved config and launches: the SDK saves the exact response bytes and their signature with the config, and checks them again at every launch, before any getter reads them. A saved config that is unsigned (for example, activated before your app set keys), saved by an SDK without this check, or doesn't verify is not used. The getters return your in-app defaults (source .default, version 0), the failure is logged (subsystem com.kemegames.pushhub, category RemoteConfig), and the next fetch skips the minimum fetch interval.
  • Saved keys: the keys are saved with the config in Application Support. When iOS launches the app in the background to deliver a config refresh, the SDK enforces the keys you set last, even before your code sets them again. Calling setTrustedSigningKeys again replaces the saved set. Public keys are not secrets.
  • Changing keys: a new key set is applied at once. The active config stays in use when a key that is still trusted signed it; otherwise the SDK drops to your defaults and the next fetch goes to the server straight away.
  • Without keys (the default): nothing is checked. setTrustedSigningKeys([:]) clears the saved keys and turns the checks off again.
  • Bad keys: setTrustedSigningKeys throws PushHubError.invalidConfiguration for a blank key ID or a key that isn't a P-256 public key, and then changes nothing. PEM text works too.

Rotating keys. List every key you trust; an app can list more than one.

  1. Prepare next key in App setup. It doesn't sign anything yet.
  2. Ship an app version that trusts both keys: [currentKeyId: currentSpki, nextKeyId: nextSpki]. Players keep their current config, because the key that signed it is still trusted.
  3. Rotate once enough players have updated. Responses are then signed with the new key, which those players already trust.
  4. In a later version, drop the old key. A player whose saved config was still signed by the old key (nothing was published since the rotation) gets your defaults until the next fetch, which goes to the server at once.

An emergency rotation without preparing signs with the new key at once. App versions that don't list it fail their fetches and keep their last verified values until players update.

Reset and diagnostics

await PushHub.shared.reset()
let state = await PushHub.shared.diagnostics()

Reset attempts server deactivation before clearing local state. Diagnostics excludes publishable and installation credentials, APNs token, identity, content, and event properties.

See compatibility.json on GitHub and the sample.