293 lines
8.0 KiB
TypeScript
293 lines
8.0 KiB
TypeScript
/**
|
|
* 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<ServiceWorkerRegistration | null> => {
|
|
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<NotificationPermission> => {
|
|
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<PushSubscription | null> => {
|
|
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<boolean> => {
|
|
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<PushSubscription | null> => {
|
|
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<boolean> => {
|
|
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<boolean> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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,
|
|
});
|
|
};
|
|
|