@notiflyio/capacitor-push
v0.1.2
Published
Capacitor plugin wiring Notifly push into PWA shell apps: FCM channel setup, injected window-event bridge, and same-origin notification deep links
Readme
@notiflyio/capacitor-push
Capacitor plugin wiring Notifly push into the fleet's PWA shell apps. It owns what every shell used to hand-copy (caly-pwa-android / marka-pwa-android#5, ~350 lines per app):
- FCM default notification channel created at launch with a real name (config-driven id/name/description).
- Injected window-event bridge (
bridge/notifly-push-bridge.js) installed at document start into every document of the app origin, re-dispatching@capacitor/push-notificationsactivity as the SAME window CustomEvents the fleet's raw-WKWebView iOS shells emit — the app's web layer keeps one set of listeners, no per-platform fork. - Notification tap → same-origin deep link. Warm taps are handled by the plugin automatically; cold-start taps are a ~6-line MainActivity hook (below). Off-origin links from push payloads are rejected (phishing surface).
Event contract (fleet-locked; see pwa-notifications references/contracts.md)
One token event on both platforms, naming its own provider — a web layer keeps one listener and never forks per platform.
| Direction | Name | Detail |
|---|---|---|
| shell → web | push-apns-token | { token: string, provider: 'apns' \| 'fcm' } |
| shell → web | push-apns-error | error message string |
| shell → web | push-notification | foreground payload (title, body, link, data) |
| shell → web | push-permission-request | 'granted' \| 'denied' |
| shell → web | push-permission-state | 'authorized' \| 'denied' \| 'notDetermined' |
| web → shell | window.NotiflyPush.requestPermission() / permissionState() / token() | control object (plus the jsGlobal alias) |
| web → shell | window.webkit.messageHandlers['push-permission-request' \| 'push-permission-state' \| 'push-token'].postMessage('') | same three calls, under the names the fleet's raw-WKWebView shells register |
Deprecated aliases (since 0.1.1)
| Name | Detail | Status |
|---|---|---|
| push-fcm-token | device token string | Android only; emitted alongside push-apns-token. Deprecated — read the primary event's provider instead. |
| push-fcm-error | error message string | Android only; emitted alongside push-apns-error. Deprecated. |
Both aliases keep 0.1.0's bare-string detail, so a consumer written against
0.1.0 needs no change. They will not be removed before 1.0. On iOS the legacy
name and the primary name are the same event, so it is dispatched exactly once,
with the object detail.
Migration for a 0.1.0 consumer: read the token from push-apns-token, accepting
either shape —
const read = (detail) => (typeof detail === 'string' ? { token: detail, provider: 'apns' } : detail);
window.addEventListener('push-apns-token', (e) => { const { token, provider } = read(e.detail); ... });— and drop the push-fcm-* listeners.
The control object appears asynchronously: the injected script retries for
~10s until Capacitor's runtime is callable (a server.url shell has not
bootstrapped it at document start), then warns [notifly] gave up installing the
push bridge if it never was. Probe for window.NotiflyPush when you need it,
not once at mount.
Install (shell app)
npm install @notiflyio/capacitor-push @capacitor/push-notifications
npx cap sync@capacitor/push-notifications is a peer dependency and LOAD-BEARING beyond
gradle wiring: Capacitor's own JS export writes
Capacitor.Plugins.PushNotifications (the probe web layers use to detect the
native push leg) only when that plugin is installed.
capacitor.config.json
{
"plugins": {
"NotiflyPush": {
"jsGlobal": "MarkaPush",
"channelId": "marka_default",
"channelName": "Marka notifications",
"channelDescription": "General notifications",
"allowedHost": "joinmarka.com"
}
}
}All keys optional — channelId defaults to notifly_default, the canonical
window.NotiflyPush global is always set, deep links always allow the app
origin (server.url).
Android app manifest (stays app-side)
<meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="marka_default" />plus the usual Firebase leg: google-services.json in android/app/ and the
com.google.gms.google-services gradle plugin. Firebase project MUST be the
app's existing OAuth GCP project (SenderId-mismatch class — see the
pwa-notifications skill's portal-setup reference).
MainActivity cold-start hook (the one last mile a plugin cannot own)
The initial page load is already in flight during plugin load, so a cold-start tap must steer that load from the activity:
import io.notifly.capacitorpush.NotiflyPushBridge;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
String link = NotiflyPushBridge.deepLinkFrom(getIntent(), getBridge().getConfig().getServerUrl());
if (link != null) {
getBridge().getWebView().loadUrl(link);
}
}Warm taps (app already running) are handled by the plugin's handleOnNewIntent
— no activity code. If your activity ALSO navigates on new intents, both
navigations target the same URL (harmless).
The app-side token relay route (session-authed, forwards the device token
to Notifly's per-device endpoints) deliberately stays app-side too — it needs
the app's auth. Recipe: pwa-notifications references/wiring.md.
Development
bridge/notifly-push-bridge.js is the canonical script; pnpm build (or
node scripts/embed-bridge.mjs) stamps it into the committed native copies
(android/src/main/assets/ + generated BridgeScript.swift).
node scripts/embed-bridge.mjs --check gates drift (runs in prepublish).
pnpm test runs test/bridge.test.mjs — the real script in a fake document
(node:vm, fake clock), pinning the install retry and the event contract. Both
also run on every PR (.github/workflows/ci.yml). Point NOTIFLY_BRIDGE_PATH at
another copy of the script to run the suite against it: with 0.1.0's bridge, 9
of the 13 checks fail, starting with the install race (notifly#61).
Verification status
- Android half: ported line-for-line from caly-pwa-android (device-proven on
the fleet, 2026-07). This npm packaging device-proven 2026-07-31: marka-pwa-android
migrated onto the packed plugin (app-local bridge deleted), gradle assembleDebug
green, and on an API-36 emulator logcat showed plugin registration, the
marka_defaultchannel created from config, and the injected bridge's addListener → checkPermissions → register → FCM registration event with no registrationError. Remaining before1.x: a real notification DELIVERY + tap deep-link pass on a physical device. 0.1.1 cures what the superbooks Android device proof found on 2026-08-01 (notifly#61): on that shell the bridge never installed on any page of the app origin, because at document start on a freshserver.urlcontext Capacitor is not callable yet and 0.1.0 returned for good. The install is now self-retrying andtest/bridge.test.mjspins it. - iOS half: authored to the same design but COMPILE-GATED — no Mac toolchain has built it as of 2026-07-31. The fleet's shipped iOS shells are raw WKWebView apps (see the NotiflyShellPush Swift package), not Capacitor; this half exists for future Capacitor-built iOS shells. Do not ship it to a device without an Xcode build + the contract smoke run.
