Files
control/public/push_subscribe.js
2026-06-07 00:33:58 +09:00

333 lines
11 KiB
JavaScript

(() => {
'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);
})();