diff --git a/Backend/README.md b/Backend/README.md index 2a78aee..34a3b04 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -179,6 +179,9 @@ Split-page entrypoints are also available: - `GET /sim/mobile-sim-activity.html` - `GET /sim/mobile-sim-settings.html` +Architecture reference page: +- `GET /sim/backend-architecture.html` + All simulator pages support the same flow: - Register as `camera` or `client` - Connect Socket.IO with bearer device token diff --git a/Backend/public/backend-architecture.html b/Backend/public/backend-architecture.html new file mode 100644 index 0000000..b69712e --- /dev/null +++ b/Backend/public/backend-architecture.html @@ -0,0 +1,934 @@ + + + + + + Backend Architecture Deep Dive + + + + + + +
+
+
SecureCam Backend Architecture
+

Backend Architecture Deep Dive (Single-Page Reference)

+

+ This document explains how the backend is built, how requests and realtime events move through the system, + how data is stored, and where each concern lives in code. + It is based on the current implementation in this repository (index.ts, + routes/*, realtime/gateway.ts, + services/*, workers/*, db/schema.ts). +

+
+ Express 5 API + Socket.IO Realtime + Better Auth + Drizzle + PostgreSQL + MinIO / S3-compatible + Mock Media Provider +
+
+ +
+ + +
+
+

1) System Context

+
+ + + + + + + + + Clients + - Browser simulator (camera/client) + - Future mobile apps + - Admin browser + - Swagger/OpenAPI consumers + + + Identity + Better Auth session cookies + Custom HMAC device bearer tokens + Role-aware device auth + + + Backend Process (Bun + Express + Socket.IO) + + + HTTP Layer + Helmet + CORS + rate limits + requestContext metrics + logs + REST routes + OpenAPI docs + + + Realtime Gateway + Socket.IO device rooms + command / stream / webrtc signals + presence + retry loop + + + Service / Worker Layer + push queue worker + recording timeout reconciler + audit logging + health + metrics endpoints + media provider adapter + + + Media Control Plane + stream session orchestration + mock credential issuance + optional SFU scaffold (noop) + recording row lifecycle + + + PostgreSQL + users, devices, links, commands + streams, recordings, events + videos, notifications, audit + Better Auth tables + + + MinIO / Object Storage + presigned PUT / GET URLs + video objects + recordings + bucket bootstrap on startup + + + + + + + + +
+
+

Core Role

Acts primarily as a control plane for auth, command routing, stream state, credential issuance, and recording metadata. It is not yet a full production media plane.

+

Transport Split

HTTP handles CRUD/state endpoints; Socket.IO handles realtime command delivery, acknowledgements, and WebRTC signaling relay.

+

Persistence Split

Postgres stores state + metadata. MinIO stores binary objects. Routes often coordinate both.

+
+
+ +
+

2) Startup Sequence

+
Process start + -> load module graph (auth, db, minio, routes) + -> create Express app + OpenAPI doc + -> mount middleware and routes + -> create HTTP server + -> start() called + -> ensureMinioBucket() + - checks bucket existence + - creates bucket if missing + - exits process on failure + -> setupRealtimeGateway(server) + - Socket.IO auth middleware + - event handlers + - command retry interval init (if required tables exist) + -> startRecordingsWorker() + - interval scans stale awaiting_upload rows -> failed + -> startPushWorker() + - interval dispatches queued push rows + -> server.listen(PORT) +
+

If MinIO initialization fails, process exits with code 1 by design (index.ts).

+
+ +
+

3) HTTP Request Pipeline (Express)

+
+ + + + + + + + Client + + + helmet() + CSP, headers + cors() + trusted origins + global rate limit + memory buckets + + + requestContext + x-request-id + counter increment + JSON finish log + express.json() + body parsing + + + Route layer + /api/auth/* (Better Auth) + /videos /devices /commands ... + route-level auth + zod validation + DB + MinIO + realtime side effects + Static + docs + /sim, /docs, /openapi.json + + + Response + Error Handler + successful JSON / HTML / static file + or fallback 500 JSON + { message: "Internal server error" } + + + + + + +
+
+ +
+

4) Authentication and Identity Model

+
+
+

A) Session auth (requireAuth)

+
    +
  • Used by user-facing REST routes like /videos, /devices/register, /device-links.
  • +
  • Reads Better Auth session from request headers/cookies via auth.api.getSession().
  • +
  • Attaches session object to req.auth.
  • +
  • Backed by Better Auth tables: users, account, session, verification.
  • +
+
+
+

B) Device auth (requireDeviceAuth)

+
    +
  • Used by device-to-backend routes and Socket.IO auth.
  • +
  • Bearer token format: base64url(payload).hmac.
  • +
  • Payload fields: userId, deviceId, role, exp.
  • +
  • Signed with HMAC-SHA256 using BETTER_AUTH_SECRET.
  • +
  • Token role is verified against device role in realtime handshake.
  • +
+
+
+

This dual model separates user session identity from per-device identity and permissions.

+
+ +
+

5) Realtime Gateway (Socket.IO)

+
+
+

Connection model

+
    +
  • Devices authenticate with token in handshake.auth.token or Authorization header.
  • +
  • Each device joins room device:{deviceId}.
  • +
  • Presence updates devices.status + lastSeenAt.
  • +
  • Disconnect applies a 500ms delay to reduce status flapping on fast reconnect.
  • +
+
+
+

Gateway responsibilities

+
    +
  • command:received delivery to target room.
  • +
  • command:ack validation + DB update + source notification.
  • +
  • webrtc:signal relay with same-owner target validation.
  • +
  • stream:frame relay fallback (base64 image snapshots).
  • +
  • Retry worker for stale sent commands every 5s, max 3 retries.
  • +
+
+
+ +

Command dispatch and ack sequence

+
Client Device API /commands DB Socket.IO Gateway Camera Device + | | | | | +1) POST /commands -------->| validate/link ---->| insert queued command | | + | | dispatchCommandById() | | + | |-----------------------------------------------> emit command:received --->| + | | | status sent/queued | | + |<--------------------| command payload | | | + +2) camera emits command:ack --------------------------------------------------------------->| + | | | | validate + update DB | + | | | command status + ack time | emit command:status -->| (to source room) +
+
+ +
+

6) Stream Lifecycle and Media Control

+
+

State machine (stream session)

+
requested --> streaming --> completed|cancelled|failed + | | | + | | +-- create recording placeholder row on end + | +-- media session created (provider + endpoints) + +-- command start_stream queued/dispatched to camera +
+
+ +

On-demand stream end-to-end sequence

+
Client Device /streams/request Camera Device /streams/:id/accept Media Provider + | | | | | +1) request stream -------------->| validate roles/link -> | | | + | | create stream_session | | | + | | create start_stream cmd | | | + | | dispatch via socket ----+-------------------------> | command:received | + |<--------------------------| stream:requested event | | | + +2) accept command (camera side) + | | | POST /accept -----------> | createSession() ------>| + | | | | status=streaming | + |<--------------------------| stream:started event ---+ | | + +3) credential issuance +camera -> GET /publish-credentials -> mediaProvider.issuePublishCredentials() +viewer -> GET /subscribe-credentials -> mediaProvider.issueSubscribeCredentials() + +4) stream end +camera/requester -> POST /end -> mark ended + optional sfuService.endSession() + createRecordingForStream() + -> emit stream:ended to camera and requester (push fallback if offline) +
+ +
+
+

Media provider abstraction

+
    +
  • Current provider: mock (media/providers/mock.ts).
  • +
  • Creates deterministic mock media session IDs.
  • +
  • Issues signed publish/subscribe tokens with TTL.
  • +
  • Uses BETTER_AUTH_SECRET for HMAC signing.
  • +
+
+
+

SFU mode status

+
    +
  • MEDIA_MODE=single_server_sfu enables SFU endpoints.
  • +
  • Current implementation is a noop scaffold with in-memory session registry + synthetic transport IDs.
  • +
  • No full server-side RTP forwarding pipeline implemented yet.
  • +
+
+
+
+ +
+

7) Data Model (Core Tables and Relationships)

+
+ + + + + + + + + users + id (PK), email, name + passwordHash, emailVerified + createdAt, updatedAt + + + devices + id (PK), userId (FK -> users) + role, status, isCamera + platform, appVersion, pushToken + lastSeenAt, timestamps + + + device_links + ownerUserId -> users + cameraDeviceId -> devices + clientDeviceId -> devices + unique(cameraDeviceId, clientDeviceId) + + + device_commands + sourceDeviceId -> devices + targetDeviceId -> devices + commandType, payload + status, retryCount, ackAt + + + stream_sessions + ownerUserId -> users + cameraDeviceId -> devices + requesterDeviceId -> devices + status, reason, mediaProvider + mediaSessionId, streamKey + + + recordings + streamSessionId -> stream_sessions + cameraDeviceId, requesterDeviceId + status awaiting_upload/ready/failed + objectKey, bucket, duration, size + + + events + userId -> users + deviceId -> devices + startedAt, endedAt, status + triggeredBy, videoUrl + + + videos (legacy upload metadata) + userId, deviceId, eventId + objectKey, bucket, uploadUrl + downloadUrl, status, expiresAt + + + push_notifications + ownerUserId -> users + recipientDeviceId -> devices + type, payload, status + attempts, nextAttemptAt, sentAt + + + audit_logs + ownerUserId -> users + actorDeviceId -> devices + action, targetType, targetId + metadata, ipAddress, createdAt + + + Better Auth tables + account + session + verification + + + + + + + + + + + +
+

Note: notifications table exists for event notification tracking; push delivery queue is modeled separately by push_notifications.

+
+ +
+

8) Route Surface and Responsibilities

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AreaAuthMain tables/resourcesSide effects
/api/auth/*Better Authusers, account, session, verificationsession cookie lifecycle
/devicessession + device token for heartbeatdevices, device_linksauto-link opposite-role devices on register; stale-status projection on list
/device-linkssessiondevice_links, devicesenforces camera/client role pairing
/commandssession + device token ack fallbackdevice_commands, devices, device_linksdispatch to Socket.IO, ack/reject propagation
/eventsdevice token (start/end), session (list)events, device_links, notificationsrealtime motion fanout, push fallback, audit log
/streamsdevice tokenstream_sessions, device_commands, recordingsstream command dispatch, media credentials, stream realtime events, push fallback, optional SFU calls
/recordingsdevice tokenrecordingsstorage object validation, presigned download URL, audit log
/videossessionvideos, devices, MinIOpresigned PUT/GET generation, object listing/deletion
/push-notificationsdevice tokenpush_notificationsmanual worker dispatch trigger
/auditdevice tokenaudit_logsnone (read only)
/opsnoneDB, MinIO, in-memory metrics, SFU servicereadiness checks, metric export
/adminHTTP Basic authMinIOembedded admin UI + object operations
+ +

Detailed endpoint groups

+
+ Devices and Links +
    +
  • POST /devices/register: creates device, sets initial online status, auto-creates links with existing opposite-role devices, returns device token.
  • +
  • GET /devices: lists user devices with computed effective presence status using DEVICE_ONLINE_STALE_SECONDS.
  • +
  • PATCH /devices/:id: updates mutable metadata and role.
  • +
  • POST /devices/:id/heartbeat: token-authenticated presence update for exact device token/device match.
  • +
  • /device-links: ensures one active camera-client pair and ownership checks.
  • +
+
+
+ Commands, Events, Streams +
    +
  • POST /commands: only client -> camera, only for active links.
  • +
  • POST /events/motion/start: camera-only; sends realtime to linked clients, queues push if offline.
  • +
  • POST /streams/request: creates stream session + start_stream command + realtime notification.
  • +
  • POST /streams/:id/accept: camera transitions stream to streaming; creates media session and optional SFU bootstrap.
  • +
  • GET /streams/:id/publish-credentials: camera-only credential issuance.
  • +
  • GET /streams/:id/subscribe-credentials: participant credential issuance.
  • +
  • POST /streams/:id/end: closes session, ends SFU (if enabled), creates recording placeholder, notifies both parties.
  • +
+
+
+ Storage and Recordings +
    +
  • POST /videos/upload-url: session route to mint presigned PUT + metadata row.
  • +
  • POST /recordings/:id/finalize: camera marks recording ready once object exists, or creates simulator placeholder if object key starts with sim/.
  • +
  • GET /recordings/:id/download-url: requester/camera only, ready-only, verifies object exists before presigning.
  • +
+
+
+ +
+

9) Workers and Reliability Mechanisms

+
+
+

Command retry loop

+

Inside realtime gateway. Scans device_commands where status is sent and stale by >10s. Re-dispatches every 5s. Fails after 3 retries.

+
+
+

Push worker

+

Interval (default 10s) dispatches queued notifications with nextAttemptAt <= now. Missing push token triggers retry backoff; max attempts configurable.

+
+
+

Recording worker

+

Interval (default 30s) marks stale awaiting_upload recordings as failed after timeout window (default 30 min).

+
+
+

Workers perform startup guards using hasRequiredTables() so they do not run before migrations are applied.

+
+ +
+

10) Security Controls and Guardrails

+
+
+

Implemented

+
    +
  • Helmet CSP with explicit script/style/font/connect/media/image directives.
  • +
  • CORS tied to BETTER_AUTH_TRUSTED_ORIGINS (or permissive fallback).
  • +
  • Rate limiting globally and on high-traffic route groups.
  • +
  • Ownership checks on almost all queries (user-scoped data access).
  • +
  • Role constraints (for example client->camera command direction).
  • +
  • Token integrity via timing-safe HMAC verification.
  • +
+
+
+

Important caveats

+
    +
  • Rate limits are in-memory; not shared across replicas.
  • +
  • Metrics are in-memory counters only (no persistence/export protocol).
  • +
  • Mock push provider treats presence of push token as delivery success.
  • +
  • Mock media provider + SFU scaffold are not production media infrastructure.
  • +
+
+
+
+ +
+

11) Configuration Map (Key Env Variables)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DomainVariablesArchitectural effect
Core serverPORT, DATABASE_URLlistener + DB connectivity
AuthBETTER_AUTH_SECRET, BETTER_AUTH_BASE_URL, BETTER_AUTH_TRUSTED_ORIGINSsession signing, base URL, trusted origins, device token signing
PresenceDEVICE_ONLINE_STALE_SECONDSeffective online/offline projection in device listings
StorageMINIO_ENDPOINT, MINIO_PORT, MINIO_USE_SSL, MINIO_ACCESS_KEY, MINIO_SECRET_KEY, MINIO_BUCKET, MINIO_PRESIGNED_EXPIRY_SECONDSobject I/O, presign TTL, startup bucket bootstrap
MediaMEDIA_MODE, MEDIA_PROVIDER, TURN_URLS, TURN_USERNAME, TURN_CREDENTIALcontrol plane mode and transport descriptor generation
WorkersPUSH_WORKER_INTERVAL_MS, PUSH_MAX_ATTEMPTS, RECORDING_WORKER_INTERVAL_MS, RECORDING_STALE_SECONDSqueue throughput, retry/failure timing
AdminADMIN_USERNAME, ADMIN_PASSWORDrequired to mount admin dashboard route logic
+
+ +
+

12) Code Ownership Map (Where to Modify What)

+
+
+

Server composition

+

index.ts: middleware stack, route mounting, startup ordering, workers, realtime setup.

+

Identity

+

auth.ts, middleware/auth.ts, middleware/device-auth.ts, utils/device-token.ts.

+

Persistence schema

+

db/schema.ts + drizzle/* migrations.

+
+
+

Realtime + command delivery

+

realtime/gateway.ts and routes/commands.ts.

+

Streaming control

+

routes/streams.ts, media/*, routes/recordings.ts.

+

Operational views

+

routes/ops.ts, observability/metrics.ts, routes/admin.ts.

+
+
+
+ +
+

13) Current Constraints and Scaling Boundaries

+
+
+

State locality

+

Presence, rate-limit counters, metrics counters, and SFU registry are process-local. Horizontal scaling requires external shared state.

+
+
+

Media realism

+

Media provider is mock; SFU service is scaffold/noop. Production deployment needs real media infrastructure for reliability and scale.

+
+
+

Queue semantics

+

Push and command retries are interval-based polling workers. Throughput, ordering guarantees, and dead-letter handling are minimal.

+
+
+

For load-bearing evolution, the natural next architecture step is extracting shared state (Redis/queue), production media plane, and distributed rate/metrics telemetry.

+
+
+
+
+ + diff --git a/WebApp/bun.lock b/WebApp/bun.lock index 38c27f2..4f4e6ac 100644 --- a/WebApp/bun.lock +++ b/WebApp/bun.lock @@ -13,10 +13,12 @@ "@tailwindcss/vite": "^4.1.18", "@types/node": "^24", "@vitest/browser-playwright": "^4.0.18", + "daisyui": "^5.5.19", "eslint": "^9.39.2", "eslint-plugin-svelte": "^3.14.0", "globals": "^17.3.0", "playwright": "^1.58.1", + "socket.io-client": "^4.8.3", "svelte": "^5.49.2", "svelte-check": "^4.3.6", "tailwindcss": "^4.1.18", @@ -171,6 +173,8 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="], @@ -307,6 +311,8 @@ "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + "daisyui": ["daisyui@5.5.19", "", {}, "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -317,6 +323,10 @@ "devalue": ["devalue@5.6.2", "", {}, "sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg=="], + "engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], @@ -517,6 +527,10 @@ "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + + "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], @@ -577,6 +591,8 @@ "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -607,6 +623,8 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "eslint/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], diff --git a/WebApp/package.json b/WebApp/package.json index 947c7c6..1be2195 100644 --- a/WebApp/package.json +++ b/WebApp/package.json @@ -23,10 +23,12 @@ "@tailwindcss/vite": "^4.1.18", "@types/node": "^24", "@vitest/browser-playwright": "^4.0.18", + "daisyui": "^5.5.19", "eslint": "^9.39.2", "eslint-plugin-svelte": "^3.14.0", "globals": "^17.3.0", "playwright": "^1.58.1", + "socket.io-client": "^4.8.3", "svelte": "^5.49.2", "svelte-check": "^4.3.6", "tailwindcss": "^4.1.18", diff --git a/WebApp/src/lib/sim/mobile-sim.js b/WebApp/src/lib/sim/mobile-sim.js new file mode 100644 index 0000000..f9427b3 --- /dev/null +++ b/WebApp/src/lib/sim/mobile-sim.js @@ -0,0 +1,2040 @@ +// @ts-nocheck +/** + * SecureCam Mobile Simulator Logic + * Refactored for modern UI/UX and stability. + */ + +import { io } from 'socket.io-client'; + +// --- 1. State Management --- +class Store { + constructor(initialState) { + this.state = initialState; + this.listeners = new Set(); + } + + get() { + return this.state; + } + + update(partialState) { + this.state = { ...this.state, ...partialState }; + this.notify(); + } + + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + notify() { + this.listeners.forEach((listener) => listener(this.state)); + } +} + +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: [], + motionNotifications: [], + activeCameraDeviceId: null, + activeStreamSessionId: null, + openLinkedCameraMenuId: null, + activityFeed: [], + loading: false, // global loading spinner state if needed +}); + +const PAGE_PATHS = { + auth: '/', + onboarding: '/onboarding', + camera: '/camera', + client: '/client', + activity: '/activity', + settings: '/settings', +}; + +const simPageElement = document.querySelector('[data-sim-page]'); +const currentPageKey = simPageElement?.dataset?.simPage || document.body?.dataset?.page || ''; +const multiPageMode = Boolean(currentPageKey); + +const getHomePageKeyForRole = (role) => (role === 'camera' ? 'camera' : 'client'); + +const getPathForScreen = (screen, role) => { + if (screen === 'home') { + return PAGE_PATHS[getHomePageKeyForRole(role)]; + } + return PAGE_PATHS[screen] || null; +}; + +const navigateToScreen = (screen, options = {}) => { + const { replace = false, role = store.get().device?.role } = options; + const targetPath = getPathForScreen(screen, role); + const currentPath = window.location.pathname.replace(/\/+$/, '') || '/'; + const normalizedTargetPath = targetPath?.replace(/\/+$/, '') || targetPath; + + if (multiPageMode && normalizedTargetPath && currentPath !== normalizedTargetPath) { + if (replace) { + window.location.replace(normalizedTargetPath); + } else { + window.location.assign(normalizedTargetPath); + } + return true; + } + + store.update({ screen }); + return false; +}; + +const getScreenForCurrentPage = () => { + if (currentPageKey === 'activity') return 'activity'; + if (currentPageKey === 'settings') return 'settings'; + if (currentPageKey === 'onboarding') return 'onboarding'; + if (currentPageKey === 'camera' || currentPageKey === 'client') return 'home'; + return 'auth'; +}; + +// --- 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 escapeHtml = (value = '') => + String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +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) }), + list: () => API.request('/devices'), + update: (deviceId, data) => API.request(`/devices/${deviceId}`, { method: 'PATCH', body: JSON.stringify(data) }), + listLinks: () => API.request('/device-links'), + link: (cameraDeviceId, clientDeviceId) => API.request('/device-links', { method: 'POST', body: JSON.stringify({ cameraDeviceId, clientDeviceId }) }), + unlink: (linkId) => API.request(`/device-links/${linkId}`, { method: 'DELETE' }), + }, + + 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, payload) => API.request(`/recordings/${id}/finalize`, { method: 'POST', body: JSON.stringify(payload) }), + }, + + 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 activeMediaRecorder = null; +let activeRecordingChunks = []; +let activeRecordingStartedAt = null; +let activeRecordingStreamSessionId = null; +let recordingModalUrl = null; +const RECORDING_VIDEO_BITS_PER_SECOND = 850_000; +const COMPRESSED_UPLOAD_MAX_WIDTH = 640; +const COMPRESSED_UPLOAD_MAX_HEIGHT = 360; +const COMPRESSED_UPLOAD_FRAME_RATE = 12; +const COMPRESSED_UPLOAD_BITS_PER_SECOND = 450_000; + +// Multi-stream state (for Client) +const peerConnections = new Map(); // streamSessionId -> RTCPeerConnection +const remoteStreams = new Map(); // streamSessionId -> MediaStream +const pendingCandidatesMap = new Map(); // streamSessionId -> Array +const streamTimers = new Map(); // streamSessionId -> frameRelay/wait timers +const connectedPeers = new Set(); // streamSessionId + +// Legacy fallback for camera single stream +let peerSessionId = null; +let peerTargetDeviceId = null; +let hasWebrtcEverConnected = false; +let webrtcConnected = false; +let frameRelayTimer = null; +let frameRelayStartTimer = null; +let frameCanvas = null; +let frameContext = null; + +const requestedStreams = new Set(); // cameraDeviceIds that have been requested + +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) { + const role = store.get().device?.role; + if (multiPageMode && (currentPageKey === 'auth' || currentPageKey === 'onboarding')) { + if (navigateToScreen('home', { replace: true, role })) return; + } + if (multiPageMode && (currentPageKey === 'camera' || currentPageKey === 'client')) { + const expectedHome = getHomePageKeyForRole(role); + if (expectedHome !== currentPageKey) { + if (navigateToScreen('home', { replace: true, role })) return; + } + } + + if (multiPageMode) { + store.update({ screen: getScreenForCurrentPage() }); + } else { + navigateBasedOnRole(); + } + connectSocket(); + startPolling(); + } else { + if (multiPageMode) { + if (currentPageKey !== 'onboarding') { + if (navigateToScreen('onboarding', { replace: true })) return; + } else { + store.update({ screen: 'onboarding' }); + } + } else { + store.update({ screen: 'onboarding' }); + } + } + } else { + if (multiPageMode) { + if (currentPageKey !== 'auth') { + if (navigateToScreen('auth', { replace: true })) return; + } else { + store.update({ screen: 'auth' }); + } + } else { + store.update({ screen: 'auth' }); + } + } + } catch { + if (multiPageMode) { + if (currentPageKey !== 'auth') { + if (navigateToScreen('auth', { replace: true })) return; + } else { + store.update({ screen: 'auth' }); + } + } else { + store.update({ screen: 'auth' }); + } + } +}; + +const navigateBasedOnRole = () => { + const { device } = store.get(); + if (!device) { + navigateToScreen('onboarding'); + return; + } + + // Default home screen based on role + navigateToScreen('home', { role: device.role }); +}; + +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: { + width: { ideal: 640, max: 960 }, + height: { ideal: 360, max: 540 }, + frameRate: { ideal: 15, max: 24 }, + }, + 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 setClientStreamPlaceholderText = (text) => { + const placeholderEl = $('clientStreamPlaceholder'); + if (!placeholderEl) return; + const label = placeholderEl.querySelector('p'); + if (label) { + label.textContent = text; + } +}; + +const setClientStreamMode = (mode) => { + const videoEl = $('clientStreamVideo'); + const imageEl = $('clientStreamImage'); + const placeholderEl = $('clientStreamPlaceholder'); + + if (videoEl) videoEl.classList.toggle('hidden', mode !== 'video'); + if (imageEl) imageEl.classList.toggle('hidden', mode !== 'image'); + + if (!placeholderEl) return; + + if (mode === 'video' || mode === 'image') { + placeholderEl.classList.add('hidden'); + return; + } + + if (mode === 'unavailable') { + setClientStreamPlaceholderText('Stream unavailable'); + } else if (mode === 'connecting') { + setClientStreamPlaceholderText('Connecting stream...'); + } else { + setClientStreamPlaceholderText('Waiting for stream'); + } + placeholderEl.classList.remove('hidden'); +}; + +const clearClientStream = () => { + const { activeStreamSessionId } = store.get(); + if (activeStreamSessionId && streamTimers.has(activeStreamSessionId)) { + clearTimeout(streamTimers.get(activeStreamSessionId)); + streamTimers.delete(activeStreamSessionId); + } + const videoEl = $('clientStreamVideo'); + const imageEl = $('clientStreamImage'); + if (activeStreamSessionId && remoteStreams.has(activeStreamSessionId)) { + remoteStreams.get(activeStreamSessionId).getTracks().forEach((track) => track.stop()); + remoteStreams.delete(activeStreamSessionId); + } + if (videoEl) { + videoEl.srcObject = null; + } + if (imageEl) { + imageEl.src = ''; + } + setClientStreamMode('none'); +}; + +const getLinkedCamera = (cameraDeviceId) => + store.get().linkedCameras.find((camera) => camera.cameraDeviceId === cameraDeviceId); + +const getCameraLabel = (cameraDeviceId, cameraName) => { + const explicitName = typeof cameraName === 'string' ? cameraName.trim() : ''; + if (explicitName) return explicitName; + + const linkedName = getLinkedCamera(cameraDeviceId)?.cameraName; + if (typeof linkedName === 'string' && linkedName.trim()) { + return linkedName.trim(); + } + + return `Camera ${cameraDeviceId?.substring(0, 6) ?? 'Unknown'}`; +}; + +const pushMotionNotification = (cameraDeviceId) => { + if (!cameraDeviceId) return; + + const notification = { + id: crypto.randomUUID(), + cameraDeviceId, + message: `${getCameraLabel(cameraDeviceId)} has detected movement`, + createdAt: new Date().toISOString(), + isRead: false, + }; + + store.update({ + motionNotifications: [notification, ...store.get().motionNotifications].slice(0, 50), + }); +}; + +const markMotionNotificationRead = (notificationId) => { + store.update({ + motionNotifications: store + .get() + .motionNotifications.map((notification) => + notification.id === notificationId ? { ...notification, isRead: true } : notification, + ), + }); +}; + +const markAllNotificationsRead = () => { + store.update({ + motionNotifications: store + .get() + .motionNotifications.map((notification) => (notification.isRead ? notification : { ...notification, isRead: true })), + }); +}; + +const openRecordingModal = (downloadUrl, title) => { + const modal = $('recordingModal'); + const videoEl = $('recordingModalVideo'); + const titleEl = $('recordingModalTitle'); + + if (!modal || !videoEl || !titleEl) return; + + recordingModalUrl = downloadUrl; + titleEl.textContent = title || 'Recording Playback'; + videoEl.src = downloadUrl; + modal.classList.remove('hidden'); + modal.classList.add('flex'); + void videoEl.play().catch(() => { }); +}; + +const closeRecordingModal = () => { + const modal = $('recordingModal'); + const videoEl = $('recordingModalVideo'); + if (!modal || !videoEl) return; + + modal.classList.add('hidden'); + modal.classList.remove('flex'); + videoEl.pause(); + videoEl.removeAttribute('src'); + videoEl.load(); + recordingModalUrl = null; +}; + +const stopFrameRelay = () => { + if (frameRelayStartTimer) { + clearTimeout(frameRelayStartTimer); + frameRelayStartTimer = null; + } + if (frameRelayTimer) { + clearInterval(frameRelayTimer); + frameRelayTimer = null; + } +}; + +const startFrameRelay = async (streamSessionId, toDeviceId) => { + if (!socket || !streamSessionId || !toDeviceId) return; + if (hasWebrtcEverConnected) return; + + const ready = await startCameraPreview(); + if (!ready) { + throw new Error('Camera permission is required before streaming'); + } + + const cameraVideoEl = $('cameraVideo'); + if (!cameraVideoEl) return; + + stopFrameRelay(); + frameRelayTimer = setInterval(() => { + if (webrtcConnected || hasWebrtcEverConnected) return; + if (!socket || cameraVideoEl.readyState < 2 || !cameraVideoEl.videoWidth || !cameraVideoEl.videoHeight) return; + + if (!frameCanvas) { + frameCanvas = document.createElement('canvas'); + frameContext = frameCanvas.getContext('2d'); + } + if (!frameCanvas || !frameContext) return; + + frameCanvas.width = cameraVideoEl.videoWidth; + frameCanvas.height = cameraVideoEl.videoHeight; + frameContext.drawImage(cameraVideoEl, 0, 0, frameCanvas.width, frameCanvas.height); + const frame = frameCanvas.toDataURL('image/jpeg', 0.6); + + socket.emit('stream:frame', { + toDeviceId, + streamSessionId, + frame, + capturedAt: new Date().toISOString(), + }); + }, 600); +}; + +const getPreferredRecordingMimeType = () => { + if (typeof MediaRecorder === 'undefined') return ''; + const preferredTypes = [ + 'video/webm;codecs=vp9', + 'video/webm;codecs=vp8', + 'video/webm', + ]; + return preferredTypes.find((type) => MediaRecorder.isTypeSupported(type)) ?? ''; +}; + +const startLocalRecording = async () => { + if (!localCameraStream || typeof MediaRecorder === 'undefined') { + addActivity('Recording', 'MediaRecorder unavailable'); + return false; + } + + if (activeMediaRecorder?.state === 'recording') { + return true; + } + + activeRecordingChunks = []; + activeRecordingStartedAt = Date.now(); + + try { + const mimeType = getPreferredRecordingMimeType(); + const recorderOptions = { + videoBitsPerSecond: RECORDING_VIDEO_BITS_PER_SECOND, + }; + if (mimeType) { + recorderOptions.mimeType = mimeType; + } + activeMediaRecorder = new MediaRecorder(localCameraStream, recorderOptions); + } catch (error) { + console.error('Failed to create MediaRecorder', error); + addActivity('Recording', 'Failed to start recorder'); + activeMediaRecorder = null; + return false; + } + + activeMediaRecorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + activeRecordingChunks.push(event.data); + } + }; + + activeMediaRecorder.start(1000); + addActivity('Recording', 'Local recording started'); + return true; +}; + +const stopLocalRecording = async () => { + if (!activeMediaRecorder || activeMediaRecorder.state === 'inactive') { + return null; + } + + return await new Promise((resolve) => { + const recorder = activeMediaRecorder; + const startedAt = activeRecordingStartedAt ?? Date.now(); + + recorder.onstop = () => { + const mimeType = recorder.mimeType || 'video/webm'; + const blob = activeRecordingChunks.length > 0 ? new Blob(activeRecordingChunks, { type: mimeType }) : null; + const durationSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000)); + + activeMediaRecorder = null; + activeRecordingChunks = []; + activeRecordingStartedAt = null; + + resolve(blob ? { blob, durationSeconds } : null); + }; + + recorder.onerror = () => { + activeMediaRecorder = null; + activeRecordingChunks = []; + activeRecordingStartedAt = null; + resolve(null); + }; + + recorder.stop(); + }); +}; + +const toEvenDimension = (value) => { + const rounded = Math.max(2, Math.floor(value)); + return rounded % 2 === 0 ? rounded : rounded - 1; +}; + +const compressRecordingBlob = async (sourceBlob) => { + if (!sourceBlob || sourceBlob.size === 0) return sourceBlob; + if (typeof document === 'undefined' || typeof MediaRecorder === 'undefined') return sourceBlob; + + const mimeType = getPreferredRecordingMimeType(); + if (!mimeType) return sourceBlob; + + const sourceUrl = URL.createObjectURL(sourceBlob); + const videoEl = document.createElement('video'); + videoEl.muted = true; + videoEl.playsInline = true; + videoEl.preload = 'auto'; + + let rafId = null; + let captureStream = null; + + try { + await new Promise((resolve, reject) => { + videoEl.onloadedmetadata = resolve; + videoEl.onerror = () => reject(new Error('Failed loading recorded clip')); + videoEl.src = sourceUrl; + }); + + const sourceWidth = videoEl.videoWidth || COMPRESSED_UPLOAD_MAX_WIDTH; + const sourceHeight = videoEl.videoHeight || COMPRESSED_UPLOAD_MAX_HEIGHT; + const scale = Math.min(1, COMPRESSED_UPLOAD_MAX_WIDTH / sourceWidth, COMPRESSED_UPLOAD_MAX_HEIGHT / sourceHeight); + const width = toEvenDimension(sourceWidth * scale); + const height = toEvenDimension(sourceHeight * scale); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d'); + if (!context || typeof canvas.captureStream !== 'function') { + return sourceBlob; + } + + captureStream = canvas.captureStream(COMPRESSED_UPLOAD_FRAME_RATE); + const compressedChunks = []; + const recorder = new MediaRecorder(captureStream, { + mimeType, + videoBitsPerSecond: COMPRESSED_UPLOAD_BITS_PER_SECOND, + }); + + const recorderStopped = new Promise((resolve, reject) => { + recorder.ondataavailable = (event) => { + if (event.data?.size > 0) { + compressedChunks.push(event.data); + } + }; + recorder.onerror = (event) => { + const message = event?.error?.message || 'Compression recorder failed'; + reject(new Error(message)); + }; + recorder.onstop = () => { + resolve(new Blob(compressedChunks, { type: recorder.mimeType || mimeType })); + }; + }); + + const drawFrame = () => { + if (videoEl.paused || videoEl.ended) return; + context.drawImage(videoEl, 0, 0, width, height); + rafId = requestAnimationFrame(drawFrame); + }; + + recorder.start(300); + await videoEl.play(); + drawFrame(); + + await new Promise((resolve, reject) => { + videoEl.onended = resolve; + videoEl.onerror = () => reject(new Error('Failed during compression playback')); + }); + + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + recorder.stop(); + const compressedBlob = await recorderStopped; + if (!compressedBlob || compressedBlob.size === 0 || compressedBlob.size >= sourceBlob.size) { + return sourceBlob; + } + + const reductionPct = Math.round(((sourceBlob.size - compressedBlob.size) / sourceBlob.size) * 100); + addActivity('Recording', `Compressed clip by ${reductionPct}% before upload`); + return compressedBlob; + } catch (error) { + console.warn('Recording compression failed, uploading original clip', error); + return sourceBlob; + } finally { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + if (captureStream) { + captureStream.getTracks().forEach((track) => track.stop()); + } + videoEl.pause(); + videoEl.removeAttribute('src'); + videoEl.load(); + URL.revokeObjectURL(sourceUrl); + } +}; + +const teardownPeerConnection = (streamSessionId) => { + if (!streamSessionId) { + // Teardown all + for (const [sid, conn] of peerConnections.entries()) { + conn.close(); + } + peerConnections.clear(); + remoteStreams.clear(); + pendingCandidatesMap.clear(); + connectedPeers.clear(); + webrtcConnected = false; + hasWebrtcEverConnected = false; + clearClientStream(); + return; + } + + if (peerConnections.has(streamSessionId)) { + const conn = peerConnections.get(streamSessionId); + conn.close(); + peerConnections.delete(streamSessionId); + } + remoteStreams.delete(streamSessionId); + pendingCandidatesMap.delete(streamSessionId); + connectedPeers.delete(streamSessionId); + + if (peerSessionId === streamSessionId) { + peerSessionId = null; + peerTargetDeviceId = null; + webrtcConnected = false; + hasWebrtcEverConnected = false; + } + + if (store.get().activeStreamSessionId === streamSessionId) { + clearClientStream(); + } +}; + +const queueRemoteCandidate = ({ streamSessionId, fromDeviceId, data }) => { + if (!streamSessionId || !fromDeviceId || !data) return; + if (!pendingCandidatesMap.has(streamSessionId)) { + pendingCandidatesMap.set(streamSessionId, []); + } + const queue = pendingCandidatesMap.get(streamSessionId); + queue.push({ streamSessionId, fromDeviceId, data, createdAt: Date.now() }); + const cutoff = Date.now() - 120000; + pendingCandidatesMap.set(streamSessionId, queue.filter((item) => item.createdAt >= cutoff).slice(-200)); +}; + +const takeQueuedCandidates = (streamSessionId, fromDeviceId) => { + if (!pendingCandidatesMap.has(streamSessionId)) return []; + const queue = pendingCandidatesMap.get(streamSessionId); + const queued = queue.filter((item) => item.fromDeviceId === fromDeviceId); + pendingCandidatesMap.set(streamSessionId, queue.filter((item) => item.fromDeviceId !== fromDeviceId)); + return queued; +}; + +const applyQueuedCandidates = async (connection, streamSessionId, fromDeviceId) => { + if (!connection?.remoteDescription) return; + const queued = takeQueuedCandidates(streamSessionId, fromDeviceId); + for (const candidate of queued) { + try { + await connection.addIceCandidate(new RTCIceCandidate(candidate.data)); + } catch (error) { + console.warn('Dropping queued ICE candidate', error); + } + } +}; + +const ensurePeerConnection = async ({ + streamSessionId, + targetDeviceId, + asCamera, +}) => { + if (peerConnections.has(streamSessionId)) { + return peerConnections.get(streamSessionId); + } + + const connection = new RTCPeerConnection(rtcConfig); + peerConnections.set(streamSessionId, connection); + + if (asCamera) { + peerSessionId = streamSessionId; + peerTargetDeviceId = targetDeviceId; + } + + connection.onicecandidate = (event) => { + if (!socket || !event.candidate) return; + socket.emit('webrtc:signal', { + toDeviceId: targetDeviceId, + streamSessionId: streamSessionId, + signalType: 'candidate', + data: event.candidate.toJSON(), + }); + }; + + connection.onconnectionstatechange = () => { + if (connection.connectionState === 'connected') { + addActivity('WebRTC', `Peer connected for ${streamSessionId}`); + connectedPeers.add(streamSessionId); + if (asCamera) { + webrtcConnected = true; + hasWebrtcEverConnected = true; + stopFrameRelay(); + } + } else if ( + connection.connectionState === 'failed' || + connection.connectionState === 'disconnected' || + connection.connectionState === 'closed' + ) { + addActivity('WebRTC', `Peer ${connection.connectionState} for ${streamSessionId}`); + connectedPeers.delete(streamSessionId); + if (asCamera) { + if (!hasWebrtcEverConnected) webrtcConnected = false; + if (connection.connectionState === 'failed' || connection.connectionState === 'closed') { + hasWebrtcEverConnected = false; + } + } + if (store.get().device?.role === 'client' && store.get().activeStreamSessionId === streamSessionId) { + if (connection.connectionState === 'failed' || connection.connectionState === 'closed') { + clearClientStream(); + } + } + } + }; + + connection.ontrack = (event) => { + if (streamTimers.has(streamSessionId)) { + clearTimeout(streamTimers.get(streamSessionId)); + streamTimers.delete(streamSessionId); + } + const [stream] = event.streams; + if (!stream) return; + + connectedPeers.add(streamSessionId); + remoteStreams.set(streamSessionId, stream); + + if (store.get().activeStreamSessionId === streamSessionId) { + const videoEl = $('clientStreamVideo'); + if (videoEl) { + videoEl.srcObject = stream; + setClientStreamMode('video'); + void videoEl.play().catch(() => { }); + store.notify(); // Re-render to show active feed + } + } else { + // If not active, play it hidden anyway so it connects properly + const tempVideo = document.createElement('video'); + tempVideo.srcObject = stream; + tempVideo.muted = true; + tempVideo.playsInline = true; + void tempVideo.play().catch(() => { }); + store.notify(); // Re-render to show stream active in list + } + }; + + 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 sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +const finalizeRecordingForStream = async (streamSessionId, captureResult) => { + const currentDevice = store.get().device; + if (!currentDevice?.id) { + addActivity('Recording', 'No device identity for finalize'); + return false; + } + + for (let attempt = 0; attempt < 8; attempt += 1) { + const recs = await API.ops.listRecordings().catch(() => ({ recordings: [] })); + const recording = (recs.recordings || []).find((rec) => rec.streamSessionId === streamSessionId && rec.status === 'awaiting_upload'); + + if (recording?.id) { + try { + if (!captureResult?.blob || captureResult.blob.size === 0) { + throw new Error('No captured video blob to upload'); + } + const compressedBlob = await compressRecordingBlob(captureResult.blob); + + const uploadMeta = await API.request('/videos/upload-url', { + method: 'POST', + body: JSON.stringify({ + fileName: `stream-${streamSessionId}.webm`, + deviceId: currentDevice.id, + prefix: 'recordings', + }), + }); + + const uploadResponse = await fetch(uploadMeta.uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': compressedBlob.type || 'video/webm' }, + body: compressedBlob, + }); + + if (!uploadResponse.ok) { + throw new Error(`Upload failed with status ${uploadResponse.status}`); + } + + await API.events.finalizeRecording(recording.id, { + objectKey: uploadMeta.objectKey, + bucket: uploadMeta.bucket, + durationSeconds: captureResult.durationSeconds, + sizeBytes: compressedBlob.size, + }); + + addActivity('Recording', 'Recording uploaded and finalized'); + return true; + } catch (error) { + console.error('Recording upload failed, falling back to simulated key', error); + const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`; + await API.events.finalizeRecording(recording.id, { + objectKey: fallbackObjectKey, + durationSeconds: captureResult?.durationSeconds ?? 15, + sizeBytes: captureResult?.blob?.size ?? 5000000, + }); + addActivity('Recording', 'Upload failed; finalized with simulator fallback'); + return true; + } + } + + await sleep(350); + } + + addActivity('Recording', 'No recording row found to finalize'); + return false; +}; + +const uploadStandaloneMotionRecording = async (captureResult) => { + const currentDevice = store.get().device; + if (!currentDevice?.id) { + addActivity('Recording', 'Cannot upload motion clip without device identity'); + return false; + } + + if (!captureResult?.blob || captureResult.blob.size === 0) { + addActivity('Recording', 'No motion clip captured for upload'); + return false; + } + + try { + const compressedBlob = await compressRecordingBlob(captureResult.blob); + const uploadMeta = await API.request('/videos/upload-url', { + method: 'POST', + body: JSON.stringify({ + fileName: `motion-${Date.now()}.webm`, + deviceId: currentDevice.id, + prefix: 'recordings', + }), + }); + + const uploadResponse = await fetch(uploadMeta.uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': compressedBlob.type || 'video/webm' }, + body: compressedBlob, + }); + + if (!uploadResponse.ok) { + throw new Error(`Upload failed with status ${uploadResponse.status}`); + } + + addActivity('Recording', `Motion clip uploaded (${uploadMeta.objectKey})`); + return true; + } catch (error) { + console.error('Standalone motion upload failed', error); + addActivity('Recording', 'Standalone motion upload failed'); + return false; + } +}; + +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 }); + stopFrameRelay(); + void stopLocalRecording(); + teardownPeerConnection(); + store.update({ activeCameraDeviceId: null, activeStreamSessionId: null }); + }); + + // 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'); + } + activeRecordingStreamSessionId = streamId; + await API.streams.accept(streamId); + await API.streams.getPublishCreds(streamId); + await startLocalRecording(); + if (payload.sourceDeviceId) { + await startOfferToClient(streamId, payload.sourceDeviceId); + frameRelayStartTimer = setTimeout(() => { + if (!webrtcConnected && !hasWebrtcEverConnected) { + void startFrameRelay(streamId, payload.sourceDeviceId); + } + }, 2500); + addActivity('Stream', 'Accepted & Published'); + } + + 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) => { + const cameraDeviceId = payload.cameraDeviceId || payload.deviceId; + addActivity('Motion', `${getCameraLabel(cameraDeviceId)} has detected movement`); + Toast.show('Motion Detected!', 'info'); + pushMotionNotification(cameraDeviceId); + + // Auto display this camera's active stream on motion + if (cameraDeviceId) { + store.update({ activeCameraDeviceId: cameraDeviceId }); + const existingSession = store.get().activeStreamSessionId; + // If we don't know the exact session ID associated, requestStream will fetch a new one or join + // For simplicity, directly requesting stream again is fine (idempotent setup). + Actions.requestStream(cameraDeviceId); + } + }); + + socket.on('stream:started', async (payload) => { + addActivity('Stream', 'Stream is live, connecting...'); + + // Always store latest session ID for the camera + if (payload.cameraDeviceId === store.get().activeCameraDeviceId) { + store.update({ activeStreamSessionId: payload.streamSessionId }); + } + + // Track camera to stream session map + store.update({ + cameraSessions: { + ...(store.get().cameraSessions || {}), + [payload.cameraDeviceId]: payload.streamSessionId + } + }); + + try { + await API.streams.getSubscribeCreds(payload.streamSessionId); + console.log(`Connected to Stream ${payload.streamSessionId}`); + + streamTimers.set(payload.streamSessionId, setTimeout(() => { + if (!remoteStreams.has(payload.streamSessionId)) { + console.log(`Stream connected but no video received for ${payload.streamSessionId}`); + addActivity('Stream', `No remote video track received for ${payload.streamSessionId}`); + } + }, 6000)); + } catch (e) { + console.error('Stream connect failed', e); + } + }); + + socket.on('stream:frame', (payload) => { + if (connectedPeers.has(payload.streamSessionId)) return; + if (!payload?.frame) return; + + if (streamTimers.has(payload.streamSessionId)) { + clearTimeout(streamTimers.get(payload.streamSessionId)); + streamTimers.delete(payload.streamSessionId); + } + + if (payload.streamSessionId === store.get().activeStreamSessionId) { + const imageEl = $('clientStreamImage'); + if (!imageEl) return; + imageEl.src = payload.frame; + imageEl.classList.remove('hidden'); + const videoEl = $('clientStreamVideo'); + if (videoEl) { + videoEl.classList.add('hidden'); + } + setClientStreamMode('image'); + } + }); + + socket.on('stream:ended', async (payload) => { + if (payload?.streamSessionId) { + const streamSessionId = payload.streamSessionId; + teardownPeerConnection(payload.streamSessionId); + if (streamSessionId === store.get().activeStreamSessionId) { + store.update({ activeStreamSessionId: null }); + } + + if (store.get().device?.role === 'camera') { + const shouldFinalize = + activeRecordingStreamSessionId === streamSessionId || activeMediaRecorder?.state === 'recording'; + + if (shouldFinalize) { + const captureResult = await stopLocalRecording(); + await finalizeRecordingForStream(streamSessionId, captureResult); + } + + if (activeRecordingStreamSessionId === streamSessionId) { + activeRecordingStreamSessionId = null; + } + } + } + }); + + 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, + }); + await connection.setRemoteDescription(new RTCSessionDescription(payload.data)); + await applyQueuedCandidates(connection, payload.streamSessionId, payload.fromDeviceId); + 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; + } + + if (payload.signalType === 'answer') { + const conn = peerConnections.get(payload.streamSessionId); + if (device.role !== 'camera' || !conn) return; + + if (conn.signalingState !== 'have-local-offer') { + if (conn.signalingState === 'stable' && conn.remoteDescription?.type === 'answer') { + return; + } + return; + } + await conn.setRemoteDescription(new RTCSessionDescription(payload.data)); + await applyQueuedCandidates(conn, payload.streamSessionId, payload.fromDeviceId); + addActivity('WebRTC', 'Answer received and applied'); + return; + } + + if (payload.signalType === 'candidate') { + if (!payload.data) return; + const conn = peerConnections.get(payload.streamSessionId); + if (!conn) { + queueRemoteCandidate(payload); + return; + } + if (!conn.remoteDescription) { + queueRemoteCandidate(payload); + return; + } + await conn.addIceCandidate(new RTCIceCandidate(payload.data)); + return; + } + + if (payload.signalType === 'hangup') { + teardownPeerConnection(payload.streamSessionId); + if (store.get().activeStreamSessionId === payload.streamSessionId) { + store.update({ activeStreamSessionId: null }); + } + addActivity('Stream', 'Remote stream ended'); + } + } catch (error) { + console.error('Failed handling WebRTC signal', error); + Toast.show('WebRTC negotiation failed', 'error'); + } + }); + + socket.on('error:webrtc_signal', (payload) => { + const message = payload?.message || 'WebRTC signaling error'; + addActivity('WebRTC', message); + Toast.show(message, 'error'); + }); +}; + +const startPolling = () => { + if (pollInterval) clearInterval(pollInterval); + + const poller = async () => { + const { device, screen } = store.get(); + if (!device) return; + + if (screen === 'home' && device.role === 'client') { + const [recs, links, deviceList] = await Promise.all([ + API.ops.listRecordings().catch(() => ({ recordings: [] })), + API.devices.listLinks().catch(() => ({ links: [] })), + API.devices.list().catch(() => ({ devices: [] })), + ]); + + const cameraById = new Map( + (deviceList.devices || []) + .filter((entry) => entry.role === 'camera') + .map((entry) => [entry.id, entry]), + ); + + const linkedCameras = (links.links || []).map((link) => { + const camera = cameraById.get(link.cameraDeviceId); + return { + ...link, + cameraName: camera?.name ?? null, + cameraStatus: camera?.status ?? 'offline', + }; + }); + + store.update({ + recordings: recs.recordings || [], + linkedCameras, + }); + + // Request streams for all linked cameras if not already requested + for (const link of linkedCameras) { + if (!requestedStreams.has(link.cameraDeviceId)) { + requestedStreams.add(link.cameraDeviceId); + void Actions.requestStream(link.cameraDeviceId); + } + } + } + + if (screen === 'activity') { + // maybe poll notifications + } + }; + + poller(); + pollInterval = setInterval(poller, 5000); +}; + +// --- Actions --- + +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'; + } + }, + + submitAuth: async () => { + const email = $('authEmail').value; + const password = $('authPassword').value; + const name = $('authName').value || email.split('@')[0]; + const isRegistering = !$('authNameField').classList.contains('hidden'); + + 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'); + + // Proceed + if (store.get().deviceToken) { + const role = store.get().device?.role; + if (multiPageMode && currentPageKey === 'auth') { + if (navigateToScreen('home', { replace: true, role })) return; + } else { + navigateBasedOnRole(); + } + connectSocket(); + startPolling(); + } else { + if (navigateToScreen('onboarding')) return; + } + } catch (e) { + // handled by API wrapper toast + } + }, + + selectRole: (role) => { + $('role').value = role; + const btnCamera = $('btn-role-camera'); + const btnClient = $('btn-role-client'); + + btnCamera.setAttribute('data-active', role === 'camera'); + btnClient.setAttribute('data-active', role === 'client'); + + // 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'); + } + }, + + registerDevice: async () => { + const name = $('deviceName').value || 'Web Simulator'; + const role = $('role').value; + const pushToken = $('pushToken').value; + + try { + const payload = { name, role, platform: 'web', appVersion: 'sim-2.0' }; + if (pushToken && pushToken.trim().length > 0) { + payload.pushToken = pushToken.trim(); + } + + const res = await API.devices.register(payload); + + store.update({ device: res.device, deviceToken: res.deviceToken }); + localStorage.setItem('mobileSimDevice', JSON.stringify({ device: res.device, deviceToken: res.deviceToken })); + + Toast.show('Device Registered', 'success'); + navigateBasedOnRole(); + connectSocket(); + startPolling(); + } catch (e) { + // handled + } + }, + + signOut: async () => { + await API.auth.signOut(); + store.update({ session: null, screen: 'auth', device: null, deviceToken: null, socketConnected: false }); + if (socket) socket.disconnect(); + stopFrameRelay(); + await stopLocalRecording(); + teardownPeerConnection(); + stopCameraPreview(); + localStorage.removeItem('mobileSimDevice'); + if (navigateToScreen('auth', { replace: true })) return; + Toast.show('Signed Out', 'info'); + }, + + // Camera Actions + startMotion: async () => { + try { + const res = await API.events.startMotion(); + await startCameraPreview(); + await startLocalRecording(); + store.update({ isMotionActive: true, lastMotionEventId: res.event.id }); + Toast.show('Motion Event Started', 'success'); + addActivity('Motion', 'Started event ' + res.event.id); + } catch (e) { } + }, + + endMotion: async () => { + const { lastMotionEventId } = store.get(); + if (!lastMotionEventId) return; + try { + const streamSessionId = activeRecordingStreamSessionId; + if (streamSessionId) { + await API.streams.end(streamSessionId); + addActivity('Stream', `Ended stream ${streamSessionId}`); + } else if (activeMediaRecorder?.state === 'recording') { + const captureResult = await stopLocalRecording(); + await uploadStandaloneMotionRecording(captureResult); + } + await API.events.endMotion(lastMotionEventId); + store.update({ isMotionActive: false }); + Toast.show('Motion Ended', 'success'); + addActivity('Motion', 'Ended event'); + } catch (e) { } + }, + + // 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) { } + }, + + renameLinkedCamera: async (cameraDeviceId) => { + const linked = getLinkedCamera(cameraDeviceId); + if (!linked?.cameraDeviceId) return; + + const currentName = linked.cameraName?.trim() || ''; + const nextName = prompt('Enter a new camera name:', currentName || getCameraLabel(linked.cameraDeviceId)); + + if (nextName == null) return; + + const trimmedName = nextName.trim(); + if (!trimmedName) { + Toast.show('Camera name cannot be empty', 'error'); + return; + } + + if (trimmedName === currentName) return; + + try { + await API.devices.update(linked.cameraDeviceId, { name: trimmedName }); + store.update({ + linkedCameras: store.get().linkedCameras.map((entry) => + entry.cameraDeviceId === linked.cameraDeviceId ? { ...entry, cameraName: trimmedName } : entry, + ), + }); + Toast.show('Camera Renamed', 'success'); + } catch (e) { } + }, + + deleteLinkedCamera: async (linkId) => { + const link = store.get().linkedCameras.find((entry) => entry.id === linkId); + if (!link) return; + + const cameraLabel = getCameraLabel(link.cameraDeviceId, link.cameraName); + const confirmed = window.confirm(`Remove "${cameraLabel}" from linked cameras?`); + if (!confirmed) return; + + try { + await API.devices.unlink(linkId); + + const remaining = store.get().linkedCameras.filter((entry) => entry.id !== linkId); + const isDeletedCameraActive = store.get().activeCameraDeviceId === link.cameraDeviceId; + + if (isDeletedCameraActive) { + clearClientStream(); + } + + requestedStreams.delete(link.cameraDeviceId); + + store.update({ + linkedCameras: remaining, + activeCameraDeviceId: isDeletedCameraActive ? null : store.get().activeCameraDeviceId, + activeStreamSessionId: isDeletedCameraActive ? null : store.get().activeStreamSessionId, + openLinkedCameraMenuId: null, + }); + + Toast.show('Camera Link Removed', 'success'); + } catch (e) { } + }, + + requestStream: async (camId) => { + try { + console.log(`Requesting Stream from ${camId}...`); + await API.streams.request(camId); + // Socket will handle the rest ('stream:started') + } catch (e) { } + }, + + openRecording: async (recordingId) => { + try { + const result = await API.ops.getRecordingDownloadUrl(recordingId); + if (!result?.downloadUrl) { + Toast.show('Recording URL unavailable', 'error'); + return; + } + const recording = store.get().recordings.find((entry) => entry.id === recordingId); + const title = recording ? `${new Date(recording.createdAt).toLocaleString()} recording` : 'Recording Playback'; + openRecordingModal(result.downloadUrl, title); + } catch (e) { + // handled by API wrapper + } + }, + + closeRecordingModal: () => { + closeRecordingModal(); + }, + + openMotionNotificationTarget: async (notificationId, cameraDeviceId) => { + markMotionNotificationRead(notificationId); + if (!cameraDeviceId) return; + + const recs = await API.ops.listRecordings().catch(() => ({ recordings: [] })); + const readyRecording = (recs.recordings || []) + .filter((recording) => recording.cameraDeviceId === cameraDeviceId && recording.status === 'ready') + .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime())[0]; + + if (readyRecording?.id) { + await Actions.openRecording(readyRecording.id); + return; + } + + if (navigateToScreen('home')) return; + await Actions.requestStream(cameraDeviceId); + }, +}; + +// --- 5. Rendering --- + +const render = (state) => { + // 1. Screen Visibility + $$('section[id^="screen-"]').forEach(el => el.classList.add('hidden')); + + const showSectionById = (id) => { + const element = $(id); + if (!element) return false; + element.classList.remove('hidden'); + return true; + }; + + if (state.screen === 'home') { + const preferredHomeId = state.device?.role === 'camera' ? 'screen-home-camera' : 'screen-home-client'; + if (!showSectionById(preferredHomeId)) { + const fallbackHomeId = preferredHomeId === 'screen-home-camera' ? 'screen-home-client' : 'screen-home-camera'; + showSectionById(fallbackHomeId); + } + } else { + showSectionById(`screen-${state.screen}`); + } + + // 2. Top Bar Status + const statusDot = $('#connectionStatus .status-dot'); + const statusText = $('#connectionStatus span:last-child'); + if (statusDot && statusText) { + 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 (authBadge) { + 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'); + const unreadNotifications = state.motionNotifications.filter((notification) => !notification.isRead).length; + updateNotificationDot(unreadNotifications > 0); + + if (nav) { + 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' && state.screen === 'home') { + const preview = $('cameraPreview'); + const offlineOverlay = $('cameraOfflineOverlay'); + const startMotionBtn = $('startMotionBtn'); + const endMotionBtn = $('endMotionBtn'); + + if (!preview || !offlineOverlay || !startMotionBtn || !endMotionBtn) return; + + 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 (list) { + if (state.linkedCameras.length === 0) { + list.innerHTML = `

No cameras linked yet

`; + } else { + list.innerHTML = state.linkedCameras.map(link => { + const cameraName = getCameraLabel(link.cameraDeviceId, link.cameraName); + const escapedCameraName = escapeHtml(cameraName); + const cameraStatus = (link.cameraStatus || '').toLowerCase() === 'online' ? 'Online' : 'Offline'; + const statusDotClass = cameraStatus === 'Online' ? 'bg-green-500' : 'bg-gray-600'; + + return ` +
+
+ ${state.activeCameraDeviceId === link.cameraDeviceId + ? ` +
+ + + + +

Viewing

+
+ ` + : ` +
+ + + +

${Array.from(connectedPeers).includes(link.cameraDeviceId) ? 'Live Stream Active' : (requestedStreams.has(link.cameraDeviceId) ? 'Connecting...' : 'Click to view')}

+
+ ` + } +
+
+
+
+
+
+

${escapedCameraName}

+

${cameraStatus}

+
+
+
+ + +
+
+
+
+ `; + }).join(''); + + // Show/hide main wrapper + const viewerWrapper = $('clientStreamViewerWrapper'); + if (viewerWrapper) { + if (state.activeCameraDeviceId) { + viewerWrapper.classList.remove('hidden'); + const title = $('clientStreamViewerTitle'); + if (title) title.textContent = `Live Feed: ${getCameraLabel(state.activeCameraDeviceId)}`; + } else { + viewerWrapper.classList.add('hidden'); + } + } + + if (state.activeCameraDeviceId) { + // Find session ID for active camera if known + let foundSessionId = state.activeStreamSessionId; + const sessions = state.cameraSessions || {}; + if (!foundSessionId && sessions[state.activeCameraDeviceId]) { + foundSessionId = sessions[state.activeCameraDeviceId]; + } + + const currentStream = foundSessionId ? remoteStreams.get(foundSessionId) : null; + if (currentStream) { + const videoEl = $('clientStreamVideo'); + if (videoEl && videoEl.srcObject !== currentStream) { + videoEl.srcObject = currentStream; + setClientStreamMode('video'); + $('clientLiveDot')?.classList.remove('hidden'); + // Only play if it's not already playing to prevent interruptions + if (videoEl.paused) { + void videoEl.play().catch(() => { }); + } + } + } else { + $('clientLiveDot')?.classList.add('hidden'); + } + } else { + $('clientLiveDot')?.classList.add('hidden'); + } + + const imageEl = $('clientStreamImage'); + if (imageEl && !imageEl.dataset.errorBound) { + imageEl.dataset.errorBound = '1'; + imageEl.addEventListener('error', () => { + const videoEl = $('clientStreamVideo'); + if (videoEl) { + videoEl.classList.add('hidden'); + } + setClientStreamMode('unavailable'); + }); + } + } + } + + const recList = $('recordingsList'); + if (recList) { + 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(''); + } + } + } + + if (state.screen === 'activity') { + const activityFeed = $('activityFeedList'); + if (activityFeed) { + if (state.motionNotifications.length === 0) { + activityFeed.innerHTML = ` +
+ + + +

No notifications yet

+
+ `; + } else { + activityFeed.innerHTML = state.motionNotifications.map((notification) => ` + + `).join(''); + } + } + } + + // 6. Settings Screen + if (state.session?.user && state.screen === 'settings') { + const profileName = $('profileName'); + const profileEmail = $('profileEmail'); + const profileInitials = $('profileInitials'); + if (profileName) profileName.textContent = state.session.user.name; + if (profileEmail) profileEmail.textContent = state.session.user.email; + if (profileInitials) profileInitials.textContent = state.session.user.name.charAt(0).toUpperCase(); + } +}; + +const addActivity = (type, msg) => { + const list = $('activityFeedList'); + if (list) { + 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 (!dot) return; + if (show) dot.classList.remove('hidden'); + else dot.classList.add('hidden'); +}; + +// --- 6. Event Listeners --- + +const bind = (id, eventName, handler) => { + const element = $(id); + if (!element) return; + element.addEventListener(eventName, handler); +}; + +bind('toggleAuthModeBtn', 'click', Actions.toggleAuthMode); +bind('signInBtn', 'click', Actions.submitAuth); +bind('registerBtn', 'click', Actions.registerDevice); +bind('loadSavedBtn', 'click', () => { /* Handle legacy loading if needed */ }); +$$('#screen-onboarding [data-role]').forEach((btn) => { + btn.addEventListener('click', () => Actions.selectRole(btn.dataset.role)); +}); +bind('recordingsList', '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); +}); +bind('activityFeedList', 'click', (event) => { + const target = event.target.closest('.motion-notification-btn'); + if (!target) return; + const notificationId = target.dataset.notificationId; + const cameraDeviceId = target.dataset.cameraDeviceId; + if (!notificationId || !cameraDeviceId) return; + Actions.openMotionNotificationTarget(notificationId, cameraDeviceId); +}); + +$('linkedCamerasList')?.addEventListener('click', (event) => { + const menuToggleBtn = event.target.closest('.linked-camera-menu-toggle'); + if (menuToggleBtn) { + event.stopPropagation(); + const linkId = menuToggleBtn.dataset.linkId; + if (!linkId) return; + + const { openLinkedCameraMenuId } = store.get(); + store.update({ + openLinkedCameraMenuId: openLinkedCameraMenuId === linkId ? null : linkId, + }); + return; + } + + const renameBtn = event.target.closest('.linked-camera-rename-btn'); + if (renameBtn) { + event.stopPropagation(); + const cameraId = renameBtn.dataset.cameraId; + if (cameraId) { + store.update({ openLinkedCameraMenuId: null }); + void Actions.renameLinkedCamera(cameraId); + } + return; + } + + const deleteBtn = event.target.closest('.linked-camera-delete-btn'); + if (deleteBtn) { + event.stopPropagation(); + const linkId = deleteBtn.dataset.linkId; + if (linkId) { + store.update({ openLinkedCameraMenuId: null }); + void Actions.deleteLinkedCamera(linkId); + } + return; + } + + const card = event.target.closest('.camera-card'); + if (!card) return; + const camId = card.dataset.cameraId; + if (camId) { + const sessions = store.get().cameraSessions || {}; + store.update({ + activeCameraDeviceId: camId, + activeStreamSessionId: sessions[camId] || null, + openLinkedCameraMenuId: null, + }); + // If not currently streamed or requested, kick off request + if (!requestedStreams.has(camId)) { + requestedStreams.add(camId); + Actions.requestStream(camId); + } else { + // Just re-render + store.notify(); + } + } +}); + +document.addEventListener('click', (event) => { + if (!event.target.closest('.linked-camera-menu')) { + const { openLinkedCameraMenuId } = store.get(); + if (openLinkedCameraMenuId) { + store.update({ openLinkedCameraMenuId: null }); + } + } +}); + +// Navbar +$$('.nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (btn.dataset.target === 'activity') { + markAllNotificationsRead(); + } + if (navigateToScreen(btn.dataset.target)) return; + }); +}); + +// Camera Controls +bind('cameraGoOnlineBtn', 'click', async () => { + if (store.get().device?.role === 'camera') { + await startCameraPreview(); + } + connectSocket(); +}); +bind('startMotionBtn', 'click', Actions.startMotion); +bind('endMotionBtn', 'click', Actions.endMotion); + +// Client Controls +bind('linkCameraBtn', 'click', Actions.linkCamera); +bind('refreshClientBtn', 'click', startPolling); + +// Settings +bind('signOutBtn', 'click', Actions.signOut); +bind('clearActivityBtn', 'click', () => { + store.update({ motionNotifications: [] }); +}); +bind('recordingModalCloseBtn', 'click', Actions.closeRecordingModal); +bind('recordingModal', 'click', (event) => { + if (event.target === $('recordingModal')) { + Actions.closeRecordingModal(); + } +}); +$('closeStreamViewerBtn')?.addEventListener('click', () => { + store.update({ activeCameraDeviceId: null, activeStreamSessionId: null }); +}); + +// Init +store.subscribe(render); +init(); + +window.addEventListener('beforeunload', () => { + stopFrameRelay(); + void stopLocalRecording(); + teardownPeerConnection(); + stopCameraPreview(); +}); + +window.Actions = Actions; diff --git a/WebApp/src/lib/sim/screens/ActivityScreen.svelte b/WebApp/src/lib/sim/screens/ActivityScreen.svelte new file mode 100644 index 0000000..c741915 --- /dev/null +++ b/WebApp/src/lib/sim/screens/ActivityScreen.svelte @@ -0,0 +1,27 @@ +
+
+

Activity History

+ +
+ +
+
+
+ + + +

All quiet. No notifications yet.

+
+
+
+
diff --git a/WebApp/src/lib/sim/screens/AuthScreen.svelte b/WebApp/src/lib/sim/screens/AuthScreen.svelte new file mode 100644 index 0000000..defbd13 --- /dev/null +++ b/WebApp/src/lib/sim/screens/AuthScreen.svelte @@ -0,0 +1,62 @@ +
+
+
+ + + +
+

SecureCam Web

+

Sign in to manage visual security from your browser.

+
+ +
+
+ + +
+
+ +
+ + +
+ +
OR
+ +
+
+
diff --git a/WebApp/src/lib/sim/screens/CameraScreen.svelte b/WebApp/src/lib/sim/screens/CameraScreen.svelte new file mode 100644 index 0000000..e417bfc --- /dev/null +++ b/WebApp/src/lib/sim/screens/CameraScreen.svelte @@ -0,0 +1,81 @@ +
+
+

Camera Feed (Broadcasting)

+
+ +
+
+
+ +
+ + REC +
+
+ +
+ + + +

Camera Offline

+ +
+
+ +
+
+

Manual Controls

+
+ + +
+
+ +
+

System Logs

+
+
Awaiting connection...
+
+
+
+
+
diff --git a/WebApp/src/lib/sim/screens/ClientScreen.svelte b/WebApp/src/lib/sim/screens/ClientScreen.svelte new file mode 100644 index 0000000..d8ff86f --- /dev/null +++ b/WebApp/src/lib/sim/screens/ClientScreen.svelte @@ -0,0 +1,88 @@ +
+
+

Client Dashboard

+
+ + +
+
+ +
+

Your Cameras

+
+
+

No cameras linked yet

+
+
+
+ +
+ + +
+
+

Recent Recordings

+
+
+
+
+
diff --git a/WebApp/src/lib/sim/screens/OnboardingScreen.svelte b/WebApp/src/lib/sim/screens/OnboardingScreen.svelte new file mode 100644 index 0000000..8f1ffbc --- /dev/null +++ b/WebApp/src/lib/sim/screens/OnboardingScreen.svelte @@ -0,0 +1,69 @@ +
+
+

Configure Device

+

Set up this browser simulator's role

+
+ +
+
+ + +
+ +
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+
+
diff --git a/WebApp/src/lib/sim/screens/SettingsScreen.svelte b/WebApp/src/lib/sim/screens/SettingsScreen.svelte new file mode 100644 index 0000000..29472ad --- /dev/null +++ b/WebApp/src/lib/sim/screens/SettingsScreen.svelte @@ -0,0 +1,74 @@ +
+

Settings

+ +
+
+ U +
+
+

User

+

user@example.com

+
+
+ +
+ + +
+ + +
diff --git a/WebApp/src/lib/sim/ui/AppChrome.svelte b/WebApp/src/lib/sim/ui/AppChrome.svelte new file mode 100644 index 0000000..72a2f41 --- /dev/null +++ b/WebApp/src/lib/sim/ui/AppChrome.svelte @@ -0,0 +1,145 @@ + + +
+
+ + + +
+
+
+
+ + OFFLINE +
+ +
+ +
+
+ ? +
+ +
+
+
+ +
+ {@render children()} +
+
+
+ + diff --git a/WebApp/src/routes/+layout.svelte b/WebApp/src/routes/+layout.svelte index 0d8eb03..fe2f029 100644 --- a/WebApp/src/routes/+layout.svelte +++ b/WebApp/src/routes/+layout.svelte @@ -1,9 +1,34 @@ - + + SecureCam Web Dashboard + + + + + {@render children()} diff --git a/WebApp/src/routes/+page.svelte b/WebApp/src/routes/+page.svelte index cc88df0..fc2fc0e 100644 --- a/WebApp/src/routes/+page.svelte +++ b/WebApp/src/routes/+page.svelte @@ -1,2 +1,8 @@ -

Welcome to SvelteKit

-

Visit svelte.dev/docs/kit to read the documentation

+ + + + + diff --git a/WebApp/src/routes/activity/+page.svelte b/WebApp/src/routes/activity/+page.svelte new file mode 100644 index 0000000..f115ef8 --- /dev/null +++ b/WebApp/src/routes/activity/+page.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/WebApp/src/routes/camera/+page.svelte b/WebApp/src/routes/camera/+page.svelte new file mode 100644 index 0000000..ca18c40 --- /dev/null +++ b/WebApp/src/routes/camera/+page.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/WebApp/src/routes/client/+page.svelte b/WebApp/src/routes/client/+page.svelte new file mode 100644 index 0000000..9518b8c --- /dev/null +++ b/WebApp/src/routes/client/+page.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/WebApp/src/routes/layout.css b/WebApp/src/routes/layout.css index d4b5078..506b470 100644 --- a/WebApp/src/routes/layout.css +++ b/WebApp/src/routes/layout.css @@ -1 +1,81 @@ @import 'tailwindcss'; +@plugin "daisyui"; + +body { + font-family: 'Inter', sans-serif; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: rgb(0 0 0 / 20%); +} + +::-webkit-scrollbar-thumb { + background: rgb(255 255 255 / 10%); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgb(255 255 255 / 20%); +} + +.glass-panel { + background: rgb(15 15 20 / 70%); + backdrop-filter: blur(16px); + border: 1px solid rgb(255 255 255 / 5%); +} + +.glass-card { + background: rgb(25 25 30 / 60%); + border: 1px solid rgb(255 255 255 / 8%); +} + +.btn-premium { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + border: none; + color: white; + transition: all 0.2s ease; +} + +.btn-premium:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgb(37 99 235 / 30%); +} + +.status-dot { + height: 8px; + width: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-online { + background-color: #10b981; + box-shadow: 0 0 8px rgb(16 185 129 / 40%); +} + +.status-offline { + background-color: #ef4444; + box-shadow: 0 0 8px rgb(239 68 68 / 40%); +} + +@keyframes slideIn { + from { + transform: translateY(100%); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +.toast-enter { + animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} diff --git a/WebApp/src/routes/onboarding/+page.svelte b/WebApp/src/routes/onboarding/+page.svelte new file mode 100644 index 0000000..2d715c1 --- /dev/null +++ b/WebApp/src/routes/onboarding/+page.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/WebApp/src/routes/page.svelte.spec.ts b/WebApp/src/routes/page.svelte.spec.ts index 9b564bb..e556300 100644 --- a/WebApp/src/routes/page.svelte.spec.ts +++ b/WebApp/src/routes/page.svelte.spec.ts @@ -4,10 +4,10 @@ import { render } from 'vitest-browser-svelte'; import Page from './+page.svelte'; describe('/+page.svelte', () => { - it('should render h1', async () => { + it('should render simulator auth heading', async () => { render(Page); - const heading = page.getByRole('heading', { level: 1 }); + const heading = page.getByRole('heading', { name: 'SecureCam Web' }); await expect.element(heading).toBeInTheDocument(); }); }); diff --git a/WebApp/src/routes/settings/+page.svelte b/WebApp/src/routes/settings/+page.svelte new file mode 100644 index 0000000..2590904 --- /dev/null +++ b/WebApp/src/routes/settings/+page.svelte @@ -0,0 +1,8 @@ + + + + + diff --git a/WebApp/vite.config.ts b/WebApp/vite.config.ts index c2d4e68..1d42031 100644 --- a/WebApp/vite.config.ts +++ b/WebApp/vite.config.ts @@ -3,8 +3,36 @@ import { playwright } from '@vitest/browser-playwright'; import tailwindcss from '@tailwindcss/vite'; import { sveltekit } from '@sveltejs/kit/vite'; +const backendTarget = process.env.BACKEND_URL ?? 'http://localhost:3000'; + +const proxiedPaths = [ + '/api', + '/devices', + '/device-links', + '/streams', + '/events', + '/recordings', + '/videos', + '/push-notifications', + '/socket.io' +] as const; + +const proxy = Object.fromEntries( + proxiedPaths.map((path) => [ + path, + { + target: backendTarget, + changeOrigin: true, + ws: path === '/socket.io' + } + ]) +); + export default defineConfig({ plugins: [tailwindcss(), sveltekit()], + server: { + proxy + }, test: { expect: { requireAssertions: true }, projects: [