[call-me] - #3 add web push notifications

This commit is contained in:
Miroslav Pejic
2026-04-07 18:22:59 +02:00
parent 142ed4bfae
commit d1a6d77c26
22 changed files with 846 additions and 22 deletions
+8
View File
@@ -59,3 +59,11 @@ API_KEY_SECRET=call_me_api_key_secret # change me
# RANDOM_IMAGE_URL='https://api.unsplash.com/photos/random?query=nature&orientation=landscape&client_id=YOUR-ACCESS-KEY';
RANDOM_IMAGE_URL=''
# Push Notifications (Web Push / VAPID)
# Generate VAPID keys with: npm run generate-vapid-keys
PUSH_ENABLED=false # true or false
PUSH_VAPID_PUBLIC_KEY=''
PUSH_VAPID_PRIVATE_KEY=''
PUSH_VAPID_EMAIL='mailto:admin@example.com'
+24
View File
@@ -58,6 +58,20 @@ paths:
'403':
description: 'Unauthorized!'
/vapidPublicKey:
get:
tags:
- 'push'
summary: 'Get VAPID public key'
description: 'Get the VAPID public key for Web Push subscription'
produces:
- 'application/json'
responses:
'200':
description: 'VAPID public key'
schema:
$ref: '#/definitions/VapidPublicKeyResponse'
securityDefinitions:
secretApiKey:
type: 'apiKey'
@@ -79,3 +93,13 @@ definitions:
connected:
type: array
example: ['https://your.domain/join?user=call-me&call=miro']
VapidPublicKeyResponse:
type: 'object'
properties:
enabled:
type: boolean
example: true
vapidPublicKey:
type: string
example: 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkPs-aP9IKV5O1BamVfGPpxtSkgUc_bUz41MXd8Aww'
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "مسح الكل",
"noCamerasFound": "لم يتم العثور على كاميرات",
"noMicrophonesFound": "لم يتم العثور على ميكروفونات",
"noSpeakersFound": "لم يتم العثور على مكبرات صوت"
"noSpeakersFound": "لم يتم العثور على مكبرات صوت",
"pushNotifications": "إشعارات الدفع",
"testPush": "اختبار"
},
"chat": {
"addEmoji": "إضافة إيموجي",
@@ -211,5 +213,14 @@
"sentFileLabel": " أرسل ملفًا: ",
"sending": "جارٍ الإرسال: __filename__",
"receiving": "جارٍ الاستلام: __filename__"
},
"push": {
"incomingCallTitle": "مكالمة واردة",
"incomingCallBody": "__caller__ يتصل بك",
"notificationSent": "__username__ غير متصل. تم إرسال إشعار — في انتظار اتصاله...",
"enabled": "تم تفعيل الإشعارات",
"disabled": "تم تعطيل الإشعارات",
"permissionDenied": "تم رفض إذن الإشعارات",
"testSent": "تم إرسال إشعار تجريبي"
}
}
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "Alles Löschen",
"noCamerasFound": "Keine Kameras gefunden",
"noMicrophonesFound": "Keine Mikrofone gefunden",
"noSpeakersFound": "Keine Lautsprecher gefunden"
"noSpeakersFound": "Keine Lautsprecher gefunden",
"pushNotifications": "Push-Benachrichtigungen",
"testPush": "Test"
},
"chat": {
"addEmoji": "Emoji hinzufügen",
@@ -211,5 +213,14 @@
"sentFileLabel": " hat eine Datei gesendet: ",
"sending": "Senden: __filename__",
"receiving": "Empfangen: __filename__"
},
"push": {
"incomingCallTitle": "Eingehender Anruf",
"incomingCallBody": "__caller__ ruft dich an",
"notificationSent": "__username__ ist offline. Eine Benachrichtigung wurde gesendet — warten auf Verbindung...",
"enabled": "Push-Benachrichtigungen aktiviert",
"disabled": "Push-Benachrichtigungen deaktiviert",
"permissionDenied": "Benachrichtigungsberechtigung verweigert",
"testSent": "Testbenachrichtigung gesendet"
}
}
+12 -1
View File
@@ -153,7 +153,9 @@
"clearAll": "Clear All",
"noCamerasFound": "No cameras found",
"noMicrophonesFound": "No microphones found",
"noSpeakersFound": "No speakers found"
"noSpeakersFound": "No speakers found",
"pushNotifications": "Push Notifications",
"testPush": "Test"
},
"chat": {
"addEmoji": "Add emoji",
@@ -212,5 +214,14 @@
"sentFileLabel": " sent file: ",
"sending": "Sending: __filename__",
"receiving": "Receiving: __filename__"
},
"push": {
"incomingCallTitle": "Incoming Call",
"incomingCallBody": "__caller__ is calling you",
"notificationSent": "__username__ is offline. A notification was sent \u2014 waiting for them to come online...",
"enabled": "Push notifications enabled",
"disabled": "Push notifications disabled",
"permissionDenied": "Notification permission denied by browser",
"testSent": "Test notification sent"
}
}
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "Borrar Todo",
"noCamerasFound": "No se encontraron cámaras",
"noMicrophonesFound": "No se encontraron micrófonos",
"noSpeakersFound": "No se encontraron altavoces"
"noSpeakersFound": "No se encontraron altavoces",
"pushNotifications": "Notificaciones push",
"testPush": "Probar"
},
"chat": {
"addEmoji": "Agregar emoji",
@@ -211,5 +213,14 @@
"sentFileLabel": " envió el archivo: ",
"sending": "Enviando: __filename__",
"receiving": "Recibiendo: __filename__"
},
"push": {
"incomingCallTitle": "Llamada entrante",
"incomingCallBody": "__caller__ te está llamando",
"notificationSent": "__username__ est\u00e1 desconectado. Se envi\u00f3 una notificaci\u00f3n \u2014 esperando a que se conecte...",
"enabled": "Notificaciones push activadas",
"disabled": "Notificaciones push desactivadas",
"permissionDenied": "Permiso de notificaciones denegado",
"testSent": "Notificación de prueba enviada"
}
}
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "Tout Effacer",
"noCamerasFound": "Aucune caméra trouvée",
"noMicrophonesFound": "Aucun microphone trouvé",
"noSpeakersFound": "Aucun haut-parleur trouvé"
"noSpeakersFound": "Aucun haut-parleur trouvé",
"pushNotifications": "Notifications push",
"testPush": "Tester"
},
"chat": {
"addEmoji": "Ajouter un emoji",
@@ -211,5 +213,14 @@
"sentFileLabel": " a envoyé le fichier : ",
"sending": "Envoi : __filename__",
"receiving": "Réception : __filename__"
},
"push": {
"incomingCallTitle": "Appel entrant",
"incomingCallBody": "__caller__ vous appelle",
"notificationSent": "__username__ est hors ligne. Une notification a \u00e9t\u00e9 envoy\u00e9e \u2014 en attente de connexion...",
"enabled": "Notifications push activ\u00e9es",
"disabled": "Notifications push d\u00e9sactiv\u00e9es",
"permissionDenied": "Permission de notification refusée",
"testSent": "Notification de test envoyée"
}
}
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "सभी साफ़ करें",
"noCamerasFound": "कोई कैमरा नहीं मिला",
"noMicrophonesFound": "कोई माइक्रोफ़ोन नहीं मिला",
"noSpeakersFound": "कोई स्पीकर नहीं मिला"
"noSpeakersFound": "कोई स्पीकर नहीं मिला",
"pushNotifications": "पुश सूचनाएं",
"testPush": "परीक्षण"
},
"chat": {
"addEmoji": "इमोजी जोड़ें",
@@ -211,5 +213,14 @@
"sentFileLabel": " ने फ़ाइल भेजी: ",
"sending": "भेजा जा रहा है: __filename__",
"receiving": "प्राप्त किया जा रहा है: __filename__"
},
"push": {
"incomingCallTitle": "आने वाली कॉल",
"incomingCallBody": "__caller__ आपको कॉल कर रहा है",
"notificationSent": "__username__ ऑफ़लाइन है। एक सूचना भेजी गई — उनके ऑनलाइन आने की प्रतीक्षा...",
"enabled": "पुश सूचनाएं सक्षम",
"disabled": "पुश सूचनाएं अक्षम",
"permissionDenied": "सूचना अनुमति अस्वीकृत",
"testSent": "परीक्षण सूचना भेजी गई"
}
}
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "Obriši sve",
"noCamerasFound": "Nisu pronađene kamere",
"noMicrophonesFound": "Nisu pronađeni mikrofoni",
"noSpeakersFound": "Nisu pronađeni zvučnici"
"noSpeakersFound": "Nisu pronađeni zvučnici",
"pushNotifications": "Push obavijesti",
"testPush": "Testiraj"
},
"chat": {
"addEmoji": "Dodaj emoji",
@@ -211,5 +213,14 @@
"sentFileLabel": " je poslao/la datoteku: ",
"sending": "Slanje: __filename__",
"receiving": "Primanje: __filename__"
},
"push": {
"incomingCallTitle": "Dolazni poziv",
"incomingCallBody": "__caller__ vas zove",
"notificationSent": "__username__ je offline. Obavijest je poslana \u2014 \u010dekanje na povezivanje...",
"enabled": "Push obavijesti uklju\u010dene",
"disabled": "Push obavijesti isklju\u010dene",
"permissionDenied": "Dozvola za obavijesti odbijena",
"testSent": "Testna obavijest poslana"
}
}
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "Cancella Tutto",
"noCamerasFound": "Nessuna fotocamera trovata",
"noMicrophonesFound": "Nessun microfono trovato",
"noSpeakersFound": "Nessun altoparlante trovato"
"noSpeakersFound": "Nessun altoparlante trovato",
"pushNotifications": "Notifiche push",
"testPush": "Prova"
},
"chat": {
"addEmoji": "Aggiungi emoji",
@@ -211,5 +213,14 @@
"sentFileLabel": " ha inviato il file: ",
"sending": "Invio: __filename__",
"receiving": "Ricezione: __filename__"
},
"push": {
"incomingCallTitle": "Chiamata in arrivo",
"incomingCallBody": "__caller__ ti sta chiamando",
"notificationSent": "__username__ \u00e8 offline. \u00c8 stata inviata una notifica \u2014 in attesa che si connetta...",
"enabled": "Notifiche push attivate",
"disabled": "Notifiche push disattivate",
"permissionDenied": "Autorizzazione notifiche negata",
"testSent": "Notifica di prova inviata"
}
}
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "すべてクリア",
"noCamerasFound": "カメラが見つかりません",
"noMicrophonesFound": "マイクが見つかりません",
"noSpeakersFound": "スピーカーが見つかりません"
"noSpeakersFound": "スピーカーが見つかりません",
"pushNotifications": "プッシュ通知",
"testPush": "テスト"
},
"chat": {
"addEmoji": "絵文字を追加",
@@ -211,5 +213,14 @@
"sentFileLabel": " がファイルを送信: ",
"sending": "送信中: __filename__",
"receiving": "受信中: __filename__"
},
"push": {
"incomingCallTitle": "着信",
"incomingCallBody": "__caller__ から着信があります",
"notificationSent": "__username__ はオフラインです。通知を送信しました — オンラインになるのを待っています...",
"enabled": "プッシュ通知が有効になりました",
"disabled": "プッシュ通知が無効になりました",
"permissionDenied": "通知の許可が拒否されました",
"testSent": "テスト通知を送信しました"
}
}
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "Limpar tudo",
"noCamerasFound": "Nenhuma câmera encontrada",
"noMicrophonesFound": "Nenhum microfone encontrado",
"noSpeakersFound": "Nenhum alto-falante encontrado"
"noSpeakersFound": "Nenhum alto-falante encontrado",
"pushNotifications": "Notificações push",
"testPush": "Testar"
},
"chat": {
"addEmoji": "Adicionar emoji",
@@ -211,5 +213,14 @@
"sentFileLabel": " enviou o arquivo: ",
"sending": "Enviando: __filename__",
"receiving": "Recebendo: __filename__"
},
"push": {
"incomingCallTitle": "Chamada recebida",
"incomingCallBody": "__caller__ está ligando para você",
"notificationSent": "__username__ está offline. Uma notificação foi enviada — aguardando conexão...",
"enabled": "Notificações push ativadas",
"disabled": "Notificações push desativadas",
"permissionDenied": "Permissão de notificação negada",
"testSent": "Notificação de teste enviada"
}
}
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "Очистить всё",
"noCamerasFound": "Камеры не найдены",
"noMicrophonesFound": "Микрофоны не найдены",
"noSpeakersFound": "Динамики не найдены"
"noSpeakersFound": "Динамики не найдены",
"pushNotifications": "Пуш-уведомления",
"testPush": "Тест"
},
"chat": {
"addEmoji": "Добавить эмодзи",
@@ -211,5 +213,14 @@
"sentFileLabel": " отправил файл: ",
"sending": "Отправка: __filename__",
"receiving": "Получение: __filename__"
},
"push": {
"incomingCallTitle": "Входящий звонок",
"incomingCallBody": "__caller__ звонит вам",
"notificationSent": "__username__ не в сети. Уведомление отправлено — ожидание подключения...",
"enabled": "Пуш-уведомления включены",
"disabled": "Пуш-уведомления отключены",
"permissionDenied": "Разрешение на уведомления отклонено",
"testSent": "Тестовое уведомление отправлено"
}
}
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "Obriši sve",
"noCamerasFound": "Nisu pronađene kamere",
"noMicrophonesFound": "Nisu pronađeni mikrofoni",
"noSpeakersFound": "Nisu pronađeni zvučnici"
"noSpeakersFound": "Nisu pronađeni zvučnici",
"pushNotifications": "Пуш обавештења",
"testPush": "Тест"
},
"chat": {
"addEmoji": "Dodaj emoji",
@@ -211,5 +213,14 @@
"sentFileLabel": " poslao fajl: ",
"sending": "Slanje: __filename__",
"receiving": "Prijem: __filename__"
},
"push": {
"incomingCallTitle": "Долазни позив",
"incomingCallBody": "__caller__ вас зове",
"notificationSent": "__username__ је офлајн. Обавештење је послато — чекање на повезивање...",
"enabled": "Пуш обавештења укључена",
"disabled": "Пуш обавештења искључена",
"permissionDenied": "Дозвола за обавештења одбијена",
"testSent": "Тест обавештење послато"
}
}
+12 -1
View File
@@ -152,7 +152,9 @@
"clearAll": "全部清除",
"noCamerasFound": "未找到摄像头",
"noMicrophonesFound": "未找到麦克风",
"noSpeakersFound": "未找到扬声器"
"noSpeakersFound": "未找到扬声器",
"pushNotifications": "推送通知",
"testPush": "测试"
},
"chat": {
"addEmoji": "添加表情",
@@ -211,5 +213,14 @@
"sentFileLabel": " 发送了文件: ",
"sending": "发送中: __filename__",
"receiving": "接收中: __filename__"
},
"push": {
"incomingCallTitle": "来电",
"incomingCallBody": "__caller__ 正在呼叫您",
"notificationSent": "__username__ 离线。已发送通知 — 等待上线...",
"enabled": "推送通知已开启",
"disabled": "推送通知已关闭",
"permissionDenied": "通知权限被拒绝",
"testSent": "测试通知已发送"
}
}
+179 -2
View File
@@ -13,6 +13,7 @@ const helmet = require('helmet');
const path = require('path');
const yaml = require('js-yaml');
const swaggerUi = require('swagger-ui-express');
const webpush = require('web-push');
const packageJson = require('../package.json');
// Logs
@@ -51,6 +52,9 @@ const users = new Map();
// Map to store user media status (video/audio enabled/disabled)
const userMediaStatus = new Map();
// Map to store push subscriptions (username -> PushSubscription[])
const pushSubscriptions = new Map();
// Configuration settings
const config = {
iceServers: [],
@@ -63,6 +67,10 @@ const config = {
hostPasswordEnabled: process.env.HOST_PASSWORD_ENABLED === 'true',
hostPassword: process.env.HOST_PASSWORD || '',
apiKeySecret: process.env.API_KEY_SECRET,
pushEnabled: process.env.PUSH_ENABLED === 'true',
pushVapidPublicKey: process.env.PUSH_VAPID_PUBLIC_KEY || '',
pushVapidPrivateKey: process.env.PUSH_VAPID_PRIVATE_KEY || '',
pushVapidEmail: process.env.PUSH_VAPID_EMAIL || 'mailto:admin@example.com',
randomImageUrl: process.env.RANDOM_IMAGE_URL || '',
apiBasePath: '/api/v1',
swaggerDocument: yaml.load(fs.readFileSync(path.join(__dirname, '/api/swagger.yaml'), 'utf8')),
@@ -85,6 +93,15 @@ if (config.turnServerEnabled && config.turnServerUrl && config.turnServerUsernam
});
}
// Configure Web Push if enabled
if (config.pushEnabled && config.pushVapidPublicKey && config.pushVapidPrivateKey) {
webpush.setVapidDetails(config.pushVapidEmail, config.pushVapidPublicKey, config.pushVapidPrivateKey);
log.info('Web Push', { enabled: true });
} else if (config.pushEnabled) {
log.warn('Web Push', { enabled: false, reason: 'VAPID keys not configured. Run: npm run generate-vapid-keys' });
config.pushEnabled = false;
}
const ngrokEnabled = process.env.NGROK_ENABLED === 'true';
const ngrokAuthToken = process.env.NGROK_AUTH_TOKEN;
@@ -330,6 +347,29 @@ app.get(`${config.apiBasePath}/users`, (req, res) => {
return res.json({ users });
});
// Get VAPID public key for push subscription
app.get(`${config.apiBasePath}/vapidPublicKey`, (req, res) => {
if (!config.pushEnabled) {
return res.json({ enabled: false });
}
return res.json({ enabled: true, vapidPublicKey: config.pushVapidPublicKey });
});
// Handle push subscription update via REST (for service worker pushsubscriptionchange)
app.post(`${config.apiBasePath}/pushSubscription`, express.json(), (req, res) => {
if (!config.pushEnabled) {
return res.status(400).json({ error: 'Push notifications not enabled' });
}
const { subscription } = req.body;
if (!subscription || !subscription.endpoint) {
return res.status(400).json({ error: 'Invalid subscription' });
}
// We can't reliably map this to a username from REST alone,
// but we store it for resubscription change events
log.debug('Push subscription updated via REST');
return res.json({ success: true });
});
// Check if Host password required
app.get('/api/hostPassword', (req, res) => {
const isPasswordRequired = config.hostPasswordEnabled;
@@ -434,6 +474,15 @@ function handleConnection(socket) {
handleChatMessage(data);
log.debug('Chat message:', data);
break;
case 'pushSubscription':
handlePushSubscription(data);
break;
case 'pushUnsubscribe':
handlePushUnsubscribe(data);
break;
case 'testPush':
handleTestPush();
break;
case 'pong':
log.debug('Client response:', data.message);
break;
@@ -449,9 +498,76 @@ function handleConnection(socket) {
type: 'ping',
message: 'Hello Client!',
iceServers: config.iceServers,
pushEnabled: config.pushEnabled,
});
}
// Function to handle push subscription registration
function handlePushSubscription(data) {
const { subscription } = data;
const username = socket.username;
if (!config.pushEnabled || !username || !subscription || !subscription.endpoint) {
return;
}
// Store subscription (support multiple devices per user)
const existing = pushSubscriptions.get(username) || [];
// Replace if same endpoint exists, otherwise add
const idx = existing.findIndex((s) => s.endpoint === subscription.endpoint);
if (idx >= 0) {
existing[idx] = subscription;
} else {
existing.push(subscription);
}
pushSubscriptions.set(username, existing);
log.debug('Push subscription stored for', username, { devices: existing.length });
}
// Function to handle push unsubscribe
function handlePushUnsubscribe(data) {
const { endpoint } = data;
const username = socket.username;
if (!username || !endpoint) return;
const existing = pushSubscriptions.get(username) || [];
const filtered = existing.filter((s) => s.endpoint !== endpoint);
if (filtered.length > 0) {
pushSubscriptions.set(username, filtered);
} else {
pushSubscriptions.delete(username);
}
log.debug('Push subscription removed for', username, { remaining: filtered.length });
}
// Function to handle test push notification
async function handleTestPush() {
const username = socket.username;
if (!config.pushEnabled || !username) return;
const subscriptions = pushSubscriptions.get(username);
if (!subscriptions || subscriptions.length === 0) {
log.debug('No push subscriptions for test push', username);
return;
}
const payload = JSON.stringify({
type: 'testPush',
title: 'Call-me',
body: 'Push notifications are working!',
});
for (const sub of subscriptions) {
try {
await webpush.sendNotification(sub, payload);
log.debug('Test push sent to', username);
} catch (err) {
log.warn('Test push failed for', username, { statusCode: err.statusCode, message: err.message });
}
}
}
// Function to handle user sign-in request
function handleSignIn(data) {
const { name } = data;
@@ -509,8 +625,19 @@ function handleConnection(socket) {
};
sendMsgTo(recipientSocket, offerData);
} else {
log.warn(`Recipient (${toName}) not found`);
sendMsgTo(socket, { type: 'notfound', username: toName });
// User is offline — try push notification
if (type === 'offerAccept' && config.pushEnabled) {
sendPushNotification(toName, socket.username).then((sent) => {
if (sent) {
sendMsgTo(socket, { type: 'pushSent', username: toName });
} else {
sendMsgTo(socket, { type: 'notfound', username: toName });
}
});
} else {
log.warn(`Recipient (${toName}) not found`);
sendMsgTo(socket, { type: 'notfound', username: toName });
}
}
break;
case 'offerDecline':
@@ -624,6 +751,56 @@ function isValidUsername(username) {
return usernamePattern.test(username);
}
// Send push notification to an offline user
async function sendPushNotification(targetUsername, callerUsername) {
const subscriptions = pushSubscriptions.get(targetUsername);
if (!subscriptions || subscriptions.length === 0) {
log.debug('No push subscription found for', targetUsername);
return false;
}
const payload = JSON.stringify({
type: 'incomingCall',
title: 'Call-me',
body: `${callerUsername} is calling you`,
caller: callerUsername,
url: `/join?user=${encodeURIComponent(targetUsername)}&call=${encodeURIComponent(callerUsername)}`,
});
let anySent = false;
const invalidIndices = [];
for (let i = 0; i < subscriptions.length; i++) {
try {
await webpush.sendNotification(subscriptions[i], payload);
anySent = true;
log.debug('Push notification sent to', targetUsername, { device: i + 1 });
} catch (err) {
log.warn('Push notification failed for', targetUsername, {
device: i + 1,
statusCode: err.statusCode,
message: err.message,
});
// Remove expired/invalid subscriptions (410 Gone, 404 Not Found)
if (err.statusCode === 410 || err.statusCode === 404) {
invalidIndices.push(i);
}
}
}
// Clean up invalid subscriptions
if (invalidIndices.length > 0) {
const filtered = subscriptions.filter((_, idx) => !invalidIndices.includes(idx));
if (filtered.length > 0) {
pushSubscriptions.set(targetUsername, filtered);
} else {
pushSubscriptions.delete(targetUsername);
}
}
return anySent;
}
// Function to get all connected users
function getConnectedUsers() {
return Array.from(users.keys());
+123 -3
View File
@@ -1,12 +1,12 @@
{
"name": "call-me",
"version": "1.3.19",
"version": "1.3.30",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "call-me",
"version": "1.3.19",
"version": "1.3.30",
"license": "AGPLv3",
"dependencies": {
"@ngrok/ngrok": "1.7.0",
@@ -19,7 +19,8 @@
"httpolyglot": "0.1.2",
"js-yaml": "4.1.1",
"socket.io": "^4.8.3",
"swagger-ui-express": "5.0.1"
"swagger-ui-express": "5.0.1",
"web-push": "^3.6.7"
},
"devDependencies": {
"nodemon": "^3.1.14",
@@ -296,6 +297,15 @@
"node": ">= 0.6"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -314,6 +324,18 @@
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -362,6 +384,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/bn.js": {
"version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
@@ -434,6 +462,12 @@
"node": ">=8"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -634,6 +668,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -1103,6 +1146,15 @@
"node": ">=18.0.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1131,6 +1183,19 @@
"node": ">=0.10.0"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
@@ -1227,6 +1292,27 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1276,6 +1362,12 @@
"node": ">= 0.6"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz",
@@ -1292,6 +1384,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -1968,6 +2069,25 @@
"node": ">= 0.8"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+5 -3
View File
@@ -1,6 +1,6 @@
{
"name": "call-me",
"version": "1.3.19",
"version": "1.3.30",
"description": "Your Go-To for Instant Video Calls",
"author": "Miroslav Pejic - miroslav.pejic.85@gmail.com",
"license": "AGPLv3",
@@ -16,7 +16,8 @@
"scripts": {
"start": "node app/server.js",
"dev": "nodemon app/server.js",
"lint": "npx prettier --write ."
"lint": "npx prettier --write .",
"generate-vapid-keys": "node -e \"const w=require('web-push');const k=w.generateVAPIDKeys();console.log('PUSH_VAPID_PUBLIC_KEY='+k.publicKey);console.log('PUSH_VAPID_PRIVATE_KEY='+k.privateKey)\""
},
"dependencies": {
"@ngrok/ngrok": "1.7.0",
@@ -29,7 +30,8 @@
"httpolyglot": "0.1.2",
"js-yaml": "4.1.1",
"socket.io": "^4.8.3",
"swagger-ui-express": "5.0.1"
"swagger-ui-express": "5.0.1",
"web-push": "^3.6.7"
},
"devDependencies": {
"nodemon": "^3.1.14",
+210 -1
View File
@@ -73,6 +73,9 @@ const incomingCallUsername = document.getElementById('incomingCallUsername');
const incomingCallTimer = document.getElementById('incomingCallTimer');
const acceptCallBtn = document.getElementById('acceptCallBtn');
const declineCallBtn = document.getElementById('declineCallBtn');
const pushNotificationGroup = document.getElementById('pushNotificationGroup');
const pushNotificationToggle = document.getElementById('pushNotificationToggle');
const pushTestBtn = document.getElementById('pushTestBtn');
// Ensure app is defined, even if config.js is not loaded
const app = window.myAppConfig || {};
@@ -99,6 +102,10 @@ let allConnectedUsers = [];
let filteredUsers = [];
let selectedUser = null;
// Push notification state
let pushEnabled = false;
let pushSubscription = null;
// Chat state
let unreadMessages = 0;
let currentTab = 'users';
@@ -455,6 +462,9 @@ function handleMessage(data) {
case 'notfound':
handleNotFound(data);
break;
case 'pushSent':
handlePushSent(data);
break;
case 'offerAccept':
offerAccept(data);
break;
@@ -1347,6 +1357,9 @@ function handlePing(data) {
if (iceServers) {
config.iceServers = iceServers;
}
if (data.pushEnabled !== undefined) {
pushEnabled = data.pushEnabled;
}
sendMsg({
type: 'pong',
message: {
@@ -1367,6 +1380,20 @@ function handleNotFound(data) {
updateParticipantCount();
}
// Handle push notification sent confirmation
function handlePushSent(data) {
const { username } = data;
hideCallingOverlay();
toast(
t('push.notificationSent', { username }) ||
`Notification sent to ${username}. Waiting for them to come online...`,
'info',
'top',
6000
);
sound('notify');
}
// Handle call declined by remote user
function handleOfferDecline(data) {
const { from } = data;
@@ -1471,6 +1498,11 @@ async function handleSignIn(data) {
// Send initial media status to server
sendMediaStatusToServer();
// Show push notification toggle if server has push enabled
if (pushEnabled) {
initPushNotificationToggle();
}
} else {
// All attempts failed, show error only now
handleMediaStreamError(lastError);
@@ -1646,6 +1678,24 @@ function offerAccept(data) {
return;
}
// Show client-side notification if tab is backgrounded
if (document.hidden && Notification.permission === 'granted') {
try {
const notification = new Notification('Call-me', {
body: t('push.incomingCallBody', { caller: data.from }) || `${data.from} is calling you`,
icon: '/favicon/favicon-32x32.png',
tag: 'call-me-incoming',
requireInteraction: true,
});
notification.onclick = () => {
window.focus();
notification.close();
};
} catch (e) {
console.warn('Client notification failed:', e);
}
}
incomingCallData = data;
showIncomingCallOverlay(data.from);
sound('ring');
@@ -2109,6 +2159,165 @@ function handleRemoteScreenShare(data) {
}
}
// Initialize push notification toggle in settings
async function initPushNotificationToggle() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.log('Push notifications not supported in this browser');
return;
}
// Show the toggle group
elemDisplay(pushNotificationGroup, true, 'flex');
// Check current state
const registration = await navigator.serviceWorker.getRegistration('/sw.js');
if (registration) {
const existing = await registration.pushManager.getSubscription();
pushNotificationToggle.checked = !!existing;
if (existing) {
pushSubscription = existing;
// Re-send subscription to server (in case server restarted)
sendMsg({ type: 'pushSubscription', subscription: existing.toJSON() });
}
}
// Restore from localStorage
if (localStorage.callMePushEnabled === 'true' && !pushNotificationToggle.checked) {
pushNotificationToggle.checked = true;
await registerPushNotifications();
}
// Show/hide test button based on current state
updatePushTestBtn();
// Handle toggle change
pushNotificationToggle.addEventListener('change', handlePushToggle);
// Handle test button click
pushTestBtn.addEventListener('click', handlePushTest);
}
// Handle push notification toggle
async function handlePushToggle() {
if (pushNotificationToggle.checked) {
await registerPushNotifications();
if (!pushSubscription) {
// Permission denied or failed — revert toggle
pushNotificationToggle.checked = false;
localStorage.callMePushEnabled = 'false';
toast(t('push.permissionDenied') || 'Notification permission denied', 'warning', 'top', 3000);
updatePushTestBtn();
return;
}
localStorage.callMePushEnabled = 'true';
toast(t('push.enabled') || 'Push notifications enabled', 'success', 'top', 3000);
} else {
await unregisterPushNotifications();
localStorage.callMePushEnabled = 'false';
toast(t('push.disabled') || 'Push notifications disabled', 'info', 'top', 3000);
}
updatePushTestBtn();
}
// Show/hide test push button based on toggle state
function updatePushTestBtn() {
elemDisplay(pushTestBtn, pushNotificationToggle.checked);
}
// Handle test push notification button
function handlePushTest() {
sendMsg({ type: 'testPush' });
toast(t('push.testSent') || 'Test notification sent', 'info', 'top', 3000);
}
// Register service worker and subscribe to push notifications
async function registerPushNotifications() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.log('Push notifications not supported in this browser');
return;
}
try {
// Fetch VAPID public key from server
const { data: vapidData } = await axios.get('/api/v1/vapidPublicKey');
if (!vapidData.enabled || !vapidData.vapidPublicKey) {
console.log('Push notifications not enabled on server');
return;
}
// Register service worker and wait for it to become active
await navigator.serviceWorker.register('/sw.js');
const registration = await navigator.serviceWorker.ready;
console.log('Service worker registered and active');
// Request notification permission
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.log('Notification permission denied');
pushSubscription = null;
return;
}
// Check for existing subscription first
pushSubscription = await registration.pushManager.getSubscription();
if (!pushSubscription) {
// Subscribe to push
const applicationServerKey = urlBase64ToUint8Array(vapidData.vapidPublicKey);
pushSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
});
console.log('Push notification: new subscription created');
} else {
console.log('Push notification: using existing subscription');
}
// Send subscription to server via Socket.IO
sendMsg({
type: 'pushSubscription',
subscription: pushSubscription.toJSON(),
});
console.log('Push notification subscription successful', pushSubscription.endpoint);
} catch (err) {
console.warn('Push notification registration failed:', err);
pushSubscription = null;
}
}
// Unsubscribe from push notifications
async function unregisterPushNotifications() {
try {
if (pushSubscription) {
const endpoint = pushSubscription.endpoint;
await pushSubscription.unsubscribe();
console.log('Push subscription removed from browser');
// Tell server to remove the subscription
sendMsg({
type: 'pushUnsubscribe',
endpoint: endpoint,
});
}
pushSubscription = null;
} catch (err) {
console.warn('Push unsubscribe failed:', err);
}
}
// Convert URL-safe base64 string to Uint8Array (for VAPID key)
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Send messages to the server
function sendMsg(message) {
// Use connectedUser if call is established, otherwise use pendingUser for signaling
@@ -2465,7 +2674,7 @@ function renderUserList() {
});
// Initialize Bootstrap tooltips on dynamically created buttons (skip on mobile)
if (!userInfo.device.isMobile) {
if (userInfo && !userInfo.device.isMobile) {
const tooltipEls = userList.querySelectorAll('[title]');
tooltipEls.forEach((el) => {
el.setAttribute('data-toggle', 'tooltip');
+14
View File
@@ -392,6 +392,20 @@
</label>
</div>
<div id="pushNotificationGroup" class="setting-group setting-toggle-group">
<label for="pushNotificationToggle" class="setting-label">
<i class="fas fa-bell"></i>
<span data-i18n="settings.pushNotifications">Push Notifications</span>
</label>
<div class="push-toggle-wrapper">
<label class="toggle-switch">
<input type="checkbox" id="pushNotificationToggle" />
<span class="toggle-slider"></span>
</label>
<button id="pushTestBtn" class="btn-setting" data-i18n="settings.testPush">Test</button>
</div>
</div>
<h3 class="settings-title">
<i class="fas fa-video"></i> <span data-i18n="settings.mediaDevicesTitle">Media Devices</span>
</h3>
+32
View File
@@ -1918,6 +1918,38 @@ input {
justify-content: space-between;
}
#pushNotificationGroup {
display: none;
}
.push-toggle-wrapper {
display: flex;
align-items: center;
gap: 10px;
}
#pushTestBtn {
display: none;
padding: 4px 14px;
border: 1px solid var(--glass-border);
border-radius: var(--border-radius);
background: var(--glass-bg);
color: var(--text-color);
font-size: 0.8rem;
font-weight: var(--font-weight-semibold);
cursor: pointer;
transition: all var(--transition-base);
white-space: nowrap;
}
#pushTestBtn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: var(--primary-color);
color: var(--primary-light);
transform: translateY(-1px);
box-shadow: 0 0 10px rgba(99, 102, 241, 0.2);
}
.toggle-switch {
position: relative;
display: inline-block;
+95
View File
@@ -0,0 +1,95 @@
'use strict';
// Service Worker for Web Push Notifications
// Activate immediately — don't wait for old clients to close
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(clients.claim());
});
self.addEventListener('push', (event) => {
console.log('Push event received');
let title = 'Call-me';
let body = 'You have a new notification';
let url = '/';
let type = 'notification';
let caller = '';
if (event.data) {
try {
const payload = event.data.json();
title = payload.title || title;
body = payload.body || body;
url = payload.url || url;
type = payload.type || type;
caller = payload.caller || caller;
} catch (e) {
body = event.data.text() || body;
}
}
const options = {
body: body,
icon: '/favicon/favicon-32x32.png',
badge: '/favicon/favicon-16x16.png',
// tag: 'call-me-' + type,
// renotify: true,
data: { url, type, caller },
};
console.log('Showing notification:', title, options);
const promiseChain = self.registration
.showNotification(title, options)
.then(() => console.log('Notification shown successfully'))
.catch((err) => console.error('showNotification failed:', err));
event.waitUntil(promiseChain);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const url = event.notification.data?.url || '/';
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
// Try to focus an existing tab with the app
for (const client of windowClients) {
if (client.url.includes(self.location.origin) && 'focus' in client) {
client.focus();
// Navigate to the call URL if different
if (url !== '/' && !client.url.includes(url)) {
client.navigate(self.location.origin + url);
}
return;
}
}
// No existing tab found, open a new one
return clients.openWindow(self.location.origin + url);
})
);
});
self.addEventListener('pushsubscriptionchange', (event) => {
event.waitUntil(
self.registration.pushManager
.subscribe(event.oldSubscription.options)
.then((subscription) => {
// Notify the server about the new subscription
return fetch('/api/v1/pushSubscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription }),
});
})
.catch((err) => {
console.error('Failed to resubscribe on pushsubscriptionchange:', err);
})
);
});