Files

176 lines
8.2 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;
async function notificationTarget(notification) {
const data = notification?.data || {};
if (!/^[a-f0-9-]{36}$/.test(data.notification_id || '') || !/^[a-f0-9]{64}$/.test(data.session_tag || '')) return null;
const response = await fetch('/api/push/session', {credentials: 'same-origin', cache: 'no-store'});
if (!response.ok || stopped) return null;
const session = await response.json();
const native = await device.getInfo();
if (!session.authenticated || session.session_tag !== data.session_tag || native.binding !== data.session_tag) return null;
return '/notifications/' + data.notification_id;
}
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', async event => {
try {
const target = await notificationTarget(event.notification);
if (target) location.assign(target);
} catch (_) { /* A tap never bypasses current-session authorization. */ }
}),
push.addListener('pushNotificationReceived', async notification => {
try {
const target = await notificationTarget(notification);
if (!target) return;
document.getElementById('push-in-app-notice')?.remove();
const banner = document.createElement('section');
banner.id = 'push-in-app-notice';
banner.className = 'native-push-panel';
banner.setAttribute('role', 'status');
const link = document.createElement('a');
link.className = 'button';
link.href = target;
// Generic localized text; never insert remote HTML or private message content.
link.textContent = texts.openNotification;
banner.append(link);
(document.querySelector('main') || document.body).prepend(banner);
} catch (_) { /* Push reception cannot interrupt use of the app. */ }
})
]).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. */ });
})();