diff --git a/.gitignore b/.gitignore
index 67f3212..b406a51 100644
--- a/.gitignore
+++ b/.gitignore
@@ -84,3 +84,6 @@ android/generated
# React Native Nitro Modules
nitrogen/
+
+# Push relay credentials (real-device APNs — never commit)
+.env.push
diff --git a/example/.maestro/background_notification.yaml b/example/.maestro/background_notification.yaml
new file mode 100644
index 0000000..ee2aba7
--- /dev/null
+++ b/example/.maestro/background_notification.yaml
@@ -0,0 +1,37 @@
+appId: nitronotification.example
+---
+- launchApp:
+ clearState: false
+
+- extendedWaitUntil:
+ visible:
+ text: Nitro Notifications
+ timeout: 10000
+
+- pressKey: Home
+
+- evalScript: ${new Promise(r => setTimeout(r, 1500))}
+
+- runScript:
+ file: send_push.js
+ env:
+ PUSH_TITLE: Background Test
+ PUSH_BODY: Tap to open the app
+
+- extendedWaitUntil:
+ visible:
+ text: Background Test
+ timeout: 8000
+
+- tapOn:
+ text: Background Test
+
+- extendedWaitUntil:
+ visible:
+ id: last-event-text
+ timeout: 8000
+
+- extendedWaitUntil:
+ visible:
+ text: 'Tapped: Background Test (com.apple.UNNotificationDefaultActionIdentifier)'
+ timeout: 8000
diff --git a/example/.maestro/foreground_notification.yaml b/example/.maestro/foreground_notification.yaml
new file mode 100644
index 0000000..fc7d96e
--- /dev/null
+++ b/example/.maestro/foreground_notification.yaml
@@ -0,0 +1,25 @@
+appId: nitronotification.example
+---
+- launchApp:
+ clearState: false
+
+- extendedWaitUntil:
+ visible:
+ text: Nitro Notifications
+ timeout: 10000
+
+- runScript:
+ file: send_push.js
+ env:
+ PUSH_TITLE: Foreground Test
+ PUSH_BODY: Notification received in foreground
+
+- extendedWaitUntil:
+ visible:
+ id: last-event-text
+ timeout: 8000
+
+- extendedWaitUntil:
+ visible:
+ text: "Received: Foreground Test — Notification received in foreground"
+ timeout: 8000
diff --git a/example/.maestro/send_push.js b/example/.maestro/send_push.js
new file mode 100644
index 0000000..08b8fd7
--- /dev/null
+++ b/example/.maestro/send_push.js
@@ -0,0 +1,10 @@
+// Maestro runScript helper - calls local push-relay
+const title = typeof PUSH_TITLE !== 'undefined' ? PUSH_TITLE : 'Test';
+const body = typeof PUSH_BODY !== 'undefined' ? PUSH_BODY : 'Test body';
+const port = typeof PUSH_RELAY_PORT !== 'undefined' ? PUSH_RELAY_PORT : '8765';
+const payload = JSON.stringify({ title, body });
+
+output.result = http.post(`http://127.0.0.1:${port}/push`, {
+ headers: { 'Content-Type': 'application/json' },
+ body: payload,
+});
diff --git a/example/src/App.tsx b/example/src/App.tsx
index ccb64e7..c51d963 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -40,19 +40,6 @@ export default function App() {
});
};
- useEffect(() => {
- (async () => {
- const status = await Notifications.getPermissionStatus();
- setPermStatus(status);
- if (status === 'granted') {
- setupNotifications();
- const t = await Notifications.getDevicePushToken();
- setToken(t);
- api.login(t);
- }
- })();
- }, []);
-
const handleRequestPermissions = async () => {
const status = await Notifications.requestPermissions();
setPermStatus(status);
@@ -121,6 +108,19 @@ export default function App() {
Alert.alert('Copied');
};
+ useEffect(() => {
+ (async () => {
+ const status = await Notifications.getPermissionStatus();
+ setPermStatus(status);
+ if (status === 'granted') {
+ setupNotifications();
+ const t = await Notifications.getDevicePushToken();
+ setToken(t);
+ api.login(t);
+ }
+ })();
+ }, []);
+
return (
Nitro Notifications
@@ -151,7 +151,9 @@ export default function App() {
- {lastEvent ?? 'None'}
+
+ {lastEvent ?? 'None'}
+
);
diff --git a/ios/NitroNotificationLoader.mm b/ios/NitroNotificationLoader.mm
new file mode 100644
index 0000000..a37d4af
--- /dev/null
+++ b/ios/NitroNotificationLoader.mm
@@ -0,0 +1,20 @@
+#import
+
+/// Forward-declare the Swift entry point generated by the NitroNotification Swift module.
+/// This is declared in NitroNotification-Swift.h but we avoid importing that header
+/// to stay away from the C++ umbrella chain.
+extern "C" void NitroNotification_setup(void);
+
+@interface NitroNotificationLoader : NSObject
+@end
+
+@implementation NitroNotificationLoader
+
++ (void)load {
+ // Touch NotificationHub.shared via the Swift @_cdecl shim so the
+ // UNUserNotificationCenterDelegate is registered before any foreground
+ // notification can arrive — without requiring an import in the app delegate.
+ NitroNotification_setup();
+}
+
+@end
diff --git a/ios/NotificationHub.swift b/ios/NotificationHub.swift
index aa1a7b6..2567db8 100644
--- a/ios/NotificationHub.swift
+++ b/ios/NotificationHub.swift
@@ -57,7 +57,7 @@ final class NotificationHub: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
- withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ withCompletionHandler completionHandler: @escaping @Sendable (Int) -> Void
) {
var options: UNNotificationPresentationOptions = []
if foregroundAlert { options.insert(.banner) }
@@ -65,7 +65,7 @@ final class NotificationHub: NSObject, UNUserNotificationCenterDelegate {
if foregroundSound { options.insert(.sound) }
let payload = payloadFrom(notification.request.content)
onNotificationReceived?(payload)
- completionHandler(options)
+ completionHandler(Int(options.rawValue))
}
func userNotificationCenter(
diff --git a/ios/NotificationsAppDelegateSubscriber.swift b/ios/NotificationsAppDelegateSubscriber.swift
index 7d93dbc..2dd9af7 100644
--- a/ios/NotificationsAppDelegateSubscriber.swift
+++ b/ios/NotificationsAppDelegateSubscriber.swift
@@ -4,3 +4,10 @@ import Foundation
/// Post this notification with userInfo ["deviceToken": Data] from your AppDelegate.
public let NitroNotificationTokenKey = "NitroNotificationDeviceToken"
public let NitroNotificationTokenNotification = Notification.Name("NitroNotificationDidRegisterToken")
+
+/// Called from NitroNotificationLoader.mm via @_cdecl so the ObjC +load
+/// method can bootstrap NotificationHub without importing the C++ umbrella.
+@_cdecl("NitroNotification_setup")
+public func nitroNotificationSetup() {
+ _ = NotificationHub.shared
+}
diff --git a/package.json b/package.json
index 463a882..fb1df79 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,11 @@
"nitrogen": "nitrogen",
"lint": "eslint \"**/*.{js,ts,tsx}\"",
"update": "yarn upgrade-interactive",
- "release": "node tools/release.ts"
+ "release": "node tools/release.ts",
+ "push-relay": "node --env-file=.env.push --experimental-strip-types tools/push-relay.ts",
+ "test:maestro:foreground": "maestro test example/.maestro/foreground_notification.yaml",
+ "test:maestro:background": "maestro test example/.maestro/background_notification.yaml",
+ "test:maestro": "maestro test example/.maestro/foreground_notification.yaml && maestro test example/.maestro/background_notification.yaml"
},
"keywords": [
"react-native",
diff --git a/tools/push-relay.ts b/tools/push-relay.ts
new file mode 100644
index 0000000..8970edd
--- /dev/null
+++ b/tools/push-relay.ts
@@ -0,0 +1,82 @@
+import { createServer } from 'node:http';
+import { execSync } from 'node:child_process';
+import { writeFileSync, unlinkSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const PORT = process.env.PUSH_RELAY_PORT ?? '8765';
+const DEFAULT_BUNDLE_ID =
+ process.env.PUSH_BUNDLE_ID ?? 'nitronotification.example';
+
+const findBootedUDID = (): string => {
+ const raw = execSync('xcrun simctl list devices booted --json', {
+ encoding: 'utf8',
+ });
+ const parsed = JSON.parse(raw) as {
+ devices: Record>;
+ };
+ for (const devices of Object.values(parsed.devices)) {
+ for (const device of devices) {
+ if (device.state === 'Booted') return device.udid;
+ }
+ }
+ throw new Error('No booted simulator found. Boot one first.');
+};
+
+const buildPayload = (input: {
+ title: string;
+ body: string;
+ data?: Record;
+}) => ({
+ aps: {
+ alert: { title: input.title, body: input.body },
+ sound: 'default',
+ },
+ ...(input.data ?? {}),
+});
+
+const handlePush = (
+ req: import('node:http').IncomingMessage,
+ res: import('node:http').ServerResponse
+) => {
+ let raw = '';
+ req.on('data', (chunk: Buffer) => {
+ raw += chunk.toString();
+ });
+ req.on('end', () => {
+ try {
+ const input = JSON.parse(raw) as {
+ title: string;
+ body: string;
+ bundleId?: string;
+ data?: Record;
+ };
+ const udid = findBootedUDID();
+ const payload = buildPayload(input);
+ const bundleId = input.bundleId ?? DEFAULT_BUNDLE_ID;
+ const tmpFile = join(tmpdir(), `push-${Date.now()}.json`);
+ writeFileSync(tmpFile, JSON.stringify(payload));
+ execSync(`xcrun simctl push ${udid} ${bundleId} ${tmpFile}`, {
+ encoding: 'utf8',
+ });
+ unlinkSync(tmpFile);
+ console.log(`[push-relay] sent "${input.title}" to ${udid}`);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, udid }));
+ } catch (err) {
+ console.error('[push-relay] error:', err);
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
+ }
+ });
+};
+
+const server = createServer((req, res) => {
+ if (req.method === 'POST' && req.url === '/push') return handlePush(req, res);
+ res.writeHead(404);
+ res.end();
+});
+
+server.listen(Number(PORT), '127.0.0.1', () => {
+ console.log(`[push-relay] listening on http://127.0.0.1:${PORT}`);
+});