(() => { 'use strict'; const button = document.querySelector('#pushEnableBtn'); const publicKey = document.querySelector('meta[name="vapid-public-key"]')?.content || ''; const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; const pushDeviceNameStorageKey = 'controlPushDeviceName'; const pushDisabledStorageKey = 'controlPushDisabled'; let pushAutoRepairRunning = false; function setButton(text, disabled = false, active = false) { if (!button) return; button.textContent = text || 'Push'; button.disabled = disabled; button.dataset.active = active ? '1' : '0'; } function publishPushStatus(detail) { window.dispatchEvent(new CustomEvent('pushstatus:update', { detail: Object.assign({ supported: ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window), permission: ('Notification' in window) ? Notification.permission : 'unsupported', browser_subscription: false, server_subscription: false, server_checked: false, manual_disabled: localStorage.getItem(pushDisabledStorageKey) === '1', }, detail || {}), })); } function hangulCount(value) { return (String(value || '').match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || []).length; } function deviceNameFromUser() { const name = prompt('등록할까요? 등록하려면 디바이스 이름을 지정하세요 최소 2글자.', ''); if (name === null) { return null; } const trimmed = String(name || '').trim(); if (hangulCount(trimmed) < 2) { throw new Error('기기 이름은 한글 2글자 이상이어야 합니다.'); } return trimmed; } function rememberDeviceName(deviceName) { const trimmed = String(deviceName || '').trim(); if (hangulCount(trimmed) >= 2) { localStorage.setItem(pushDeviceNameStorageKey, trimmed); localStorage.removeItem(pushDisabledStorageKey); } return trimmed; } function savedDeviceName() { const stored = String(localStorage.getItem(pushDeviceNameStorageKey) || '').trim(); return hangulCount(stored) >= 2 ? stored : '자동복구'; } function urlBase64ToUint8Array(value) { const padding = '='.repeat((4 - value.length % 4) % 4); const base64 = (value + padding).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(base64); const output = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) { output[i] = raw.charCodeAt(i); } return output; } async function postForm(action, body) { const fd = new URLSearchParams(); Object.entries(body).forEach(([key, value]) => fd.append(key, String(value))); fd.append('action', action); fd.append('csrf', csrf); const res = await fetch('/api.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrf, }, body: fd.toString(), }); const json = await res.json(); if (!res.ok || !json.ok) { throw new Error(json?.message || json?.error || 'push_request_failed'); } return json.data; } async function saveSubscription(subscription, deviceName) { const payload = subscription.toJSON(); payload.device_name = rememberDeviceName(deviceName); const res = await fetch('/api/save_subscription.php', { method: 'POST', credentials: 'same-origin', cache: 'no-store', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf, }, body: JSON.stringify(payload), }); const json = await res.json(); if (!res.ok || !json.ok) { throw new Error(json?.message || json?.error || 'subscription_save_failed'); } } async function registration() { const reg = await navigator.serviceWorker.register('/sw.js', { scope: '/', updateViaCache: 'none', }); await reg.update().catch(() => {}); return reg; } async function currentSubscription() { if (!('serviceWorker' in navigator) || !('PushManager' in window)) { return null; } const existing = await navigator.serviceWorker.getRegistration('/'); if (existing) { await existing.update().catch(() => {}); } return existing ? existing.pushManager.getSubscription() : null; } async function subscribePush(reg) { return reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey), }); } async function pushServerStatus(subscription) { const endpoint = subscription ? subscription.endpoint : ''; const res = await fetch('/api.php?action=push_status&endpoint=' + encodeURIComponent(endpoint), { credentials: 'same-origin', cache: 'no-store', headers: { 'X-CSRF-Token': csrf, }, }); const json = await res.json(); if (!res.ok || !json.ok) { throw new Error(json?.message || json?.error || 'push_status_failed'); } return json.data || {}; } async function repairSubscriptionIfNeeded() { if (pushAutoRepairRunning) return; if (!publicKey) return; if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) return; if (Notification.permission !== 'granted') return; if (localStorage.getItem(pushDisabledStorageKey) === '1') return; pushAutoRepairRunning = true; try { const reg = await registration(); let subscription = await reg.pushManager.getSubscription(); if (!subscription) { subscription = await subscribePush(reg); await saveSubscription(subscription, savedDeviceName()); await refreshButton(); window.dispatchEvent(new CustomEvent('pushdevices:refresh')); return; } const status = await pushServerStatus(subscription); publishPushStatus({ browser_subscription: true, server_subscription: status.subscribed === true, server_checked: true, }); if (status.device_name) { rememberDeviceName(status.device_name); } if (status.subscribed) { await refreshButton(); return; } await saveSubscription(subscription, savedDeviceName()); publishPushStatus({ browser_subscription: true, server_subscription: true, server_checked: true, }); await refreshButton(); window.dispatchEvent(new CustomEvent('pushdevices:refresh')); } catch (error) { console.warn('push auto repair failed', error); } finally { pushAutoRepairRunning = false; } } async function refreshButton() { if (!button) return; if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) { setButton('Push', true, false); publishPushStatus({ supported: false }); return; } const subscription = await currentSubscription(); if (subscription) { setButton('Push', false, true); publishPushStatus({ browser_subscription: true, }); } else if (Notification.permission === 'denied') { setButton('Push', true, false); publishPushStatus({ browser_subscription: false, }); } else { setButton('Push', false, false); publishPushStatus({ browser_subscription: false, }); } } async function subscribe() { if (!publicKey) { setButton('Push', true, false); return; } const deviceName = deviceNameFromUser(); if (deviceName === null) { await refreshButton(); return; } setButton('Push', true, false); const permission = await Notification.requestPermission(); if (permission !== 'granted') { setButton('Push', false, false); return; } const reg = await registration(); let subscription = await reg.pushManager.getSubscription(); if (!subscription) { subscription = await subscribePush(reg); } await saveSubscription(subscription, deviceName); publishPushStatus({ browser_subscription: true, server_subscription: true, server_checked: true, manual_disabled: false, }); await refreshButton(); window.dispatchEvent(new CustomEvent('pushdevices:refresh')); } async function unsubscribe() { if (!confirm('푸시 기기 삭제할까요?')) { await refreshButton(); return; } const subscription = await currentSubscription(); if (!subscription) { await refreshButton(); return; } setButton('Push', true, true); localStorage.setItem(pushDisabledStorageKey, '1'); await subscription.unsubscribe(); await postForm('delete_push_endpoint', { endpoint: subscription.endpoint, }); publishPushStatus({ browser_subscription: false, server_subscription: false, server_checked: true, manual_disabled: true, }); await refreshButton(); window.dispatchEvent(new CustomEvent('pushdevices:refresh')); } if (!button) return; button.addEventListener('click', () => { const active = button.dataset.active === '1'; const job = active ? unsubscribe() : subscribe(); job.catch(error => { alert(error.message || 'Push failed'); refreshButton().catch(() => {}); }); }); refreshButton().catch(() => setButton('Push', false, false)); repairSubscriptionIfNeeded(); document.addEventListener('visibilitychange', () => { if (!document.hidden) { repairSubscriptionIfNeeded(); } }); setInterval(repairSubscriptionIfNeeded, 5 * 60 * 1000); })();