feat: add UnifiedPush as an alternative to FCM for push notifications

This commit is contained in:
Linus Rath
2026-09-24 18:17:43 +02:00
parent 6c82130267
commit ee04e95d4f
14 changed files with 1701 additions and 37 deletions
@@ -29,9 +29,18 @@ import {
setStoredRelayBaseUrl,
setupPushNotifications,
teardownPushNotificationsForAccount,
getStoredPushTransport,
setStoredPushTransport,
type PushDevice,
type PushSetupPhase,
type PushTransport,
} from '../../lib/push-notifications';
import {
getSavedUnifiedPushDistributor,
getUnifiedPushDistributors,
isUnifiedPushSupported,
saveUnifiedPushDistributor,
} from '../../lib/unified-push';
type PushStatus =
| { kind: 'idle' }
@@ -48,7 +57,8 @@ type DevicesState =
const PHASE_KEYS: Record<PushSetupPhase, { key: string; fallback: string }> = {
platform: { key: 'settings.notifications.push.phase_platform', fallback: 'Platform' },
permission: { key: 'settings.notifications.push.phase_permission', fallback: 'Notification permission' },
token: { key: 'settings.notifications.push.phase_token', fallback: 'Device token (Firebase)' },
token: { key: 'settings.notifications.push.phase_token', fallback: 'Device token' },
distributor: { key: 'settings.notifications.push.phase_distributor', fallback: 'UnifiedPush distributor' },
account: { key: 'settings.notifications.push.phase_account', fallback: 'Account' },
relay: { key: 'settings.notifications.push.phase_relay', fallback: 'Relay registration' },
jmap: { key: 'settings.notifications.push.phase_jmap', fallback: 'Mail server subscription' },
@@ -71,7 +81,11 @@ export function NotificationSettings() {
const invitationParsing = useSettingsStore((s) => s.calendarInvitationParsingEnabled);
const supported = isPushSupported();
const upSupported = isUnifiedPushSupported();
const [relayUrl, setRelayUrl] = useState(DEFAULT_RELAY_BASE_URL);
const [transport, setTransport] = useState<PushTransport>('fcm');
const [distributors, setDistributors] = useState<string[]>([]);
const [savedDistributor, setSavedDistributor] = useState<string | null>(null);
const [pushEnabled, setPushEnabled] = useState(false);
const [pushStatus, setPushStatus] = useState<PushStatus>({ kind: 'idle' });
const [confirmDisable, setConfirmDisable] = useState(false);
@@ -92,8 +106,16 @@ export function NotificationSettings() {
if (stored) setRelayUrl(stored);
setPushEnabled(enabled);
setPushStatus(enabled ? { kind: 'enabled' } : { kind: 'idle' });
const storedTransport = await getStoredPushTransport();
const upDistributors = upSupported ? await getUnifiedPushDistributors() : [];
const upSaved = upSupported ? await getSavedUnifiedPushDistributor() : null;
if (cancelled) return;
setTransport(storedTransport ?? 'fcm');
setDistributors(upDistributors);
setSavedDistributor(upSaved);
})();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeAccountId]);
const trimmed = relayUrl.trim().replace(/\/+$/, '');
@@ -155,6 +177,27 @@ export function NotificationSettings() {
}
};
// Switching the transport while push is on re-registers immediately so the
// relay record flips to the new delivery path; while off it just records
// the preference for the next enable.
const handleSelectTransport = async (next: PushTransport) => {
if (next === transport || busy) return;
setTransport(next);
await setStoredPushTransport(next);
if (pushEnabled) void handleEnable(true);
};
const handleSelectDistributor = async (distributor: string) => {
if (distributor === savedDistributor || busy) return;
setSavedDistributor(distributor);
try {
await saveUnifiedPushDistributor(distributor);
} catch {
return;
}
if (pushEnabled && transport === 'unifiedpush') void handleEnable(true);
};
const performDisable = async () => {
setConfirmDisable(false);
setPushStatus({
@@ -239,6 +282,68 @@ export function NotificationSettings() {
</View>
</SettingItem>
{upSupported && (
<>
<SettingItem
label={t('settings.notifications.push.transport_label', 'Delivery method')}
description={t(
'settings.notifications.push.transport_desc',
'How notifications reach this device: Google’s Firebase Cloud Messaging, or a UnifiedPush distributor app (such as ntfy) for Google-free delivery.',
)}
>
<View style={styles.row}>
<Button
variant={transport === 'fcm' ? 'default' : 'outline'}
size="sm"
onPress={() => void handleSelectTransport('fcm')}
disabled={busy}
>
{t('settings.notifications.push.transport_fcm', 'Google (FCM)')}
</Button>
<Button
variant={transport === 'unifiedpush' ? 'default' : 'outline'}
size="sm"
onPress={() => void handleSelectTransport('unifiedpush')}
disabled={busy}
>
{t('settings.notifications.push.transport_unifiedpush', 'UnifiedPush')}
</Button>
</View>
</SettingItem>
{transport === 'unifiedpush' && distributors.length === 0 && (
<Text style={styles.errorText}>
{t(
'settings.notifications.push.up_no_distributor',
'No UnifiedPush distributor app is installed on this device. Install one (for example ntfy) and try again.',
)}
</Text>
)}
{transport === 'unifiedpush' && distributors.length > 1 && (
<SettingItem
label={t('settings.notifications.push.up_distributor_label', 'Distributor')}
description={t(
'settings.notifications.push.up_distributor_desc',
'Several UnifiedPush distributor apps are installed - pick the one that should deliver notifications.',
)}
>
<View style={styles.distributorList}>
{distributors.map((d) => (
<Button
key={d}
variant={d === savedDistributor ? 'default' : 'outline'}
size="sm"
onPress={() => void handleSelectDistributor(d)}
disabled={busy}
>
{d}
</Button>
))}
</View>
</SettingItem>
)}
</>
)}
<SettingItem
label={t('settings.notifications.push.relay_label', 'Push relay')}
description={t(
@@ -450,6 +555,7 @@ function makeStyles(c: ThemePalette) {
alignSelf: 'stretch',
},
urlInputInvalid: { borderColor: c.error },
distributorList: { flexDirection: 'column', alignItems: 'flex-end', gap: spacing.sm },
errorRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6, marginTop: spacing.sm },
errorText: { ...typography.caption, color: c.error },
actions: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm, marginTop: spacing.sm },
@@ -17,6 +17,19 @@ vi.mock('react-native', () => {
getToken: vi.fn(async () => 'fcm-token-xyz'),
deleteToken: vi.fn(async () => undefined),
},
BulwarkUnifiedPush: {
getDistributors: vi.fn(async () => ['io.heckel.ntfy']),
getSavedDistributor: vi.fn(async () => 'io.heckel.ntfy'),
getAckDistributor: vi.fn(async () => 'io.heckel.ntfy'),
saveDistributor: vi.fn(async () => undefined),
register: vi.fn(async () => undefined),
unregister: vi.fn(async () => undefined),
getEndpoint: vi.fn(async () => ({
url: 'https://ntfy.sh/upAbCdEf?up=1',
p256dh: 'B'.repeat(87),
auth: 'a'.repeat(22),
})),
},
},
NativeEventEmitter,
PermissionsAndroid: { RESULTS: { GRANTED: 'granted' }, request: vi.fn(async () => 'granted') },
@@ -273,6 +286,103 @@ describe('setupPushNotifications subscription shape', () => {
});
});
describe('setupPushNotifications over UnifiedPush', () => {
type UpNative = {
getDistributors: ReturnType<typeof vi.fn>;
getSavedDistributor: ReturnType<typeof vi.fn>;
register: ReturnType<typeof vi.fn>;
getEndpoint: ReturnType<typeof vi.fn>;
};
const upNative = () => (NativeModules as { BulwarkUnifiedPush: UpNative }).BulwarkUnifiedPush;
// Like installFetch, plus the two UnifiedPush relay endpoints; captures the
// registration body so tests can assert on it.
function installUpFetch(): { body: () => Record<string, unknown> | null } {
let captured: Record<string, unknown> | null = null;
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input.toString();
if (url.includes('/api/push/vapid-public-key')) {
return { ok: true, status: 200, json: async () => ({ publicKey: 'VAPID-PUB' }) } as Response;
}
if (url.includes('/api/push/register/unifiedpush')) {
captured = JSON.parse(String(init?.body ?? 'null')) as Record<string, unknown>;
return { ok: true, status: 200, json: async () => ({ ok: true }) } as Response;
}
if (url.includes('/api/push/verify/')) {
return { ok: true, status: 200, json: async () => ({ verificationCode: 'CODE' }) } as Response;
}
throw new Error(`unexpected fetch: ${url}`);
}) as typeof fetch;
return { body: () => captured };
}
beforeEach(async () => {
vi.clearAllMocks();
await AsyncStorage.clear();
await AsyncStorage.setItem(deviceClientIdKey(ACCOUNT_ID), OUR_DCID);
await AsyncStorage.setItem('push:transport:v1', 'unifiedpush');
listMock.mockResolvedValue([]);
});
it('registers the distributor endpoint with the relay instead of an FCM token', async () => {
const relayReg = installUpFetch();
const result = await setupPushNotifications({ relayBaseUrl: RELAY });
expect(result.verified).toBe(true);
const native = (NativeModules as { BulwarkFcm: { getToken: ReturnType<typeof vi.fn> } }).BulwarkFcm;
expect(native.getToken).not.toHaveBeenCalled();
// The relay's VAPID key is handed to the distributor at registration.
expect(upNative().register).toHaveBeenCalledWith('VAPID-PUB');
expect(relayReg.body()).toEqual({
subscriptionId: OUR_DCID,
endpoint: 'https://ntfy.sh/upAbCdEf?up=1',
keys: { p256dh: 'B'.repeat(87), auth: 'a'.repeat(22) },
accountLabel: undefined,
});
// The JMAP subscription flow is transport-independent.
expect(createMock).toHaveBeenCalledTimes(1);
});
it('sends keys: null for a legacy distributor without Web Push keys', async () => {
upNative().getEndpoint.mockResolvedValue({
url: 'https://legacy.example/up123456',
p256dh: null,
auth: null,
});
const relayReg = installUpFetch();
await setupPushNotifications({ relayBaseUrl: RELAY });
expect(relayReg.body()).toMatchObject({
endpoint: 'https://legacy.example/up123456',
keys: null,
});
});
it('fails with the distributor phase when no distributor is installed', async () => {
upNative().getDistributors.mockResolvedValue([]);
installUpFetch();
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
expect(err).toBeInstanceOf(PushSetupError);
expect(err.phase).toBe('distributor');
expect(createMock).not.toHaveBeenCalled();
});
it('requires an explicit choice when several distributors are installed', async () => {
upNative().getDistributors.mockResolvedValue(['io.heckel.ntfy', 'org.unifiedpush.distributor.sunup']);
upNative().getSavedDistributor.mockResolvedValue(null);
installUpFetch();
const err = await setupPushNotifications({ relayBaseUrl: RELAY }).catch((e) => e);
expect(err.phase).toBe('distributor');
expect(err.message).toContain('choose one');
});
});
describe('teardownPushNotificationsForAccount', () => {
beforeEach(async () => {
vi.clearAllMocks();
+139 -15
View File
@@ -11,11 +11,22 @@ import { getMailboxes, getSharedMailboxes } from '../api/email';
import { jmapClient } from '../api/jmap-client';
import type { EmailPushConfig, Mailbox } from '../api/types';
import { generateAccountId } from './account-utils';
import {
getUnifiedPushDistributors,
isUnifiedPushSupported,
registerUnifiedPush,
unregisterUnifiedPush,
UnifiedPushRegisterError,
type UnifiedPushEndpoint,
} from './unified-push';
// Persist identifiers across launches so we reuse the same JMAP subscription
// after app restarts. Each account gets its own deviceClientId so the hosted
// relay can distinguish per-account pushes via the URL slot it forwards.
const RELAY_BASE_URL_KEY = 'push:relayBaseUrl:v1';
// Which transport carries pushes to this device: FCM (default) or a
// UnifiedPush distributor. Device-wide, like the relay URL.
const PUSH_TRANSPORT_KEY = 'push:transport:v1';
const PUSH_ACCOUNT_IDS_KEY = 'push:accountIds:v1';
// Local account id (username@host) → JMAP primary account id. The relay tags
// every forwarded push with the JMAP account id it came from, which is the
@@ -276,9 +287,28 @@ function getNative(): BulwarkFcmNative | null {
return (NativeModules as Record<string, unknown>).BulwarkFcm as BulwarkFcmNative | undefined ?? null;
}
/** True on platforms that have a push transport wired up (Android/FCM only). */
/** True on platforms that have a push transport wired up (Android only). */
export function isPushSupported(): boolean {
return getNative() !== null;
return getNative() !== null || isUnifiedPushSupported();
}
export type PushTransport = 'fcm' | 'unifiedpush';
export async function getStoredPushTransport(): Promise<PushTransport | null> {
const raw = await AsyncStorage.getItem(PUSH_TRANSPORT_KEY);
return raw === 'fcm' || raw === 'unifiedpush' ? raw : null;
}
export async function setStoredPushTransport(transport: PushTransport | null): Promise<void> {
if (!transport) {
await AsyncStorage.removeItem(PUSH_TRANSPORT_KEY);
} else {
await AsyncStorage.setItem(PUSH_TRANSPORT_KEY, transport);
}
}
export async function getEffectivePushTransport(): Promise<PushTransport> {
return (await getStoredPushTransport()) ?? 'fcm';
}
export interface PushSetupParams {
@@ -303,6 +333,7 @@ export type PushSetupPhase =
| 'platform'
| 'permission'
| 'token'
| 'distributor'
| 'account'
| 'relay'
| 'jmap'
@@ -390,16 +421,22 @@ export async function getFcmToken(): Promise<string | null> {
// Firebase rejects getToken() on devices without Google Play services (or
// with a broken Firebase configuration), and briefly right after a
// deleteToken(). Turn the raw native rejection into a phase-tagged error the
// settings pane can explain.
// settings pane can explain - and point de-Googled devices at UnifiedPush
// when a distributor is around to take over.
async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
let token: string | null = null;
try {
token = await native.getToken();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
const distributors = await getUnifiedPushDistributors().catch(() => []);
const hint =
distributors.length > 0
? ' This device has a UnifiedPush distributor installed - switch the delivery method to UnifiedPush instead.'
: '';
throw new PushSetupError(
'token',
`Firebase could not issue a device token (${detail}). Push needs Google Play services on this device.`,
`Firebase could not issue a device token (${detail}). Push over FCM needs Google Play services on this device.${hint}`,
);
}
if (!token) {
@@ -408,6 +445,39 @@ async function getFcmTokenOrThrow(native: BulwarkFcmNative): Promise<string> {
return token;
}
/**
* The relay's VAPID public key, when it has one. Passed to the UnifiedPush
* distributor at registration so distributors that pin the application server
* accept the relay's pushes; registration proceeds without it (503 on relays
* without configured VAPID keys - UnifiedPush delivery still works there).
*/
async function fetchRelayVapidPublicKey(relayBaseUrl: string): Promise<string | null> {
try {
const res = await fetch(buildRelayUrl(relayBaseUrl, '/api/push/vapid-public-key'));
if (!res.ok) return null;
const body = (await res.json()) as { publicKey?: unknown };
return typeof body.publicKey === 'string' && body.publicKey ? body.publicKey : null;
} catch {
return null;
}
}
/** Acquire a UnifiedPush endpoint, mapping failures onto setup phases. */
async function getUnifiedPushEndpointOrThrow(
relayBaseUrl: string,
): Promise<UnifiedPushEndpoint> {
const vapid = await fetchRelayVapidPublicKey(relayBaseUrl);
try {
return await registerUnifiedPush({ vapid });
} catch (err) {
if (err instanceof UnifiedPushRegisterError) {
const phase = err.reason === 'failed' ? 'token' : 'distributor';
throw new PushSetupError(phase, err.message);
}
throw new PushSetupError('token', err instanceof Error ? err.message : String(err));
}
}
function buildRelayUrl(base: string, suffix: string): string {
return base.replace(/\/+$/, '') + suffix;
}
@@ -454,6 +524,37 @@ async function registerWithRelay(params: {
}
}
async function registerWithRelayUnifiedPush(params: {
relayBaseUrl: string;
subscriptionId: string;
endpoint: UnifiedPushEndpoint;
accountLabel?: string;
}): Promise<void> {
const { endpoint } = params;
let res: Response;
try {
res = await fetch(buildRelayUrl(params.relayBaseUrl, '/api/push/register/unifiedpush'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
subscriptionId: params.subscriptionId,
endpoint: endpoint.url,
keys:
endpoint.p256dh && endpoint.auth
? { p256dh: endpoint.p256dh, auth: endpoint.auth }
: null,
accountLabel: params.accountLabel,
}),
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new PushSetupError('relay', `Could not reach the push relay at ${params.relayBaseUrl} (${detail}).`);
}
if (!res.ok) {
throw new PushSetupError('relay', `The push relay rejected the registration: ${await readRelayError(res)}`);
}
}
/** The relay's view of a subscription - see relayStatusFor. */
export type PushRelayStatus = 'active' | 'inactive' | 'unknown';
@@ -570,10 +671,14 @@ function logPhase(phase: string, detail?: string): void {
async function setupPushNotificationsInner(
params: PushSetupParams,
): Promise<PushSetupResult> {
const transport = await getEffectivePushTransport();
const native = getNative();
if (!native) {
if (transport === 'fcm' && !native) {
throw new PushSetupError('platform', 'Push notifications are only available on Android.');
}
if (transport === 'unifiedpush' && !isUnifiedPushSupported()) {
throw new PushSetupError('platform', 'UnifiedPush is only available on Android.');
}
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
if (!relayBaseUrl) throw new PushSetupError('relay', 'relayBaseUrl is required');
@@ -587,8 +692,14 @@ async function setupPushNotificationsInner(
throw new PushSetupError('permission', 'Notification permission was not granted.');
}
logPhase('token');
const fcmToken = await getFcmTokenOrThrow(native);
logPhase('token', transport);
let fcmToken: string | null = null;
let upEndpoint: UnifiedPushEndpoint | null = null;
if (transport === 'unifiedpush') {
upEndpoint = await getUnifiedPushEndpointOrThrow(relayBaseUrl);
} else {
fcmToken = await getFcmTokenOrThrow(native!);
}
// setupPushNotifications operates on the currently-loaded jmapClient. We
// need its username/serverUrl up-front so we can key per-account state.
@@ -606,15 +717,25 @@ async function setupPushNotificationsInner(
// Register this account's device-client-id with the relay. Multiple
// accounts on the same device end up as separate registrations sharing
// one fcmToken - the relay forwards each push individually and tags it
// with the JMAP account id so the headless task can route it.
// one FCM token / UnifiedPush endpoint - the relay forwards each push
// individually and tags it with the JMAP account id so the headless task
// can route it.
logPhase('relay', relayBaseUrl);
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
fcmToken,
accountLabel: params.accountLabel,
});
if (upEndpoint) {
await registerWithRelayUnifiedPush({
relayBaseUrl,
subscriptionId: deviceClientId,
endpoint: upEndpoint,
accountLabel: params.accountLabel,
});
} else {
await registerWithRelay({
relayBaseUrl,
subscriptionId: deviceClientId,
fcmToken: fcmToken!,
accountLabel: params.accountLabel,
});
}
// Reuse the previous JMAP subscription when the server still has it, but
// push the expiry forward so it doesn't time out before the next app start.
@@ -853,6 +974,9 @@ export async function teardownPushNotifications(): Promise<void> {
if (native) {
await native.deleteToken().catch(() => undefined);
}
// Also release any UnifiedPush registration so the distributor stops
// holding a channel for us. No-op when never registered.
await unregisterUnifiedPush().catch(() => undefined);
}
export interface PushDevice {
+216
View File
@@ -0,0 +1,216 @@
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
// Thin JS surface over the BulwarkUnifiedPush native module. UnifiedPush
// (https://unifiedpush.org) delivers pushes through a user-installed
// distributor app (ntfy, NextPush, ...) instead of Google Play services -
// the transport for de-Googled devices. Incoming messages themselves are
// dispatched natively through the same headless task / `fcm:message` event
// as FCM, so only registration management lives here.
export interface UnifiedPushEndpoint {
url: string;
// RFC 8291 Web Push keys from the connector; null with legacy distributors,
// in which case the relay falls back to plain (unencrypted ping) delivery.
p256dh: string | null;
auth: string | null;
}
type BulwarkUnifiedPushNative = {
getDistributors(): Promise<string[]>;
getSavedDistributor(): Promise<string | null>;
getAckDistributor(): Promise<string | null>;
saveDistributor(distributor: string): Promise<void>;
register(vapid: string | null): Promise<void>;
unregister(): Promise<void>;
getEndpoint(): Promise<UnifiedPushEndpoint | null>;
};
function getNative(): BulwarkUnifiedPushNative | null {
if (Platform.OS !== 'android') return null;
return (
((NativeModules as Record<string, unknown>).BulwarkUnifiedPush as
| BulwarkUnifiedPushNative
| undefined) ?? null
);
}
/** True when the app build carries the UnifiedPush connector (Android only). */
export function isUnifiedPushSupported(): boolean {
return getNative() !== null;
}
/** Package names of the distributor apps installed on this device. */
export async function getUnifiedPushDistributors(): Promise<string[]> {
const native = getNative();
if (!native) return [];
try {
return await native.getDistributors();
} catch {
return [];
}
}
export async function getSavedUnifiedPushDistributor(): Promise<string | null> {
const native = getNative();
if (!native) return null;
try {
return await native.getSavedDistributor();
} catch {
return null;
}
}
export async function saveUnifiedPushDistributor(distributor: string): Promise<void> {
const native = getNative();
if (!native) throw new Error('UnifiedPush is not available on this platform.');
await native.saveDistributor(distributor);
}
export async function getUnifiedPushEndpoint(): Promise<UnifiedPushEndpoint | null> {
const native = getNative();
if (!native) return null;
try {
return await native.getEndpoint();
} catch {
return null;
}
}
export async function unregisterUnifiedPush(): Promise<void> {
const native = getNative();
if (!native) return;
await native.unregister().catch(() => undefined);
}
function emitter(): NativeEventEmitter {
return new NativeEventEmitter(NativeModules.BulwarkUnifiedPush);
}
/**
* Fires whenever the distributor hands out a (new) endpoint - including
* rotations while the app runs. Callers re-register the endpoint with the
* relay, mirroring the FCM token-refresh listener.
*/
export function addUnifiedPushEndpointListener(
listener: (endpoint: UnifiedPushEndpoint) => void,
): () => void {
if (!isUnifiedPushSupported()) return () => undefined;
const sub = emitter().addListener('up:newEndpoint', listener);
return () => sub.remove();
}
export function addUnifiedPushUnregisteredListener(listener: () => void): () => void {
if (!isUnifiedPushSupported()) return () => undefined;
const sub = emitter().addListener('up:unregistered', listener);
return () => sub.remove();
}
export class UnifiedPushRegisterError extends Error {
// 'no-distributor' - nothing installed; 'choose-distributor' - several
// installed and none selected yet; 'failed' - the distributor rejected or
// never answered the registration.
readonly reason: 'no-distributor' | 'choose-distributor' | 'failed';
constructor(reason: UnifiedPushRegisterError['reason'], message: string) {
super(message);
this.name = 'UnifiedPushRegisterError';
this.reason = reason;
}
}
/**
* Register with the saved distributor (auto-selecting it when exactly one is
* installed) and resolve with the endpoint. The endpoint arrives as an async
* event from the distributor; a previously stored endpoint short-circuits the
* wait - if the distributor then rotates it, the up:newEndpoint listener
* re-runs setup with the fresh one.
*/
export async function registerUnifiedPush(params: {
vapid?: string | null;
timeoutMs?: number;
}): Promise<UnifiedPushEndpoint> {
const native = getNative();
if (!native) {
throw new UnifiedPushRegisterError('failed', 'UnifiedPush is not available on this platform.');
}
const distributors = await getUnifiedPushDistributors();
if (distributors.length === 0) {
throw new UnifiedPushRegisterError(
'no-distributor',
'No UnifiedPush distributor app is installed. Install one (for example ntfy) and try again.',
);
}
let saved = await getSavedUnifiedPushDistributor();
if (!saved || !distributors.includes(saved)) {
if (distributors.length > 1) {
throw new UnifiedPushRegisterError(
'choose-distributor',
'Several UnifiedPush distributors are installed - choose one in the notification settings.',
);
}
saved = distributors[0];
await native.saveDistributor(saved);
}
const timeoutMs = params.timeoutMs ?? 30_000;
return new Promise<UnifiedPushEndpoint>((resolve, reject) => {
let settled = false;
const cleanups: Array<() => void> = [];
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
for (const c of cleanups) c();
fn();
};
const epSub = emitter().addListener('up:newEndpoint', (endpoint: UnifiedPushEndpoint) => {
finish(() => resolve(endpoint));
});
cleanups.push(() => epSub.remove());
const failSub = emitter().addListener(
'up:registrationFailed',
(payload: { reason?: string }) => {
finish(() =>
reject(
new UnifiedPushRegisterError(
'failed',
`The distributor rejected the registration (${payload?.reason ?? 'unknown'}).`,
),
),
);
},
);
cleanups.push(() => failSub.remove());
const timer = setTimeout(() => {
finish(() =>
reject(
new UnifiedPushRegisterError(
'failed',
`The distributor did not provide an endpoint within ${Math.round(timeoutMs / 1000)} s.`,
),
),
);
}, timeoutMs);
cleanups.push(() => clearTimeout(timer));
void native
.register(params.vapid ?? null)
.then(async () => {
// A re-registration usually keeps the existing endpoint, and the
// distributor may not re-announce it - use the stored one if present.
const stored = await native.getEndpoint().catch(() => null);
if (stored) finish(() => resolve(stored));
})
.catch((err: unknown) => {
finish(() =>
reject(
new UnifiedPushRegisterError(
'failed',
err instanceof Error ? err.message : String(err),
),
),
);
});
});
}