const state = { session: null, device: null, deviceToken: null, socket: null, currentScreen: 'auth', lastMotionEventId: null, lastStreamSessionId: null, lastRecordingId: null, latestPushNotificationId: null, }; const screens = ['auth', 'onboarding', 'home-client', 'home-camera', 'activity', 'account']; const $ = (id) => document.getElementById(id); const safeJson = (value) => JSON.stringify(value, null, 2); const log = (message, payload) => { const ts = new Date().toISOString(); const line = `[${ts}] ${message}`; const next = `${line}${payload ? `\n${safeJson(payload)}` : ''}\n\n${$('eventLog').textContent}`; $('eventLog').textContent = next; }; const saveLocal = () => { localStorage.setItem('mobileSimDevice', JSON.stringify({ device: state.device, deviceToken: state.deviceToken })); }; const loadLocal = () => { const raw = localStorage.getItem('mobileSimDevice'); if (!raw) return; try { const parsed = JSON.parse(raw); state.device = parsed.device ?? null; state.deviceToken = parsed.deviceToken ?? null; } catch { state.device = null; state.deviceToken = null; } }; const setScreen = (name) => { state.currentScreen = name; for (const screen of screens) { $(`screen-${screen}`).classList.toggle('active', screen === name); } }; const updateTop = () => { const signedIn = Boolean(state.session?.session); $('authChip').textContent = signedIn ? 'signed in' : 'signed out'; $('authChip').className = `chip ${signedIn ? 'online' : 'offline'}`; const socketConnected = Boolean(state.socket?.connected); $('socketChip').textContent = socketConnected ? 'online' : 'offline'; $('socketChip').className = `chip ${socketConnected ? 'online' : 'offline'}`; if (!signedIn) { $('topSubtitle').textContent = 'Sign in to continue'; return; } if (!state.device) { $('topSubtitle').textContent = 'Set up this phone as camera or client'; return; } $('topSubtitle').textContent = `${state.device.role} mode ยท ${state.device.name || state.device.id}`; }; const renderState = () => { $('activityState').textContent = safeJson({ device: state.device, lastStreamSessionId: state.lastStreamSessionId, lastRecordingId: state.lastRecordingId, latestPushNotificationId: state.latestPushNotificationId, }); $('accountState').textContent = safeJson({ session: state.session, deviceTokenPresent: Boolean(state.deviceToken), }); }; const updateFlow = () => { const signedIn = Boolean(state.session?.session); if (!signedIn) { $('bottomNav').classList.add('hidden'); setScreen('auth'); updateTop(); renderState(); return; } if (!state.device || !state.deviceToken) { $('bottomNav').classList.add('hidden'); setScreen('onboarding'); updateTop(); renderState(); return; } $('bottomNav').classList.remove('hidden'); if (!['home-client', 'home-camera', 'activity', 'account'].includes(state.currentScreen)) { setScreen(state.device.role === 'camera' ? 'home-camera' : 'home-client'); } updateTop(); renderState(); }; const authFetch = async (url, options = {}) => { const response = await fetch(url, { credentials: 'include', ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}), }, }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload.message || response.statusText); } return payload; }; const deviceFetch = async (url, options = {}) => { if (!state.deviceToken) { throw new Error('No device token'); } return authFetch(url, { ...options, headers: { Authorization: `Bearer ${state.deviceToken}`, ...(options.headers || {}), }, }); }; const getAuthPayload = () => ({ name: $('authName').value.trim() || undefined, email: $('authEmail').value.trim(), password: $('authPassword').value, }); const connectSocket = () => { if (!state.deviceToken) throw new Error('Register device first'); if (state.socket) { state.socket.disconnect(); } state.socket = io({ auth: { token: state.deviceToken } }); state.socket.on('connect', () => { updateTop(); log('socket connected'); }); state.socket.on('disconnect', () => { updateTop(); log('socket disconnected'); }); state.socket.on('connected', (payload) => log('connected', payload)); state.socket.on('command:status', (payload) => log('command:status', payload)); state.socket.on('stream:requested', (payload) => { state.lastStreamSessionId = payload.streamSessionId; renderState(); log('stream:requested', payload); }); state.socket.on('stream:started', (payload) => { state.lastStreamSessionId = payload.streamSessionId; renderState(); log('stream:started', payload); if (state.device?.role === 'client') { deviceFetch(`/streams/${payload.streamSessionId}/subscribe-credentials`) .then((x) => log('auto subscribe credentials', x)) .catch((e) => log('auto subscribe credentials failed', { error: e.message })); } }); state.socket.on('stream:ended', (payload) => log('stream:ended', payload)); state.socket.on('motion:detected', (payload) => log('motion:detected', payload)); state.socket.on('motion:ended', (payload) => log('motion:ended', payload)); state.socket.on('command:received', async (payload) => { log('command:received', payload); try { if (payload.commandType === 'start_stream' && payload.payload?.streamSessionId) { await deviceFetch(`/streams/${payload.payload.streamSessionId}/accept`, { method: 'POST', body: JSON.stringify({}), }); const creds = await deviceFetch(`/streams/${payload.payload.streamSessionId}/publish-credentials`); log('auto publish credentials', creds); } if (payload.commandType === 'stop_stream' && payload.payload?.streamSessionId) { await deviceFetch(`/streams/${payload.payload.streamSessionId}/end`, { method: 'POST', body: JSON.stringify({ reason: 'completed' }), }); } state.socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' }); } catch (error) { state.socket.emit('command:ack', { commandId: payload.commandId, status: 'rejected', error: error.message, }); } }); }; $('signUpBtn').addEventListener('click', async () => { try { const payload = await authFetch('/api/auth/sign-up/email', { method: 'POST', body: JSON.stringify(getAuthPayload()), }); log('sign up', payload); $('sessionBtn').click(); } catch (error) { log('sign up failed', { error: error.message }); } }); $('signInBtn').addEventListener('click', async () => { try { const authPayload = getAuthPayload(); const payload = await authFetch('/api/auth/sign-in/email', { method: 'POST', body: JSON.stringify({ email: authPayload.email, password: authPayload.password }), }); log('sign in', payload); $('sessionBtn').click(); } catch (error) { log('sign in failed', { error: error.message }); } }); $('sessionBtn').addEventListener('click', async () => { try { const payload = await authFetch('/api/auth/get-session'); state.session = payload?.session ? payload : null; log('session', payload); } catch (error) { state.session = null; log('session check failed', { error: error.message }); } updateFlow(); }); $('signOutBtn').addEventListener('click', async () => { try { await authFetch('/api/auth/sign-out', { method: 'POST', body: JSON.stringify({}) }); } catch (error) { log('sign out failed', { error: error.message }); } state.session = null; state.device = null; state.deviceToken = null; state.socket?.disconnect(); state.socket = null; localStorage.removeItem('mobileSimDevice'); updateFlow(); }); $('registerBtn').addEventListener('click', async () => { try { const payload = await authFetch('/devices/register', { method: 'POST', body: JSON.stringify({ role: $('role').value, name: $('deviceName').value.trim() || undefined, platform: 'web', appVersion: 'sim-ux-1', pushToken: $('pushToken').value.trim() || undefined, }), }); state.device = payload.device; state.deviceToken = payload.deviceToken; saveLocal(); log('device registered', payload); updateFlow(); } catch (error) { log('register failed', { error: error.message }); } }); $('loadSavedBtn').addEventListener('click', async () => { loadLocal(); try { if (state.device?.id && $('pushToken').value.trim()) { await authFetch(`/devices/${state.device.id}`, { method: 'PATCH', body: JSON.stringify({ pushToken: $('pushToken').value.trim() }), }); } } catch (error) { log('push token update failed', { error: error.message }); } updateFlow(); }); const bindConnectButtons = (connectId, disconnectId) => { $(connectId).addEventListener('click', () => { try { connectSocket(); } catch (error) { log('connect failed', { error: error.message }); } }); $(disconnectId).addEventListener('click', () => { state.socket?.disconnect(); }); }; bindConnectButtons('connectBtn', 'disconnectBtn'); bindConnectButtons('connectBtnCam', 'disconnectBtnCam'); $('linkBtn').addEventListener('click', async () => { try { if (!state.device?.id || state.device.role !== 'client') throw new Error('Current phone is not in client mode'); const cameraDeviceId = $('targetCameraId').value.trim(); if (!cameraDeviceId) throw new Error('Enter camera device id'); const payload = await authFetch('/device-links', { method: 'POST', body: JSON.stringify({ cameraDeviceId, clientDeviceId: state.device.id }), }); log('camera linked', payload); } catch (error) { log('link failed', { error: error.message }); } }); $('requestStreamBtn').addEventListener('click', async () => { try { const cameraDeviceId = $('targetCameraId').value.trim(); if (!cameraDeviceId) throw new Error('Enter camera device id'); const payload = await deviceFetch('/streams/request', { method: 'POST', body: JSON.stringify({ cameraDeviceId, reason: 'on_demand' }), }); state.lastStreamSessionId = payload.streamSession.id; renderState(); log('stream requested', payload); } catch (error) { log('stream request failed', { error: error.message }); } }); $('fetchSubscribeBtn').addEventListener('click', async () => { try { if (!state.lastStreamSessionId) throw new Error('No stream session yet'); const payload = await deviceFetch(`/streams/${state.lastStreamSessionId}/subscribe-credentials`); log('subscribe credentials', payload); } catch (error) { log('subscribe credentials failed', { error: error.message }); } }); $('fetchPlaybackBtn').addEventListener('click', async () => { try { if (!state.lastStreamSessionId) throw new Error('No stream session yet'); const payload = await deviceFetch(`/streams/${state.lastStreamSessionId}/playback-token`); log('playback token', payload); } catch (error) { log('playback token failed', { error: error.message }); } }); $('listRecordingsBtn').addEventListener('click', async () => { try { const payload = await deviceFetch('/recordings/me/list'); if (payload.recordings?.length) { state.lastRecordingId = payload.recordings[0].id; } renderState(); log('recordings', payload); } catch (error) { log('recordings list failed', { error: error.message }); } }); $('downloadLatestRecordingBtn').addEventListener('click', async () => { try { if (!state.lastRecordingId) throw new Error('No recording selected'); const payload = await deviceFetch(`/recordings/${state.lastRecordingId}/download-url`); log('recording download url', payload); } catch (error) { log('recording download failed', { error: error.message }); } }); $('startMotionBtn').addEventListener('click', async () => { try { const payload = await deviceFetch('/events/motion/start', { method: 'POST', body: JSON.stringify({ title: 'Motion from simulator', triggeredBy: 'motion' }), }); state.lastMotionEventId = payload.event.id; renderState(); log('motion started', payload); } catch (error) { log('motion start failed', { error: error.message }); } }); $('endMotionBtn').addEventListener('click', async () => { try { if (!state.lastMotionEventId) throw new Error('No active motion event id'); const payload = await deviceFetch(`/events/${state.lastMotionEventId}/motion/end`, { method: 'POST', body: JSON.stringify({ status: 'completed' }), }); log('motion ended', payload); } catch (error) { log('motion end failed', { error: error.message }); } }); $('fetchPublishBtn').addEventListener('click', async () => { try { if (!state.lastStreamSessionId) throw new Error('No stream session yet'); const payload = await deviceFetch(`/streams/${state.lastStreamSessionId}/publish-credentials`); log('publish credentials', payload); } catch (error) { log('publish credentials failed', { error: error.message }); } }); $('finalizeRecordingBtn').addEventListener('click', async () => { try { if (!state.lastStreamSessionId) throw new Error('No stream session yet'); const list = await deviceFetch('/recordings/me/list'); const target = list.recordings?.find((r) => r.streamSessionId === state.lastStreamSessionId) ?? list.recordings?.[0]; if (!target) throw new Error('No recording placeholder found'); state.lastRecordingId = target.id; renderState(); const payload = await deviceFetch(`/recordings/${target.id}/finalize`, { method: 'POST', body: JSON.stringify({ objectKey: `recordings/${target.id}.mp4`, bucket: 'videos', durationSeconds: 12, sizeBytes: 8 * 1024 * 1024, }), }); log('recording finalized', payload); } catch (error) { log('recording finalize failed', { error: error.message }); } }); $('pollPushInboxBtn').addEventListener('click', async () => { try { const payload = await deviceFetch('/push-notifications/me'); if (payload.notifications?.length) { state.latestPushNotificationId = payload.notifications[0].id; } renderState(); log('push inbox', payload); } catch (error) { log('push inbox failed', { error: error.message }); } }); $('markLatestPushReadBtn').addEventListener('click', async () => { try { if (!state.latestPushNotificationId) throw new Error('No push notification selected'); const payload = await deviceFetch(`/push-notifications/${state.latestPushNotificationId}/read`, { method: 'POST', body: JSON.stringify({}), }); log('push marked read', payload); } catch (error) { log('mark push read failed', { error: error.message }); } }); $('dispatchPushWorkerBtn').addEventListener('click', async () => { try { const payload = await deviceFetch('/push-notifications/worker/dispatch', { method: 'POST', body: JSON.stringify({}), }); log('push worker dispatch', payload); } catch (error) { log('push worker dispatch failed', { error: error.message }); } }); $('fetchAuditBtn').addEventListener('click', async () => { try { const payload = await deviceFetch('/audit/device'); log('audit logs', payload); } catch (error) { log('audit fetch failed', { error: error.message }); } }); $('checkLiveBtn').addEventListener('click', async () => { try { const payload = await authFetch('/ops/live'); log('ops live', payload); } catch (error) { log('ops live failed', { error: error.message }); } }); $('checkReadyBtn').addEventListener('click', async () => { try { const payload = await authFetch('/ops/ready'); log('ops ready', payload); } catch (error) { log('ops ready failed', { error: error.message }); } }); $('checkMetricsBtn').addEventListener('click', async () => { try { const payload = await authFetch('/ops/metrics'); log('ops metrics', payload); } catch (error) { log('ops metrics failed', { error: error.message }); } }); $('navHome').addEventListener('click', () => { setScreen(state.device?.role === 'camera' ? 'home-camera' : 'home-client'); updateFlow(); }); $('navActivity').addEventListener('click', () => { setScreen('activity'); updateFlow(); }); $('navAccount').addEventListener('click', () => { setScreen('account'); updateFlow(); }); const init = async () => { loadLocal(); try { const payload = await authFetch('/api/auth/get-session'); state.session = payload?.session ? payload : null; } catch { state.session = null; } updateFlow(); log('simulator initialized', { hasSession: Boolean(state.session), hasSavedDevice: Boolean(state.device), }); }; init();