Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,6 @@ android/generated

# React Native Nitro Modules
nitrogen/

# Push relay credentials (real-device APNs — never commit)
.env.push
37 changes: 37 additions & 0 deletions example/.maestro/background_notification.yaml
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions example/.maestro/foreground_notification.yaml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions example/.maestro/send_push.js
Original file line number Diff line number Diff line change
@@ -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,
});
30 changes: 16 additions & 14 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 (
<ScrollView contentContainerStyle={container}>
<Text style={title}>Nitro Notifications</Text>
Expand Down Expand Up @@ -151,7 +151,9 @@ export default function App() {
<Button title="Reset UI" color="#7f8c8d" onPress={handleReset} />
</Section>
<Section label="Last Event">
<Text style={event}>{lastEvent ?? 'None'}</Text>
<Text testID="last-event-text" style={event}>
{lastEvent ?? 'None'}
</Text>
</Section>
</ScrollView>
);
Expand Down
20 changes: 20 additions & 0 deletions ios/NitroNotificationLoader.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#import <Foundation/Foundation.h>

/// 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
4 changes: 2 additions & 2 deletions ios/NotificationHub.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@ 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) }
if foregroundBadge { options.insert(.badge) }
if foregroundSound { options.insert(.sound) }
let payload = payloadFrom(notification.request.content)
onNotificationReceived?(payload)
completionHandler(options)
completionHandler(Int(options.rawValue))
}

func userNotificationCenter(
Expand Down
7 changes: 7 additions & 0 deletions ios/NotificationsAppDelegateSubscriber.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 82 additions & 0 deletions tools/push-relay.ts
Original file line number Diff line number Diff line change
@@ -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<string, Array<{ state: string; udid: string }>>;
};
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<string, string>;
}) => ({
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<string, string>;
};
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}`);
});
Loading