Add shouldBypassNotificationAuth() when test mode or a backend URL override is set so register/refresh can proceed without DID/Bearer headers. Production paths still require auth when bypass is off; log bypass vs authenticated request modes for easier WAKEUP_PING and panel smoke testing.
315 lines
9.0 KiB
TypeScript
315 lines
9.0 KiB
TypeScript
/**
|
|
* Firebase Cloud Messaging (JS SDK) + Capacitor Push Notifications (native bridge).
|
|
*
|
|
* Initializes the Firebase web app when VITE_FIREBASE_* env vars are set, wires
|
|
* Capacitor push listeners, requests permission before registration/token flow,
|
|
* and attaches Firebase messaging when the browser/WebView reports support.
|
|
*/
|
|
|
|
import { Capacitor } from "@capacitor/core";
|
|
import { PushNotifications } from "@capacitor/push-notifications";
|
|
import {
|
|
type FirebaseApp,
|
|
type FirebaseOptions,
|
|
getApps,
|
|
initializeApp,
|
|
} from "firebase/app";
|
|
import {
|
|
getMessaging,
|
|
getToken,
|
|
isSupported,
|
|
onMessage,
|
|
} from "firebase/messaging";
|
|
import { logger } from "@/utils/logger";
|
|
import { handleCapacitorPushNotificationReceived } from "./NativeNotificationService";
|
|
import { getNotificationApiHeaders } from "./notificationApiAuth";
|
|
import { deferFcmRegistration } from "./notificationAuthLifecycle";
|
|
import { registerToken } from "./NotificationService";
|
|
import {
|
|
logPushNotificationActionPerformed,
|
|
logPushNotificationReceived,
|
|
logTokenRegistrationSkippedDuplicate,
|
|
} from "./notificationLog";
|
|
|
|
const LOG = "[FirebaseMessaging]";
|
|
|
|
let firebaseAppSingleton: FirebaseApp | null = null;
|
|
let nativeInitPromise: Promise<void> | null = null;
|
|
/** Avoid duplicate POSTs when the same token is delivered more than once. */
|
|
let lastRegisteredFcmToken: string | null = null;
|
|
/** Last token received from Capacitor/Firebase (may match registered). */
|
|
let lastSeenFcmToken: string | null = null;
|
|
|
|
async function registerRetrievedToken(
|
|
token: string,
|
|
options?: { force?: boolean },
|
|
): Promise<void> {
|
|
const trimmed = token.trim();
|
|
if (!trimmed) {
|
|
return;
|
|
}
|
|
lastSeenFcmToken = trimmed;
|
|
if (!options?.force && trimmed === lastRegisteredFcmToken) {
|
|
logTokenRegistrationSkippedDuplicate(trimmed);
|
|
return;
|
|
}
|
|
|
|
const auth = await getNotificationApiHeaders("register");
|
|
if (!auth.ok) {
|
|
if (options?.force) {
|
|
throw new Error(`FCM registration auth unavailable: ${auth.message}`);
|
|
}
|
|
deferFcmRegistration(trimmed);
|
|
return;
|
|
}
|
|
|
|
await registerToken(trimmed);
|
|
lastRegisteredFcmToken = trimmed;
|
|
}
|
|
|
|
/** Most recent FCM token from native/web push registration (for debug UI). */
|
|
export function getLastKnownFcmToken(): string | null {
|
|
return lastSeenFcmToken ?? lastRegisteredFcmToken;
|
|
}
|
|
|
|
/**
|
|
* Re-runs token registration immediately (debug). Bypasses duplicate-token skip.
|
|
*/
|
|
export async function reregisterFcmTokenNow(): Promise<string> {
|
|
if (!Capacitor.isNativePlatform()) {
|
|
throw new Error("FCM registration is only available on native platforms");
|
|
}
|
|
|
|
lastRegisteredFcmToken = null;
|
|
|
|
const cached = lastSeenFcmToken?.trim();
|
|
if (cached) {
|
|
await registerRetrievedToken(cached, { force: true });
|
|
return cached;
|
|
}
|
|
|
|
const app = ensureFirebaseApp();
|
|
if (app && (await isSupported())) {
|
|
const messaging = getMessaging(app);
|
|
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as
|
|
| string
|
|
| undefined;
|
|
const token = await getToken(
|
|
messaging,
|
|
vapidKey ? { vapidKey } : undefined,
|
|
);
|
|
if (!token?.trim()) {
|
|
throw new Error("Firebase getToken returned an empty token");
|
|
}
|
|
await registerRetrievedToken(token, { force: true });
|
|
return token.trim();
|
|
}
|
|
|
|
return new Promise<string>((resolve, reject) => {
|
|
const timeoutMs = 15_000;
|
|
const timeoutId = window.setTimeout(() => {
|
|
void listenerPromise.then((h) => h.remove());
|
|
reject(new Error("Timed out waiting for push registration token"));
|
|
}, timeoutMs);
|
|
|
|
const listenerPromise = PushNotifications.addListener(
|
|
"registration",
|
|
(token) => {
|
|
window.clearTimeout(timeoutId);
|
|
void listenerPromise.then((h) => h.remove());
|
|
const value = token.value?.trim() ?? "";
|
|
if (!value) {
|
|
reject(new Error("Capacitor registration returned an empty token"));
|
|
return;
|
|
}
|
|
void registerRetrievedToken(value, { force: true })
|
|
.then(() => resolve(value))
|
|
.catch(reject);
|
|
},
|
|
);
|
|
|
|
void PushNotifications.register().catch((err) => {
|
|
window.clearTimeout(timeoutId);
|
|
void listenerPromise.then((h) => h.remove());
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
function readFirebaseOptions(): FirebaseOptions | null {
|
|
const env = import.meta.env;
|
|
const apiKey = env.VITE_FIREBASE_API_KEY as string | undefined;
|
|
const projectId = env.VITE_FIREBASE_PROJECT_ID as string | undefined;
|
|
const appId = env.VITE_FIREBASE_APP_ID as string | undefined;
|
|
const messagingSenderId = env.VITE_FIREBASE_MESSAGING_SENDER_ID as
|
|
| string
|
|
| undefined;
|
|
|
|
if (!apiKey || !projectId || !appId || !messagingSenderId) {
|
|
logger.debug(
|
|
`${LOG} Missing one or more VITE_FIREBASE_* keys; Firebase app not initialized`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const authDomain =
|
|
(env.VITE_FIREBASE_AUTH_DOMAIN as string | undefined) ||
|
|
`${projectId}.firebaseapp.com`;
|
|
const storageBucket =
|
|
(env.VITE_FIREBASE_STORAGE_BUCKET as string | undefined) ||
|
|
`${projectId}.appspot.com`;
|
|
|
|
const opts: FirebaseOptions = {
|
|
apiKey,
|
|
authDomain,
|
|
projectId,
|
|
storageBucket,
|
|
messagingSenderId,
|
|
appId,
|
|
};
|
|
|
|
const measurementId = env.VITE_FIREBASE_MEASUREMENT_ID as string | undefined;
|
|
if (measurementId) {
|
|
opts.measurementId = measurementId;
|
|
}
|
|
|
|
return opts;
|
|
}
|
|
|
|
/**
|
|
* Ensures a single Firebase app instance for the client when config is present.
|
|
*/
|
|
export function ensureFirebaseApp(): FirebaseApp | null {
|
|
if (firebaseAppSingleton) {
|
|
return firebaseAppSingleton;
|
|
}
|
|
const options = readFirebaseOptions();
|
|
if (!options) {
|
|
return null;
|
|
}
|
|
firebaseAppSingleton =
|
|
getApps().length > 0 ? getApps()[0]! : initializeApp(options);
|
|
logger.info(`${LOG} Firebase app initialized`);
|
|
return firebaseAppSingleton;
|
|
}
|
|
|
|
async function attachFirebaseMessagingIfSupported(
|
|
app: FirebaseApp,
|
|
): Promise<void> {
|
|
if (!(await isSupported())) {
|
|
logger.debug(
|
|
`${LOG} firebase/messaging not supported in this context; skipping getMessaging`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const messaging = getMessaging(app);
|
|
const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as
|
|
| string
|
|
| undefined;
|
|
|
|
try {
|
|
const token = await getToken(
|
|
messaging,
|
|
vapidKey ? { vapidKey } : undefined,
|
|
);
|
|
logger.info(`${LOG} Firebase getToken completed`, {
|
|
tokenPrefix: token ? `${token.slice(0, 12)}…` : "(empty)",
|
|
});
|
|
await registerRetrievedToken(token);
|
|
} catch (err) {
|
|
logger.warn(
|
|
`${LOG} Firebase getToken failed (common on native WebView without SW)`,
|
|
err,
|
|
);
|
|
}
|
|
|
|
onMessage(messaging, (payload) => {
|
|
logger.debug(`${LOG} onMessage (foreground)`, payload);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Native: register Capacitor push listeners, request permissions, register for push,
|
|
* then initialize Firebase Messaging when env config and platform support allow.
|
|
*/
|
|
async function initializeNativePushAndFirebaseMessagingImpl(): Promise<void> {
|
|
if (!Capacitor.isNativePlatform()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const app = ensureFirebaseApp();
|
|
|
|
await PushNotifications.addListener("registration", (token) => {
|
|
if (token.value?.trim()) {
|
|
lastSeenFcmToken = token.value.trim();
|
|
}
|
|
logger.info(`${LOG} Capacitor registration token`, {
|
|
valuePrefix: token.value ? `${token.value.slice(0, 12)}…` : "(empty)",
|
|
});
|
|
void registerRetrievedToken(token.value).catch((err) => {
|
|
logger.warn(
|
|
`${LOG} registerToken after Capacitor registration failed`,
|
|
err,
|
|
);
|
|
});
|
|
});
|
|
|
|
await PushNotifications.addListener("registrationError", (err) => {
|
|
logger.error(`${LOG} registrationError`, err);
|
|
});
|
|
|
|
await PushNotifications.addListener(
|
|
"pushNotificationReceived",
|
|
(notification) => {
|
|
logger.debug(`${LOG} pushNotificationReceived`, notification);
|
|
logPushNotificationReceived(notification);
|
|
void handleCapacitorPushNotificationReceived(notification).catch(
|
|
(err) => {
|
|
logger.warn(
|
|
`${LOG} handleCapacitorPushNotificationReceived failed`,
|
|
err,
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
await PushNotifications.addListener(
|
|
"pushNotificationActionPerformed",
|
|
(action) => {
|
|
logger.debug(`${LOG} pushNotificationActionPerformed`, action);
|
|
logPushNotificationActionPerformed(action);
|
|
},
|
|
);
|
|
|
|
const perm = await PushNotifications.requestPermissions();
|
|
if (perm.receive !== "granted") {
|
|
logger.warn(`${LOG} Push permission not granted`, perm);
|
|
return;
|
|
}
|
|
|
|
await PushNotifications.register();
|
|
|
|
if (app) {
|
|
await attachFirebaseMessagingIfSupported(app);
|
|
}
|
|
} catch (err) {
|
|
logger.error(`${LOG} Native push / Firebase messaging init failed`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Idempotent startup hook for Capacitor iOS/Android.
|
|
*/
|
|
export function initializeNativePushAndFirebaseMessaging(): Promise<void> {
|
|
if (!Capacitor.isNativePlatform()) {
|
|
return Promise.resolve();
|
|
}
|
|
if (!nativeInitPromise) {
|
|
nativeInitPromise = initializeNativePushAndFirebaseMessagingImpl();
|
|
}
|
|
return nativeInitPromise;
|
|
}
|