From a2f6a22f97b20653f3cdc19f9044e989d6426a18 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Tue, 3 Feb 2026 11:00:00 +0000 Subject: [PATCH] refactor(simulator): enhance mobile simulator UI with updated styles, improved security policies, and WebRTC signaling support --- Backend/index.ts | 8 +- Backend/public/mobile-sim.html | 509 +++++++----- Backend/public/mobile-sim.js | 1404 +++++++++++++++++++------------- Backend/realtime/gateway.ts | 35 + Backend/routes/devices.ts | 18 +- 5 files changed, 1184 insertions(+), 790 deletions(-) diff --git a/Backend/index.ts b/Backend/index.ts index 3afcf4a..e085974 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -51,8 +51,12 @@ app.use( helmet({ contentSecurityPolicy: { directives: { - scriptSrc: ["'self'", 'https://cdn.jsdelivr.net'], - connectSrc: ["'self'", 'https://cdn.jsdelivr.net'], + ...helmet.contentSecurityPolicy.getDefaultDirectives(), + "script-src": ["'self'", "'unsafe-inline'", "cdn.jsdelivr.net", "cdn.tailwindcss.com"], + "style-src": ["'self'", "'unsafe-inline'", "cdn.jsdelivr.net", "fonts.googleapis.com"], + "font-src": ["'self'", "fonts.gstatic.com"], + "connect-src": ["'self'", "cdn.jsdelivr.net"], + "img-src": ["'self'", "data:", "blob:"], }, }, }), diff --git a/Backend/public/mobile-sim.html b/Backend/public/mobile-sim.html index 5bd1398..07a1cff 100644 --- a/Backend/public/mobile-sim.html +++ b/Backend/public/mobile-sim.html @@ -1,243 +1,312 @@ - - + + SecureCam Mobile Simulator - - + + + + + - -
-
-
-
-
-
- + -
-
-
-
-

Welcome

-

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

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

Set Up This Phone

-

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

- - - - - - - - - -
-
-
- -
-
-
-

Client Home

-

Monitor cameras and request live streams.

- -
- - -
- - - -
- - -
- -
- - -
- -
- - -
-
-
-
- -
-
-
-

Camera Home

-

Detect motion, stream live, and finalize recordings.

- -
- - -
- -
- - -
- -
- - -
-
-
-
- -
-
-
-

Activity

-

Realtime events and push inbox.

- -
- - -
- -
- - -
- -
-
-
-
-
- -
-
-
-

Account

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

SecureCam

+
+ + OFFLINE +
-
+ +
+
+ Signed Out +
+
+ + + +
+ + +
+
+
+ + + +
+

Welcome Back

+

Sign in to manage visual security.

+
+ +
+
+ +
+
+ +
+ +
+ +
+ +
OR
+ +
+
+ + + + + + + + + + + + + + + + + +
+ + + +
- + diff --git a/Backend/public/mobile-sim.js b/Backend/public/mobile-sim.js index d9cf7bd..52f1da4 100644 --- a/Backend/public/mobile-sim.js +++ b/Backend/public/mobile-sim.js @@ -1,610 +1,880 @@ - const state = { - session: null, - device: null, - deviceToken: null, - socket: null, - currentScreen: 'auth', - lastMotionEventId: null, - lastStreamSessionId: null, - lastRecordingId: null, - latestPushNotificationId: null, - }; +/** + * SecureCam Mobile Simulator Logic + * Refactored for modern UI/UX and stability. + */ - const screens = ['auth', 'onboarding', 'home-client', 'home-camera', 'activity', 'account']; +// --- 1. State Management --- +class Store { + constructor(initialState) { + this.state = initialState; + this.listeners = new Set(); + } - const $ = (id) => document.getElementById(id); + get() { + return this.state; + } - const safeJson = (value) => JSON.stringify(value, null, 2); + update(partialState) { + this.state = { ...this.state, ...partialState }; + this.notify(); + } - 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; - }; + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } - const saveLocal = () => { - localStorage.setItem('mobileSimDevice', JSON.stringify({ device: state.device, deviceToken: state.deviceToken })); - }; + notify() { + this.listeners.forEach((listener) => listener(this.state)); + } +} - 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 store = new Store({ + screen: 'auth', // auth, onboarding, home, activity, settings + session: null, + device: null, + deviceToken: null, + socketConnected: false, + isMotionActive: false, + cameraStatus: 'idle', // idle, recording, streaming + linkedCameras: [], + recordings: [], + activityFeed: [], + loading: false, // global loading spinner state if needed +}); + +// --- 2. UI Utilities --- +const $ = (selector) => { + // If it looks like a simple ID (no spaces, dots, hash), use getElementById + if (/^[a-zA-Z0-9_\-]+$/.test(selector)) { + return document.getElementById(selector); + } + // Otherwise use querySelector (handles #id, .class, complex selectors) + return document.querySelector(selector); +}; +const $$ = (selector) => document.querySelectorAll(selector); + +const Toast = { + show(message, type = 'info') { + const container = $('toast-container'); + const toast = document.createElement('div'); + + let alertClass = 'alert-info'; + let icon = ''; + + if (type === 'success') { + alertClass = 'alert-success'; + icon = ''; + } else if (type === 'error') { + alertClass = 'alert-error'; + icon = ''; + } + + toast.className = `alert ${alertClass} text-white shadow-lg text-xs py-2 px-3 flex flex-row gap-2 toast-enter`; + toast.innerHTML = `${icon}${message}`; + + container.appendChild(toast); + setTimeout(() => { + toast.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + toast.style.opacity = '0'; + toast.style.transform = 'translateY(100%)'; + setTimeout(() => toast.remove(), 300); + }, 3000); + } +}; + +// --- 3. API Client --- +const API = { + async request(path, options = {}) { + const { deviceToken } = store.get(); + const headers = { 'Content-Type': 'application/json' }; + + if (deviceToken) { + headers['Authorization'] = `Bearer ${deviceToken}`; + } + + try { + const res = await fetch(path, { ...options, headers: { ...headers, ...options.headers } }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error(data.message || data.error || res.statusText); + } + return data; + } catch (e) { + Toast.show(e.message, 'error'); + throw e; + } + }, + + auth: { + signUp: (data) => API.request('/api/auth/sign-up/email', { method: 'POST', body: JSON.stringify(data) }), + signIn: (data) => API.request('/api/auth/sign-in/email', { method: 'POST', body: JSON.stringify(data) }), + getSession: () => API.request('/api/auth/get-session'), + signOut: () => API.request('/api/auth/sign-out', { method: 'POST', body: JSON.stringify({}) }), + }, + + devices: { + register: (data) => API.request('/devices/register', { method: 'POST', body: JSON.stringify(data) }), + listLinks: () => API.request('/device-links'), + link: (cameraDeviceId, clientDeviceId) => API.request('/device-links', { method: 'POST', body: JSON.stringify({ cameraDeviceId, clientDeviceId }) }), + }, + + streams: { + request: (cameraDeviceId) => API.request('/streams/request', { method: 'POST', body: JSON.stringify({ cameraDeviceId, reason: 'on_demand' }) }), + accept: (id) => API.request(`/streams/${id}/accept`, { method: 'POST', body: JSON.stringify({}) }), + end: (id) => API.request(`/streams/${id}/end`, { method: 'POST', body: JSON.stringify({ reason: 'completed' }) }), + getPublishCreds: (id) => API.request(`/streams/${id}/publish-credentials`), + getSubscribeCreds: (id) => API.request(`/streams/${id}/subscribe-credentials`), + }, + + events: { + startMotion: () => API.request('/events/motion/start', { method: 'POST', body: JSON.stringify({ title: 'Simulated Motion', triggeredBy: 'motion' }) }), + endMotion: (id) => API.request(`/events/${id}/motion/end`, { method: 'POST', body: JSON.stringify({ status: 'completed' }) }), + finalizeRecording: (id, objectKey) => API.request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify({ objectKey, bucket: 'videos', durationSeconds: 15, sizeBytes: 5000000 }) }), + }, + + ops: { + listRecordings: () => API.request('/recordings/me/list'), + getRecordingDownloadUrl: (recordingId) => API.request(`/recordings/${recordingId}/download-url`), + listNotifications: () => API.request('/push-notifications/me'), + } +}; + +// --- 4. Logic & Controllers --- + +let socket = null; +let pollInterval = null; +let localCameraStream = null; +let remoteClientStream = null; +let peerConnection = null; +let peerSessionId = null; +let peerTargetDeviceId = null; +let remoteStreamWaitTimer = null; +const rtcConfig = { + iceServers: [{ urls: 'stun:stun.l.google.com:19302' }], +}; + +const init = async () => { + // Load local storage + const saved = localStorage.getItem('mobileSimDevice'); + if (saved) { + try { + const parsed = JSON.parse(saved); + store.update({ device: parsed.device, deviceToken: parsed.deviceToken }); + } catch (e) { console.error('Failed to load saved device', e); } + } + + try { + const session = await API.auth.getSession(); + if (session && session.session) { + store.update({ session }); + if (store.get().deviceToken) { + // If we have a token, skip onboarding + navigateBasedOnRole(); + connectSocket(); + startPolling(); + } else { + store.update({ screen: 'onboarding' }); + } + } else { + store.update({ screen: 'auth' }); + } + } catch { + store.update({ screen: 'auth' }); + } +}; + +const navigateBasedOnRole = () => { + const { device } = store.get(); + if (!device) return store.update({ screen: 'onboarding' }); + + // Default home screen based on role + store.update({ screen: 'home' }); +}; + +const startCameraPreview = async () => { + const videoEl = $('cameraVideo'); + if (!videoEl || !navigator.mediaDevices?.getUserMedia) { + Toast.show('Camera API is not available in this browser', 'error'); + return false; + } + + if (localCameraStream) { + videoEl.srcObject = localCameraStream; + videoEl.classList.remove('hidden'); + return true; + } + + try { + localCameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); + videoEl.srcObject = localCameraStream; + videoEl.classList.remove('hidden'); + addActivity('Camera', 'Camera access granted'); + return true; + } catch (error) { + Toast.show('Camera permission denied or unavailable', 'error'); + addActivity('Camera', 'Camera access failed'); + return false; + } +}; + +const stopCameraPreview = () => { + const videoEl = $('cameraVideo'); + if (localCameraStream) { + localCameraStream.getTracks().forEach((track) => track.stop()); + localCameraStream = null; + } + if (videoEl) { + videoEl.srcObject = null; + videoEl.classList.add('hidden'); + } +}; + +const setClientStreamVisibility = (isVisible) => { + const videoEl = $('clientStreamVideo'); + const placeholderEl = $('clientStreamPlaceholder'); + if (videoEl) { + videoEl.classList.toggle('hidden', !isVisible); + } + if (placeholderEl) { + placeholderEl.classList.toggle('hidden', isVisible); + } +}; + +const clearClientStream = () => { + if (remoteStreamWaitTimer) { + clearTimeout(remoteStreamWaitTimer); + remoteStreamWaitTimer = null; + } + const videoEl = $('clientStreamVideo'); + if (remoteClientStream) { + remoteClientStream.getTracks().forEach((track) => track.stop()); + remoteClientStream = null; + } + if (videoEl) { + videoEl.srcObject = null; + } + setClientStreamVisibility(false); +}; + +const teardownPeerConnection = () => { + if (peerConnection) { + peerConnection.onicecandidate = null; + peerConnection.ontrack = null; + peerConnection.onconnectionstatechange = null; + peerConnection.close(); + } + + peerConnection = null; + peerSessionId = null; + peerTargetDeviceId = null; + clearClientStream(); +}; + +const ensurePeerConnection = async ({ + streamSessionId, + targetDeviceId, + asCamera, +}) => { + if (peerConnection && peerSessionId === streamSessionId && peerTargetDeviceId === targetDeviceId) { + return peerConnection; + } + + teardownPeerConnection(); + + const connection = new RTCPeerConnection(rtcConfig); + peerConnection = connection; + peerSessionId = streamSessionId; + peerTargetDeviceId = targetDeviceId; + + connection.onicecandidate = (event) => { + if (!socket || !event.candidate || !peerSessionId || !peerTargetDeviceId) return; + socket.emit('webrtc:signal', { + toDeviceId: peerTargetDeviceId, + streamSessionId: peerSessionId, + signalType: 'candidate', + data: event.candidate.toJSON(), + }); + }; + + connection.onconnectionstatechange = () => { + addActivity('WebRTC', `Peer ${connection.connectionState}`); + if (connection.connectionState === 'failed' || connection.connectionState === 'disconnected' || connection.connectionState === 'closed') { + if (store.get().device?.role === 'client') { + clearClientStream(); + } + } + }; + + connection.ontrack = (event) => { + if (remoteStreamWaitTimer) { + clearTimeout(remoteStreamWaitTimer); + remoteStreamWaitTimer = null; + } + const [stream] = event.streams; + if (!stream) return; + remoteClientStream = stream; + const videoEl = $('clientStreamVideo'); + if (videoEl) { + videoEl.srcObject = stream; + setClientStreamVisibility(true); + void videoEl.play().catch(() => {}); + } + }; + + if (asCamera) { + const ready = await startCameraPreview(); + if (!ready || !localCameraStream || localCameraStream.getVideoTracks().length === 0) { + throw new Error('Camera stream unavailable for WebRTC publish'); + } + if (localCameraStream) { + localCameraStream.getTracks().forEach((track) => connection.addTrack(track, localCameraStream)); + } + } + + return connection; +}; + +const startOfferToClient = async (streamSessionId, requesterDeviceId) => { + if (!socket) return; + + const connection = await ensurePeerConnection({ + streamSessionId, + targetDeviceId: requesterDeviceId, + asCamera: true, + }); + + const offer = await connection.createOffer(); + await connection.setLocalDescription(offer); + socket.emit('webrtc:signal', { + toDeviceId: requesterDeviceId, + streamSessionId, + signalType: 'offer', + data: offer, + }); +}; + +const connectSocket = () => { + const { deviceToken } = store.get(); + if (!deviceToken) return; + + if (socket) socket.disconnect(); + + socket = io({ auth: { token: deviceToken } }); + + socket.on('connect', () => { + store.update({ socketConnected: true }); + addActivity('System', 'Connected to realtime server'); + if (store.get().device?.role === 'camera') { + startCameraPreview(); + } + }); + + socket.on('disconnect', () => { + store.update({ socketConnected: false }); + teardownPeerConnection(); + }); + + // Handle commands (as Camera) + socket.on('command:received', async (payload) => { + addActivity('Command', `Received ${payload.commandType}`); + + try { + if (payload.commandType === 'start_stream') { + const streamId = payload.payload.streamSessionId; + const ready = await startCameraPreview(); + if (!ready) { + throw new Error('Camera permission is required before streaming'); } - }; - - const setScreen = (name) => { - state.currentScreen = name; - for (const screen of screens) { - $(`screen-${screen}`).classList.toggle('active', screen === name); + await API.streams.accept(streamId); + await API.streams.getPublishCreds(streamId); + if (payload.sourceDeviceId) { + await startOfferToClient(streamId, payload.sourceDeviceId); } - }; - - const updateTop = () => { - const signedIn = Boolean(state.session?.session); - $('authChip').textContent = signedIn ? 'signed in' : 'signed out'; - $('authChip').classList.toggle('online', signedIn); - $('authChip').classList.toggle('offline', !signedIn); - - const socketConnected = Boolean(state.socket?.connected); - $('socketChip').textContent = socketConnected ? 'online' : 'offline'; - $('socketChip').classList.toggle('online', socketConnected); - $('socketChip').classList.toggle('offline', !socketConnected); - - 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) { - const errorMessage = - payload?.message || - payload?.error?.message || - payload?.error || - payload?.code || - `${response.status} ${response.statusText}`; - throw new Error(errorMessage); - } - - 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 = () => { - const email = $('authEmail').value.trim(); - const explicitName = $('authName').value.trim(); - const inferredName = email.includes('@') ? email.split('@')[0] : email; - - return { - name: explicitName || inferredName || 'SecureCam User', - email, - 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, + addActivity('Stream', 'Accepted & Published'); + // Auto-stop after 15s for simulation + setTimeout(async () => { + await API.streams.end(streamId); + if (socket && payload.sourceDeviceId) { + socket.emit('webrtc:signal', { + toDeviceId: payload.sourceDeviceId, + streamSessionId: streamId, + signalType: 'hangup', }); } + teardownPeerConnection(); + addActivity('Stream', 'Ended auto-simulation'); + }, 15000); + } + + socket.emit('command:ack', { commandId: payload.commandId, status: 'acknowledged' }); + } catch (e) { + socket.emit('command:ack', { commandId: payload.commandId, status: 'rejected', error: e.message }); + } + }); + + // Handle Events (as Client) + socket.on('motion:detected', (payload) => { + addActivity('Motion', `Detected on camera ${payload.deviceId?.split('-')[0]}...`); + Toast.show('Motion Detected!', 'info'); + updateNotificationDot(true); + }); + + socket.on('stream:started', async (payload) => { + addActivity('Stream', 'Stream is live, connecting...'); + clearClientStream(); + try { + await API.streams.getSubscribeCreds(payload.streamSessionId); + Toast.show('Connected to Stream', 'success'); + remoteStreamWaitTimer = setTimeout(() => { + if (!remoteClientStream) { + Toast.show('Stream connected but no video received', 'error'); + addActivity('Stream', 'No remote video track received'); + } + }, 6000); + } catch (e) { + Toast.show('Stream connect failed', 'error'); + } + }); + + socket.on('webrtc:signal', async (payload) => { + const device = store.get().device; + if (!device || !payload?.streamSessionId || !payload?.signalType || !payload?.fromDeviceId) return; + + try { + if (payload.signalType === 'offer') { + if (device.role !== 'client') return; + addActivity('WebRTC', 'Offer received'); + const connection = await ensurePeerConnection({ + streamSessionId: payload.streamSessionId, + targetDeviceId: payload.fromDeviceId, + asCamera: false, }); - }; - - $('signUpBtn').addEventListener('click', async () => { - try { - const authPayload = getAuthPayload(); - if (!authPayload.email || !authPayload.password) { - throw new Error('Email and password are required'); - } - - if (authPayload.password.length < 8) { - throw new Error('Password must be at least 8 characters'); - } - - const payload = await authFetch('/api/auth/sign-up/email', { - method: 'POST', - body: JSON.stringify(authPayload), - }); - log('sign up', payload); - $('sessionBtn').click(); - } catch (error) { - log('sign up failed', { error: error.message }); - } - }); - - $('signInBtn').addEventListener('click', async () => { - try { - const authPayload = getAuthPayload(); - if (!authPayload.email || !authPayload.password) { - throw new Error('Email and password are required'); - } - - 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 }); - } + await connection.setRemoteDescription(new RTCSessionDescription(payload.data)); + const answer = await connection.createAnswer(); + await connection.setLocalDescription(answer); + socket.emit('webrtc:signal', { + toDeviceId: payload.fromDeviceId, + streamSessionId: payload.streamSessionId, + signalType: 'answer', + data: answer, }); + addActivity('WebRTC', 'Answer sent'); + return; + } - $(disconnectId).addEventListener('click', () => { - state.socket?.disconnect(); - }); - }; + if (payload.signalType === 'answer') { + if (device.role !== 'camera' || !peerConnection) return; + await peerConnection.setRemoteDescription(new RTCSessionDescription(payload.data)); + addActivity('WebRTC', 'Answer received'); + return; + } - bindConnectButtons('connectBtn', 'disconnectBtn'); - bindConnectButtons('connectBtnCam', 'disconnectBtnCam'); + if (payload.signalType === 'candidate') { + if (!peerConnection || !payload.data) return; + await peerConnection.addIceCandidate(new RTCIceCandidate(payload.data)); + addActivity('WebRTC', 'ICE candidate added'); + return; + } - $('linkBtn').addEventListener('click', async () => { - try { - if (!state.device?.id || state.device.role !== 'client') throw new Error('Current phone is not in client mode'); + if (payload.signalType === 'hangup') { + teardownPeerConnection(); + addActivity('Stream', 'Remote stream ended'); + } + } catch (error) { + console.error('Failed handling WebRTC signal', error); + Toast.show('WebRTC negotiation failed', 'error'); + } + }); - const cameraDeviceId = $('targetCameraId').value.trim(); - if (!cameraDeviceId) throw new Error('Enter camera device id'); + socket.on('error:webrtc_signal', (payload) => { + const message = payload?.message || 'WebRTC signaling error'; + addActivity('WebRTC', message); + Toast.show(message, 'error'); + }); +}; - 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 }); - } - }); +const startPolling = () => { + if (pollInterval) clearInterval(pollInterval); - $('requestStreamBtn').addEventListener('click', async () => { - try { - const cameraDeviceId = $('targetCameraId').value.trim(); - if (!cameraDeviceId) throw new Error('Enter camera device id'); + const poller = async () => { + const { device, screen } = store.get(); + if (!device) return; - const payload = await deviceFetch('/streams/request', { - method: 'POST', - body: JSON.stringify({ cameraDeviceId, reason: 'on_demand' }), - }); + if (screen === 'home' && device.role === 'client') { + const recs = await API.ops.listRecordings().catch(() => ({ recordings: [] })); + store.update({ recordings: recs.recordings || [] }); - state.lastStreamSessionId = payload.streamSession.id; - renderState(); - log('stream requested', payload); - } catch (error) { - log('stream request failed', { error: error.message }); - } - }); + const links = await API.devices.listLinks().catch(() => ({ links: [] })); + store.update({ linkedCameras: links.links || [] }); + } - $('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 }); - } - }); + if (screen === 'activity') { + // maybe poll notifications + } + }; - $('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 }); - } - }); + poller(); + pollInterval = setInterval(poller, 5000); +}; - $('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 }); - } - }); +// --- Actions --- - $('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 }); - } - }); +const Actions = { + toggleAuthMode: () => { + const isRegistering = !$('authNameField').classList.contains('hidden'); + if (isRegistering) { + $('authNameField').classList.add('hidden'); + $('signInBtn').textContent = 'Sign In'; + $('toggleAuthModeBtn').textContent = 'Create an account'; + } else { + $('authNameField').classList.remove('hidden'); + $('signInBtn').textContent = 'Create Account'; + $('toggleAuthModeBtn').textContent = 'I already have an account'; + } + }, - $('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 }); - } - }); + submitAuth: async () => { + const email = $('authEmail').value; + const password = $('authPassword').value; + const name = $('authName').value || email.split('@')[0]; + const isRegistering = !$('authNameField').classList.contains('hidden'); - $('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 }); - } - }); + try { + if (isRegistering) { + await API.auth.signUp({ email, password, name }); + } + await API.auth.signIn({ email, password }); + const session = await API.auth.getSession(); + store.update({ session }); + Toast.show(`Welcome, ${session.user.name}`, 'success'); - $('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 }); - } - }); + // Proceed + if (store.get().deviceToken) { + navigateBasedOnRole(); + connectSocket(); + startPolling(); + } else { + store.update({ screen: 'onboarding' }); + } + } catch (e) { + // handled by API wrapper toast + } + }, - $('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'); + selectRole: (role) => { + $('role').value = role; + const btnCamera = $('btn-role-camera'); + const btnClient = $('btn-role-client'); - state.lastRecordingId = target.id; - renderState(); + btnCamera.setAttribute('data-active', role === 'camera'); + btnClient.setAttribute('data-active', role === 'client'); - 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 }); - } - }); + // DaisyUI/Tailwind manual toggle logic for visual feedback + if (role === 'camera') { + btnCamera.classList.add('bg-blue-600', 'text-white'); + btnCamera.classList.remove('text-gray-400'); + btnClient.classList.remove('bg-blue-600', 'text-white'); + btnClient.classList.add('text-gray-400'); + } else { + btnClient.classList.add('bg-blue-600', 'text-white'); + btnClient.classList.remove('text-gray-400'); + btnCamera.classList.remove('bg-blue-600', 'text-white'); + btnCamera.classList.add('text-gray-400'); + } + }, - $('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 }); - } - }); + registerDevice: async () => { + const name = $('deviceName').value || 'Web Simulator'; + const role = $('role').value; + const pushToken = $('pushToken').value; - $('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 }); - } - }); + try { + const payload = { name, role, platform: 'web', appVersion: 'sim-2.0' }; + if (pushToken && pushToken.trim().length > 0) { + payload.pushToken = pushToken.trim(); + } - $('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 }); - } - }); + const res = await API.devices.register(payload); - $('fetchAuditBtn').addEventListener('click', async () => { - try { - const payload = await deviceFetch('/audit/device'); - log('audit logs', payload); - } catch (error) { - log('audit fetch failed', { error: error.message }); - } - }); + store.update({ device: res.device, deviceToken: res.deviceToken }); + localStorage.setItem('mobileSimDevice', JSON.stringify({ device: res.device, deviceToken: res.deviceToken })); - $('checkLiveBtn').addEventListener('click', async () => { - try { - const payload = await authFetch('/ops/live'); - log('ops live', payload); - } catch (error) { - log('ops live failed', { error: error.message }); - } - }); + Toast.show('Device Registered', 'success'); + navigateBasedOnRole(); + connectSocket(); + startPolling(); + } catch (e) { + // handled + } + }, - $('checkReadyBtn').addEventListener('click', async () => { - try { - const payload = await authFetch('/ops/ready'); - log('ops ready', payload); - } catch (error) { - log('ops ready failed', { error: error.message }); - } - }); + signOut: async () => { + await API.auth.signOut(); + store.update({ session: null, screen: 'auth', device: null, deviceToken: null, socketConnected: false }); + if (socket) socket.disconnect(); + teardownPeerConnection(); + stopCameraPreview(); + localStorage.removeItem('mobileSimDevice'); + Toast.show('Signed Out', 'info'); + }, - $('checkMetricsBtn').addEventListener('click', async () => { - try { - const payload = await authFetch('/ops/metrics'); - log('ops metrics', payload); - } catch (error) { - log('ops metrics failed', { error: error.message }); - } - }); + // Camera Actions + startMotion: async () => { + try { + const res = await API.events.startMotion(); + store.update({ isMotionActive: true, lastMotionEventId: res.event.id }); + Toast.show('Motion Event Started', 'success'); + addActivity('Motion', 'Started event ' + res.event.id); + } catch (e) { } + }, - $('navHome').addEventListener('click', () => { - setScreen(state.device?.role === 'camera' ? 'home-camera' : 'home-client'); - updateFlow(); - }); + endMotion: async () => { + const { lastMotionEventId } = store.get(); + if (!lastMotionEventId) return; + try { + await API.events.endMotion(lastMotionEventId); + store.update({ isMotionActive: false }); + Toast.show('Motion Ended', 'success'); + addActivity('Motion', 'Ended event'); + } catch (e) { } + }, - $('navActivity').addEventListener('click', () => { - setScreen('activity'); - updateFlow(); - }); + // Client Actions + linkCamera: async () => { + const id = prompt('Enter Camera Device ID:'); // Simple prompt for now, could be better UI + if (!id) return; + try { + await API.devices.link(id, store.get().device.id); + Toast.show('Camera Linked', 'success'); + startPolling(); // refresh list + } catch (e) { } + }, - $('navAccount').addEventListener('click', () => { - setScreen('account'); - updateFlow(); - }); + requestStream: async (camId) => { + try { + Toast.show('Requesting Stream...', 'info'); + await API.streams.request(camId); + // Socket will handle the rest ('stream:started') + } catch (e) { } + }, - const init = async () => { - loadLocal(); - try { - const payload = await authFetch('/api/auth/get-session'); - state.session = payload?.session ? payload : null; - } catch { - state.session = null; - } + openRecording: async (recordingId) => { + try { + const result = await API.ops.getRecordingDownloadUrl(recordingId); + if (!result?.downloadUrl) { + Toast.show('Recording URL unavailable', 'error'); + return; + } + window.open(result.downloadUrl, '_blank', 'noopener,noreferrer'); + } catch (e) { + // handled by API wrapper + } + }, +}; - updateFlow(); - log('simulator initialized', { - hasSession: Boolean(state.session), - hasSavedDevice: Boolean(state.device), - }); - }; +// --- 5. Rendering --- - init(); +const render = (state) => { + // 1. Screen Visibility + $$('section[id^="screen-"]').forEach(el => el.classList.add('hidden')); + + if (state.screen === 'home') { + const homeId = state.device?.role === 'camera' ? 'screen-home-camera' : 'screen-home-client'; + $(homeId).classList.remove('hidden'); + } else { + $(`screen-${state.screen}`).classList.remove('hidden'); + } + + // 2. Top Bar Status + const statusDot = $('#connectionStatus .status-dot'); + const statusText = $('#connectionStatus span:last-child'); + if (state.socketConnected) { + statusDot.className = 'status-dot status-online transition-colors duration-300'; + statusText.textContent = 'ONLINE'; + } else { + statusDot.className = 'status-dot status-offline transition-colors duration-300'; + statusText.textContent = 'OFFLINE'; + } + + const authBadge = $('authStatusBadge'); + if (state.session?.user) { + authBadge.textContent = state.session.user.email; + authBadge.classList.add('text-blue-400'); + } else { + authBadge.textContent = 'Signed Out'; + authBadge.classList.remove('text-blue-400'); + } + + // 3. Bottom Nav Visibility & State + const nav = $('bottomNav'); + if (state.session && state.device) { + nav.classList.remove('hidden'); + $$('.nav-btn').forEach(btn => { + const target = btn.dataset.target; + const isActive = target === state.screen || (target === 'home' && (state.screen === 'home-camera' || state.screen === 'home-client')); + btn.setAttribute('data-active', isActive); + }); + } else { + nav.classList.add('hidden'); + } + + // 4. Camera Mode specifics + if (state.device?.role === 'camera') { + const preview = $('cameraPreview'); + const offlineOverlay = $('cameraOfflineOverlay'); + + if (state.socketConnected) { + offlineOverlay.classList.add('hidden'); + if (state.isMotionActive) { + preview.classList.remove('bg-black/50'); + preview.classList.add('bg-red-900/20'); + $('startMotionBtn').classList.add('hidden'); + $('endMotionBtn').classList.remove('hidden'); + $('endMotionBtn').disabled = false; + } else { + preview.classList.add('bg-black/50'); + preview.classList.remove('bg-red-900/20'); + $('startMotionBtn').classList.remove('hidden'); + $('endMotionBtn').classList.add('hidden'); + } + } else { + offlineOverlay.classList.remove('hidden'); + } + } + + // 5. Client Mode Lists + if (state.device?.role === 'client' && state.screen === 'home') { + const list = $('linkedCamerasList'); + if (state.linkedCameras.length === 0) { + list.innerHTML = `

No cameras linked yet

`; + } else { + list.innerHTML = state.linkedCameras.map(link => ` +
+
+
+ +
+
+

Camera ${link.cameraDeviceId.substring(0, 6)}

+

${link.status}

+
+
+ +
+ `).join(''); + } + + const recList = $('recordingsList'); + if (state.recordings.length === 0) { + recList.innerHTML = `

No recordings found

`; + } else { + recList.innerHTML = state.recordings.slice(0, 5).map(rec => ` +
+
+ ${new Date(rec.createdAt).toLocaleString()} + ${rec.durationSeconds != null ? `${rec.durationSeconds}s duration` : 'Duration pending'} · ${rec.status ?? 'unknown'} +
+ +
+ `).join(''); + } + } + + // 6. Settings Screen + if (state.session?.user && state.screen === 'settings') { + $('profileName').textContent = state.session.user.name; + $('profileEmail').textContent = state.session.user.email; + $('profileInitials').textContent = state.session.user.name.charAt(0).toUpperCase(); + } +}; + +const addActivity = (type, msg) => { + const list = $('activityFeedList'); + const item = document.createElement('div'); + item.className = 'p-3 rounded-lg bg-gray-900/40 border border-white/5 flex flex-col gap-1'; + item.innerHTML = ` +
+ ${type} + ${new Date().toLocaleTimeString()} +
+

${msg}

+ `; + list.prepend(item); + + // Also update camera logs if applicable + if ($('cameraLogs')) { + const logLine = document.createElement('div'); + logLine.textContent = `[${new Date().toLocaleTimeString()}] ${type}: ${msg}`; + $('cameraLogs').prepend(logLine); + } +}; + +const updateNotificationDot = (show) => { + const dot = $('notificationDot'); + if (show) dot.classList.remove('hidden'); + else dot.classList.add('hidden'); +}; + +// --- 6. Event Listeners --- + +$('toggleAuthModeBtn').addEventListener('click', Actions.toggleAuthMode); +$('signInBtn').addEventListener('click', Actions.submitAuth); +$('registerBtn').addEventListener('click', Actions.registerDevice); +$('loadSavedBtn').addEventListener('click', () => { /* Handle legacy loading if needed */ }); +$$('#screen-onboarding [data-role]').forEach((btn) => { + btn.addEventListener('click', () => Actions.selectRole(btn.dataset.role)); +}); +$('linkedCamerasList').addEventListener('click', (event) => { + const target = event.target.closest('.request-stream-btn'); + if (!target) return; + const cameraDeviceId = target.dataset.cameraDeviceId; + if (!cameraDeviceId) return; + Actions.requestStream(cameraDeviceId); +}); +$('recordingsList').addEventListener('click', (event) => { + const target = event.target.closest('.download-recording-btn'); + if (!target || target.disabled) return; + const recordingId = target.dataset.recordingId; + if (!recordingId) return; + Actions.openRecording(recordingId); +}); + +// Navbar +$$('.nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + store.update({ screen: btn.dataset.target }); + if (btn.dataset.target === 'activity') updateNotificationDot(false); + }); +}); + +// Camera Controls +$('cameraGoOnlineBtn').addEventListener('click', async () => { + if (store.get().device?.role === 'camera') { + await startCameraPreview(); + } + connectSocket(); +}); +$('startMotionBtn').addEventListener('click', Actions.startMotion); +$('endMotionBtn').addEventListener('click', Actions.endMotion); + +// Client Controls +$('linkCameraBtn').addEventListener('click', Actions.linkCamera); +$('refreshClientBtn').addEventListener('click', startPolling); + +// Settings +$('signOutBtn').addEventListener('click', Actions.signOut); + +// Init +store.subscribe(render); +init(); + +window.addEventListener('beforeunload', () => { + teardownPeerConnection(); + stopCameraPreview(); +}); + +window.Actions = Actions; diff --git a/Backend/realtime/gateway.ts b/Backend/realtime/gateway.ts index 531f16f..569a2cd 100644 --- a/Backend/realtime/gateway.ts +++ b/Backend/realtime/gateway.ts @@ -19,6 +19,13 @@ const commandAckSchema = z.object({ error: z.string().optional(), }); +const webrtcSignalSchema = z.object({ + toDeviceId: z.string().uuid(), + streamSessionId: z.string().uuid(), + signalType: z.enum(['offer', 'answer', 'candidate', 'hangup']), + data: z.record(z.string(), z.unknown()).nullable().optional(), +}); + const roomForDevice = (deviceId: string): string => `device:${deviceId}`; let io: SocketIOServer | null = null; @@ -286,6 +293,34 @@ export const setupRealtimeGateway = (server: HttpServer): SocketIOServer => { }); }); + socket.on('webrtc:signal', async (input) => { + const parsed = webrtcSignalSchema.safeParse(input); + + if (!parsed.success) { + socket.emit('error:webrtc_signal', { + message: 'Invalid WebRTC signal payload', + errors: parsed.error.flatten(), + }); + return; + } + + const targetDevice = await db.query.devices.findFirst({ + where: and(eq(devices.id, parsed.data.toDeviceId), eq(devices.userId, auth.userId)), + }); + + if (!targetDevice) { + socket.emit('error:webrtc_signal', { message: 'Target device not found for this account' }); + return; + } + + io?.to(roomForDevice(parsed.data.toDeviceId)).emit('webrtc:signal', { + fromDeviceId: auth.deviceId, + streamSessionId: parsed.data.streamSessionId, + signalType: parsed.data.signalType, + data: parsed.data.data ?? null, + }); + }); + socket.on('disconnect', async () => { // Small delay allows fast reconnects to reuse presence without flapping. setTimeout(async () => { diff --git a/Backend/routes/devices.ts b/Backend/routes/devices.ts index 912f64c..565cf86 100644 --- a/Backend/routes/devices.ts +++ b/Backend/routes/devices.ts @@ -3,7 +3,7 @@ import { Router } from 'express'; import { z } from 'zod'; import { db } from '../db/client'; -import { devices } from '../db/schema'; +import { deviceLinks, devices } from '../db/schema'; import { requireAuth } from '../middleware/auth'; import { requireDeviceAuth } from '../middleware/device-auth'; import { createDeviceToken } from '../utils/device-token'; @@ -84,6 +84,22 @@ router.post('/register', requireAuth, async (req, res) => { return; } + const oppositeRole = device.role === 'camera' ? 'client' : 'camera'; + const oppositeDevices = await db.query.devices.findMany({ + where: and(eq(devices.userId, device.userId), eq(devices.role, oppositeRole)), + }); + + if (oppositeDevices.length > 0) { + const linksToCreate = oppositeDevices.map((otherDevice) => ({ + ownerUserId: device.userId, + cameraDeviceId: device.role === 'camera' ? device.id : otherDevice.id, + clientDeviceId: device.role === 'client' ? device.id : otherDevice.id, + status: 'active' as const, + })); + + await db.insert(deviceLinks).values(linksToCreate).onConflictDoNothing(); + } + const deviceToken = createDeviceToken({ userId: device.userId, deviceId: device.id,