/** * Web Push 알림 유틸리티 * Service Worker 등록 및 Push 구독 관리 * * ⚠️ 주의: Service Worker와 Web Push는 HTTPS 또는 localhost에서만 작동합니다! * HTTP 환경에서는 브라우저 알림만 사용하세요. */ // VAPID 공개 키 (백엔드에서 생성) // 주의: 이 키는 백엔드 application.yml의 push.vapid.public-key와 동일해야 합니다! const VAPID_PUBLIC_KEY = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE4xZ0NOe_lJmuwRmNvEy8sokT3IqDxQiKBOx8YRJfw3fbURO36jPl0WC-SD9ZKbgldcnfUIYJHHOdCmanShM90g'; /** * HTTPS 환경 확인 * Service Worker는 HTTPS 또는 localhost에서만 작동 */ export const isSecureContext = (): boolean => { // HTTPS 또는 localhost 확인 return window.isSecureContext || window.location.protocol === 'https:' || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; }; /** * PWA Web Push 지원 확인 */ export const isPWAPushSupported = (): boolean => { return isSecureContext() && 'serviceWorker' in navigator && 'PushManager' in window; }; /** * Service Worker 등록 * ⚠️ HTTPS 또는 localhost에서만 작동 */ export const registerServiceWorker = async (): Promise => { if (!isSecureContext()) { console.warn('[Push] ⚠️ HTTP 환경에서는 Service Worker가 작동하지 않습니다.'); console.warn('[Push] HTTPS를 사용하거나 localhost에서 실행하세요.'); console.warn('[Push] 현재는 브라우저 알림만 사용됩니다.'); return null; } if (!('serviceWorker' in navigator)) { console.warn('[Push] Service Worker not supported in this browser'); return null; } try { const registration = await navigator.serviceWorker.register('/service-worker.js', { scope: '/', }); console.log('[Push] ✅ Service Worker registered:', registration); // Wait for the service worker to be ready await navigator.serviceWorker.ready; console.log('[Push] ✅ Service Worker ready'); return registration; } catch (error) { console.error('[Push] ❌ Service Worker registration failed:', error); return null; } }; /** * 알림 권한 요청 */ export const requestNotificationPermission = async (): Promise => { if (!('Notification' in window)) { console.warn('Notifications not supported in this browser'); return 'denied'; } if (Notification.permission === 'granted') { return 'granted'; } if (Notification.permission === 'denied') { return 'denied'; } const permission = await Notification.requestPermission(); console.log('[Push] Notification permission:', permission); return permission; }; /** * Base64를 Uint8Array로 변환 (VAPID 키 변환용) */ const urlBase64ToUint8Array = (base64String: string): Uint8Array => { 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; }; /** * Push 구독 생성 */ export const subscribeToPush = async (): Promise => { try { // Service Worker 등록 확인 const registration = await navigator.serviceWorker.ready; // 기존 구독 확인 let subscription = await registration.pushManager.getSubscription(); if (subscription) { console.log('[Push] Already subscribed:', subscription); return subscription; } // 알림 권한 요청 const permission = await requestNotificationPermission(); if (permission !== 'granted') { console.warn('[Push] Notification permission not granted'); return null; } // 새 구독 생성 subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), }); console.log('[Push] New subscription created:', subscription); return subscription; } catch (error) { console.error('[Push] Failed to subscribe:', error); return null; } }; /** * Push 구독 해제 */ export const unsubscribeFromPush = async (): Promise => { try { const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); if (!subscription) { console.log('[Push] No subscription to unsubscribe'); return true; } const result = await subscription.unsubscribe(); console.log('[Push] Unsubscribed:', result); return result; } catch (error) { console.error('[Push] Failed to unsubscribe:', error); return false; } }; /** * 현재 구독 상태 확인 */ export const getSubscription = async (): Promise => { try { if (!('serviceWorker' in navigator)) { return null; } const registration = await navigator.serviceWorker.ready; const subscription = await registration.pushManager.getSubscription(); return subscription; } catch (error) { console.error('[Push] Failed to get subscription:', error); return null; } }; /** * 구독 정보를 서버로 전송 */ export const sendSubscriptionToServer = async ( subscription: PushSubscription, endpoint: string = '/api/user/settings/push-subscription' ): Promise => { try { const token = localStorage.getItem('accessToken'); if (!token) { console.warn('[Push] No access token found'); return false; } const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ subscription: subscription.toJSON(), }), }); if (!response.ok) { throw new Error(`Failed to send subscription: ${response.statusText}`); } console.log('[Push] Subscription sent to server'); return true; } catch (error) { console.error('[Push] Failed to send subscription to server:', error); return false; } }; /** * 구독 삭제를 서버에 알림 */ export const deleteSubscriptionFromServer = async ( endpoint: string = '/api/user/settings/push-subscription' ): Promise => { try { const token = localStorage.getItem('accessToken'); if (!token) { console.warn('[Push] No access token found'); return false; } const response = await fetch(endpoint, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}`, }, }); if (!response.ok) { throw new Error(`Failed to delete subscription: ${response.statusText}`); } console.log('[Push] Subscription deleted from server'); return true; } catch (error) { console.error('[Push] Failed to delete subscription from server:', error); return false; } }; /** * PWA 초기화 (Service Worker 등록 + Push 구독) */ export const initializePWA = async (): Promise => { console.log('[PWA] Initializing...'); // Service Worker 등록 const registration = await registerServiceWorker(); if (!registration) { console.warn('[PWA] Service Worker registration failed'); return; } // Push 구독은 사용자가 설정에서 수동으로 활성화 console.log('[PWA] Initialized. Push subscription available in settings.'); }; /** * 테스트 알림 표시 */ export const showTestNotification = async (): Promise => { const permission = await requestNotificationPermission(); if (permission !== 'granted') { alert('알림 권한이 거부되었습니다.'); return; } const registration = await navigator.serviceWorker.ready; await registration.showNotification('테스트 알림', { body: '푸시 알림이 정상적으로 작동합니다! 🎉', icon: '/favicon.ico', badge: '/favicon.ico', vibrate: [200, 100, 200], tag: 'test-notification', requireInteraction: false, }); };