← KlugPush

Documentation

KlugPush documentation

com.klug.push.Push

Everything you need to add push notifications to an AIR app on the Mac: signing and APNs setup, the token flow, permission status, the full API and the events.

macOS Version 1.0.0 Product page · Buy

Introduction

KlugPush adds remote push notifications to AIR apps on macOS. It asks the user for permission to show notifications, registers the app with the Apple Push Notification service (APNs), hands the device token to ActionScript and forwards notifications that arrive while the app is running.

Because macOS asks only once for permission, KlugPush also reports the current permission status and opens the Notifications page of System Settings, so a user who declined can switch notifications on later.

Notifications go straight from your server to APNs. No third-party push provider and no extra SDK are involved.

Extension IDcom.klug.push.Push
Current version1.0.0
PlatformsmacOS (Apple silicon and Intel, one universal binary). On every other platform the extension loads, isSupported is false and all calls are safe no-ops.
ActionScript APIOne class: com.klug.push.Push
Deliverycom.klug.push.Push.ane (the extension), the ActionScript library (SWC) for code completion, and this documentation

Requirements

AIR SDK51.x (HARMAN)
macOSmacOS 11 or later
Apple developer accountMembership in the Apple Developer Program, an App ID with the Push Notifications capability, and a Mac provisioning profile that contains it
SigningThe app bundle must be signed with the com.apple.developer.aps-environment entitlement and the matching provisioning profile
ServerAny server that can send HTTP/2 requests to APNs with a token-based (.p8) or certificate-based key
PermissionsThe notification permission is requested at runtime by register(); no Info.plist usage text is needed
A build signed ad hoc or without the push entitlement still runs, and isSupported is still true, but registration fails and onError receives the system's message. Test push with a properly provisioned build.

Add the extension

Self-contained: no core library ANE and no dependency ANEs are needed. One file, one extensionID.

1 · Copy the ANE and declare it

Copy com.klug.push.Push.ane into your project's extension folder (the folder you pass to ADT with -extdir, or the one your IDE links as native extensions) and add the extension ID to the application descriptor:

app.xml
<extensions>
    <extensionID>com.klug.push.Push</extensionID>
</extensions>

KlugPush needs no InfoAdditions and no Android or iOS settings.

2 · Enable push for your App ID

  • In your Apple developer account, open Certificates, Identifiers & Profiles → Identifiers and select the App ID of your Mac app. Its bundle ID is the <id> of your application descriptor.
  • Enable the Push Notifications capability and save.
  • Create or regenerate the Mac provisioning profile for this App ID (development profile for testing, distribution profile for the Mac App Store or Developer ID). A profile created before push was enabled does not contain it.

3 · Entitlements

macOS entitlements are applied when you sign the app bundle (for example codesign --entitlements entitlements.plist). Add the push entitlement next to the entitlements your app already uses. TEAMID and com.example.yourapp are placeholders for your team ID and bundle ID:

entitlements.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <!-- push: "development" for development-signed builds, "production" for release builds -->
    <key>com.apple.developer.aps-environment</key>
    <string>production</string>

    <!-- must match the App ID in the embedded provisioning profile -->
    <key>com.apple.application-identifier</key>
    <string>TEAMID.com.example.yourapp</string>
    <key>com.apple.developer.team-identifier</key>
    <string>TEAMID</string>

    <!-- if the app is sandboxed: outgoing connections, e.g. to send the token to your server -->
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <key>com.apple.security.network.client</key>
    <true/>
</dict>
</plist>

Embed the provisioning profile in the bundle as Contents/embedded.provisionprofile before you sign. The entitlement on macOS is called com.apple.developer.aps-environment; the iOS key aps-environment has no effect on the Mac.

4 · Sandbox or production

APNs has two environments, and a device token belongs to exactly one of them:

developmentBuilds signed with a development certificate and a development profile. Tokens are valid on api.sandbox.push.apple.com.
productionBuilds for the Mac App Store, TestFlight or Developer ID distribution. Tokens are valid on api.push.apple.com.

Sending a token to the wrong environment fails with the APNs error BadDeviceToken. Store the environment together with each token on your server if you test both kinds of build.

Getting started

The smallest working setup: check support, register, and send the token to your server. Every call returns at once; the token, errors and notifications arrive later in your callbacks.

Main.as
import com.klug.push.Push;

var push:Push = Push.getInstance();   // singleton: never call new Push() yourself

if ( push.isSupported )                 // true on macOS only
{
    push.register(
        function( token:String ):void { sendTokenToServer( token ); },     // your app function
        function( message:String ):void { trace( "push: " + message ); },
        function( json:String ):void { handlePush( JSON.parse( json ) ); }  // your app function
    );
}

On the first start macOS shows its permission prompt. The device token arrives whatever the user answers there: the permission only controls whether notifications are displayed.

Guides

Register and keep the token up to date

Call register() on every app start. APNs normally returns the same token, but it can change, for example after the app was reinstalled or the Mac was restored from a backup. Compare it with the token you sent last and update your server only when it changed. Calling register() again later in the same session is safe; the callbacks you pass replace the earlier ones.

The token is a lowercase hexadecimal string of the bytes APNs returns (currently 64 characters). The getter token keeps the last value, so code that runs later, for example after a sign-in, can read it without registering again.

PushSetup.as
import com.klug.push.Push;
import flash.net.SharedObject;

private var push:Push = Push.getInstance();

public function initPush():void
{
    if ( !push.isSupported ) return;      // other platforms: use their own push solution
    if ( !push.register( onToken, onPushError, onNotification ) )
        trace( "push: register() could not be called" );
}

private function onToken( token:String ):void
{
    var so:SharedObject = SharedObject.getLocal( "push" );
    if ( so.data.token != token )
    {
        sendTokenToServer( token );         // your app function, e.g. an HTTPS POST
        so.data.token = token;
        so.flush();
    }
}

private function onPushError( message:String ):void
{
    // typical cause: the build is not signed with the push entitlement and a matching profile
    trace( "push registration failed: " + message );
}

// later, e.g. after the user signed in to another account
public function onSignedIn():void
{
    if ( push.token != null ) sendTokenToServer( push.token );
}

Receive notifications while the app is running

onNotification receives the complete payload your server sent, as a JSON string: the aps dictionary plus all your own keys. It fires when a notification is delivered while the app is running. When the app is in the foreground, macOS normally does not show a banner for it, so this callback is the place to react.

When the app is not running, macOS shows the notification in Notification Center. Clicking it starts the app, but the payload of that notification is not passed to ActionScript; load the current state from your server at startup instead.

Incoming.as
private function onNotification( json:String ):void
{
    var payload:Object;
    try { payload = JSON.parse( json ); }
    catch ( e:Error ) { return; }

    // standard part
    var alert:Object = payload.aps ? payload.aps.alert : null;
    var text:String = ( alert is String ) ? String( alert )
                    : ( alert ? alert.body : "" );

    // your own keys, e.g. { "aps": {...}, "eventId": "1234" }
    if ( payload.hasOwnProperty( "eventId" ) )
        showEvent( payload.eventId, text );   // your app function
}

Permission status and System Settings

macOS shows the permission prompt only once. After the user declined, register() cannot ask again; the only way back is the Notifications page in System Settings. KlugPush gives you the status so that your app can show the right hint:

-1Unknown: neither register() nor refreshStatus() has delivered a status yet
0 · STATUS_NOT_DETERMINEDThe user has not been asked yet
1 · STATUS_DENIEDNotifications are switched off for the app
2 · STATUS_AUTHORIZEDNotifications are allowed
3, 4Also possible from refreshStatus(): provisional and ephemeral authorization as reported by macOS (no constants)

refreshStatus(onStatus) reads the live status asynchronously. The callback is stored: it is called with the result, and again whenever register() receives the answer to its prompt (then always 1 or 2). authStatus returns the last value without asking the system.

NotificationSettings.as
import com.klug.push.Push;
import flash.desktop.NativeApplication;
import flash.events.Event;

private var push:Push = Push.getInstance();

public function initSettingsScreen():void
{
    push.refreshStatus( showStatus );

    // the user may change the setting in System Settings: read it again on return
    NativeApplication.nativeApplication.addEventListener( Event.ACTIVATE, onActivate );
}

private function onActivate( e:Event ):void
{
    push.refreshStatus( showStatus );
}

private function showStatus( status:int ):void
{
    switch ( status )
    {
        case Push.STATUS_AUTHORIZED:     setSwitch( "on" ); break;       // your app UI
        case Push.STATUS_DENIED:         setSwitch( "off" ); break;
        case Push.STATUS_NOT_DETERMINED: setSwitch( "ask" ); break;
        default:                         setSwitch( "on" );            // 3, 4: limited authorization
    }
}

public function onSwitchClicked():void
{
    if ( push.authStatus == Push.STATUS_NOT_DETERMINED )
        push.register( onToken, onPushError, onNotification );   // shows the prompt
    else if ( push.authStatus == Push.STATUS_DENIED )
        push.openSettings();                                     // the only way back
}

Listen with events instead of callbacks

Every result is also dispatched as a StatusEvent.STATUS on the Push instance. Note the layout of these events: the level names the kind (Push.TOKEN, Push.ERROR, Push.NOTIFICATION), and the code carries the value.

PushEvents.as
import com.klug.push.Push;
import flash.events.StatusEvent;

var push:Push = Push.getInstance();
push.addEventListener( StatusEvent.STATUS, onPushStatus );
push.register();   // all callbacks are optional

function onPushStatus( e:StatusEvent ):void
{
    switch ( e.level )
    {
        case Push.TOKEN:        sendTokenToServer( e.code ); break;   // hex token
        case Push.ERROR:        trace( "push error: " + e.code ); break;
        case Push.NOTIFICATION: handlePush( JSON.parse( e.code ) ); break;   // JSON payload
    }
}

The permission status is delivered only to the refreshStatus() callback, not as an event.

Send a test notification

To send notifications, your server authenticates with APNs. The token-based way uses one key for all your apps:

  • In your Apple developer account, open Certificates, Identifiers & Profiles → Keys, create a key with Apple Push Notifications service (APNs) enabled and download the .p8 file. It can be downloaded only once; keep it secret and never ship it inside the app.
  • Note the Key ID of the key and your Team ID (shown in the membership details).
  • Create a JSON Web Token with any JWT library: algorithm ES256, signed with the .p8 key, header kid = Key ID, claims iss = Team ID and iat = the current time in seconds. A token stays valid for up to one hour.

Then send an HTTP/2 request. All values in capitals are placeholders:

test-push.sh
# development build: api.sandbox.push.apple.com · release build: api.push.apple.com
curl --http2 \
  -H "authorization: bearer JWT_FROM_YOUR_P8_KEY" \
  -H "apns-topic: com.example.yourapp" \
  -H "apns-push-type: alert" \
  -H "apns-priority: 10" \
  -d '{"aps":{"alert":{"title":"Hello","body":"Test from my server"},"sound":"default"},"eventId":"1234"}' \
  https://api.sandbox.push.apple.com/3/device/DEVICE_TOKEN_FROM_ONTOKEN

apns-topic is the bundle ID of your Mac app. A response with status 200 means APNs accepted the notification. For a quick test without your own server, the Push Notifications Console in your Apple developer account can also send a notification to a device token.

Cross-platform apps

The extension contains a fallback for every platform other than macOS. There isSupported is false, register() returns false, and the other methods do nothing, so the same code can stay in an app that also builds for Windows, iOS or Android. Use the push solution of those platforms behind the isSupported check.

API reference

com.klug.push.Push

Extends flash.events.EventDispatcher. Singleton.

getInstance()public static function getInstance():Push
Returns the single instance and creates it on the first call. Calling new Push() a second time throws an Error.
isSupportedpublic function get isSupported():Boolean
True on macOS, false on all other platforms. It does not check signing or entitlements.
tokenpublic function get token():String
The last device token received in this session, or null until registration has delivered one.
authStatuspublic function get authStatus():int
The last known permission status (see the status constants); -1 until register() or refreshStatus() has delivered one. Does not query the system.
register()public function register(onToken:Function = null, onError:Function = null, onNotification:Function = null):Boolean
Asks for permission to show alerts, sounds and badges (prompt only on the first time), then registers with APNs. onToken(token:String) receives the device token, onError(message:String) the system's message if registration fails, onNotification(json:String) the payload of each notification delivered while the app runs. The callbacks replace those of an earlier call. Returns true if the request was handed to the system, false where push is not supported. Asynchronous.
refreshStatus()public function refreshStatus(onStatus:Function = null):void
Reads the live permission status. onStatus(status:int) is called asynchronously with the result and is kept for the answer to a later register() prompt. Updates authStatus. No-op where not supported.
openSettings()public function openSettings():void
Opens System Settings at Notifications. No-op where not supported.
TOKENpublic static const TOKEN:String = "token"
StatusEvent level: the code is the device token.
ERRORpublic static const ERROR:String = "error"
StatusEvent level: the code is the error message.
NOTIFICATIONpublic static const NOTIFICATION:String = "notification"
StatusEvent level: the code is the notification payload as JSON.
STATUS_NOT_DETERMINEDpublic static const STATUS_NOT_DETERMINED:int = 0
The user has not been asked yet.
STATUS_DENIEDpublic static const STATUS_DENIED:int = 1
Notifications are switched off for the app.
STATUS_AUTHORIZEDpublic static const STATUS_AUTHORIZED:int = 2
Notifications are allowed.

Events

All results arrive asynchronously, on the main thread of your app, both through the callbacks of register() and as flash.events.StatusEvent.STATUS on the Push instance.

level "token"Push.TOKEN. Fires after register() when APNs has issued the device token. code = the token as a lowercase hex string. Also calls onToken and updates token.
level "error"Push.ERROR. Fires when registration fails, for example without the push entitlement. code = the system's error message. Also calls onError.
level "notification"Push.NOTIFICATION. Fires when a notification is delivered while the app is running. code = the full payload as JSON. Also calls onNotification.
onStatus(status:int)Callback only (no event). Fires with the result of refreshStatus(), and with 1 or 2 when the user answers the prompt of register().

Troubleshooting

onError reports a missing entitlement, or nothing arrives at all

The app bundle is not signed with com.apple.developer.aps-environment, or the embedded provisioning profile does not contain the Push Notifications capability. Enable push for the App ID, regenerate the profile, embed it and sign again. Ad hoc signed local builds cannot receive a token.

The server gets BadDeviceToken

The token and the APNs host belong to different environments. Tokens of development-signed builds work only with api.sandbox.push.apple.com, tokens of release builds only with api.push.apple.com.

The user declined and the prompt never appears again

This is how macOS works. Check authStatus or call refreshStatus(); if it is STATUS_DENIED, explain the situation and call openSettings(). Read the status again when your app becomes active.

authStatus is -1

The status is cached only after register() or refreshStatus() has delivered it, and both answer asynchronously. Use the onStatus callback instead of reading the getter right after the call.

I listen for StatusEvents, but e.code is not "token"

For this extension the kind is in e.level and the value is in e.code. Compare e.level with Push.TOKEN, Push.ERROR and Push.NOTIFICATION.

A notification arrived but no banner was shown

When your app is in the foreground, macOS normally delivers the notification to the app without showing a banner. Handle it in onNotification. If the app is in the background and still no banner appears, check the notification settings of the app in System Settings (alert style, Focus modes).

The payload of a clicked notification is missing

onNotification covers notifications delivered while the app is running. A click on a notification that started the app is not forwarded. Fetch the current state from your server at startup.

isSupported is true, but push does not work in my test build

isSupported only tells you that the native part is present (macOS). Signing problems show up later, through onError.

Changelog

1.0.0

  • Permission status: authStatus, refreshStatus() and the status constants
  • openSettings() opens System Settings at Notifications
  • First release: permission prompt, APNs registration, device token, notifications delivered to ActionScript as JSON, StatusEvents and callbacks
  • Universal binary for Apple silicon and Intel Macs; safe fallback on all other platforms