'use strict'; // ========== Storage abstraction (decisions #1024, #1025) ========== // // #1024: probe availability with a real write, NOT typeof/in // #1025: fallback chain: localStorage → sessionStorage → in-memory let _storage = null; let _storageType = 'memory'; const _mem = {}; function _probeStorage(s) { try { const k = '__baton_probe__'; s.setItem(k, '1'); s.removeItem(k); return true; } catch (_) { return false; } } function _initStorage() { if (_probeStorage(localStorage)) { _storage = localStorage; _storageType = 'local'; } else if (_probeStorage(sessionStorage)) { _storage = sessionStorage; _storageType = 'session'; } else { _storage = { getItem: (k) => (Object.prototype.hasOwnProperty.call(_mem, k) ? _mem[k] : null), setItem: (k, v) => { _mem[k] = String(v); }, removeItem: (k) => { delete _mem[k]; }, }; _storageType = 'memory'; } } // ========== User identity ========== function _getOrCreateUserId() { let id = _storage.getItem('baton_user_id'); if (!id) { id = crypto.randomUUID(); _storage.setItem('baton_user_id', id); } return id; } function _isRegistered() { return _storage.getItem('baton_registered') === '1'; } function _getUserName() { return _storage.getItem('baton_user_name') || ''; } function _getApiKey() { return _storage.getItem('baton_api_key') || ''; } function _saveRegistration(name, apiKey) { _storage.setItem('baton_user_name', name); _storage.setItem('baton_registered', '1'); if (apiKey) _storage.setItem('baton_api_key', apiKey); } function _getInitials(name) { const trimmed = (name || '').trim(); if (!trimmed) return '?'; return trimmed .split(/\s+/) .map((w) => w[0] || '') .join('') .toUpperCase() .slice(0, 2); } // ========== UI helpers ========== function _showScreen(id) { document.querySelectorAll('.screen').forEach((el) => { el.hidden = el.id !== id; }); } function _setStatus(msg, cls) { const el = document.getElementById('status'); el.textContent = msg; el.className = 'status' + (cls ? ' status--' + cls : ''); el.hidden = !msg; } function _updateNetworkIndicator() { const el = document.getElementById('indicator-network'); if (!el) return; el.className = 'network-indicator ' + (navigator.onLine ? 'online' : 'offline'); el.setAttribute('aria-label', navigator.onLine ? 'Online' : 'Offline'); } function _updateUserAvatar() { const el = document.getElementById('user-avatar'); if (!el) return; el.textContent = _getInitials(_getUserName()); } // ========== API calls ========== async function _apiPost(path, body, extraHeaders) { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...extraHeaders }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); const err = new Error('HTTP ' + res.status + (text ? ': ' + text : '')); err.status = res.status; throw err; } return res.json(); } // ========== Geolocation (optional, non-blocking) ========== function _getGeo() { return new Promise((resolve) => { if (!navigator.geolocation) { resolve(null); return; } navigator.geolocation.getCurrentPosition( ({ coords }) => resolve({ lat: coords.latitude, lon: coords.longitude, accuracy: coords.accuracy, }), () => resolve(null), { timeout: 5000, maximumAge: 30000 } ); }); } // ========== Handlers ========== async function _handleRegister() { const input = document.getElementById('name-input'); const btn = document.getElementById('btn-confirm'); const name = input.value.trim(); if (!name) return; btn.disabled = true; _setStatus('', ''); try { const uuid = _getOrCreateUserId(); const data = await _apiPost('/api/register', { uuid, name }); _saveRegistration(name, data.api_key); _updateUserAvatar(); _showMain(); } catch (_) { _setStatus('Error. Please try again.', 'error'); btn.disabled = false; } } function _setSosState(state) { const btn = document.getElementById('btn-sos'); if (!btn) return; btn.dataset.state = state; btn.disabled = state === 'sending'; } async function _handleSignal() { // v1: no offline queue — show error and return (decision #1019) if (!navigator.onLine) { _setStatus('No connection. Check your network and try again.', 'error'); return; } _setSosState('sending'); _setStatus('', ''); try { const geo = await _getGeo(); const uuid = _getOrCreateUserId(); const body = { user_id: uuid, timestamp: Date.now() }; if (geo) body.geo = geo; const apiKey = _getApiKey(); const authHeaders = apiKey ? { Authorization: 'Bearer ' + apiKey } : {}; await _apiPost('/api/signal', body, authHeaders); _setSosState('success'); _setStatus('Signal sent!', 'success'); setTimeout(() => { _setSosState('default'); _setStatus('', ''); }, 2000); } catch (err) { _setSosState('default'); if (err && err.status === 401) { _setStatus('Session expired or key is invalid. Please re-register.', 'error'); } else { _setStatus('Error sending. Try again.', 'error'); } } } // ========== Screens ========== function _showOnboarding() { _showScreen('screen-onboarding'); const input = document.getElementById('name-input'); const btn = document.getElementById('btn-confirm'); input.addEventListener('input', () => { btn.disabled = input.value.trim().length === 0; }); input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !btn.disabled) _handleRegister(); }); btn.addEventListener('click', _handleRegister); } function _showMain() { _showScreen('screen-main'); _updateUserAvatar(); const btn = document.getElementById('btn-sos'); if (btn && !btn.dataset.listenerAttached) { btn.addEventListener('click', _handleSignal); btn.dataset.listenerAttached = '1'; } } // ========== Service Worker ========== function _registerSW() { if (!('serviceWorker' in navigator)) return; navigator.serviceWorker.register('/sw.js').catch((err) => { console.warn('[baton] SW registration failed:', err); }); } // ========== VAPID / Push subscription ========== async function _fetchVapidPublicKey() { try { const res = await fetch('/api/push/public-key'); if (!res.ok) { console.warn('[baton] /api/push/public-key returned', res.status); return null; } const data = await res.json(); return data.vapid_public_key || null; } catch (err) { console.warn('[baton] Failed to fetch VAPID public key:', err); return null; } } function _urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + 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 _initPushSubscription(vapidPublicKey) { if (!vapidPublicKey) { console.warn('[baton] VAPID public key not available — push subscription skipped'); return; } if (!('serviceWorker' in navigator) || !('PushManager' in window)) { return; } try { const registration = await navigator.serviceWorker.ready; const existing = await registration.pushManager.getSubscription(); if (existing) return; const applicationServerKey = _urlBase64ToUint8Array(vapidPublicKey); const subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey, }); _storage.setItem('baton_push_subscription', JSON.stringify(subscription)); console.info('[baton] Push subscription created'); } catch (err) { console.warn('[baton] Push subscription failed:', err); } } // ========== Init ========== function _init() { _initStorage(); // Pre-generate and persist UUID on first visit (per arch spec flow) _getOrCreateUserId(); // Private mode graceful degradation (decision #1041): // show inline banner with explicit action guidance when localStorage is unavailable if (_storageType !== 'local') { const banner = document.getElementById('private-mode-banner'); if (banner) banner.hidden = false; } // Network indicator — initial state + live updates _updateNetworkIndicator(); window.addEventListener('online', _updateNetworkIndicator); window.addEventListener('offline', _updateNetworkIndicator); // Route to correct screen if (_isRegistered()) { _showMain(); } else { _showOnboarding(); } // Fire-and-forget: fetch VAPID key from API and subscribe to push (non-blocking) _fetchVapidPublicKey().then(_initPushSubscription).catch((err) => { console.warn('[baton] Push init error:', err); }); } document.addEventListener('DOMContentLoaded', () => { _registerSW(); _init(); });