-
-
-
-
-
SecureCam Mobile
-
Open app to continue
-
-
-
- signed out
- offline
-
-
+
-
-
-
-
-
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
+
-
+
+
+
+
+
+
+
+
+
+
+
+
Welcome Back
+
Sign in to manage visual security.
+
+
+
+
+
+
+
OR
+
+
+
+
+
+
+
+
Device Setup
+
Configure this browser session as a device.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Camera Offline
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
![Live stream preview]()
+
+
+
No active live stream
+
+
+
+
+
+
+
+
+
Linked Cameras
+
+
+
+
No cameras linked yet
+
+
+
+
+
+
+
Recent Recordings
+
+
+
+
+
+
+
+
+
+
Activity Feed
+
+
+
+
+
+
+
+
No notifications yet
+
+
+
+
+
+
+
+
U
+
User
+
user@example.com
+
+
+
+
+
+
+
+
+
+
+
+
-
+
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 = `
`;
+ } 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 = `
`;
+ } 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,