Add FCM device registration and Gitea bug reporter

This commit is contained in:
2026-09-15 01:01:08 +02:00
parent b2af866cf0
commit b96b1890d2
33 changed files with 1232 additions and 18 deletions
+17
View File
@@ -592,3 +592,20 @@ h1, .page-title h1 {
color: var(--muted);
font-size: 0.85rem;
}
.native-push-panel { padding:16px; margin:16px 0; border:1px solid var(--border); border-radius:10px; background:var(--surface); }
.native-push-panel .button { margin:4px 8px 4px 0; }
.bug-report-section { max-width:820px; margin:0 auto; }
.bug-report-form { display:grid; gap:10px; max-width:none; }
.bug-report-form input:not([type="checkbox"]):not([type="hidden"]),
.bug-report-form textarea, .bug-report-form select { width:100%; margin:0; padding:12px; border:1px solid var(--border); border-radius:8px; background:#101010; color:var(--text); font:inherit; }
.bug-report-form textarea { resize:vertical; }
.bug-report-options { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
.bug-report-options label { display:grid; gap:8px; }
.bug-report-form .bug-context { display:flex; align-items:flex-start; gap:10px; margin-top:14px; }
.bug-context input { width:auto; margin-top:3px; }
.bug-help { color:var(--muted); font-size:.9rem; margin:0 0 8px; overflow-wrap:anywhere; }
.bug-error { color:#fca5a5; }
.bug-success { color:#bbf7d0; }
.bug-report-form button:disabled { opacity:.6; cursor:wait; }
@media(max-width:600px) { .bug-report-options { grid-template-columns:1fr; } }
+13
View File
@@ -0,0 +1,13 @@
(() => {
const form = document.querySelector('.bug-report-form');
if (!form) return;
const cap = window.Capacitor;
if (cap?.getPlatform() === 'android') {
document.getElementById('bug-platform').value = 'Android';
cap.Plugins.MetalCircleDevice?.getInfo().then(info => {
document.getElementById('bug-app-version').value = info.appVersion || '';
}).catch(() => {});
}
form.addEventListener('submit', () => { form.querySelector('button[type="submit"]').disabled = true; });
window.addEventListener('pageshow', event => { if (event.persisted) location.reload(); });
})();
+144
View File
@@ -0,0 +1,144 @@
/* 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. */ });
})();