diff --git a/.env.template b/.env.template index 01ab6e5..03ff235 100644 --- a/.env.template +++ b/.env.template @@ -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' diff --git a/app/api/swagger.yaml b/app/api/swagger.yaml index b95bc9b..98f6d56 100644 --- a/app/api/swagger.yaml +++ b/app/api/swagger.yaml @@ -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' diff --git a/app/locales/ar.json b/app/locales/ar.json index 9d50d89..2a35bb7 100644 --- a/app/locales/ar.json +++ b/app/locales/ar.json @@ -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": "تم إرسال إشعار تجريبي" } } diff --git a/app/locales/de.json b/app/locales/de.json index 39498c8..a34008e 100644 --- a/app/locales/de.json +++ b/app/locales/de.json @@ -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" } } diff --git a/app/locales/en.json b/app/locales/en.json index 924e9a0..0e6470b 100644 --- a/app/locales/en.json +++ b/app/locales/en.json @@ -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" } } diff --git a/app/locales/es.json b/app/locales/es.json index b328e68..ed12781 100644 --- a/app/locales/es.json +++ b/app/locales/es.json @@ -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" } } diff --git a/app/locales/fr.json b/app/locales/fr.json index edca938..10f13b0 100644 --- a/app/locales/fr.json +++ b/app/locales/fr.json @@ -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" } } diff --git a/app/locales/hi.json b/app/locales/hi.json index d73ac8f..7ad3af1 100644 --- a/app/locales/hi.json +++ b/app/locales/hi.json @@ -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": "परीक्षण सूचना भेजी गई" } } diff --git a/app/locales/hr.json b/app/locales/hr.json index dd0adeb..7635cd0 100644 --- a/app/locales/hr.json +++ b/app/locales/hr.json @@ -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" } } diff --git a/app/locales/it.json b/app/locales/it.json index fcac0cf..7290ae1 100644 --- a/app/locales/it.json +++ b/app/locales/it.json @@ -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" } } diff --git a/app/locales/ja.json b/app/locales/ja.json index a8daef7..42ee584 100644 --- a/app/locales/ja.json +++ b/app/locales/ja.json @@ -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": "テスト通知を送信しました" } } diff --git a/app/locales/pt.json b/app/locales/pt.json index b2c3137..8854ee7 100644 --- a/app/locales/pt.json +++ b/app/locales/pt.json @@ -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" } } diff --git a/app/locales/ru.json b/app/locales/ru.json index 871d77a..b2b3e1e 100644 --- a/app/locales/ru.json +++ b/app/locales/ru.json @@ -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": "Тестовое уведомление отправлено" } } diff --git a/app/locales/sr.json b/app/locales/sr.json index 96d326a..4a6ab70 100644 --- a/app/locales/sr.json +++ b/app/locales/sr.json @@ -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": "Тест обавештење послато" } } diff --git a/app/locales/zh.json b/app/locales/zh.json index 5701dbf..4f03e2b 100644 --- a/app/locales/zh.json +++ b/app/locales/zh.json @@ -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": "测试通知已发送" } } diff --git a/app/server.js b/app/server.js index 6b986b6..da484ec 100755 --- a/app/server.js +++ b/app/server.js @@ -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()); diff --git a/package-lock.json b/package-lock.json index f17cab4..f40c47a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3d1ff31..07fba52 100755 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/client.js b/public/client.js index 9c9825e..9c271c5 100755 --- a/public/client.js +++ b/public/client.js @@ -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'); diff --git a/public/index.html b/public/index.html index 1146337..ba5cead 100755 --- a/public/index.html +++ b/public/index.html @@ -392,6 +392,20 @@ +
+ +
+ + +
+
+

Media Devices

diff --git a/public/style.css b/public/style.css index eb716f5..a5ed5b2 100644 --- a/public/style.css +++ b/public/style.css @@ -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; diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..7b5e301 --- /dev/null +++ b/public/sw.js @@ -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); + }) + ); +});