333 lines
11 KiB
JavaScript
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);
|
|
})();
|