Sur thisishumanmade.com, quand un visiteur écrit un message dans le chat, je reçois une notification push sur mon iPhone - même si l'app est tuée. Je réponds, et le visiteur voit ma réponse en temps réel. Pas de Crisp, pas d'Intercom, pas de SaaS. Juste Firebase, du Swift et une Cloud Function de 50 lignes.
Voici comment tout ça s'articule.
Vue d'ensemble
Le système tient en 4 composants :
- Le site web - un chat widget en vanilla JS qui écrit dans Firebase Realtime DB
- Firebase Realtime DB - la base de données temps réel qui synchronise tout
- L'app native - SwiftUI, disponible sur macOS (menu bar) et iOS (NavigationStack)
- Une Cloud Function - déclenchée à chaque nouveau message, envoie un push via FCM
Le site web : chat widget en vanilla JS
Le chat sur le site est un widget JavaScript sans framework. Quand un visiteur ouvre la page, un conversationId est génère et stocke dans sessionStorage. Chaque message est écrit dans Firebase Realtime DB via le SDK JavaScript :
// Ecriture d'un message visiteur
const msgRef = push(ref(db, `conversations/${convId}/messages`));
set(msgRef, {
text: message,
sender: visitorName,
timestamp: Date.now()
});
Le choix de sessionStorage (et non localStorage) est délibéré : un refresh garde la conversation, mais un nouvel onglet en crée une nouvelle. Le visiteur peut ainsi avoir des conversations indépendantes.
Le SDK Firebase écoute aussi les réponses en temps réel avec onChildAdded, ce qui affiche les réponses instantanément sans polling.
Firebase Realtime DB : la colonne vertébrale
Toute la donnée transite par Firebase Realtime Database. Pas de backend custom, pas d'API REST à maintenir. La structure est simple :
{
"status": {
"current": "available",
"updatedAt": 1707840000000
},
"statusConfig": {
"available": { "label": "Disponible", "emoji": "🟢", "chatMessage": "..." },
"busy": { "label": "Occupe", "emoji": "đźź ", "chatMessage": "..." },
"offline": { "label": "Absent", "emoji": "⚪", "chatMessage": "..." }
},
"conversations": {
"-ABC123": {
"visitorName": "Marie",
"startedAt": 1707840000000,
"lastMessageAt": 1707840060000,
"status": "active",
"messages": {
"-MSG001": { "text": "Bonjour !", "sender": "Marie", "timestamp": ... },
"-MSG002": { "text": "Salut Marie", "sender": "emmanuel", "timestamp": ... }
}
}
},
"admin": {
"fcmToken": "dhOeso..."
}
}
Pourquoi Realtime DB et pas Firestore ?
Pour ce cas d'usage (1 admin, quelques visiteurs simultanés, données simples), Realtime Database est plus adapte que Firestore :
- Latence - RTDB est optimisé pour les petites données temps réel (~10ms vs ~50ms pour Firestore)
- Pricing - facture au bandwidth + storage, pas au nombre de reads (un observer RTDB compte comme 1 read, pas N)
- SSE natif - le protocole Server-Sent Events permet de brancher n'importe quel client REST sans SDK
Firestore brille pour les queries complexes, les indexes composes et le scaling horizontal. Ici, on n'a besoin de rien de tout ça.
Règles de sécurité
{
"rules": {
"conversations": { ".read": true, ".write": true },
"admin": {
"fcmToken": { ".read": false, ".write": true }
}
}
}
Le FCM token est en write-only. L'app iOS peut l'écrire, mais aucun client ne peut le lire. Seule la Cloud Function y accède - via l'Admin SDK qui bypass les règles de sécurité. Cela empêche un visiteur de récupérer le token pour envoyer des pushes non autorises.
L'app macOS : menu bar avec SwiftUI
L'app macOS vit dans la menu bar. Pas de dock icon, pas de fenêtre permanente - juste une icône discrète avec un badge de messages non lus.
Elle utilise le Firebase iOS SDK (qui fonctionne aussi sur macOS via Swift Package Manager) et maintient un observer .observe(.value) sur le nœud /conversations. Chaque changement dans la DB déclenche un callback instantané.
// Observer temps reel sur toutes les conversations
conversationsHandle = db.child("conversations")
.observe(.value) { snapshot in
// Parse + diff + notification locale si nouveau message
}
Quand un nouveau message visiteur arrive, l'app :
- Détecte les messages inconnus par diff d'IDs (
Setexistant vs nouveau) - Incremente le compteur
unreadCount - Envoie une notification locale via
UNUserNotificationCenter
Sur macOS, pas besoin de push FCM - l'app est toujours vivante dans la menu bar. Les observers Firebase ne meurent jamais.
L'app iOS : même code, problèmes différents
L'app iOS partage 90% du code grâce à #if os(iOS) / #if os(macOS). Les vues sont dans des extensions conditionnelles :
struct MenuBarView: View {
var body: some View {
#if os(iOS)
iOSBody // NavigationStack
#else
macOSBody // HStack avec sidebar
#endif
}
}
Le problème du background iOS
Sur macOS, les observers Firebase tournent indéfiniment. Sur iOS, c'est une autre histoire :
- iOS suspend l'app ~30 secondes après le passage en background
- Les connexions réseau sont coupées - les observers Firebase meurent
- Les messages s'accumulent et arrivent tous d'un coup à la réouverture
C'est pour ça que les notifications locales ne suffisent pas sur iOS. Il faut un système server-side qui pousse les notifications même quand l'app est morte.
FCM + Cloud Function : les push qui marchent vraiment
La solution est en deux pièces :
1. L'app iOS s'enregistre auprès de FCM
Au lancement, l'app demande un token APNs Ă Apple, le passe Ă Firebase Cloud Messaging, et stocke le FCM token dans la DB :
// iOSAppDelegate.swift
class iOSAppDelegate: NSObject, UIApplicationDelegate, MessagingDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions ...) -> Bool {
FirebaseApp.configure()
Messaging.messaging().delegate = self
application.registerForRemoteNotifications()
return true
}
// Apple donne un token APNs binaire -> on le passe a FCM
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
Messaging.messaging().apnsToken = deviceToken
}
// FCM echange le token APNs contre son propre token string
func messaging(_ messaging: Messaging,
didReceiveRegistrationToken fcmToken: String?) {
guard let token = fcmToken else { return }
// Stocke dans Firebase RTDB a /admin/fcmToken
FirebaseService.shared.storeFCMToken(token)
}
}
Le point clé : il y a deux tokens différents. Le token APNs est un blob binaire d'Apple. FCM l'échange contre son propre token string. C'est ce token FCM que la Cloud Function utilise.
2. La Cloud Function envoie le push
Une Cloud Function onValueCreated se déclenche à chaque nouveau message dans la DB :
// functions/index.js
const { onValueCreated } = require("firebase-functions/v2/database");
exports.notifyNewMessage = onValueCreated(
{ ref: "/conversations/{convId}/messages/{msgId}", region: "us-central1" },
async (event) => {
const message = event.data.val();
// Ignore nos propres reponses
if (message.sender === "emmanuel") return null;
const visitorName = await getVisitorName(event.params.convId);
const fcmToken = await getFCMToken();
if (!fcmToken) return null;
await getMessaging().send({
token: fcmToken,
notification: { title: visitorName, body: message.text },
data: { conversationId: event.params.convId },
apns: { payload: { aps: { sound: "default", badge: 1 } } }
});
}
);
Points importants :
onValueCreatedne se déclenche qu'à la création d'un nœud, pas à la modification - pas de double notification si un message est édité- Le filtre
sender === "emmanuel"évite de se notifier soi-même - La région
us-central1est obligatoire - les triggers RTDB doivent être dans la même région que la base de données - Si le token est invalide (app desintallee), la fonction le nettoie automatiquement de la DB
Le flux complet d'une notification
Éviter les doublons de notifications
Sans précaution, sur iOS on recevrait deux notifications quand l'app est en foreground :
- Une notification locale (depuis l'observer Firebase qui tourne encore)
- Une notification push (depuis la Cloud Function via FCM)
La solution est simple : sur iOS, la méthode sendNotification() fait un return immédiat. Les notifications locales sont désactivées - FCM s'en charge :
private func sendNotification(title: String, body: String, conversationId: String) {
#if os(iOS)
return // FCM gere les push sur iOS
#else
// macOS : notification locale classique
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
UNUserNotificationCenter.current().add(...)
#endif
}
Gestion du badge et navigation
Badge sur l'icĂ´ne
La Cloud Function envoie badge: 1 dans le payload APNs. Quand l'utilisateur ouvre l'app, le badge se remet à zéro via scenePhase :
// HumanMadeApp.swift
WindowGroup { ... }
.onChange(of: scenePhase) { phase in
if phase == .active {
UNUserNotificationCenter.current().setBadgeCount(0)
}
}
Tap sur la notification -> bonne conversation
Le payload FCM inclut data.conversationId. Quand l'utilisateur tapé la notification, le NotificationDelegate récupère l'ID et le stocke dans une published property. Le NavigationStack écoute ce changement et push la bonne vue :
// NotificationDelegate
func userNotificationCenter(_ center: ..., didReceive response: ...) {
let convId = response.notification.request.content.userInfo["conversationId"]
FirebaseService.shared.pendingConversationId = convId
}
// MenuBarView (iOS)
.onChange(of: firebase.pendingConversationId) { convId in
guard let convId else { return }
firebase.pendingConversationId = nil
navigationPath = NavigationPath() // Reset
navigationPath.append(convId) // Push la conversation
}
Indicateur de frappe ("est en train d'écrire...")
Quand je tapé une réponse sur l'app iOS, le visiteur sur le site voit apparaitre les trois petits points animes - exactement comme sur iMessage ou WhatsApp. L'implémentation repose sur un nœud éphémère dans Firebase.
Le nœud Firebase
Chaque conversation peut contenir un nœud typing. Ce nœud n'existe que quand Emmanuel est en train de taper - il est supprime des qu'il arrête ou envoie le message :
"conversations": {
"-ABC123": {
"messages": { ... },
"typing": true // existe uniquement pendant la frappe
}
}
L'utilisation de removeValue() plutôt que setValue(false) est délibérée : ça évite de stocker des données inutiles. Le nœud n'existe que quand il à une raison d'exister.
Côté iOS : détection de la frappe
L'app observe les changements dans le champ texte via .onChange(of: replyText). À chaque frappe, on écrit typing: true dans Firebase et on relance un timer de 2 secondes :
// ChatView.swift - detection de frappe
TextField("Repondre...", text: $replyText)
.onChange(of: replyText) { newValue in
if !newValue.trimmingCharacters(in: .whitespaces).isEmpty {
firebase.setTyping(conversationId: conversationId, isTyping: true)
}
}
// A l'envoi : reset immediat
func sendReply() {
firebase.setTyping(conversationId: conversationId, isTyping: false)
firebase.sendReply(conversationId: conversationId, text: text)
}
Le debounce côté service
Le FirebaseService géré un timer interne. Chaque appel à setTyping(isTyping: true) relance le timer de 2 secondes. Si l'utilisateur arrête de taper, le timer expire et supprime le nœud automatiquement :
// FirebaseService.swift
func setTyping(conversationId: String, isTyping: Bool) {
let typingRef = db.child("conversations/\(conversationId)/typing")
if isTyping {
typingRef.setValue(true)
typingRef.onDisconnectRemoveValue() // securite : cleanup si crash
typingTimer?.cancel()
let timer = DispatchWorkItem {
typingRef.removeValue()
}
typingTimer = timer
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: timer)
} else {
typingTimer?.cancel()
typingRef.removeValue()
}
}
Le onDisconnectRemoveValue() est une sécurité : si l'app crash ou perd sa connexion, Firebase supprime automatiquement le nœud typing côté serveur. Sans ça, le visiteur verrait un fantôme "en train d'écrire..." indéfiniment.
Côté site web : affichage des points
Le site écoute le nœud typing avec un listener onValue. Quand la valeur passe à true, les trois points animes apparaissent. Quand elle disparait, ils se cachent :
// Listener sur le noeud typing
fb.onValue(fb.ref(fb.db, 'conversations/' + convId + '/typing'), function(snapshot) {
if (snapshot.val() === true) {
if (!document.getElementById('typingIndicator')) showTyping();
} else {
hideTyping();
}
});
Les fonctions showTyping() et hideTyping() existaient déjà pour la séquence d'intro - on les réutilise telles quelles pour l'indicateur temps réel.
Le flux complet
Structure du projet
Stack technique
- Site web - HTML/CSS/JS vanilla, Firebase JS SDK v11 (modules ES)
- Base de données - Firebase Realtime Database (plan Blaze, coût réel : 0 euros/mois)
- App native - SwiftUI (multi-plateforme macOS + iOS), Firebase iOS SDK 12.9
- Push notifications - Firebase Cloud Messaging + APNs
- Cloud Function - Node.js 20, firebase-functions v5, trigger
onValueCreated - Auth - aucune (single-admin, règles DB ouvertes, token FCM protège en write-only)
Ce que ça coute
Avec le plan Blaze (pay-as-you-go) :
- Realtime Database - free tier : 1 Go stockage, 10 Go/mois transfer. Largement suffisant.
- Cloud Functions - free tier : 2 millions invocations/mois. Pour quelques messages par jour, c'est ~0.
- FCM - gratuit, illimité.
Coût total : 0 euros/mois. Le seul investissement est le compte Apple Developer a 99 euros/an (nécessaire pour les push APNs).