From a32f7ae7666f45245f102aafdc82608684155823 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Sun, 25 Jan 2026 14:15:00 +0000 Subject: [PATCH] feat(simulator): redesign to app-like mobile UX with auth onboarding and role-based home screens --- Backend/public/mobile-sim.html | 1004 +++++++++++++++++++------------- 1 file changed, 593 insertions(+), 411 deletions(-) diff --git a/Backend/public/mobile-sim.html b/Backend/public/mobile-sim.html index 7f47e86..043b76c 100644 --- a/Backend/public/mobile-sim.html +++ b/Backend/public/mobile-sim.html @@ -3,88 +3,181 @@ - Mobile Client Simulator + SecureCam Mobile Simulator -
-

Mobile Client Simulator

-

- Use this page like a phone app: authenticate, register device role, connect socket with bearer device token, and run - camera/client actions. -

-
-

App-Like Onboarding Flow

-
    -
  1. Sign up/sign in in the Auth panel.
  2. -
  3. Pick role (camera or client) and register device.
  4. -
  5. Connect socket to become online.
  6. -
  7. Client links to camera, requests stream; camera accepts automatically via command handling.
  8. -
  9. Camera finalizes recording; client fetches download URL and polls push inbox when offline.
  10. -
-
+
+
+
+
SecureCam Mobile
+
+ signed out + offline +
+
+

Open app to continue

+
-
-
-

Auth

- - - - - - -
- - +
+
+
+

Welcome

+

Sign in or create an account to use this phone as a camera or client.

+ + + + + + +
+ + +
-
- - -
-

         
-
-

Device Bootstrap

- - - - - - - - - -
- - +
+
+

Set Up This Phone

+

Choose how this phone should behave in your home security setup.

+ + + + + + + +
- -
- - -
- - Socket disconnected -

         
-
-

Client Actions

- - -
- - +
+
+

Client Home

+

Monitor cameras and request live streams.

+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
- - - - - -

         
-
-

Camera Actions

- - - - -

+        
+
+

Camera Home

+

Detect motion, stream live, and finalize recordings.

+
+ + +
+
+ + +
+
+ + +
+
-
-
-

Live Event Log

-

-      
+
+
+

Activity

+

Realtime events and push inbox.

+
+ + +
+
+ + +
+
+
+
+
+ +
+
+

Account

+
+ + +
+
+ + + +
+
+
+
+
+ +
@@ -219,38 +360,146 @@ 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 current = $('log').textContent; - $('log').textContent = `${line}${payload ? `\n${JSON.stringify(payload, null, 2)}` : ''}\n\n${current}`; + 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 render = () => { - $('authState').textContent = JSON.stringify({ session: state.session }, null, 2); - $('deviceState').textContent = JSON.stringify({ device: state.device, hasToken: Boolean(state.deviceToken) }, null, 2); - $('clientState').textContent = JSON.stringify({ lastStreamSessionId: state.lastStreamSessionId }, null, 2); - $('cameraState').textContent = JSON.stringify( - { - lastMotionEventId: state.lastMotionEventId, - lastRecordingId: state.lastRecordingId, - latestPushNotificationId: state.latestPushNotificationId, + 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 || {}), }, - null, - 2, - ); + }); + + 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 = () => ({ @@ -259,12 +508,83 @@ 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 authPayload = getAuthPayload(); const payload = await authFetch('/api/auth/sign-up/email', { method: 'POST', - body: JSON.stringify(authPayload), + body: JSON.stringify(getAuthPayload()), }); log('sign up', payload); $('sessionBtn').click(); @@ -291,151 +611,39 @@ try { const payload = await authFetch('/api/auth/get-session'); state.session = payload?.session ? payload : null; - render(); - log('session check', payload); + log('session', payload); } catch (error) { state.session = null; - render(); log('session check failed', { error: error.message }); } + updateFlow(); }); $('signOutBtn').addEventListener('click', async () => { try { - const payload = await authFetch('/api/auth/sign-out', { method: 'POST', body: JSON.stringify({}) }); - state.session = null; - render(); - log('sign out', payload); + 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(); }); - 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. Register device first.'); - } - - return authFetch(url, { - ...options, - headers: { - Authorization: `Bearer ${state.deviceToken}`, - ...(options.headers || {}), - }, - }); - }; - - const connectSocket = () => { - if (!state.deviceToken) { - throw new Error('No device token'); - } - - if (state.socket) { - state.socket.disconnect(); - } - - state.socket = io({ auth: { token: state.deviceToken } }); - - state.socket.on('connect', () => { - $('connectionState').textContent = 'Socket connected'; - $('connectionState').className = 'online'; - log('socket connected'); - }); - - state.socket.on('disconnect', () => { - $('connectionState').textContent = 'Socket disconnected'; - $('connectionState').className = 'offline'; - 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; - render(); - log('stream:requested', payload); - }); - state.socket.on('stream:started', (payload) => { - state.lastStreamSessionId = payload.streamSessionId; - render(); - log('stream:started', payload); - - if (state.device?.role === 'client') { - deviceFetch(`/streams/${payload.streamSessionId}/subscribe-credentials`) - .then((credentials) => log('auto subscribe credentials', credentials)) - .catch((error) => log('auto subscribe credentials failed', { error: error.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)); - - // Mobile-camera behavior: accept start_stream commands and acknowledge. - 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 publishCredentials = await deviceFetch( - `/streams/${payload.payload.streamSessionId}/publish-credentials`, - ); - log('auto publish credentials', publishCredentials); - } - - 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, - }); - } - }); - }; - $('registerBtn').addEventListener('click', async () => { try { - if (!state.session) throw new Error('Authenticate first'); - const role = $('role').value; - const name = $('deviceName').value.trim(); const payload = await authFetch('/devices/register', { method: 'POST', body: JSON.stringify({ - role, - name: name || undefined, + role: $('role').value, + name: $('deviceName').value.trim() || undefined, platform: 'web', - appVersion: 'sim-1', + appVersion: 'sim-ux-1', pushToken: $('pushToken').value.trim() || undefined, }), }); @@ -443,94 +651,59 @@ state.device = payload.device; state.deviceToken = payload.deviceToken; saveLocal(); - render(); log('device registered', payload); + updateFlow(); } catch (error) { log('register failed', { error: error.message }); } }); - $('loadSavedBtn').addEventListener('click', () => { - const raw = localStorage.getItem('mobileSimDevice'); - if (!raw) return; - const parsed = JSON.parse(raw); - state.device = parsed.device; - state.deviceToken = parsed.deviceToken; - render(); - log('loaded saved device', parsed); - }); - - const auditPanel = document.createElement('section'); - auditPanel.className = 'panel'; - auditPanel.style.marginTop = '16px'; - auditPanel.innerHTML = ` -

Audit Logs

- - `; - document.querySelector('.page').appendChild(auditPanel); - - $('fetchAuditBtn').addEventListener('click', async () => { - try { - const payload = await deviceFetch('/audit/device'); - log('audit logs', payload); - } catch (error) { - log('audit fetch failed', { error: error.message }); - } - }); - $('loadSavedBtn').addEventListener('click', async () => { + loadLocal(); + try { - if (!state.device?.id) return; - const token = $('pushToken').value.trim(); - if (!token) return; - await authFetch(`/devices/${state.device.id}`, { - method: 'PATCH', - body: JSON.stringify({ pushToken: token }), - }); - log('push token updated', { deviceId: state.device.id }); + 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(); }); - $('connectBtn').addEventListener('click', () => { - try { - connectSocket(); - } catch (error) { - log('connect failed', { error: error.message }); - } - }); + const bindConnectButtons = (connectId, disconnectId) => { + $(connectId).addEventListener('click', () => { + try { + connectSocket(); + } catch (error) { + log('connect failed', { error: error.message }); + } + }); - $('disconnectBtn').addEventListener('click', () => { - state.socket?.disconnect(); - }); + $(disconnectId).addEventListener('click', () => { + state.socket?.disconnect(); + }); + }; - $('refreshDevicesBtn').addEventListener('click', async () => { - try { - const payload = await authFetch('/devices'); - log('devices', payload); - } catch (error) { - log('fetch devices failed', { error: error.message }); - } - }); + bindConnectButtons('connectBtn', 'disconnectBtn'); + bindConnectButtons('connectBtnCam', 'disconnectBtnCam'); $('linkBtn').addEventListener('click', async () => { try { - if (!state.device?.id) throw new Error('Register/load a device first'); - if (state.device.role !== 'client') throw new Error('This simulator device is not a client role'); + 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 target camera device id'); + 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, - }), + body: JSON.stringify({ cameraDeviceId, clientDeviceId: state.device.id }), }); - - log('link created', payload); + log('camera linked', payload); } catch (error) { log('link failed', { error: error.message }); } @@ -539,7 +712,7 @@ $('requestStreamBtn').addEventListener('click', async () => { try { const cameraDeviceId = $('targetCameraId').value.trim(); - if (!cameraDeviceId) throw new Error('Enter target camera device id'); + if (!cameraDeviceId) throw new Error('Enter camera device id'); const payload = await deviceFetch('/streams/request', { method: 'POST', @@ -547,17 +720,26 @@ }); state.lastStreamSessionId = payload.streamSession.id; - render(); + 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 known stream session'); - + 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) { @@ -568,19 +750,19 @@ $('listRecordingsBtn').addEventListener('click', async () => { try { const payload = await deviceFetch('/recordings/me/list'); - if (payload.recordings?.length > 0) { + if (payload.recordings?.length) { state.lastRecordingId = payload.recordings[0].id; - render(); } + renderState(); log('recordings', payload); } catch (error) { - log('list recordings failed', { error: error.message }); + log('recordings list failed', { error: error.message }); } }); $('downloadLatestRecordingBtn').addEventListener('click', async () => { try { - if (!state.lastRecordingId) throw new Error('No recording id available'); + 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) { @@ -588,25 +770,14 @@ } }); - $('fetchSubscribeBtn').addEventListener('click', async () => { - try { - if (!state.lastStreamSessionId) throw new Error('No known stream session'); - const payload = await deviceFetch(`/streams/${state.lastStreamSessionId}/subscribe-credentials`); - log('subscribe credentials', payload); - } catch (error) { - log('subscribe credentials failed', { error: error.message }); - } - }); - $('startMotionBtn').addEventListener('click', async () => { try { const payload = await deviceFetch('/events/motion/start', { method: 'POST', - body: JSON.stringify({ title: 'Motion from web simulator', triggeredBy: 'motion' }), + body: JSON.stringify({ title: 'Motion from simulator', triggeredBy: 'motion' }), }); - state.lastMotionEventId = payload.event.id; - render(); + renderState(); log('motion started', payload); } catch (error) { log('motion start failed', { error: error.message }); @@ -615,13 +786,11 @@ $('endMotionBtn').addEventListener('click', async () => { try { - if (!state.lastMotionEventId) throw new Error('No motion event to end'); - + 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 }); @@ -630,7 +799,7 @@ $('fetchPublishBtn').addEventListener('click', async () => { try { - if (!state.lastStreamSessionId) throw new Error('No known stream session'); + 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) { @@ -641,14 +810,12 @@ $('finalizeRecordingBtn').addEventListener('click', async () => { try { if (!state.lastStreamSessionId) throw new Error('No stream session yet'); - - const listPayload = await deviceFetch('/recordings/me/list'); - const target = listPayload.recordings?.find((r) => r.streamSessionId === state.lastStreamSessionId) ?? listPayload.recordings?.[0]; - - if (!target) throw new Error('No recording placeholder exists'); + 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; - render(); + renderState(); const payload = await deviceFetch(`/recordings/${target.id}/finalize`, { method: 'POST', @@ -656,49 +823,25 @@ objectKey: `recordings/${target.id}.mp4`, bucket: 'videos', durationSeconds: 12, - sizeBytes: 1024 * 1024 * 8, + sizeBytes: 8 * 1024 * 1024, }), }); - log('recording finalized', payload); } catch (error) { - log('finalize recording failed', { error: error.message }); - } - }); - - const pushPanel = document.createElement('section'); - pushPanel.className = 'panel'; - pushPanel.style.marginTop = '16px'; - pushPanel.innerHTML = ` -

Push Inbox (Offline Fallback)

-
- - -
- - `; - document.querySelector('.page').appendChild(pushPanel); - - $('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 }); + log('recording finalize failed', { error: error.message }); } }); $('pollPushInboxBtn').addEventListener('click', async () => { try { const payload = await deviceFetch('/push-notifications/me'); - const latest = payload.notifications?.[0]; - if (latest) { - state.latestPushNotificationId = latest.id; + if (payload.notifications?.length) { + state.latestPushNotificationId = payload.notifications[0].id; } - render(); + renderState(); log('push inbox', payload); } catch (error) { - log('poll push inbox failed', { error: error.message }); + log('push inbox failed', { error: error.message }); } }); @@ -715,18 +858,26 @@ } }); - const opsPanel = document.createElement('section'); - opsPanel.className = 'panel'; - opsPanel.style.marginTop = '16px'; - opsPanel.innerHTML = ` -

Ops Checks

-
- - -
- - `; - document.querySelector('.page').appendChild(opsPanel); + $('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 { @@ -755,7 +906,38 @@ } }); - render(); + $('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();