145 lines
6.4 KiB
JavaScript
145 lines
6.4 KiB
JavaScript
/* Android-only bridge. FCM tokens stay in memory and are never logged or persisted in JS. */
|
|
(() => {
|
|
const config = document.getElementById('native-push-config');
|
|
const cap = window.Capacitor;
|
|
if (!config || !cap || cap.getPlatform() !== 'android') return;
|
|
const push = cap.Plugins.PushNotifications;
|
|
const device = cap.Plugins.MetalCircleDevice;
|
|
if (!push || !device) return;
|
|
const texts = JSON.parse(config.textContent);
|
|
let binding = '';
|
|
let info;
|
|
let stopped = false;
|
|
let running = false;
|
|
let ready = false;
|
|
let sendQueue = Promise.resolve();
|
|
let panel;
|
|
|
|
function notice(message, offerPermission = false) {
|
|
if (!panel) {
|
|
panel = document.createElement('section');
|
|
panel.className = 'native-push-panel';
|
|
(document.querySelector('main') || document.querySelector('.container') || document.body).prepend(panel);
|
|
}
|
|
panel.replaceChildren();
|
|
const label = document.createElement('p');
|
|
label.textContent = message;
|
|
label.setAttribute('role', 'status');
|
|
panel.append(label);
|
|
if (offerPermission) {
|
|
const allow = document.createElement('button');
|
|
allow.type = 'button';
|
|
allow.className = 'button';
|
|
allow.textContent = texts.enable;
|
|
allow.onclick = async () => {
|
|
allow.disabled = true;
|
|
try {
|
|
await push.requestPermissions();
|
|
localStorage.setItem('metalcircle_push_prompt', 'seen');
|
|
await synchronize();
|
|
} catch (_) { notice(texts.failed); }
|
|
};
|
|
const later = document.createElement('button');
|
|
later.type = 'button';
|
|
later.className = 'button button-secondary';
|
|
later.textContent = texts.later;
|
|
later.onclick = () => {
|
|
localStorage.setItem('metalcircle_push_prompt', 'seen');
|
|
panel.remove(); panel = null;
|
|
};
|
|
panel.append(allow, later);
|
|
}
|
|
if (ready && info?.debug && location.pathname === '/profile') {
|
|
const debug = document.createElement('button');
|
|
debug.type = 'button';
|
|
debug.className = 'button button-secondary';
|
|
debug.textContent = texts.debug;
|
|
debug.onclick = () => device.showDebugToken({language: document.documentElement.lang}).catch(() => notice(texts.failed));
|
|
panel.append(debug);
|
|
}
|
|
}
|
|
|
|
async function sendToken(token) {
|
|
if (stopped || !binding || !info) return;
|
|
const expected = binding;
|
|
const response = await fetch('/api/push/devices', {
|
|
method: 'POST', credentials: 'same-origin', redirect: 'error',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({device_id: info.deviceId, token, platform: 'android',
|
|
app_version: info.appVersion, session_tag: expected})
|
|
});
|
|
if (stopped || binding !== expected) return;
|
|
ready = response.ok;
|
|
if (!response.ok) { notice(texts.failed); return; }
|
|
if (location.pathname === '/profile') notice(texts.ready);
|
|
else if (panel) { panel.remove(); panel = null; }
|
|
}
|
|
|
|
async function synchronize() {
|
|
if (stopped || running) return;
|
|
running = true;
|
|
try {
|
|
const response = await fetch('/api/push/session', {credentials: 'same-origin', cache: 'no-store', redirect: 'error'});
|
|
if (!response.ok) return;
|
|
const session = await response.json();
|
|
info = await device.getInfo();
|
|
if (stopped) return;
|
|
if (!session.authenticated) {
|
|
binding = ''; ready = false;
|
|
await device.prepareSession({binding: ''});
|
|
return;
|
|
}
|
|
const permissions = await push.checkPermissions();
|
|
if (permissions.receive !== 'granted') {
|
|
binding = ''; ready = false;
|
|
await fetch('/api/push/devices/' + encodeURIComponent(info.deviceId), {method: 'DELETE', credentials: 'same-origin'});
|
|
await device.prepareSession({binding: ''});
|
|
const prompt = permissions.receive === 'prompt' || permissions.receive === 'prompt-with-rationale';
|
|
if (location.pathname === '/profile' || (!localStorage.getItem('metalcircle_push_prompt') && prompt)) {
|
|
notice(prompt ? texts.permission : texts.denied, prompt);
|
|
}
|
|
return;
|
|
}
|
|
// Invalidate the previous account/session's FCM token before acquiring a new one.
|
|
binding = '';
|
|
await device.prepareSession({binding: session.session_tag});
|
|
if (stopped) return;
|
|
binding = session.session_tag;
|
|
await push.register();
|
|
} catch (_) {
|
|
if (!stopped && location.pathname === '/profile') notice(texts.failed);
|
|
} finally { running = false; }
|
|
}
|
|
|
|
document.addEventListener('submit', async event => {
|
|
const form = event.target;
|
|
if (!(form instanceof HTMLFormElement) || !['/logout', '/profile/delete'].includes(new URL(form.action).pathname)) return;
|
|
event.preventDefault();
|
|
stopped = true; binding = ''; ready = false;
|
|
try {
|
|
await sendQueue;
|
|
await device.prepareSession({binding: ''});
|
|
} catch (_) { /* Server-side session deletion still revokes every bound device. */ }
|
|
form.submit();
|
|
}, true);
|
|
|
|
Promise.all([
|
|
push.addListener('registration', event => {
|
|
sendQueue = sendQueue.then(() => sendToken(event.value)).catch(() => {
|
|
if (!stopped) notice(texts.failed);
|
|
});
|
|
}),
|
|
push.addListener('registrationError', () => { if (!stopped) notice(texts.failed); }),
|
|
push.addListener('pushNotificationActionPerformed', () => {
|
|
// Do not navigate to arbitrary URLs supplied by a notification payload.
|
|
location.assign('/');
|
|
})
|
|
]).then(() => {
|
|
synchronize();
|
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) synchronize(); });
|
|
document.addEventListener('resume', synchronize);
|
|
window.addEventListener('online', synchronize);
|
|
window.addEventListener('pageshow', synchronize);
|
|
}).catch(() => { /* The website remains usable when the bridge is unavailable. */ });
|
|
})();
|