Compare commits

...

10 Commits

64 changed files with 1197 additions and 2873 deletions

View File

@@ -7,6 +7,10 @@ DEVICE_ONLINE_STALE_SECONDS=30
MINIO_ENDPOINT=localhost MINIO_ENDPOINT=localhost
MINIO_PORT=9000 MINIO_PORT=9000
MINIO_USE_SSL=false MINIO_USE_SSL=false
MINIO_PUBLIC_ORIGIN=
MINIO_PUBLIC_ENDPOINT=
MINIO_PUBLIC_PORT=
MINIO_PUBLIC_USE_SSL=
MINIO_ACCESS_KEY=minioadmin MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=videos MINIO_BUCKET=videos

View File

@@ -41,6 +41,8 @@ Required env vars:
| `MEDIA_RECORDINGS_DIR` | Local output directory for server-side recording workers (planned in SFU mode) | | `MEDIA_RECORDINGS_DIR` | Local output directory for server-side recording workers (planned in SFU mode) |
| `MEDIA_MAX_PUBLISHERS` / `MEDIA_MAX_SUBSCRIBERS_PER_ROOM` | Soft concurrency limits for single-server media mode (planned) | | `MEDIA_MAX_PUBLISHERS` / `MEDIA_MAX_SUBSCRIBERS_PER_ROOM` | Soft concurrency limits for single-server media mode (planned) |
| `MINIO_*` | Connection settings for the MinIO/S3 endpoint | | `MINIO_*` | Connection settings for the MinIO/S3 endpoint |
| `MINIO_PUBLIC_ORIGIN` | Optional browser-facing MinIO origin used for presigned URLs and CSP (for example `https://storage.example.com`) |
| `MINIO_PUBLIC_ENDPOINT` / `MINIO_PUBLIC_PORT` / `MINIO_PUBLIC_USE_SSL` | Optional browser-facing MinIO host settings if you prefer host/port flags instead of `MINIO_PUBLIC_ORIGIN` |
| `MINIO_CA_CERT_PATH` | Optional path to a PEM CA bundle used to trust a private/self-managed MinIO certificate | | `MINIO_CA_CERT_PATH` | Optional path to a PEM CA bundle used to trust a private/self-managed MinIO certificate |
| `MINIO_TLS_REJECT_UNAUTHORIZED` | TLS verification toggle for MinIO HTTPS requests (`true` by default) | | `MINIO_TLS_REJECT_UNAUTHORIZED` | TLS verification toggle for MinIO HTTPS requests (`true` by default) |
| `MINIO_INSECURE_SKIP_TLS_VERIFY` | Dev-only escape hatch to skip MinIO TLS certificate verification | | `MINIO_INSECURE_SKIP_TLS_VERIFY` | Dev-only escape hatch to skip MinIO TLS certificate verification |
@@ -56,6 +58,7 @@ bun run dev
``` ```
- Server boots after ensuring the configured MinIO bucket exists. - Server boots after ensuring the configured MinIO bucket exists.
- If the backend reaches MinIO on an internal host (for example `minio:9000`) but browsers must upload/download through a different public host, set `MINIO_PUBLIC_ORIGIN` so presigned URLs target the browser-reachable origin instead of the internal one.
- If MinIO uses a private or incomplete certificate chain, prefer setting `MINIO_CA_CERT_PATH` to a trusted PEM bundle. Only use `MINIO_INSECURE_SKIP_TLS_VERIFY=true` for local development or temporary debugging. - If MinIO uses a private or incomplete certificate chain, prefer setting `MINIO_CA_CERT_PATH` to a trusted PEM bundle. Only use `MINIO_INSECURE_SKIP_TLS_VERIFY=true` for local development or temporary debugging.
## Database (Drizzle ORM) ## Database (Drizzle ORM)

View File

@@ -21,7 +21,7 @@ import opsRoutes from './routes/ops';
import { rateLimit } from './middleware/security'; import { rateLimit } from './middleware/security';
import { requestContext } from './middleware/observability'; import { requestContext } from './middleware/observability';
import { setupRealtimeGateway } from './realtime/gateway'; import { setupRealtimeGateway } from './realtime/gateway';
import { ensureMinioBucket } from './utils/minio'; import { ensureMinioBucket, minioPublicOrigin } from './utils/minio';
import { startRecordingsWorker } from './workers/recordings'; import { startRecordingsWorker } from './workers/recordings';
import { startPushWorker } from './services/push'; import { startPushWorker } from './services/push';
@@ -35,31 +35,8 @@ const corsMiddleware = cors({
credentials: true, credentials: true,
}); });
const buildMinioConnectOrigin = (): string | null => { const connectSrcDirectives = ["'self'", 'cdn.jsdelivr.net', ...(minioPublicOrigin ? [minioPublicOrigin] : [])];
const endpoint = process.env.MINIO_ENDPOINT?.trim(); const mediaSrcDirectives = ["'self'", 'blob:', 'data:', ...(minioPublicOrigin ? [minioPublicOrigin] : [])];
if (!endpoint) {
return null;
}
if (endpoint.startsWith('http://') || endpoint.startsWith('https://')) {
try {
return new URL(endpoint).origin;
} catch {
return null;
}
}
const useSSL = (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true';
const port = Number(process.env.MINIO_PORT ?? (useSSL ? 443 : 80));
const scheme = useSSL ? 'https' : 'http';
const includePort = !(useSSL && port === 443) && !(!useSSL && port === 80);
return `${scheme}://${endpoint}${includePort ? `:${port}` : ''}`;
};
const minioConnectOrigin = buildMinioConnectOrigin();
const connectSrcDirectives = ["'self'", 'cdn.jsdelivr.net', ...(minioConnectOrigin ? [minioConnectOrigin] : [])];
const mediaSrcDirectives = ["'self'", 'blob:', 'data:', ...(minioConnectOrigin ? [minioConnectOrigin] : [])];
app.get('/', (_req, res) => { app.get('/', (_req, res) => {
res.send('API is running'); res.send('API is running');

View File

@@ -2,7 +2,13 @@ import type { NextFunction, Request, Response } from 'express';
import { Router } from 'express'; import { Router } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import { ensureMinioBucket, minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio'; import {
ensureMinioBucket,
minioBucket,
minioClient,
minioPresignClient,
minioPresignedExpirySeconds,
} from '../utils/minio';
const adminUsername = process.env.ADMIN_USERNAME; const adminUsername = process.env.ADMIN_USERNAME;
const adminPassword = process.env.ADMIN_PASSWORD; const adminPassword = process.env.ADMIN_PASSWORD;
@@ -325,7 +331,7 @@ router.post('/upload-url', async (req, res) => {
await ensureMinioBucket(); await ensureMinioBucket();
const objectKey = buildObjectKey(parsed.data.fileName, parsed.data.prefix); const objectKey = buildObjectKey(parsed.data.fileName, parsed.data.prefix);
const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); const uploadUrl = await minioPresignClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds);
const expiresAt = new Date(Date.now() + minioPresignedExpirySeconds * 1000); const expiresAt = new Date(Date.now() + minioPresignedExpirySeconds * 1000);
res.status(201).json({ res.status(201).json({

View File

@@ -6,7 +6,13 @@ import { db } from '../db/client';
import { recordings } from '../db/schema'; import { recordings } from '../db/schema';
import { requireDeviceAuth } from '../middleware/device-auth'; import { requireDeviceAuth } from '../middleware/device-auth';
import { writeAuditLog } from '../services/audit'; import { writeAuditLog } from '../services/audit';
import { ensureMinioBucket, minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio'; import {
ensureMinioBucket,
minioBucket,
minioClient,
minioPresignClient,
minioPresignedExpirySeconds,
} from '../utils/minio';
const router = Router(); const router = Router();
@@ -227,7 +233,7 @@ router.get('/:recordingId/download-url', requireDeviceAuth, async (req, res) =>
throw error; throw error;
} }
const downloadUrl = await minioClient.presignedGetObject( const downloadUrl = await minioPresignClient.presignedGetObject(
recording.bucket, recording.bucket,
recording.objectKey, recording.objectKey,
minioPresignedExpirySeconds, minioPresignedExpirySeconds,

View File

@@ -9,7 +9,9 @@ import {
ensureMinioBucket, ensureMinioBucket,
minioBucket, minioBucket,
minioClient, minioClient,
minioPresignClient,
minioPresignedExpirySeconds, minioPresignedExpirySeconds,
minioPublicOrigin,
} from '../utils/minio'; } from '../utils/minio';
const router = Router(); const router = Router();
@@ -26,6 +28,10 @@ const downloadUrlSchema = z.object({
objectKey: z.string().trim().min(1), objectKey: z.string().trim().min(1),
}); });
const uploadProxyParamsSchema = z.object({
recordingId: z.string().uuid(),
});
const listSchema = z.object({ const listSchema = z.object({
prefix: z.string().trim().optional(), prefix: z.string().trim().optional(),
limit: z.coerce.number().int().min(1).max(100).default(20), limit: z.coerce.number().int().min(1).max(100).default(20),
@@ -42,6 +48,105 @@ const buildObjectKey = (userId: string, fileName: string, prefix?: string): stri
router.use(requireAuth); router.use(requireAuth);
router.put('/upload/:recordingId', async (req, res) => {
const parsedParams = uploadProxyParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
res.status(400).json({ message: 'Invalid recordingId', errors: parsedParams.error.flatten() });
return;
}
const authSession = req.auth;
if (!authSession?.user) {
res.status(401).json({ message: 'Unauthorized' });
return;
}
const recording = await db.query.recordings.findFirst({
where: and(eq(recordings.id, parsedParams.data.recordingId), eq(recordings.ownerUserId, authSession.user.id)),
});
if (!recording) {
res.status(404).json({ message: 'Recording not found' });
return;
}
if (!recording.bucket || !recording.objectKey) {
res.status(409).json({ message: 'Recording does not have a storage target yet' });
return;
}
if (recording.status !== 'awaiting_upload') {
res.status(409).json({ message: `Recording is not awaiting upload (current status: ${recording.status})` });
return;
}
const contentType = typeof req.headers['content-type'] === 'string' && req.headers['content-type'].trim()
? req.headers['content-type'].trim()
: 'application/octet-stream';
const rawContentLength = Array.isArray(req.headers['content-length'])
? req.headers['content-length'][0]
: req.headers['content-length'];
const parsedSize = rawContentLength ? Number(rawContentLength) : undefined;
if (parsedSize !== undefined && (!Number.isFinite(parsedSize) || parsedSize < 0)) {
res.status(400).json({ message: 'Invalid Content-Length header' });
return;
}
try {
await ensureMinioBucket();
console.info('[recording.proxy-upload] streaming upload via backend', {
ownerUserId: authSession.user.id,
recordingId: recording.id,
deviceId: recording.cameraDeviceId,
bucket: recording.bucket,
objectKey: recording.objectKey,
contentType,
sizeBytes: parsedSize ?? null,
});
const uploadResult = await minioClient.putObject(
recording.bucket,
recording.objectKey,
req,
parsedSize,
{ 'Content-Type': contentType },
);
console.info('[recording.proxy-upload] upload complete', {
ownerUserId: authSession.user.id,
recordingId: recording.id,
bucket: recording.bucket,
objectKey: recording.objectKey,
etag: uploadResult.etag,
versionId: uploadResult.versionId ?? null,
sizeBytes: parsedSize ?? null,
});
res.status(201).json({
message: 'Recording uploaded via backend proxy',
recordingId: recording.id,
bucket: recording.bucket,
objectKey: recording.objectKey,
etag: uploadResult.etag,
versionId: uploadResult.versionId ?? null,
sizeBytes: parsedSize ?? null,
});
} catch (error) {
console.error('[recording.proxy-upload] failed', {
ownerUserId: authSession.user.id,
recordingId: recording.id,
bucket: recording.bucket,
objectKey: recording.objectKey,
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
});
router.post('/upload-url', async (req, res) => { router.post('/upload-url', async (req, res) => {
const parsed = uploadUrlSchema.safeParse(req.body); const parsed = uploadUrlSchema.safeParse(req.body);
@@ -83,7 +188,7 @@ router.post('/upload-url', async (req, res) => {
} }
const objectKey = buildObjectKey(authSession.user.id, parsed.data.fileName, parsed.data.prefix); const objectKey = buildObjectKey(authSession.user.id, parsed.data.fileName, parsed.data.prefix);
const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); const uploadUrl = await minioPresignClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds);
const now = new Date(); const now = new Date();
const expiresAt = new Date(now.getTime() + minioPresignedExpirySeconds * 1000); const expiresAt = new Date(now.getTime() + minioPresignedExpirySeconds * 1000);
@@ -98,6 +203,7 @@ router.post('/upload-url', async (req, res) => {
minioEndpoint: process.env.MINIO_ENDPOINT ?? 'localhost', minioEndpoint: process.env.MINIO_ENDPOINT ?? 'localhost',
minioPort: Number(process.env.MINIO_PORT ?? 9000), minioPort: Number(process.env.MINIO_PORT ?? 9000),
minioUseSSL: (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true', minioUseSSL: (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true',
minioPublicOrigin,
}); });
let persistedRecording; let persistedRecording;
@@ -183,7 +289,7 @@ router.get('/download-url', async (req, res) => {
await ensureMinioBucket(); await ensureMinioBucket();
const downloadUrl = await minioClient.presignedGetObject( const downloadUrl = await minioPresignClient.presignedGetObject(
minioBucket, minioBucket,
parsed.data.objectKey, parsed.data.objectKey,
minioPresignedExpirySeconds, minioPresignedExpirySeconds,

View File

@@ -2,14 +2,90 @@ import { readFileSync } from 'node:fs';
import { Agent as HttpsAgent } from 'node:https'; import { Agent as HttpsAgent } from 'node:https';
import { Client } from 'minio'; import { Client } from 'minio';
const endpoint = process.env.MINIO_ENDPOINT ?? 'localhost'; type MinioTarget = {
const port = Number(process.env.MINIO_PORT ?? 9000); endPoint: string;
const useSSL = (process.env.MINIO_USE_SSL ?? 'false').toLowerCase() === 'true'; port: number;
useSSL: boolean;
origin: string;
};
const parseBoolean = (value: string | undefined, fallback: boolean): boolean => {
if (value == null || value.trim() === '') {
return fallback;
}
return value.toLowerCase() === 'true';
};
const resolveMinioTarget = ({
origin,
endpoint,
port,
useSSL,
}: {
origin?: string;
endpoint?: string;
port?: string | number;
useSSL: boolean;
}): MinioTarget => {
const rawOrigin = origin?.trim();
if (rawOrigin) {
const url = new URL(rawOrigin);
const targetUseSSL = url.protocol === 'https:';
const targetPort = Number(url.port || (targetUseSSL ? 443 : 80));
return {
endPoint: url.hostname,
port: targetPort,
useSSL: targetUseSSL,
origin: url.origin,
};
}
const rawEndpoint = endpoint?.trim() || 'localhost';
if (rawEndpoint.startsWith('http://') || rawEndpoint.startsWith('https://')) {
const url = new URL(rawEndpoint);
const targetUseSSL = url.protocol === 'https:';
const targetPort = Number(url.port || (targetUseSSL ? 443 : 80));
return {
endPoint: url.hostname,
port: targetPort,
useSSL: targetUseSSL,
origin: url.origin,
};
}
const targetPort = Number(port ?? (useSSL ? 443 : 80));
const includePort = !(useSSL && targetPort === 443) && !(!useSSL && targetPort === 80);
return {
endPoint: rawEndpoint,
port: targetPort,
useSSL,
origin: `${useSSL ? 'https' : 'http'}://${rawEndpoint}${includePort ? `:${targetPort}` : ''}`,
};
};
const accessKey = process.env.MINIO_ACCESS_KEY; const accessKey = process.env.MINIO_ACCESS_KEY;
const secretKey = process.env.MINIO_SECRET_KEY; const secretKey = process.env.MINIO_SECRET_KEY;
const insecureSkipTlsVerify = (process.env.MINIO_INSECURE_SKIP_TLS_VERIFY ?? 'false').toLowerCase() === 'true'; const insecureSkipTlsVerify = parseBoolean(process.env.MINIO_INSECURE_SKIP_TLS_VERIFY, false);
const tlsRejectUnauthorized = (process.env.MINIO_TLS_REJECT_UNAUTHORIZED ?? 'true').toLowerCase() !== 'false'; const tlsRejectUnauthorized = parseBoolean(process.env.MINIO_TLS_REJECT_UNAUTHORIZED, true);
const minioCaCertPath = process.env.MINIO_CA_CERT_PATH?.trim(); const minioCaCertPath = process.env.MINIO_CA_CERT_PATH?.trim();
const internalUseSSL = parseBoolean(process.env.MINIO_USE_SSL, false);
const internalTarget = resolveMinioTarget({
endpoint: process.env.MINIO_ENDPOINT ?? 'localhost',
port: process.env.MINIO_PORT ?? 9000,
useSSL: internalUseSSL,
});
const publicTarget = resolveMinioTarget({
origin: process.env.MINIO_PUBLIC_ORIGIN,
endpoint: process.env.MINIO_PUBLIC_ENDPOINT ?? internalTarget.endPoint,
port: process.env.MINIO_PUBLIC_PORT ?? internalTarget.port,
useSSL: parseBoolean(process.env.MINIO_PUBLIC_USE_SSL, internalTarget.useSSL),
});
if (!accessKey || !secretKey) { if (!accessKey || !secretKey) {
throw new Error('MINIO_ACCESS_KEY and MINIO_SECRET_KEY must be set'); throw new Error('MINIO_ACCESS_KEY and MINIO_SECRET_KEY must be set');
@@ -17,8 +93,9 @@ if (!accessKey || !secretKey) {
export const minioBucket = process.env.MINIO_BUCKET ?? 'videos'; export const minioBucket = process.env.MINIO_BUCKET ?? 'videos';
export const minioPresignedExpirySeconds = Number(process.env.MINIO_PRESIGNED_EXPIRY_SECONDS ?? 60 * 10); export const minioPresignedExpirySeconds = Number(process.env.MINIO_PRESIGNED_EXPIRY_SECONDS ?? 60 * 10);
export const minioPublicOrigin = publicTarget.origin;
const customCa = minioCaCertPath ? readFileSync(minioCaCertPath) : undefined; const customCa = minioCaCertPath ? readFileSync(minioCaCertPath) : undefined;
const transportAgent = useSSL const transportAgent = internalTarget.useSSL
? new HttpsAgent({ ? new HttpsAgent({
keepAlive: true, keepAlive: true,
ca: customCa, ca: customCa,
@@ -27,14 +104,24 @@ const transportAgent = useSSL
: undefined; : undefined;
export const minioClient = new Client({ export const minioClient = new Client({
endPoint: endpoint, endPoint: internalTarget.endPoint,
port, port: internalTarget.port,
useSSL, useSSL: internalTarget.useSSL,
accessKey, accessKey,
secretKey, secretKey,
region: process.env.MINIO_REGION ?? 'us-east-1',
transportAgent, transportAgent,
}); });
export const minioPresignClient = new Client({
endPoint: publicTarget.endPoint,
port: publicTarget.port,
useSSL: publicTarget.useSSL,
accessKey,
secretKey,
region: process.env.MINIO_REGION ?? 'us-east-1',
});
let ensureBucketPromise: Promise<void> | null = null; let ensureBucketPromise: Promise<void> | null = null;
export const ensureMinioBucket = async (): Promise<void> => { export const ensureMinioBucket = async (): Promise<void> => {

Binary file not shown.

213
README.md Normal file
View File

@@ -0,0 +1,213 @@
# SecureCam Final Year Project
This repository contains the current SecureCam prototype:
- `Backend/` - Bun + Express API, Better Auth, Socket.IO, Drizzle ORM, MinIO integration
- `WebApp/` - SvelteKit browser dashboard for camera and client flows
- `docker-compose.yml` - local development stack for Postgres, MinIO, TURN, backend, and web app
The easiest way to run the project is with Docker Compose. Manual setup instructions are included below if you want to run the backend and web app directly on your machine.
## Quick Start
### Prerequisites
- Docker
- Docker Compose
### Start the full local stack
From the repository root:
```bash
docker compose up --build
```
This starts:
- Postgres
- MinIO
- coturn
- Backend on `http://localhost:3000`
- Web app on `http://localhost:5173`
### Open the app
- Web app: `http://localhost:5173`
- Backend API: `http://localhost:3000`
- Swagger docs: `http://localhost:3000/docs`
- OpenAPI JSON: `http://localhost:3000/openapi.json`
- Admin dashboard: `http://localhost:3000/admin`
- MinIO console: `http://localhost:9001`
Default admin dashboard credentials:
- Username: `admin`
- Password: `strong-password`
Default MinIO credentials:
- Username: `minioadmin`
- Password: `minioadmin`
### Stop the stack
```bash
docker compose down
```
To remove volumes as well:
```bash
docker compose down -v
```
## Manual Development Setup
Use this path if you want to run the backend and web app outside Docker.
### Prerequisites
- [Bun](https://bun.sh) for the backend
- Node.js + npm for the web app
- A running Postgres instance
- A running MinIO-compatible object store
- Optional: coturn if you want the local TURN configuration to work as-is
### 1. Configure the backend
```bash
cd Backend
cp .env.example .env
```
Set the values in `Backend/.env` for your environment, especially:
- `DATABASE_URL`
- `BETTER_AUTH_SECRET`
- `BETTER_AUTH_BASE_URL`
- `BETTER_AUTH_TRUSTED_ORIGINS`
- `MINIO_ENDPOINT`
- `MINIO_PORT`
- `MINIO_USE_SSL`
- `MINIO_ACCESS_KEY`
- `MINIO_SECRET_KEY`
- `MINIO_BUCKET`
If your backend connects to MinIO on an internal hostname but the browser must upload to a different public hostname, also set:
- `MINIO_PUBLIC_ORIGIN`
Example:
```env
BETTER_AUTH_BASE_URL=http://localhost:3000
BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:5173
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
```
### 2. Install backend dependencies
```bash
cd Backend
bun install
```
### 3. Run backend migrations
```bash
cd Backend
bun run db:migrate
```
### 4. Start the backend
```bash
cd Backend
bun run dev
```
The backend will be available at `http://localhost:3000`.
### 5. Install web app dependencies
```bash
cd WebApp
npm install
```
### 6. Start the web app
```bash
cd WebApp
npm run dev
```
The web app will be available at `http://localhost:5173`.
## Useful Commands
### Docker
```bash
docker compose up --build
docker compose logs -f backend webapp
docker compose down
```
### Backend
```bash
cd Backend
bun run dev
bun test
bun run db:migrate
bun run db:studio
```
### Web app
```bash
cd WebApp
npm run dev
npm run build
npm run check
npm run test
```
## First-Time Usage
Once the app is running:
1. Open `http://localhost:5173`
2. Create an account or sign in
3. Register the browser as either a `camera` or `client` device
4. Open a second browser window or profile if you want to simulate both roles locally
The web app is the primary interface now. The old static simulator pages and mobile scaffold have been removed from the repo.
## Troubleshooting
### Browser upload fails with a MinIO URL that looks unreachable
If uploads point to an internal hostname such as `http://minio:9000`, set `MINIO_PUBLIC_ORIGIN` in the backend env to a browser-reachable MinIO host and restart the backend.
### Backend starts but MinIO checks fail
Make sure:
- the MinIO server is running
- the bucket credentials are correct
- the backend can reach the configured MinIO host
- TLS settings such as `MINIO_CA_CERT_PATH` or `MINIO_INSECURE_SKIP_TLS_VERIFY` match your environment
### TURN issues on other devices
The default compose setup uses `127.0.0.1` in coturn-related configuration. If camera and client browsers are on different devices, replace those localhost TURN references with the host machine's LAN IP.
## More Detail
- Backend setup, env, and API details: [Backend/README.md](./Backend/README.md)
- Web app source: [WebApp/](./WebApp)

View File

@@ -110,8 +110,8 @@
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
/* Custom brand colors and glassmorphism tokens */ /* Custom brand colors and glassmorphism tokens */
--color-premium: #2563eb; --color-premium: #ff7a59;
--color-premium-foreground: #ffffff; --color-premium-foreground: #17110d;
--color-glass-background: rgb(25 25 30 / 60%); --color-glass-background: rgb(25 25 30 / 60%);
--color-glass-border: rgb(255 255 255 / 8%); --color-glass-border: rgb(255 255 255 / 8%);
--color-glass-panel: rgb(15 15 20 / 70%); --color-glass-panel: rgb(15 15 20 / 70%);

View File

@@ -13,7 +13,7 @@
</head> </head>
<body <body
data-sveltekit-preload-data="hover" data-sveltekit-preload-data="hover"
class="h-screen overflow-hidden flex bg-[#0a0a0c] text-gray-200" class="bg-[#0a0a0c] text-gray-200"
style="background:#0a0a0c; color:#e5e7eb" style="background:#0a0a0c; color:#e5e7eb"
> >
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>

View File

@@ -13,6 +13,17 @@ const toBackendUrl = (path) => {
return `${backendUrl}${path.startsWith('/') ? path : `/${path}`}`; return `${backendUrl}${path.startsWith('/') ? path : `/${path}`}`;
}; };
const parseResponseBody = async (response) => {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return response.json().catch(() => ({}));
}
const text = await response.text().catch(() => '');
return text ? { message: text } : {};
};
const request = async (path, options = {}) => { const request = async (path, options = {}) => {
const { deviceToken } = getAppState(); const { deviceToken } = getAppState();
const headers = { 'Content-Type': 'application/json' }; const headers = { 'Content-Type': 'application/json' };
@@ -38,6 +49,36 @@ const request = async (path, options = {}) => {
return data; return data;
}; };
const uploadBinary = async (path, body, options = {}) => {
const { deviceToken } = getAppState();
const headers = {};
if (deviceToken) {
headers.Authorization = `Bearer ${deviceToken}`;
}
if (options.contentType) {
headers['Content-Type'] = options.contentType;
}
const response = await fetch(toBackendUrl(path), {
method: options.method || 'PUT',
body,
credentials: 'include',
headers: {
...headers,
...(options.headers || {})
}
});
const data = await parseResponseBody(response);
if (!response.ok) {
throw new Error(data.message || data.error || response.statusText || 'Request failed');
}
return data;
};
export const getBackendUrl = () => backendUrl; export const getBackendUrl = () => backendUrl;
export const api = { export const api = {
@@ -83,5 +124,9 @@ export const api = {
}, },
pushNotifications: { pushNotifications: {
markRead: (notificationId) => request(`/push-notifications/${notificationId}/read`, { method: 'POST', body: JSON.stringify({}) }) markRead: (notificationId) => request(`/push-notifications/${notificationId}/read`, { method: 'POST', body: JSON.stringify({}) })
},
uploads: {
uploadRecordingBlob: (recordingId, blob, contentType = 'application/octet-stream') =>
uploadBinary(`/videos/upload/${recordingId}`, blob, { method: 'PUT', contentType })
} }
}; };

View File

@@ -36,6 +36,8 @@ export const createControllerMediaModule = ({
let cameraVideoElement = null; let cameraVideoElement = null;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const uploadFailureHint =
'Check MinIO bucket CORS and the public upload host/certificate if browser uploads are failing.';
const updateMotionDetectionRuntime = (updates) => { const updateMotionDetectionRuntime = (updates) => {
patchAppState((state) => ({ patchAppState((state) => ({
@@ -148,6 +150,62 @@ export const createControllerMediaModule = ({
return parts.join(' · '); return parts.join(' · ');
}; };
const uploadRecordingBlobToStorage = async ({ uploadMeta, blob, streamSessionId = null, eventId = null }) => {
const contentType = blob.type || 'video/webm';
const recordingId = uploadMeta?.video?.id ?? null;
try {
const uploadResponse = await fetch(uploadMeta.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': contentType },
body: blob
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${await summarizeFetchFailure(uploadResponse)}`);
}
return {
mode: 'direct',
status: uploadResponse.status
};
} catch (directError) {
const directErrorMessage =
directError instanceof Error ? directError.message : String(directError);
console.warn('[recording.upload] direct upload failed, retrying via backend proxy', {
recordingId,
streamSessionId,
eventId,
objectKey: uploadMeta?.objectKey,
error: directErrorMessage
});
addActivity(
'Recording',
`Direct upload failed for ${uploadMeta?.objectKey ?? 'recording'}, retrying via backend proxy`
);
if (!recordingId) {
throw directError;
}
try {
const proxyUpload = await api.uploads.uploadRecordingBlob(recordingId, blob, contentType);
return {
mode: 'proxy',
status: 201,
proxyUpload
};
} catch (proxyError) {
const proxyErrorMessage =
proxyError instanceof Error ? proxyError.message : String(proxyError);
throw new Error(
`Direct upload failed (${directErrorMessage}); backend proxy upload failed (${proxyErrorMessage})`
);
}
}
};
const refreshCameraInputDevices = async () => { const refreshCameraInputDevices = async () => {
if (!navigator.mediaDevices?.enumerateDevices) { if (!navigator.mediaDevices?.enumerateDevices) {
setAppState({ cameraInputDevices: [], selectedCameraInputId: '' }); setAppState({ cameraInputDevices: [], selectedCameraInputId: '' });
@@ -566,23 +624,19 @@ export const createControllerMediaModule = ({
'Recording', 'Recording',
`Upload URL ready for ${uploadMeta.objectKey} via ${uploadOrigin}` `Upload URL ready for ${uploadMeta.objectKey} via ${uploadOrigin}`
); );
const uploadResult = await uploadRecordingBlobToStorage({
const uploadResponse = await fetch(uploadMeta.uploadUrl, { uploadMeta,
method: 'PUT', blob: compressedBlob,
headers: { 'Content-Type': compressedBlob.type || 'video/webm' }, streamSessionId
body: compressedBlob
}); });
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${await summarizeFetchFailure(uploadResponse)}`);
}
console.info('[recording.upload] object uploaded', { console.info('[recording.upload] object uploaded', {
recordingId: recording.id, recordingId: recording.id,
streamSessionId, streamSessionId,
objectKey: uploadMeta.objectKey, objectKey: uploadMeta.objectKey,
bucket: uploadMeta.bucket, bucket: uploadMeta.bucket,
status: uploadResponse.status, mode: uploadResult.mode,
status: uploadResult.status,
sizeBytes: compressedBlob.size sizeBytes: compressedBlob.size
}); });
addActivity('Recording', `Upload completed for ${uploadMeta.objectKey}`); addActivity('Recording', `Upload completed for ${uploadMeta.objectKey}`);
@@ -604,7 +658,7 @@ export const createControllerMediaModule = ({
addActivity('Recording', 'Recording uploaded and finalized'); addActivity('Recording', 'Recording uploaded and finalized');
return true; return true;
} catch (error) { } catch (error) {
console.error('[recording.upload] stream upload failed, falling back to simulated key', { console.error('[recording.upload] stream upload failed before object reached storage', {
recordingId: recording.id, recordingId: recording.id,
streamSessionId, streamSessionId,
error: error instanceof Error ? error.message : String(error) error: error instanceof Error ? error.message : String(error)
@@ -613,14 +667,9 @@ export const createControllerMediaModule = ({
'Recording', 'Recording',
`Upload failed for stream ${streamSessionId}: ${error instanceof Error ? error.message : 'unknown error'}` `Upload failed for stream ${streamSessionId}: ${error instanceof Error ? error.message : 'unknown error'}`
); );
const fallbackObjectKey = `sim/${streamSessionId}/${Date.now()}.webm`; addActivity('Recording', uploadFailureHint);
await api.events.finalizeRecording(recording.id, { pushToast('Recording upload failed before reaching storage.', 'error');
objectKey: fallbackObjectKey, return false;
durationSeconds: captureResult?.durationSeconds ?? 6,
sizeBytes: captureResult?.blob?.size ?? 5000000
});
addActivity('Recording', 'Upload failed; finalized with simulator fallback');
return true;
} }
} }
@@ -672,23 +721,19 @@ export const createControllerMediaModule = ({
blobType: compressedBlob.type || 'video/webm' blobType: compressedBlob.type || 'video/webm'
}); });
addActivity('Recording', `Standalone upload URL ready via ${uploadOrigin}`); addActivity('Recording', `Standalone upload URL ready via ${uploadOrigin}`);
const uploadResult = await uploadRecordingBlobToStorage({
const uploadResponse = await fetch(uploadMeta.uploadUrl, { uploadMeta,
method: 'PUT', blob: compressedBlob,
headers: { 'Content-Type': compressedBlob.type || 'video/webm' }, eventId: lastMotionEventId
body: compressedBlob
}); });
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${await summarizeFetchFailure(uploadResponse)}`);
}
console.info('[recording.upload] standalone object uploaded', { console.info('[recording.upload] standalone object uploaded', {
eventId: lastMotionEventId, eventId: lastMotionEventId,
recordingId: uploadMeta.video?.id, recordingId: uploadMeta.video?.id,
objectKey: uploadMeta.objectKey, objectKey: uploadMeta.objectKey,
bucket: uploadMeta.bucket, bucket: uploadMeta.bucket,
status: uploadResponse.status, mode: uploadResult.mode,
status: uploadResult.status,
sizeBytes: compressedBlob.size sizeBytes: compressedBlob.size
}); });
addActivity('Recording', `Standalone upload completed for ${uploadMeta.objectKey}`); addActivity('Recording', `Standalone upload completed for ${uploadMeta.objectKey}`);
@@ -718,6 +763,8 @@ export const createControllerMediaModule = ({
'Recording', 'Recording',
`Standalone motion upload failed: ${error instanceof Error ? error.message : 'unknown error'}` `Standalone motion upload failed: ${error instanceof Error ? error.message : 'unknown error'}`
); );
addActivity('Recording', uploadFailureHint);
pushToast('Motion clip upload failed before reaching storage.', 'error');
return false; return false;
} }
}; };

View File

@@ -2,12 +2,12 @@
// @ts-nocheck // @ts-nocheck
const PAGE_PATHS = { const PAGE_PATHS = {
auth: '/', auth: '/auth/login',
onboarding: '/onboarding', onboarding: '/app/onboarding',
camera: '/camera', camera: '/app/camera',
client: '/client', client: '/app/client',
activity: '/activity', activity: '/app/activity',
settings: '/settings' settings: '/app/settings'
}; };
const DEVICE_STORAGE_KEY = 'mobileSimDevice'; const DEVICE_STORAGE_KEY = 'mobileSimDevice';
@@ -66,15 +66,21 @@ const normalizePath = (path) => path.replace(/\/+$/, '') || '/';
export const pageFromPath = (path) => { export const pageFromPath = (path) => {
switch (normalizePath(path)) { switch (normalizePath(path)) {
case '/onboarding': case '/auth':
case '/auth/login':
case '/auth/signup':
return 'auth';
case '/app':
return 'app';
case '/app/onboarding':
return 'onboarding'; return 'onboarding';
case '/camera': case '/app/camera':
return 'camera'; return 'camera';
case '/client': case '/app/client':
return 'client'; return 'client';
case '/activity': case '/app/activity':
return 'activity'; return 'activity';
case '/settings': case '/app/settings':
return 'settings'; return 'settings';
default: default:
return 'auth'; return 'auth';

View File

@@ -55,6 +55,9 @@ let initPromise = null;
let socket = null; let socket = null;
let pollInterval = null; let pollInterval = null;
let socketHeartbeatInterval = null; let socketHeartbeatInterval = null;
const handleBeforeUnload = () => {
void cleanupConnectionState();
};
let clientVideoElement = null; let clientVideoElement = null;
@@ -839,7 +842,13 @@ const invalidateSavedDevice = async (message, options = {}) => {
const enforceRouteForSession = () => { const enforceRouteForSession = () => {
const state = getAppState(); const state = getAppState();
const page = pageFromPath(window.location.pathname); const page = pageFromPath(window.location.pathname);
setAppState({ page }); const initialPage =
page === 'app'
? state.deviceToken
? getHomePageKeyForRole(state.device?.role)
: 'onboarding'
: page;
setAppState({ page: initialPage });
if (!state.session) { if (!state.session) {
if (page !== 'auth') { if (page !== 'auth') {
@@ -856,7 +865,7 @@ const enforceRouteForSession = () => {
} }
const expectedHome = getHomePageKeyForRole(state.device?.role); const expectedHome = getHomePageKeyForRole(state.device?.role);
if ((page === 'auth' || page === 'onboarding') && expectedHome) { if ((page === 'auth' || page === 'onboarding' || page === 'app') && expectedHome) {
navigateToScreen('home', { replace: true, role: state.device?.role }); navigateToScreen('home', { replace: true, role: state.device?.role });
return; return;
} }
@@ -903,9 +912,7 @@ const init = async () => {
void refreshCameraInputDevices(); void refreshCameraInputDevices();
applyMotionDetectionReadiness(); applyMotionDetectionReadiness();
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', handleBeforeUnload);
void cleanupConnectionState();
});
initialized = true; initialized = true;
})().finally(() => { })().finally(() => {
@@ -923,6 +930,7 @@ const destroy = async () => {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', onVisibilityChange); document.removeEventListener('visibilitychange', onVisibilityChange);
} }
window.removeEventListener('beforeunload', handleBeforeUnload);
initialized = false; initialized = false;
await cleanupConnectionState(); await cleanupConnectionState();
}; };
@@ -955,6 +963,10 @@ const actions = {
patchAppState((state) => ({ isRegistering: !state.isRegistering })); patchAppState((state) => ({ isRegistering: !state.isRegistering }));
}, },
setAuthMode(isRegistering) {
setAppState({ isRegistering });
},
async submitAuth() { async submitAuth() {
const state = getAppState(); const state = getAppState();
const { email, password, name } = state.authForm; const { email, password, name } = state.authForm;

View File

@@ -1 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<title>PhoneCam</title>
<rect width="512" height="512" rx="120" fill="#0A0A0C" />
<rect x="28" y="28" width="456" height="456" rx="96" fill="#111827" stroke="#2563EB" stroke-width="8" />
<text
x="256"
y="230"
fill="#F8FAFC"
font-family="DejaVu Sans, Arial, sans-serif"
font-size="88"
font-weight="700"
text-anchor="middle"
>
Phone
</text>
<text
x="256"
y="330"
fill="#60A5FA"
font-family="DejaVu Sans, Arial, sans-serif"
font-size="88"
font-weight="700"
text-anchor="middle"
>
Cam
</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 666 B

View File

@@ -0,0 +1,110 @@
<script lang="ts">
// @ts-nocheck
import { appController } from '$lib/app/controller';
import { appState } from '$lib/app/store';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
let { mode = 'login' } = $props<{ mode?: 'login' | 'signup' }>();
const isSignup = () => mode === 'signup';
const title = () => (isSignup() ? 'Create your PhoneCam account' : 'Sign in to PhoneCam');
const description = () =>
isSignup()
? 'Create an account to finish setting up this browser.'
: 'Use your account to open the dashboard and continue setup.';
const primaryLabel = () => (isSignup() ? 'Create account' : 'Sign in');
const secondaryLabel = () => (isSignup() ? 'Sign in instead' : 'Create an account');
const secondaryPrompt = () => (isSignup() ? 'Already have an account?' : 'Need an account?');
const secondaryHref = () => (isSignup() ? '/auth/login' : '/auth/signup');
$effect(() => {
appController.setAuthMode(isSignup());
});
</script>
<section class="mx-auto flex min-h-full w-full max-w-5xl flex-col px-4 py-8 sm:px-6">
<nav class="flex items-center border-b border-white/10 pb-4">
<Button
href="/"
variant="ghost"
class="h-auto rounded-full px-3 text-sm font-semibold text-white hover:bg-white/5 hover:text-sky-200"
>
PhoneCam
</Button>
</nav>
<div class="flex flex-1 items-center justify-center pt-8">
<Card variant="glass" class="w-full max-w-md rounded-3xl border-white/10 bg-black/45 backdrop-blur-xl">
<CardHeader class="space-y-3 px-6 pt-6 text-left sm:px-7 sm:pt-7">
<p class="text-xs font-semibold uppercase tracking-[0.28em] text-sky-200/80">PhoneCam</p>
<div class="space-y-2">
<CardTitle class="text-3xl font-semibold tracking-tight text-white">{title()}</CardTitle>
<CardDescription class="text-sm leading-6 text-slate-400">{description()}</CardDescription>
</div>
</CardHeader>
<CardContent class="space-y-5 px-6 pb-6 sm:px-7 sm:pb-7">
{#if isSignup()}
<div id="authNameField" class="space-y-2">
<Label for="authName" class="text-sm font-medium text-slate-200">Display name</Label>
<Input
id="authName"
type="text"
placeholder="Jane Doe"
class="h-11 rounded-2xl border-white/10 bg-white/[0.04] text-sm text-white placeholder:text-slate-500"
value={$appState.authForm.name}
oninput={(event) => appController.setAuthField('name', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
{/if}
<div class="space-y-2">
<Label for="authEmail" class="text-sm font-medium text-slate-200">Email</Label>
<Input
id="authEmail"
type="email"
placeholder="name@example.com"
class="h-11 rounded-2xl border-white/10 bg-white/[0.04] text-sm text-white placeholder:text-slate-500"
value={$appState.authForm.email}
oninput={(event) => appController.setAuthField('email', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
<div class="space-y-2">
<Label for="authPassword" class="text-sm font-medium text-slate-200">Password</Label>
<Input
id="authPassword"
type="password"
placeholder="Enter your password"
class="h-11 rounded-2xl border-white/10 bg-white/[0.04] text-sm text-white placeholder:text-slate-500"
value={$appState.authForm.password}
oninput={(event) => appController.setAuthField('password', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
<Button
id="signInBtn"
variant="premium"
class="h-11 w-full rounded-2xl text-sm font-semibold"
onclick={() => appController.submitAuth()}
>
{primaryLabel()}
</Button>
<div class="border-t border-white/10 pt-4 text-center text-sm text-slate-400">
<span>{secondaryPrompt()}</span>
<Button
href={secondaryHref()}
variant="ghost"
class="ml-1 h-auto rounded-full px-2 text-sm font-medium text-white hover:bg-transparent hover:text-sky-200"
>
{secondaryLabel()}
</Button>
</div>
</CardContent>
</Card>
</div>
</section>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
// @ts-nocheck
import { onMount } from 'svelte';
import { appController } from '$lib/app/controller';
let { children } = $props();
onMount(() => {
void appController.init();
return () => {
void appController.destroy();
};
});
</script>
<div class="flex h-screen overflow-hidden bg-[#0a0a0c] text-gray-200">
{@render children()}
</div>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
// @ts-nocheck
import { appController } from '$lib/app/controller';
import { appState } from '$lib/app/store';
let redirected = $state(false);
$effect(() => {
if (redirected || $appState.loading) {
return;
}
if (!$appState.session) {
appController.navigate('auth');
redirected = true;
return;
}
if (!$appState.deviceToken) {
appController.navigate('onboarding');
redirected = true;
return;
}
appController.navigate('home');
redirected = true;
});
</script>
<section class="flex flex-1 items-center justify-center px-6">
<div class="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm text-slate-300">
Preparing your workspace…
</div>
</section>

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(307, '/auth/login');
}

View File

@@ -0,0 +1,9 @@
<script lang="ts">
// @ts-nocheck
import AuthPanel from '$lib/auth/AuthPanel.svelte';
import AppChrome from '$lib/sim/ui/AppChrome.svelte';
</script>
<AppChrome pageKey="auth">
<AuthPanel mode="login" />
</AppChrome>

View File

@@ -0,0 +1,9 @@
<script lang="ts">
// @ts-nocheck
import AuthPanel from '$lib/auth/AuthPanel.svelte';
import AppChrome from '$lib/sim/ui/AppChrome.svelte';
</script>
<AppChrome pageKey="auth">
<AuthPanel mode="signup" />
</AppChrome>

View File

@@ -3,7 +3,6 @@
import '../app.css'; import '../app.css';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { appController } from '$lib/app/controller';
import { import {
clearInstallPrompt, clearInstallPrompt,
setInstallPrompt, setInstallPrompt,
@@ -85,14 +84,11 @@
window.addEventListener('online', handleOnline); window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline); window.addEventListener('offline', handleOffline);
void appController.init();
return () => { return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
window.removeEventListener('appinstalled', handleAppInstalled); window.removeEventListener('appinstalled', handleAppInstalled);
window.removeEventListener('online', handleOnline); window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline); window.removeEventListener('offline', handleOffline);
void appController.destroy();
}; };
}); });
</script> </script>

View File

@@ -1,90 +1,342 @@
<script lang="ts"> <script lang="ts">
// @ts-nocheck // @ts-nocheck
import AppChrome from '$lib/sim/ui/AppChrome.svelte'; import { onMount } from 'svelte';
import { appController } from '$lib/app/controller'; import { fly } from 'svelte/transition';
import { appState } from '$lib/app/store';
import { Button } from '$lib/components/ui/button/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '$lib/components/ui/card/index.js'; import { ArrowRight } from '@lucide/svelte';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js'; let shaderCanvas: HTMLCanvasElement | null = null;
import { Separator } from '$lib/components/ui/separator/index.js';
import { ShieldCheck } from '@lucide/svelte'; const heroPoints = ['Camera station', 'Client viewer', 'Motion events'];
const vertexShaderSource = `
attribute vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
`;
const fragmentShaderSource = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash(i);
float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0));
float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 5; i++) {
value += amplitude * noise(p);
p = p * 2.0 + vec2(17.1, 9.2);
amplitude *= 0.5;
}
return value;
}
mat2 rot(float angle) {
float s = sin(angle);
float c = cos(angle);
return mat2(c, -s, s, c);
}
void main() {
vec2 p = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
float t = u_time * 0.18;
vec2 q = p * 1.45;
q *= rot(-0.25);
float fieldA = fbm(q * 2.1 + vec2(t, -t * 0.35));
float fieldB = fbm(q * 3.0 - vec2(t * 0.7, -t));
float ribbonA = smoothstep(
0.62,
0.96,
1.0 - abs(p.y + 0.20 * sin(p.x * 2.7 + t * 1.8) + (fieldA - 0.5) * 0.30)
);
float ribbonB = smoothstep(
0.58,
0.95,
1.0 - abs(p.y - 0.26 * cos(p.x * 1.9 - t * 1.2) + (fieldB - 0.5) * 0.22)
);
float glow = exp(-3.4 * length(p * vec2(0.8, 1.15)));
float grain = noise(gl_FragCoord.xy * 0.35 + t * 8.0) * 0.04;
vec3 base = vec3(0.055, 0.05, 0.07);
vec3 underpaint = vec3(0.14, 0.09, 0.08);
vec3 accentA = vec3(1.0, 0.48, 0.35);
vec3 accentB = vec3(0.99, 0.67, 0.46);
vec3 color = base;
color += underpaint * (fieldA * 0.38 + fieldB * 0.18);
color += accentA * ribbonA * 0.52;
color += accentB * ribbonB * 0.34;
color += accentA * glow * 0.12;
color += grain;
float vignette = smoothstep(1.35, 0.18, length(p));
color *= vignette;
color += vec3(0.012, 0.012, 0.018);
gl_FragColor = vec4(color, 1.0);
}
`;
const createShader = (gl: WebGLRenderingContext, type: number, source: string) => {
const shader = gl.createShader(type);
if (!shader) {
return null;
}
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
};
const createProgram = (gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string) => {
const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexSource);
const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vertexShader || !fragmentShader) {
return null;
}
const program = gl.createProgram();
if (!program) {
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return null;
}
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
gl.deleteProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
return null;
}
return { program, vertexShader, fragmentShader };
};
onMount(() => {
if (!shaderCanvas) {
return;
}
const gl = shaderCanvas.getContext('webgl', {
alpha: true,
antialias: true,
premultipliedAlpha: false
});
if (!gl) {
return;
}
const programBundle = createProgram(gl, vertexShaderSource, fragmentShaderSource);
if (!programBundle) {
return;
}
const { program, vertexShader, fragmentShader } = programBundle;
const positionLocation = gl.getAttribLocation(program, 'a_position');
const timeLocation = gl.getUniformLocation(program, 'u_time');
const resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
const positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
gl.STATIC_DRAW
);
let frameId = 0;
let lastWidth = 0;
let lastHeight = 0;
const resize = () => {
if (!shaderCanvas) {
return;
}
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.floor(shaderCanvas.clientWidth * dpr);
const height = Math.floor(shaderCanvas.clientHeight * dpr);
if (width === lastWidth && height === lastHeight) {
return;
}
lastWidth = width;
lastHeight = height;
shaderCanvas.width = width;
shaderCanvas.height = height;
gl.viewport(0, 0, width, height);
};
const render = (time: number) => {
resize();
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.uniform1f(timeLocation, time * 0.001);
gl.uniform2f(resolutionLocation, shaderCanvas?.width || 0, shaderCanvas?.height || 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
frameId = window.requestAnimationFrame(render);
};
window.addEventListener('resize', resize);
resize();
frameId = window.requestAnimationFrame(render);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('resize', resize);
gl.deleteBuffer(positionBuffer);
gl.deleteProgram(program);
gl.deleteShader(vertexShader);
gl.deleteShader(fragmentShader);
};
});
</script> </script>
<AppChrome pageKey="auth"> <svelte:head>
<section id="screen-auth" class="flex flex-col items-center justify-center min-h-[70vh] animate-fade-in max-w-sm mx-auto"> <title>PhoneCam | Browser-Based Security Intelligence</title>
<Card variant="glass" class="w-full rounded-3xl"> <meta
<CardHeader class="items-center gap-4 px-6 pt-6 text-center"> name="description"
<div content="PhoneCam turns any browser into a practical security console with live monitoring, motion detection, and recording playback."
class="flex size-20 items-center justify-center rounded-3xl bg-gradient-to-tr from-blue-600 to-indigo-600 shadow-lg shadow-blue-900/20" />
</svelte:head>
<section
class="relative min-h-screen overflow-hidden bg-[#0a0910] text-[#f6efe7]"
style="font-family: var(--landing-sans);"
>
<canvas bind:this={shaderCanvas} class="absolute inset-0 h-full w-full"></canvas>
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(10,9,16,0.24),transparent_28%),linear-gradient(180deg,rgba(10,9,16,0.45),rgba(10,9,16,0.78))]"></div>
<div class="absolute inset-0 bg-[linear-gradient(90deg,rgba(10,9,16,0.88)_0%,rgba(10,9,16,0.62)_42%,rgba(10,9,16,0.28)_100%)]"></div>
<div class="relative z-10 flex min-h-screen flex-col">
<header class="px-6 py-5 sm:px-8 lg:px-10">
<div class="mx-auto flex max-w-6xl items-center justify-between">
<a
href="/"
class="text-xl font-semibold tracking-tight text-[#f6efe7]"
style="font-family: var(--landing-display);"
> >
<ShieldCheck class="size-10 text-white" /> PhoneCam
</a>
<nav class="flex items-center gap-2">
<Button
href="/auth/login"
variant="ghost"
class="rounded-full px-4 text-sm text-[#d7c9bd] hover:bg-white/5 hover:text-[#f6efe7]"
>
Sign in
</Button>
<Button
href="/auth/signup"
variant="premium"
class="rounded-full px-5 text-sm font-semibold"
>
Get started
</Button>
</nav>
</div>
</header>
<div class="mx-auto flex w-full max-w-6xl flex-1 items-center px-6 pb-12 pt-8 sm:px-8 lg:px-10">
<div in:fly={{ y: 28, duration: 500 }} class="max-w-3xl space-y-8">
<p class="text-xs font-semibold uppercase tracking-[0.32em] text-[#ff7a59]">
Live browser monitoring
</p>
<div class="space-y-5">
<p
class="text-[clamp(3.2rem,10vw,7rem)] leading-none tracking-[-0.07em] text-[#f6efe7]"
style="font-family: var(--landing-display);"
>
PhoneCam
</p>
<h1
class="max-w-2xl text-4xl font-semibold leading-tight tracking-[-0.05em] text-[#f6efe7] sm:text-5xl lg:text-6xl"
style="font-family: var(--landing-display);"
>
Monitoring that feels immediate.
</h1>
<p class="max-w-2xl text-base leading-7 text-[#d7c9bd] sm:text-lg">
Use one browser as the camera station, another as the viewer, and keep feeds,
recordings, and activity in one place.
</p>
</div> </div>
<div class="flex flex-col gap-2">
<CardTitle class="text-3xl font-bold tracking-tight text-white">PhoneCam Web</CardTitle> <div class="flex flex-col gap-3 sm:flex-row">
<CardDescription class="text-sm text-gray-400">Sign in to manage visual security from your browser.</CardDescription> <Button href="/auth/signup" variant="premium" class="h-12 rounded-full px-6 text-sm font-semibold">
Create account
<ArrowRight class="ml-2 size-4" />
</Button>
<Button
href="/app"
variant="ghost"
class="h-12 rounded-full border border-white/12 bg-white/[0.03] px-6 text-sm font-medium text-[#f6efe7] hover:bg-white/[0.08]"
>
Open dashboard
</Button>
</div> </div>
</CardHeader>
<CardContent class="flex flex-col gap-4 px-6"> <div class="flex flex-wrap items-center gap-3 pt-2 text-sm text-[#b7a696]">
<div class="flex flex-col gap-2"> {#each heroPoints as item, index}
<Label class="sr-only" for="authEmail">Email</Label> <span>{item}</span>
<Input {#if index < heroPoints.length - 1}
id="authEmail" <span class="text-[#ff7a59]"></span>
type="email" {/if}
placeholder="Email address" {/each}
class="h-12 rounded-xl border-white/10 bg-black/40 text-sm text-white placeholder:text-gray-500"
value={$appState.authForm.email}
oninput={(event) => appController.setAuthField('email', (event.currentTarget as HTMLInputElement).value)}
/>
</div> </div>
<div class="flex flex-col gap-2"> </div>
<Label class="sr-only" for="authPassword">Password</Label> </div>
<Input </div>
id="authPassword" </section>
type="password"
placeholder="Password" <style>
class="h-12 rounded-xl border-white/10 bg-black/40 text-sm text-white placeholder:text-gray-500" :global(:root) {
value={$appState.authForm.password} --landing-display: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Georgia, serif;
oninput={(event) => appController.setAuthField('password', (event.currentTarget as HTMLInputElement).value)} --landing-sans: 'Inter Variable', 'Inter', 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
/> }
</div>
{#if $appState.isRegistering} :global(body) {
<div id="authNameField" class="flex flex-col gap-2"> background-color: #0a0910;
<Label class="sr-only" for="authName">Name</Label> }
<Input </style>
id="authName"
type="text"
placeholder="Your Name"
class="h-12 rounded-xl border-white/10 bg-black/40 text-sm text-white placeholder:text-gray-500"
value={$appState.authForm.name}
oninput={(event) => appController.setAuthField('name', (event.currentTarget as HTMLInputElement).value)}
/>
</div>
{/if}
</CardContent>
<CardFooter class="flex flex-col items-stretch gap-4 border-0 bg-transparent px-6 pb-6 pt-2">
<Button
id="signInBtn"
variant="premium"
class="h-12 w-full rounded-xl text-base shadow-lg shadow-blue-900/20"
onclick={() => appController.submitAuth()}
>
{$appState.isRegistering ? 'Create Account' : 'Sign In'}
</Button>
<div class="flex w-full items-center gap-3 text-xs text-gray-600">
<Separator class="flex-1 bg-white/10" />
<span>OR</span>
<Separator class="flex-1 bg-white/10" />
</div>
<Button
id="toggleAuthModeBtn"
variant="ghost"
class="h-12 w-full rounded-xl border border-white/5 text-gray-400 hover:bg-white/5 hover:text-white"
onclick={() => appController.toggleAuthMode()}
>
{$appState.isRegistering ? 'I already have an account' : 'Create an account'}
</Button>
</CardFooter>
</Card>
</section>
</AppChrome>

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(307, '/app/activity');
}

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(307, '/app/camera');
}

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(307, '/app/client');
}

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(307, '/app/onboarding');
}

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export function load() {
throw redirect(307, '/app/settings');
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,16 +1,27 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="120" fill="#0A0A0C"/> <title>PhoneCam</title>
<rect x="84" y="84" width="344" height="344" rx="92" fill="url(#bg)"/> <rect width="512" height="512" rx="120" fill="#0A0A0C" />
<rect x="116" y="132" width="280" height="248" rx="48" fill="#15161D" stroke="rgba(255,255,255,0.08)" stroke-width="8"/> <rect x="28" y="28" width="456" height="456" rx="96" fill="#111827" stroke="#2563EB" stroke-width="8" />
<rect x="146" y="164" width="220" height="160" rx="28" fill="#0E1016"/> <text
<circle cx="256" cy="244" r="52" fill="#2563EB"/> x="256"
<circle cx="256" cy="244" r="28" fill="#0A0A0C"/> y="230"
<path d="M186 122C186 108.745 196.745 98 210 98H302C315.255 98 326 108.745 326 122V144H186V122Z" fill="#2563EB"/> fill="#F8FAFC"
<path d="M220 290L256 326L322 214" stroke="white" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/> font-family="DejaVu Sans, Arial, sans-serif"
<defs> font-size="88"
<linearGradient id="bg" x1="84" y1="84" x2="428" y2="428" gradientUnits="userSpaceOnUse"> font-weight="700"
<stop stop-color="#1D4ED8"/> text-anchor="middle"
<stop offset="1" stop-color="#1E40AF"/> >
</linearGradient> Phone
</defs> </text>
<text
x="256"
y="330"
fill="#60A5FA"
font-family="DejaVu Sans, Arial, sans-serif"
font-size="88"
font-weight="700"
text-anchor="middle"
>
Cam
</text>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 991 B

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,32 +0,0 @@
# Foreground Web Motion Detection Absolute Checklist
- `MWM-001` Confirm first-release scope is web camera role only and foreground-tab only.
- `MWM-002` Add detector-related state fields to [WebApp/src/lib/app/store.js](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/lib/app/store.js:4).
- `MWM-003` Define default motion detector config values for low-power foreground operation.
- `MWM-004` Create a dedicated detector module under `WebApp/src/lib/app/`.
- `MWM-005` Implement low-resolution frame sampling from the existing local camera stream.
- `MWM-006` Implement grayscale conversion and normalized frame-difference scoring.
- `MWM-007` Implement score smoothing or block aggregation to reduce noise flicker.
- `MWM-008` Implement detector state machine transitions `idle`, `warming_up`, `monitoring`, `triggered`, `cooldown`.
- `MWM-009` Implement adaptive sampling so idle sampling is slower than candidate-motion sampling.
- `MWM-010` Ensure the detector starts only when the device role is `camera`, preview is ready, and detection is armed.
- `MWM-011` Ensure the detector pauses or stops on camera permission loss, preview teardown, socket disconnect, or page hide.
- `MWM-012` Add camera dashboard controls for arm/disarm and sensitivity.
- `MWM-013` Add camera dashboard status UI for detector state and live motion score.
- `MWM-014` Keep existing manual motion buttons intact and usable.
- `MWM-015` Wire automatic detector trigger to the existing `startMotion` flow with `triggeredBy: "auto_motion"`.
- `MWM-016` Prevent duplicate `startMotion` calls while a motion event is already active.
- `MWM-017` Implement minimum event duration and quiet cooldown before automatic end.
- `MWM-018` Wire automatic quiet-state completion to the existing `endMotion` flow.
- `MWM-019` Add activity-log messages for detector armed/disarmed, trigger start, trigger end, and detector pause reasons.
- `MWM-020` Add local persistence for detector settings so operator choices survive reloads.
- `MWM-021` Validate that existing client notifications still fire through the unchanged backend event routes.
- `MWM-022` Validate that existing recording behavior still works for auto-started motion events.
- `MWM-023` Add unit-level tests for detector scoring and state-machine transitions.
- `MWM-024` Add integration tests or deterministic mocks for event deduplication and cooldown handling.
- `MWM-025` Perform manual runtime validation for no-motion idle scene, real person entry, lighting flicker, and camera shake.
- `MWM-026` Measure runtime CPU/thermal behavior during a long foreground session and tune defaults if needed.
- `MWM-027` Document the foreground-only assumption and known limits in project docs.
- `MWM-028` Document recommended operator settings for lowest battery and heat.
- `MWM-029` Add rollback behavior so disabling detection immediately stops the detector loop and leaves manual controls intact.
- `MWM-030` Finalize a rollout note describing first-release non-goals such as background detection and ML classification.

View File

@@ -1,40 +0,0 @@
# Foreground Web Motion Detection Execution Prompt
Implement automatic foreground-only motion detection for the web camera role using the artifacts below as the source of truth:
- [FOREGROUND_WEB_MOTION_DETECTION_IMPLEMENTATION_PLAN.md](/home/matiss/Documents/Final%20Year%20Project/docs/FOREGROUND_WEB_MOTION_DETECTION_IMPLEMENTATION_PLAN.md)
- [FOREGROUND_WEB_MOTION_DETECTION_ABSOLUTE_CHECKLIST.md](/home/matiss/Documents/Final%20Year%20Project/docs/FOREGROUND_WEB_MOTION_DETECTION_ABSOLUTE_CHECKLIST.md)
- [FOREGROUND_WEB_MOTION_DETECTION_VALIDATION_MATRIX.md](/home/matiss/Documents/Final%20Year%20Project/docs/FOREGROUND_WEB_MOTION_DETECTION_VALIDATION_MATRIX.md)
## Delivery Rules
- Treat the implementation plan as authoritative for architecture and sequencing.
- Complete checklist items atomically and update the validation matrix with evidence as work lands.
- Do not silently skip hidden work such as store updates, detector lifecycle cleanup, tests, docs, and runtime validation.
- Reuse the existing backend motion lifecycle before introducing any new backend motion pipeline.
- Preserve manual motion controls and existing client notification behavior.
## Evidence Rules
- Every completed checklist item must have at least one evidence entry in the validation matrix.
- `Code` evidence must reference the file changed.
- `Test` evidence must reference a deterministic automated or manual validation step.
- `Runtime` evidence must reference an observed app behavior, command result, or manual scenario outcome.
## Completion Gates
- Automatic motion detection works only for the web `camera` role.
- The detector runs only while the browser tab is in the foreground and the detector is armed.
- The detector starts and ends existing motion events automatically without duplicate starts.
- Client notifications and recordings continue to function through existing backend routes.
- Manual motion controls still work.
- Detector settings are user-visible and persisted locally.
- Tests and runtime validation cover idle scenes, real motion, flicker, and camera shake.
## Suggested First Batch
1. Add store fields and camera dashboard controls.
2. Add the detector module with live score reporting only.
3. Tune thresholds manually on the camera dashboard.
4. Wire automatic start/end to the existing motion event flow.
5. Add tests, docs, and validation evidence.

View File

@@ -1,229 +0,0 @@
# Foreground Web Motion Detection Implementation Plan
## Summary
Implement automatic motion detection for the `camera` role in the web app while the browser remains open in the foreground on a plugged-in phone. Reuse the existing backend motion event, notification, recording, and stream flows instead of introducing a new backend motion pipeline.
## Goals
- Detect motion automatically from the local camera preview in the web app.
- Trigger the existing motion event lifecycle with minimal backend change.
- Minimize battery and heat by using low-resolution frame analysis and adaptive sampling.
- Keep the first release heuristic and deterministic rather than ML-based.
- Make behavior observable and tunable from the camera dashboard.
## Constraints
- The browser tab is assumed to remain visible in the foreground.
- The initial target is the web app camera dashboard, not the Expo mobile app.
- Existing backend endpoints remain the source of truth for motion events.
- The current camera role UX in [WebApp/src/routes/camera/+page.svelte](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/routes/camera/+page.svelte:31) must continue to work.
- The current motion event routes in [Backend/routes/events.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/routes/events.ts:35) and [Backend/routes/events.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/routes/events.ts:142) should be reused.
## Assumptions
- Manual motion controls remain available as an operator override.
- `triggeredBy` can distinguish manual versus automatic events without a schema change.
- Persisted per-device motion settings are desirable, but an in-memory first pass is acceptable.
- The existing recording behavior tied to stream/motion events remains the intended user experience.
## Non-Goals
- True background detection while the browser tab is hidden.
- ML person/pet/vehicle classification in the first release.
- Replacing the current notification or recording architecture.
- Solving appliance-grade uptime guarantees.
## Existing Integration Points
- Camera preview and camera dashboard actions are already hosted in [WebApp/src/routes/camera/+page.svelte](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/routes/camera/+page.svelte:105).
- Local camera capture and preview lifecycle already exist in [WebApp/src/lib/app/controller.js](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/lib/app/controller.js:233).
- Manual motion start/end already call the backend in [WebApp/src/lib/app/controller.js](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/lib/app/controller.js:1374).
- Camera-side state is stored in [WebApp/src/lib/app/store.js](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/lib/app/store.js:4).
## Strategy
### Phase 1: Add Motion Detector State And Controls
Add camera-side detector state to the Svelte store:
- `motionDetectionEnabled`
- `motionDetectorStatus` such as `idle`, `warming_up`, `monitoring`, `triggered`, `cooldown`
- `motionSensitivity`
- `motionSampleIntervalMs`
- `motionTriggerConsecutiveFrames`
- `motionQuietCooldownMs`
- `motionMinimumEventMs`
- `motionScore`
- `motionDebugEnabled`
Expose controls on the camera dashboard:
- Arm/disarm automatic detection
- Sensitivity slider or preset selector
- Low-power mode preset
- Live debug score and current detector state
Keep the existing manual `Simulate Motion Event` and `Stop Recording` actions for fallback.
### Phase 2: Build A Lightweight Detector Engine
Create a dedicated detector module, for example `WebApp/src/lib/app/motion-detector.js`, owned by the camera dashboard flow.
Detector design:
- Read from the existing `localCameraStream`
- Draw frames into an offscreen or hidden canvas
- Downsample aggressively to about `160x90` or `192x108`
- Convert to grayscale
- Compare the current frame against the previous smoothed frame
- Compute a normalized motion score such as changed-pixel ratio or block-delta score
- Ignore tiny isolated noise with thresholding and optional block aggregation
Battery and heat controls:
- Default sampling at `1 fps`
- Burst to `4-6 fps` only after suspicious motion begins
- Return to low sampling after cooldown
- Skip work if preview is not ready, detector is disarmed, or document visibility changes
- Avoid full-resolution processing and avoid network uploads during detection itself
### Phase 3: Add Event State Machine And Backend Reuse
Implement a camera-side state machine:
- `idle` -> `monitoring`
- `monitoring` -> `candidate_motion`
- `candidate_motion` -> `triggered`
- `triggered` -> `cooldown`
- `cooldown` -> `monitoring`
Trigger rules:
- Require `N` consecutive high-motion frames before starting an event
- Call the existing backend motion start endpoint once
- Set `triggeredBy` to `auto_motion`
- Hold the event open for at least `motionMinimumEventMs`
- Only end after the score stays below threshold for `motionQuietCooldownMs`
This keeps backend changes minimal because the existing event lifecycle already fans out realtime alerts and push notifications.
### Phase 4: Make Recording Behavior Predictable
The detector should not record constantly.
Recommended behavior:
- Motion detection itself only analyzes low-res frames locally
- When automatic motion is confirmed, call the existing start-motion flow
- Continue using the current recording logic already associated with motion and streaming
- End the motion event only after quiet cooldown, not on every instantaneous dip
This avoids repeated start/stop loops that waste device resources.
### Phase 5: Add Persistence For Operator Settings
Initial implementation can use local storage on the web app for speed.
Second step:
- Add motion settings persistence per camera device
- Store settings either in `devices.metadata` if introduced later, or in a new `device_motion_settings` table
- Load persisted settings on device registration or camera dashboard open
Suggested settings:
- Enabled/armed
- Sensitivity
- Sample interval
- Quiet cooldown
- Minimum event duration
- Optional region of interest
### Phase 6: Observability And Debugging
Add operator-visible debug surfaces:
- Current motion score
- Detector state
- Last trigger time
- Count of suppressed candidate triggers
Add activity log entries for:
- Detector armed/disarmed
- Detector warmed up
- Automatic motion started
- Automatic motion ended
- Detector paused because preview or socket is unavailable
### Phase 7: Test And Tune
Testing should cover:
- Low-motion idle scenes
- Moderate lighting flicker
- Real person entry into frame
- Camera shake false positives
- Reconnection behavior
- Event deduplication
Tuning targets:
- Low false positive rate in static indoor scenes
- Trigger latency below about `2 seconds`
- CPU usage low enough to avoid obvious thermal throttling during foreground operation
## File-Level Change Plan
Primary files:
- [WebApp/src/lib/app/store.js](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/lib/app/store.js:4)
- [WebApp/src/lib/app/controller.js](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/lib/app/controller.js:233)
- [WebApp/src/routes/camera/+page.svelte](/home/matiss/Documents/Final%20Year%20Project/WebApp/src/routes/camera/+page.svelte:105)
Likely new files:
- `WebApp/src/lib/app/motion-detector.js`
- `WebApp/src/lib/app/motion-detector.test.js` or equivalent
Optional later backend files:
- [Backend/db/schema.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/db/schema.ts:14)
- [Backend/routes/devices.ts](/home/matiss/Documents/Final%20Year%20Project/Backend/routes/devices.ts:41)
## Risk Controls
- Use hysteresis so one threshold starts motion and a lower threshold ends it.
- Require consecutive-frame confirmation before starting events.
- Pause detection when preview, permission, or socket connectivity is unavailable.
- Keep all frame processing local and low resolution.
- Keep manual controls available during rollout.
- Ship with debug mode so threshold tuning is possible without code changes.
## Recommended Operator Settings
- Start with the `Balanced` profile on a plugged-in phone.
- Use `Low Power` if the phone runs warm or the scene is mostly static.
- Keep the browser tab visible and the camera dashboard open while detection is armed.
- Leave debug mode off during normal operation and enable it only while tuning thresholds.
- Prefer a stable camera mount and a consistent indoor lighting setup to reduce false positives.
## Acceptance Criteria
- A camera-role web device can arm automatic motion detection from the camera dashboard.
- When visible motion enters frame, the web app starts one backend motion event without duplicate starts.
- Linked clients receive the same notifications they currently receive for manual motion events.
- Motion events remain open through continuous motion and close only after quiet cooldown.
- The detector does not continuously upload frames or full video for analysis.
- Manual motion controls continue to work.
- Detector state survives normal page usage and fails safely on disconnect or permission loss.
## Recommended Delivery Order
1. Add store state and camera dashboard controls.
2. Add local detector engine with score reporting only, no event triggering.
3. Tune thresholds against manual test scenes.
4. Wire score transitions to automatic event start/end.
5. Add persistence for detector settings.
6. Add tests, docs, and rollout notes.

View File

@@ -1,34 +0,0 @@
# Foreground Web Motion Detection Validation Matrix
| Row ID | Checklist Item | Status | Code | Test | Runtime |
| --- | --- | --- | --- | --- | --- |
| `VM-MWM-001` | Confirm first-release scope is web camera role only and foreground-tab only | done | `WebApp/src/lib/app/controller.js` `shouldRunMotionDetector`; `docs/FOREGROUND_WEB_MOTION_DETECTION_IMPLEMENTATION_PLAN.md` Constraints/Non-Goals | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-002` | Add detector-related state fields to store | done | `WebApp/src/lib/app/store.js` `createInitialState`; `WebApp/src/lib/app/controller.js` `getDefaultMotionDetectionState` | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-003` | Define default detector config values | done | `WebApp/src/lib/app/controller.js` `MOTION_DETECTION_PROFILES`, `buildMotionDetectionState` | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-004` | Create dedicated detector module | done | `WebApp/src/lib/app/motion-detector.js` `createMotionDetector` | `bun run test -- src/lib/app/motion-detector.spec.js` | `bun run test -- src/lib/app/motion-detector.spec.js` -> `4 passed` |
| `VM-MWM-005` | Implement low-resolution frame sampling | done | `WebApp/src/lib/app/motion-detector.js` `DEFAULT_FRAME_WIDTH`, `DEFAULT_FRAME_HEIGHT`, `analyzeFrame` | `bun run test -- src/lib/app/motion-detector.spec.js` | `bun run test -- src/lib/app/motion-detector.spec.js` -> `4 passed` |
| `VM-MWM-006` | Implement grayscale conversion and frame-difference scoring | done | `WebApp/src/lib/app/motion-detector.js` `computeLuma`, `computeChangedPixelRatio` | `src/lib/app/motion-detector.spec.js` `computes grayscale luminance...`, `reports the ratio...` | `bun run test -- src/lib/app/motion-detector.spec.js` -> `4 passed` |
| `VM-MWM-007` | Implement score smoothing or block aggregation | done | `WebApp/src/lib/app/motion-detector.js` `DEFAULT_SMOOTHING_FACTOR`, `analyzeFrame` | `bun run test -- src/lib/app/motion-detector.spec.js` | `bun run test -- src/lib/app/motion-detector.spec.js` -> `4 passed` |
| `VM-MWM-008` | Implement detector state machine transitions | done | `WebApp/src/lib/app/motion-detector.js` `createMotionMachineSnapshot`, `applyMotionStateMachine` | `src/lib/app/motion-detector.spec.js` `requires consecutive high-motion frames...`, `holds motion until minimum duration...` | `bun run test -- src/lib/app/motion-detector.spec.js` -> `4 passed` |
| `VM-MWM-009` | Implement adaptive sampling | done | `WebApp/src/lib/app/motion-detector.js` `tick`; `WebApp/src/lib/app/controller.js` `MOTION_DETECTION_PROFILES` | `bun run test -- src/lib/app/motion-detector.spec.js` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-010` | Start detector only when camera role, preview, and armed state are valid | done | `WebApp/src/lib/app/controller.js` `shouldRunMotionDetector`, `applyMotionDetectionReadiness` | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-011` | Pause detector on permission loss, preview teardown, disconnect, or page hide | done | `WebApp/src/lib/app/controller.js` `getMotionDetectionPauseReason`, `applyMotionDetectionReadiness`, `onVisibilityChange` | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-012` | Add camera dashboard arm/disarm and sensitivity controls | done | `WebApp/src/routes/camera/+page.svelte` Automatic Detection controls; `WebApp/src/lib/app/controller.js` `setMotionDetectionEnabled`, `setMotionDetectionProfile` | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-013` | Add camera dashboard detector status and score UI | done | `WebApp/src/routes/camera/+page.svelte` Detector State/Motion Score cards | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-014` | Preserve manual motion controls | done | `WebApp/src/routes/camera/+page.svelte` manual buttons; `WebApp/src/lib/app/controller.js` `actions.startMotion`, `actions.endMotion` | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-015` | Trigger existing start-motion flow with `auto_motion` | done | `WebApp/src/lib/app/controller.js` `getMotionStartPayload`, `startMotionEvent`, `syncAutoMotionLifecycle`; `WebApp/src/lib/app/api.js` `events.startMotion` | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-016` | Prevent duplicate automatic motion starts | done | `WebApp/src/lib/app/controller.js` `startMotionEvent`, `syncAutoMotionLifecycle`, `autoMotionTransitionInFlight` | `src/lib/app/motion-detector.spec.js` `requires consecutive high-motion frames...` | `bun run test -- src/lib/app/motion-detector.spec.js` -> `4 passed` |
| `VM-MWM-017` | Implement minimum event duration and quiet cooldown | done | `WebApp/src/lib/app/motion-detector.js` `applyMotionStateMachine`; `WebApp/src/lib/app/controller.js` `syncAutoMotionLifecycle` | `src/lib/app/motion-detector.spec.js` `holds motion until minimum duration...` | `bun run test -- src/lib/app/motion-detector.spec.js` -> `4 passed` |
| `VM-MWM-018` | Wire quiet-state completion to existing end-motion flow | done | `WebApp/src/lib/app/controller.js` `endMotionEvent`, `syncAutoMotionLifecycle`, `applyMotionDetectionReadiness` | `src/lib/app/motion-detector.spec.js` `holds motion until minimum duration...` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-019` | Add activity-log messages for detector lifecycle | done | `WebApp/src/lib/app/controller.js` `addActivity` calls in `applyMotionDetectionReadiness`, `setMotionDetectionEnabled`, `startMotionEvent`, `endMotionEvent` | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-020` | Add local persistence for detector settings | done | `WebApp/src/lib/app/controller.js` `loadMotionDetectionSettings`, `persistMotionDetectionSettings`, `updateMotionDetectionState` | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-021` | Validate existing client notifications still work | pending | Existing backend fan-out remains unchanged in `Backend/routes/events.ts` | | Runtime validation still required with linked client devices |
| `VM-MWM-022` | Validate recording behavior still works for auto-started events | pending | Existing recording paths remain in `WebApp/src/lib/app/controller.js` `startLocalRecording`, `endMotionEvent`, `uploadStandaloneMotionRecording` | | Runtime validation still required with an actual auto-triggered event |
| `VM-MWM-023` | Add unit tests for scoring and state-machine transitions | done | `WebApp/src/lib/app/motion-detector.js` exported helpers; `WebApp/src/lib/app/motion-detector.spec.js` | `bun run test -- src/lib/app/motion-detector.spec.js` | `bun run test -- src/lib/app/motion-detector.spec.js` -> `4 passed` |
| `VM-MWM-024` | Add integration tests or deterministic mocks for cooldown and dedupe | in_progress | `WebApp/src/lib/app/motion-detector.spec.js` covers deterministic cooldown and trigger gating; controller dedupe still untested | `bun run test -- src/lib/app/motion-detector.spec.js` | Additional controller-level mock coverage still needed |
| `VM-MWM-025` | Perform manual validation for idle, real motion, flicker, and camera shake | pending | | | |
| `VM-MWM-026` | Measure runtime CPU and thermal behavior during long foreground session | pending | | | |
| `VM-MWM-027` | Document foreground-only assumption and limits | done | `docs/FOREGROUND_WEB_MOTION_DETECTION_IMPLEMENTATION_PLAN.md` Constraints/Non-Goals/Acceptance Criteria | | Doc updated in repo |
| `VM-MWM-028` | Document recommended low-battery and low-heat settings | done | `docs/FOREGROUND_WEB_MOTION_DETECTION_IMPLEMENTATION_PLAN.md` Battery and heat controls; Recommended Operator Settings | | Doc updated in repo |
| `VM-MWM-029` | Add immediate rollback behavior when detection is disabled | done | `WebApp/src/lib/app/controller.js` `setMotionDetectionEnabled`, `applyMotionDetectionReadiness`, `endMotionEvent` | `bun run check` | `bun run check` -> `svelte-check found 0 errors and 0 warnings` |
| `VM-MWM-030` | Publish rollout note for first-release non-goals | done | `docs/FOREGROUND_WEB_MOTION_DETECTION_IMPLEMENTATION_PLAN.md` Non-Goals | | Doc updated in repo |

View File

@@ -1,75 +0,0 @@
# Absolute Checklist: Streaming Simplification (WebRTC-only)
## Backend
1. Add/confirm `SIMPLE_STREAMING` environment/config switch (default: true for new deployment profile).
2. In `Backend/routes/streams.ts`, update `POST /streams/request` to:
- keep device role/link validation
- create session with status `requested`
- emit request event directly compatible with WebRTC-only flow
- remove stream-provider payload assumptions from response
3. In `Backend/routes/streams.ts`, update `POST /streams/:id/accept` to:
- validate camera ownership and state
- set status to `streaming`
- set `startedAt`
- emit `stream:started`
- remove or gate all publish/subscribe/media-provider dependency
4. In `Backend/routes/streams.ts`, update `POST /streams/:id/end` to:
- require authorized participant (camera or requester)
- set status to `ended`
- set `endedAt`
- emit `stream:ended`
- stop any active SFU session only if explicitly still active and configured
5. In `Backend/routes/streams.ts`, remove any hard dependency on `mediaProvider.createSession` for live functionality.
6. Gate recording creation in stream end logic behind feature flag (default OFF) or remove endpoint path from phase-1 path.
7. In `Backend/routes/streams.ts`, return minimal deterministic payload for stream start/accept/end responses.
8. In `Backend/db/schema.ts`, mark legacy media fields as deprecated comments only (no schema migration required in phase 1).
9. In `Backend/media/service.ts`, make provider calls no-op for phase-1 streaming runtime or guard them behind non-simplified feature flag.
10. In `Backend/media/providers/mock.ts`, annotate mock provider methods as metadata-only and remove calls from request/accept/end flow.
11. In `Backend/media/sfu/service.ts` and `Backend/media/sfu/noop.ts`, ensure no required startup path is executed for default simplified mode.
12. In `Backend/routes/commands.ts`, document command-table deprecation for stream lifecycle command path; remove active usage if simplified mode is enabled.
## Realtime Gateway
13. In `Backend/realtime/gateway.ts`, remove `stream:frame` event listener and sender path.
14. In `Backend/realtime/gateway.ts`, remove frame-relay participant cache and related validation branch.
15. In `Backend/realtime/gateway.ts`, keep only validated `webrtc:signal` logic for offer/answer/candidate/hangup.
16. In `Backend/realtime/gateway.ts`, simplify signal forwarding so target validation is session ownership + same user context.
17. In `Backend/realtime/gateway.ts`, remove periodic command retry polling for stream commands when in simplified mode.
18. Keep `stream:started` and `stream:ended` emit paths with recipient resolution.
19. Ensure socket middleware still enforces device token auth and role identity checks.
## Web frontend
20. In `WebApp/src/lib/app/controller.js`, collapse stream state to `idle|connecting|active|ended`.
21. Remove fallback image mode, latest-frame state, and frame receiver logic from controller.
22. Ensure client side only uses WebRTC peer connection and `<video>` rendering path.
23. Simplify `ontrack` handling to set remote stream once and ignore duplicate tracks.
24. Add deterministic peer teardown on `stream:ended`, page unload, and navigation changes.
25. In `WebApp/src/routes/camera/+page.svelte`, remove preview-to-frame interval fallback sender.
26. In `WebApp/src/routes/camera/+page.svelte`, ensure camera-side stream path emits offer only and no JPEG payload.
27. In `WebApp/src/routes/camera/+page.svelte`, remove recording branch that depends on frame transport.
28. In `WebApp/src/routes/client/+page.svelte`, remove fallback image DOM branch and related status labels.
## Mobile frontend
29. In `MobileApp/src/app-context.tsx`, remove base64 frame stream state and timer lifecycle for frame relay.
30. In `MobileApp/src/app-context.tsx` and `MobileApp/app/(tabs)/index.tsx`, align stream lifecycle state transitions with web logic.
31. In `MobileApp/app/(tabs)/index.tsx`, remove capture+`stream:frame` emission loop.
32. In `MobileApp/app/(tabs)/index.tsx`, remove any image fallback UI path and render strategy for active stream.
33. In `MobileApp/src/api.ts`, remove calls to publish/subscribe credentials endpoints from simplified runtime flow.
34. Ensure mobile camera cleanup stops local media and closes RTCPeerConnection on end and unmount.
## Cross-cutting API/docs/config
35. In `Backend/docs/openapi.ts`, update documented streaming contract to only include:
- request
- accept
- end
- webrtc signal event behavior
- remove references implying frame fallback as primary path
36. Update any internal docs or README sections describing mock media endpoints as runtime-critical.
37. Add migration notes to avoid breaking integrations (deprecation and rollback behavior).
38. Add explicit log markers for stream request/accept/offer/answer/end events with session id and participant ids.
39. Add compile/build compatibility checks for both apps after removing unused symbols/imports.
40. Add runtime rollback checklist for disabling `SIMPLE_STREAMING` and re-enabling legacy paths.

View File

@@ -1,73 +0,0 @@
# Execution Prompt: Streaming Simplification
You are implementing `STREAMING_SIMPLIFICATION` in `/home/matiss/Documents/Final Year Project`.
## Objective
Deliver a WebRTC-only streaming architecture for this codebase by implementing the four artifacts:
1. `docs/STREAMING_SIMPLIFICATION_IMPLEMENTATION_PLAN.md`
2. `docs/STREAMING_SIMPLIFICATION_ABSOLUTE_CHECKLIST.md`
3. `docs/STREAMING_SIMPLIFICATION_VALIDATION_MATRIX.md`
4. This execution prompt.
## Governing constraints
- Preserve device token auth and role checks.
- Keep request/accept/end endpoints and WebRTC signaling functional.
- Remove socket JPEG frame fallback from runtime path.
- Keep rollback-capable behavior using `SIMPLE_STREAMING` where possible.
- Do not reintroduce any media-path dependency on mock provider endpoints.
## Execution sequence (strict)
1. Implement all backend stream endpoint and session-state simplifications first.
2. Simplify realtime gateway signaling path second.
3. Update web frontend streaming controller and pages.
4. Update mobile frontend streaming behavior.
5. Update docs, config notes, and OpenAPI/event description.
6. Run validation matrix gating; do not mark complete until all required evidence rows are filled.
## Completion gates
### Gate A — Backend
- `Backend/routes/streams.ts` follows simplified lifecycle only.
- `Backend/realtime/gateway.ts` contains no frame-relay path in active flow.
- Unauthorized access checks remain strict and verifiable.
### Gate B — Frontends
- Web and Mobile each have one stream state machine and one active rendering strategy.
- No scheduled frame-relay sender/receiver code remains in runtime path.
- End-of-stream cleanup is deterministic and symmetric.
### Gate C — Delivery readiness
- OpenAPI/docs updated for simplified contract.
- Feature flag/rollback documented.
- Validation matrix entries populated from observed evidence.
## Evidence rules
For every checklist line in `docs/STREAMING_SIMPLIFICATION_ABSOLUTE_CHECKLIST.md`:
1. Fill corresponding row in `docs/STREAMING_SIMPLIFICATION_VALIDATION_MATRIX.md` from pending -> done.
2. Populate columns:
- **Code Evidence**: file path + symbols/line references
- **Test Evidence**: command output or test report artifact names
- **Runtime Evidence**: screenshot/video/log excerpt references and scenario details
3. Keep one evidence item per row if possible; if blocked, include blocker and retry plan.
## Required validation commands (when execution resumes)
- Backend: validate API/types/contracts compile/build and lint status.
- Gateway: static check that `stream:frame` handlers are absent in effective runtime mode.
- Frontend: build check for both web and mobile after removing unused imports/state.
- Runtime smoke checks:
- client request -> camera accept -> stream start -> stream end
- unauthorized request rejection
- cleanup path after end and reconnect
## Final report format
Return a final report with:
1. Completed tasks grouped by file.
2. Validation matrix fully updated.
3. Any deviations from this spec and why.
4. Exact first and second risk identified after rollout.
Do not introduce additional assumptions not covered in the checklist without updating the checklist and matrix.

View File

@@ -1,138 +0,0 @@
# Implementation Plan: Streaming Simplification (WebRTC-only)
## 0) Goal and scope
### Goal
Simplify the current streaming architecture to a single production-pragmatic implementation path:
- Control plane via REST + socket signaling
- Media plane via WebRTC only
- No JPEG frame fallback relay
- Preserve security and device-role authorization
### Scope in/out
- In scope:
- Stream request/accept/end lifecycle
- Device role and ownership checks
- WebRTC signaling and connection setup/teardown
- Frontend Web + Mobile stream runtime paths
- API/docs updates for simplified contract
- Out of scope:
- Multi-party broadcast or conferencing
- SFU/ingest pipeline replacement in this phase
- Media provider feature parity with mock provider URLs
- Full recording workflow and upload/finalization
## 1) Current-state constraints to keep
- Devices use bearer tokens and role-based socket/session authentication.
- Stream request is initiated by a `client` device targeting a linked `camera`.
- Current schema/event names exist and are used across components.
- Backend and frontends already support `stream:requested`, `stream:started`, `stream:ended`, and `webrtc:signal`.
## 2) Explicit requirements
1. Runtime complexity reduction:
- Keep one media transport: WebRTC.
- Remove socket JPEG fallback flow from runtime.
2. Reliability and safety:
- Keep strict owner/role checks for all stream/session endpoints.
- Ensure deterministic teardown when stream ends.
3. Maintainability:
- Remove dead or non-critical branches.
- Minimize special-case flags and dual state machines.
4. Backward compatibility strategy:
- Prefer a feature flag `SIMPLE_STREAMING` for controlled rollout.
## 3) Assumptions
- Existing database schema can remain (deprecating fields is acceptable for now).
- Camera and client are expected to be online for primary WebRTC path; if not reachable, stream request/accept will fail gracefully.
- `stream:frame` is not required for core user value in phase 1.
- Recording features can be delayed or gated without blocking stream acceptance.
- Existing command dispatch infrastructure can be simplified or replaced by direct request/session events without redesigning auth.
## 4) Non-goals and excluded behaviors
- No immediate migration to true production-grade media server or SFU.
- No auto-retry queue for offline command delivery.
- No new codec/transcoding pipeline.
## 5) Target architecture
### Backend
- Stream endpoints: request -> accept -> end.
- Session state transitions:
- `requested` -> `streaming` -> `ended`
- Commands table may be retained only for audit/logging or removed later.
- Gateway relays:
- `webrtc:signal` only for media negotiation.
- `stream:started` and `stream:ended` for lifecycle updates.
### Frontend (Web + Mobile)
- Shared runtime model:
- `idle` -> `connecting` -> `active` -> `ended`
- On camera accept: start WebRTC offer.
- On client started event: start WebRTC answer/handling.
- On ended: stop peer connections, clear state.
- No frame image fallback state/UI.
## 6) Phased implementation strategy
### Phase 1: Contract freeze and safety gate (Day 0)
1. Lock simplified state contract and event matrix.
2. Add/validate `SIMPLE_STREAMING` flag and default behavior.
3. Add migration notes for legacy paths.
### Phase 2: Backend control-plane simplification (Day 1)
1. Refactor stream request/accept/end handlers to only manage session lifecycle and signaling.
2. Remove/disable stream-provider-specific runtime side effects from these handlers.
3. Ensure response payloads are minimal and stable.
### Phase 3: Gateway simplification (Day 1)
1. Remove `stream:frame` event handler and related relay state.
2. Keep only validated WebRTC signaling and lifecycle event emitters.
3. Remove command retry loop if not required by flag.
### Phase 4: Web frontend simplification (Day 2)
1. Simplify stream controller into single mode.
2. Remove image fallback flow and related timers/state.
3. Keep single video render path and deterministic cleanup.
### Phase 5: Mobile frontend simplification (Day 2)
1. Remove periodic frame capture/relay code and image fallback rendering.
2. Keep camera capture + WebRTC publish path.
3. Align end/cleanup behavior with web.
### Phase 6: API/doc/ops cleanup (Day 3)
1. Update OpenAPI/event docs and internal docs.
2. Deprecate references to frame-based streaming and mock endpoints in code comments.
3. Add rollout and rollback runbook.
### Phase 7: Validation and rollout (Day 4)
1. Manual runtime smoke tests for request/accept/offer/answer/end.
2. Validate unauthorized access rejection for all endpoints.
3. Rollout in flag-off/flag-on modes and monitor.
## 7) Sequence and dependency reasoning
- Backend simplification must land before frontend simplification to avoid client calling removed paths.
- Gateway simplification is independent after stream endpoints are stable but should be merged before frontend hardening.
- Frontend changes rely on a stable event contract (`webrtc:signal`, `stream:started`, `stream:ended`).
- Docs/validation are final tasks once behavior stabilizes.
## 8) Failure handling and risk controls
1. **Rollback strategy:** feature flag switch routes behavior back to existing legacy logic.
2. **Forward progress gate:** stream accept/start should not require any media provider credential.
3. **Safety gate:** reject unknown/non-matching event payloads in gateway before forwarding.
4. **Error handling:** explicit peer connection cleanup on timeout/error and session end.
5. **Observability:** structured logs for request/accept/offer/answer/hangup/end with stream session ids.
## 9) Acceptance criteria
1. `client` -> request -> camera `accept` -> live WebRTC stream works on web and mobile.
2. No `stream:frame` event is used in runtime media path.
3. Stream teardown always emits `stream:ended`, clears peer connections, and stops capture.
4. Unauthorized role/device combinations are rejected with explicit errors.
5. OpenAPI and internal docs only advertise simplified contract by default.
6. Feature flag allows toggling between simplified and legacy behavior for quick rollback.

View File

@@ -1,45 +0,0 @@
# Streaming Simplification Rollout And Rollback
## Rollout intent
`SIMPLE_STREAMING` enables the new WebRTC-only control path:
- direct `stream:requested` fan-out to the camera
- no media-provider session requirement for request/accept/end
- no `stream:frame` relay in the backend gateway
- web client uses only WebRTC offer/answer/candidate signaling
## Migration notes
1. Native mobile is not yet on the WebRTC path.
2. `MobileApp/package.json` does not include a native WebRTC dependency such as `react-native-webrtc`.
3. `MobileApp/README.md` documents that the mobile app still relies on legacy `stream:frame` relay.
4. Because of this, `SIMPLE_STREAMING` defaults to `false`.
5. Web and backend are prepared for the simplified path now.
## Safe enablement sequence
1. Deploy backend and web changes with `SIMPLE_STREAMING=false`.
2. Validate legacy mobile behavior still works.
3. Validate web camera/client behavior with `SIMPLE_STREAMING=true` in a controlled environment.
4. Add native mobile WebRTC support before changing the default flag value.
## Runtime checks when enabling
1. Request a stream from a web client to a web camera.
2. Confirm the camera receives `stream:requested`.
3. Confirm `stream:started` is emitted after accept.
4. Confirm WebRTC offer/answer/candidate exchange succeeds.
5. Confirm `stream:ended` tears down both sides cleanly.
6. Confirm no `stream:frame` messages appear in gateway logs.
## Rollback checklist
1. Set `SIMPLE_STREAMING=false`.
2. Restart backend instances.
3. Verify `/streams/request` creates `start_stream` commands again.
4. Verify mobile frame-relay streaming resumes.
5. Re-run web and mobile smoke checks on the legacy path.
## Known blocker
Native mobile parity is blocked until a supported RN WebRTC stack is added and wired into `MobileApp/src/app-context.tsx`.

View File

@@ -1,44 +0,0 @@
# Validation Matrix: Streaming Simplification (WebRTC-only)
| Row ID | Checklist Item | Status | Code Evidence | Test Evidence | Runtime Evidence |
|---|---|---|---|---|---|
| B1 | Add/confirm `SIMPLE_STREAMING` environment/config switch | done | `Backend/media/config.ts:28,44-50` (`parseFeatureFlag`, `simpleStreamingEnabled`, `streamRecordingEnabled`) | `bun test` -> `tests/media-config.test.ts` passed (3 cases) | `bunx tsc --noEmit` exited 0 in `Backend/` |
| B2 | Update stream request endpoint with simplified flow and role/link checks | done | `Backend/routes/streams.ts:168-184` (`createStreamRequestedPayload`, simplified `/streams/request` response) | `bun test` -> `tests/streaming-simple.test.ts` passed (`builds deterministic requested and started payloads`) | `bunx tsc --noEmit` exited 0 in `Backend/` |
| B3 | Update stream accept endpoint to set `streaming` and emit started | done | `Backend/routes/streams.ts:344-356` (`createStreamStartedPayload`, simplified accept response) | `bun test` -> `tests/streaming-simple.test.ts` passed (`builds deterministic requested and started payloads`) | `bunx tsc --noEmit` exited 0 in `Backend/` |
| B4 | Update stream end endpoint with proper status, end timestamp, lifecycle emit | done | `Backend/routes/streams.ts:660-721` (`status: nextStatus`, `createStreamEndedPayload`, simplified end response) | `bun test` -> `tests/streaming-simple.test.ts` passed (`normalizes ended payload and strips provider fields from API response`) | `bunx tsc --noEmit` exited 0 in `Backend/` |
| B5 | Remove media provider dependency in accept logic | done | `Backend/media/service.ts:17-24` (`mediaProviderRuntimeEnabled`, `createLiveMediaSession`), `Backend/routes/streams.ts:320-334` (accept uses wrapper result) | `bun test` -> `tests/media-config.test.ts`, `tests/streaming-simple.test.ts` both passed | `bunx tsc --noEmit` exited 0 in `Backend/` |
| B6 | Gate/remove recording creation path in stream end logic | done | `Backend/media/config.ts:45,50`, `Backend/routes/streams.ts:681-683` (`streamRecordingEnabled`) | `bun test` passed all 17 tests in `Backend/tests/*` | `bunx tsc --noEmit` exited 0 in `Backend/` |
| B7 | Return minimal deterministic stream payloads | done | `Backend/streaming/simple.ts:35-73` (`createStream*Payload`, `toSimpleStreamSessionResponse`), `Backend/routes/streams.ts:184,356,721` | `bun test` -> `tests/streaming-simple.test.ts` passed (3 cases) | `bunx tsc --noEmit` exited 0 in `Backend/` |
| B8 | Document deprecated media fields in schema | done | `Backend/db/schema.ts:68-69` (legacy provider field comments) | `bun test` passed all 17 tests in `Backend/tests/*` | `bunx tsc --noEmit` exited 0 in `Backend/` |
| B9 | Guard media service calls behind non-simplified mode | done | `Backend/media/service.ts:17-24` (`mediaProviderRuntimeEnabled`, `createLiveMediaSession`) | `bun test` -> `tests/media-config.test.ts` passed (3 cases) | `bunx tsc --noEmit` exited 0 in `Backend/` |
| B10 | Annotate mock provider as metadata-only in phase-1 path | done | `Backend/media/providers/mock.ts:40-41` (runtime compatibility comment), `Backend/routes/streams.ts:320-334` (runtime no longer requires provider session in simplified mode) | `bun test` passed all 17 tests in `Backend/tests/*` | `bunx tsc --noEmit` exited 0 in `Backend/` |
| B11 | Ensure SFU service not required in simplified mode | done | `Backend/media/sfu/service.ts:1,6-8` (`simpleStreamingEnabled` short-circuit) | `bun test` passed all 17 tests in `Backend/tests/*` | `bunx tsc --noEmit` exited 0 in `Backend/` |
| B12 | Deprecate command-table stream lifecycle usage under simplified mode | done | `Backend/routes/commands.ts:53`, `Backend/routes/streams.ts:168-184`, `Backend/realtime/gateway.ts:110-116` | `bun test` passed all 17 tests in `Backend/tests/*` | `bunx tsc --noEmit` exited 0 in `Backend/` |
| R13 | Remove `stream:frame` event listener/sender from gateway | done | `Backend/realtime/gateway.ts:323-349` (`webrtc:signal` only in streaming path) | `bun test` passed all 17 tests in `Backend/tests/*` | `rg -n "stream:frame" Backend/realtime/gateway.ts` returned no matches; `bunx tsc --noEmit` exited 0 |
| R14 | Remove frame-relay participant cache and validation branches | done | `Backend/realtime/gateway.ts:240-349` (no `verifiedRelayTargets`, no frame validation branch) | `bun test` passed all 17 tests in `Backend/tests/*` | `rg -n "stream:frame" Backend/realtime/gateway.ts` returned no matches; `bunx tsc --noEmit` exited 0 |
| R15 | Keep only `webrtc:signal` relay logic for streaming | done | `Backend/realtime/gateway.ts:323-349` (`socket.on('webrtc:signal'...)`) | `bun test` -> `tests/streaming-simple.test.ts` passed (`only relays WebRTC signals between stream participants`) | `rg -n "stream:frame" Backend/realtime/gateway.ts` returned no matches; `bunx tsc --noEmit` exited 0 |
| R16 | Simplify signal validation to ownership + session checks | done | `Backend/realtime/gateway.ts:330-343` (`streamSessions` lookup + `canRelayWebrtcSignal`), `Backend/streaming/simple.ts:20-33` | `bun test` -> `tests/streaming-simple.test.ts` passed (`only relays WebRTC signals between stream participants`) | `bunx tsc --noEmit` exited 0 in `Backend/` |
| R17 | Remove command retry polling for stream command queue when flag is enabled | done | `Backend/realtime/gateway.ts:110-116,155-161` (`start_stream` dispatch/retry disabled under `SIMPLE_STREAMING`) | `bun test` passed all 17 tests in `Backend/tests/*` | `bunx tsc --noEmit` exited 0 in `Backend/` |
| R18 | Preserve `stream:started` and `stream:ended` event emission | done | `Backend/routes/streams.ts:344-353,685-714`, `Backend/realtime/gateway.ts:36-49` (`sendRealtimeToDevice`) | `bun test` -> `tests/streaming-simple.test.ts` passed (`builds deterministic requested and started payloads`, `normalizes ended payload`) | `bunx tsc --noEmit` exited 0 in `Backend/` |
| R19 | Keep strict socket auth and room handling for devices | done | `Backend/realtime/gateway.ts:176-235` (token auth, role match, room join) | `bun test` -> `tests/device-token.test.ts` passed (2 cases) | `bunx tsc --noEmit` exited 0 in `Backend/` |
| W20 | Simplify web stream state machine | done | `WebApp/src/lib/app/controller.js:144-152,950-975`, `WebApp/src/lib/app/store.js:43-44` | `npm run check` in `WebApp/` passed (`svelte-check found 0 errors and 0 warnings`) | `npm run build` in `WebApp/` passed |
| W21 | Remove web fallback image mode and frame state | done | `WebApp/src/lib/app/store.js:43-44`, `WebApp/src/lib/app/controller.js:144-152`, `WebApp/src/routes/client/+page.svelte:183-189` | `npm run check` in `WebApp/` passed (`svelte-check found 0 errors and 0 warnings`) | `rg -n "stream:frame|clientFallbackFrame|getPublishCreds|getSubscribeCreds|frameRelay" WebApp/src` returned no matches; `npm run build` passed |
| W22 | Enforce video-only rendering path on client | done | `WebApp/src/routes/client/+page.svelte:183-189`, `WebApp/src/lib/app/controller.js:723,950-968` | `npm run check` in `WebApp/` passed (`svelte-check found 0 errors and 0 warnings`) | `npm run build` in `WebApp/` passed |
| W23 | Simplify `ontrack` handling | done | `WebApp/src/lib/app/controller.js:711-723` (`connection.ontrack`) | `npm run check` in `WebApp/` passed (`svelte-check found 0 errors and 0 warnings`) | `npm run build` in `WebApp/` passed |
| W24 | Add deterministic teardown path on end/unmount/navigation | done | `WebApp/src/lib/app/controller.js:596-614,975-992,1125-1132` (`teardownPeerConnection`, `stream:ended`, cleanup) | `npm run check` in `WebApp/` passed (`svelte-check found 0 errors and 0 warnings`) | `npm run build` in `WebApp/` passed |
| W25 | Remove frame capture/sender fallback on web camera page | done | `WebApp/src/lib/app/controller.js:869-882,908-928` (camera request handling now only starts preview/accept/offer), `WebApp/src/routes/camera/+page.svelte` remains preview-only | `npm run check` in `WebApp/` passed (`svelte-check found 0 errors and 0 warnings`) | `rg -n "stream:frame|clientFallbackFrame|getPublishCreds|getSubscribeCreds|frameRelay" WebApp/src` returned no matches; `npm run build` passed |
| W26 | Ensure camera only sends WebRTC offer path | done | `WebApp/src/lib/app/controller.js:738-749,869-882,908-928` (`startOfferToClient`, `handleCameraStreamRequest`) | `npm run check` in `WebApp/` passed (`svelte-check found 0 errors and 0 warnings`) | `npm run build` in `WebApp/` passed |
| W27 | Remove recording hooks tied to fallback transport | done | `WebApp/src/lib/app/controller.js:869-882,975-992` (recording kept, fallback transport removed), no frame relay refs remain in `WebApp/src` | `npm run check` in `WebApp/` passed (`svelte-check found 0 errors and 0 warnings`) | `rg -n "stream:frame|clientFallbackFrame|getPublishCreds|getSubscribeCreds|frameRelay" WebApp/src` returned no matches; `npm run build` passed |
| W28 | Remove client-side image fallback UI branches | done | `WebApp/src/routes/client/+page.svelte:183-189`, `WebApp/src/lib/app/store.js:43-44` | `npm run check` in `WebApp/` passed (`svelte-check found 0 errors and 0 warnings`) | `npm run build` in `WebApp/` passed |
| M29 | Remove mobile frame relay state and timer lifecycle | blocked | `MobileApp/src/app-context.tsx:86-87,175-213`, `MobileApp/README.md:75-79` show native mobile still depends on frame relay; `MobileApp/package.json` has no `react-native-webrtc` dependency | `npm run lint` in `MobileApp/` passed | `rg -n "react-native-webrtc" MobileApp/package.json` returned no matches; native WebRTC stack still absent |
| M30 | Align mobile state transitions with web logic | blocked | `MobileApp/src/app-context.tsx:105-113,349-381`, `MobileApp/src/state.ts:92-93`, `MobileApp/README.md:75-79` | `npm run lint` in `MobileApp/` passed | `npx tsc --noEmit` exited 0 in `MobileApp/`; implementation remains blocked by missing native WebRTC runtime |
| M31 | Remove mobile JPEG relay loop | blocked | `MobileApp/src/app-context.tsx:183-213,312-319`, `MobileApp/README.md:75-79` | `npm run lint` in `MobileApp/` passed | `rg -n "react-native-webrtc" MobileApp/package.json` returned no matches; removing the JPEG loop would break the only current mobile stream path |
| M32 | Remove mobile image fallback rendering path | blocked | `MobileApp/app/(tabs)/index.tsx:150-151,284`, `MobileApp/src/state.ts:92-93` | `npm run lint` in `MobileApp/` passed | `npx tsc --noEmit` exited 0 in `MobileApp/`; image fallback remains required until native WebRTC view/render support exists |
| M33 | Remove publish/subscribe credential calls from streaming runtime | blocked | `MobileApp/src/app-context.tsx:312,361`, `MobileApp/src/api.ts:84-85` | `npm run lint` in `MobileApp/` passed | `npx tsc --noEmit` exited 0 in `MobileApp/`; credential calls remain because mobile still follows the legacy relay path |
| M34 | Ensure mobile peer cleanup on end/unmount | blocked | `MobileApp/src/app-context.tsx:375-412` has no RTCPeerConnection lifecycle to clean up; `MobileApp/README.md:79` documents missing RN WebRTC stack | `npm run lint` in `MobileApp/` passed | `rg -n "react-native-webrtc" MobileApp/package.json` returned no matches; unblock by adding supported native WebRTC dependency and peer lifecycle code |
| C35 | Update OpenAPI streaming contract | done | `Backend/docs/openapi.ts:740,786,832`; legacy credential/playback stream paths removed from OpenAPI | `bunx tsc --noEmit` exited 0 in `Backend/` | `rg -n "publish-credentials|subscribe-credentials|playback-token" Backend/docs/openapi.ts` returned no matches |
| C36 | Update docs/comments to remove fallback as primary path | done | `Backend/README.md:133,147,191`, `MobileApp/README.md:75-79`, `Backend/public/backend-architecture.html:499-500`, `Backend/db/schema.ts:68-69` | `npm run check` in `WebApp/` passed; `bunx tsc --noEmit` in `Backend/` passed | `rg -n "stream:frame|frame relay|SIMPLE_STREAMING" Backend/README.md MobileApp/README.md Backend/public/backend-architecture.html docs/STREAMING_SIMPLIFICATION_ROLLOUT_AND_ROLLBACK.md` confirmed updated docs |
| C37 | Add migration and deprecation guidance | done | `docs/STREAMING_SIMPLIFICATION_ROLLOUT_AND_ROLLBACK.md:11-19`, `Backend/README.md:147-149` | `bunx tsc --noEmit` exited 0 in `Backend/` | `rg -n "Migration notes|Safe enablement sequence" docs/STREAMING_SIMPLIFICATION_ROLLOUT_AND_ROLLBACK.md` returned matches at lines 11 and 19 |
| C38 | Add structured stream lifecycle logging | done | `Backend/routes/streams.ts:171,230,359,719`, `Backend/realtime/gateway.ts:348` | `bun test` passed all 17 tests in `Backend/tests/*` | `bunx tsc --noEmit` exited 0 in `Backend/`; log markers present for request/accept/end/signal events |
| C39 | Add compile/build checks after cleanup | done | Validation spans `Backend/`, `WebApp/`, and `MobileApp/` | `bun test` passed (17/17), `npm run check` passed, `npm run lint` passed | `bunx tsc --noEmit` in `Backend/` exited 0, `npm run build` in `WebApp/` passed, `npx tsc --noEmit` in `MobileApp/` exited 0 |
| C40 | Add rollback runbook for flag behavior | done | `docs/STREAMING_SIMPLIFICATION_ROLLOUT_AND_ROLLBACK.md:35-43` | `bunx tsc --noEmit` exited 0 in `Backend/` | `rg -n "Rollback checklist|Known blocker" docs/STREAMING_SIMPLIFICATION_ROLLOUT_AND_ROLLBACK.md` returned matches at lines 35 and 43 |

File diff suppressed because it is too large Load Diff

View File

@@ -1,48 +0,0 @@
# SecureCam codebase architecture
```mermaid
flowchart LR
CAMERA["Camera device
Mobile camera app / browser simulator"]:::client
USER["User devices
Web app / mobile app"]:::client
subgraph PLATFORM["SecureCam platform"]
BACKEND["Backend service
• authentication
• device coordination
• live stream control
• motion events
• recordings access"]:::backend
end
DATA["Core data layer
PostgreSQL"]:::data
MEDIA["Media storage
MinIO / S3-compatible object storage"]:::data
NOTIFY["Notifications
push + in-app activity updates"]:::service
CAMERA -->|"motion, stream, recordings"| BACKEND
USER -->|"auth, viewing, control"| BACKEND
BACKEND -->|"app state, users, devices, events"| DATA
BACKEND -->|"video and recording assets"| MEDIA
BACKEND -->|"alerts and activity fan-out"| NOTIFY
NOTIFY --> USER
classDef client fill:#e8f1ff,stroke:#3b82f6,stroke-width:2px,color:#111827;
classDef backend fill:#ecfdf3,stroke:#16a34a,stroke-width:2px,color:#111827;
classDef data fill:#fff7e8,stroke:#f59e0b,stroke-width:2px,color:#111827;
classDef service fill:#f3e8ff,stroke:#9333ea,stroke-width:2px,color:#111827;
```
## Notes
- This version is intentionally high level.
- It treats the web app, mobile app, and simulator as client surfaces around one backend platform.
- Internal backend modules such as routes, workers, realtime gateway, and media scaffolding are abstracted into a single service block.

View File

@@ -1,218 +0,0 @@
# SecureCam database schema
```mermaid
erDiagram
USERS {
uuid id PK
varchar email UK
varchar name
varchar password_hash
boolean email_verified
text image
timestamp created_at
timestamp updated_at
}
DEVICES {
uuid id PK
uuid user_id FK
varchar name
varchar role
varchar platform
varchar app_version
text push_token
varchar status
boolean is_camera
timestamp last_seen_at
timestamp updated_at
timestamp created_at
}
DEVICE_LINKS {
uuid id PK
uuid owner_user_id FK
uuid camera_device_id FK
uuid client_device_id FK
varchar status
timestamp created_at
timestamp updated_at
}
COMMANDS {
uuid id PK
uuid owner_user_id FK
uuid source_device_id FK
uuid target_device_id FK
varchar command_type
jsonb payload
varchar status
integer retry_count
timestamp last_dispatched_at
timestamp acknowledged_at
text error
timestamp created_at
timestamp updated_at
}
STREAM_SESSIONS {
uuid id PK
uuid owner_user_id FK
uuid camera_device_id FK
uuid requester_device_id FK
varchar status
varchar reason
varchar media_provider
varchar media_session_id
text publish_endpoint
text subscribe_endpoint
varchar stream_key
timestamp started_at
timestamp ended_at
jsonb metadata
timestamp created_at
timestamp updated_at
}
RECORDINGS {
uuid id PK
uuid owner_user_id FK
uuid stream_session_id FK
uuid camera_device_id FK
uuid requester_device_id FK
uuid event_id FK
varchar object_key
varchar bucket
integer duration_seconds
integer size_bytes
varchar status
timestamp available_at
text error
timestamp created_at
timestamp updated_at
}
EVENTS {
uuid id PK
uuid user_id FK
uuid device_id FK
varchar title
varchar triggered_by
varchar status
timestamp started_at
timestamp ended_at
timestamp notified_at
varchar video_url UK
timestamp created_at
timestamp updated_at
}
NOTIFICATIONS {
uuid id PK
uuid event_id FK
uuid user_id FK
timestamp sent_at
varchar channel
varchar status
boolean is_read
}
NOTIFICATION_DELIVERIES {
uuid id PK
uuid owner_user_id FK
uuid recipient_device_id FK
varchar type
jsonb payload
varchar status
integer attempts
text last_error
timestamp sent_at
timestamp next_attempt_at
timestamp created_at
timestamp updated_at
}
AUDIT_LOGS {
uuid id PK
uuid owner_user_id FK
uuid actor_device_id FK
varchar action
varchar target_type
varchar target_id
jsonb metadata
text ip_address
timestamp created_at
}
ACCOUNT {
uuid id PK
uuid user_id FK
text account_id
text provider_id
text access_token
text refresh_token
timestamp access_token_expires_at
timestamp refresh_token_expires_at
text id_token
text scope
text password
timestamp created_at
timestamp updated_at
}
SESSION {
uuid id PK
text token UK
uuid user_id FK
timestamp expires_at
text ip_address
text user_agent
timestamp created_at
timestamp updated_at
}
VERIFICATION {
uuid id PK
text identifier
text value
timestamp expires_at
timestamp created_at
timestamp updated_at
}
USERS ||--o{ DEVICES : owns
USERS ||--o{ DEVICE_LINKS : owns
USERS ||--o{ COMMANDS : owns
USERS ||--o{ STREAM_SESSIONS : owns
USERS ||--o{ RECORDINGS : owns
USERS ||--o{ EVENTS : creates
USERS ||--o{ NOTIFICATIONS : receives
USERS ||--o{ NOTIFICATION_DELIVERIES : owns
USERS ||--o{ AUDIT_LOGS : owns
USERS ||--o{ ACCOUNT : auth_account
USERS ||--o{ SESSION : auth_session
DEVICES ||--o{ DEVICE_LINKS : camera_device
DEVICES ||--o{ DEVICE_LINKS : client_device
DEVICES ||--o{ COMMANDS : source_device
DEVICES ||--o{ COMMANDS : target_device
DEVICES ||--o{ STREAM_SESSIONS : camera_device
DEVICES ||--o{ STREAM_SESSIONS : requester_device
DEVICES ||--o{ RECORDINGS : camera_device
DEVICES ||--o{ RECORDINGS : requester_device
DEVICES ||--o{ EVENTS : triggers
DEVICES ||--o{ NOTIFICATION_DELIVERIES : recipient_device
DEVICES ||--o{ AUDIT_LOGS : actor_device
STREAM_SESSIONS ||--o{ RECORDINGS : produces
EVENTS o|--o{ RECORDINGS : may_attach
EVENTS ||--o{ NOTIFICATIONS : generates
```
## Notes
- This is generated from the current Drizzle schema in [schema.ts](/home/matiss/Documents/Final Year Project/Backend/db/schema.ts).
- `VERIFICATION` is currently standalone in the schema and does not reference `USERS`.
- `DEVICE_LINKS`, `COMMANDS`, `STREAM_SESSIONS`, and `RECORDINGS` each reference `DEVICES` more than once for different roles.
- `ACCOUNT`, `SESSION`, and `VERIFICATION` are the Better Auth support tables.
- `recordings` now absorbs the old upload metadata responsibility that previously lived in `videos`.
- `notification_deliveries` represents push delivery jobs, while `notifications` remains the user-facing event notification history.

View File

@@ -1,56 +0,0 @@
# SecureCam ideal project Gantt chart
```mermaid
gantt
title SecureCam Project Timeline
dateFormat YYYY-MM-DD
axisFormat %b
excludes weekends
section Initiation and Planning
Project scoping and proposal :done, a1, 2025-10-15, 10d
Requirements gathering and research :done, a2, after a1, 12d
Early architecture and feasibility :done, a3, after a1, 15d
section Initial Prototype
Core prototype implementation :done, b1, 2025-10-28, 18d
Basic camera-to-client workflow :done, b2, after b1, 10d
section Scope Change and Refactor
Major scope change identified :milestone, m1, 2025-11-12, 0d
Requirements redefinition :crit, c1, 2025-11-13, 8d
Architecture redesign :crit, c2, after c1, 10d
Backend refactor and model cleanup :crit, c3, after c2, 18d
Web and mobile flow realignment :crit, c4, after c2, 14d
section Platform Rebuild
Authentication and device model :d1, 2025-12-09, 12d
Realtime communication layer :d2, after d1, 14d
Stream control and recording pipeline :d3, after d2, 16d
Storage integration and signed media URLs :d4, after d1, 12d
Motion events and activity feed :d5, after d2, 10d
section Client Applications
Web app feature completion :e1, 2026-01-20, 18d
Mobile app implementation :e2, 2026-01-27, 24d
Push notifications and user feedback loop :e3, after d5, 12d
section Quality and Evaluation
Integration testing and bug fixing :f1, 2026-02-24, 18d
Performance tuning and resilience review :f2, after f1, 10d
Final validation and demo preparation :f3, after f2, 8d
section Dissertation and Delivery
Documentation and architecture diagrams :g1, 2026-03-17, 12d
Report writing and iteration :g2, 2026-03-10, 26d
Final edits and submission prep :g3, after g2, 8d
Project complete :milestone, m2, 2026-04-17, 0d
```
## Notes
- Start date assumed: `2025-10-15` to reflect a mid-October project start.
- Final deadline set to: `2026-04-17`.
- The chart assumes a major scope shift in mid-November that forced the team to revisit requirements and refactor the platform.
- The refactor is treated as a real disruption rather than a minor iteration, so the later phases are shown as a rebuild on firmer architecture rather than a straight continuation of the original prototype.
- This is an "idealised but realistic" plan for documentation purposes, not a claim that every task happened exactly in this order.

View File

@@ -1,29 +0,0 @@
# 5.3.1 Server Bootstrap and Runtime Setup
This diagram shows how the backend starts, mounts services, and becomes ready to handle API and realtime traffic.
```mermaid
sequenceDiagram
autonumber
participant Node as Node Runtime
participant Index as Backend/index.ts
participant App as Express App
participant Auth as Better Auth
participant Routes as Route Modules
participant MinIO as MinIO
participant RT as Socket.IO Gateway
participant Rec as Recordings Worker
participant Push as Push Worker
Node->>Index: start process
Index->>App: create express app
Index->>App: configure helmet + cors + JSON middleware
Index->>Auth: mount /api/auth/*
Index->>Routes: mount videos/admin/devices/links/streams/events/recordings/ops
Index->>MinIO: ensureMinioBucket()
MinIO-->>Index: bucket ready
Index->>RT: setupRealtimeGateway(server)
Index->>Rec: startRecordingsWorker()
Index->>Push: startPushWorker()
Index->>App: listen on configured port
```

View File

@@ -1,29 +0,0 @@
# 5.3.10 Operational Documentation and Support Assets
This diagram maps the implementation code to the support assets used to inspect, document, and validate the system.
```mermaid
flowchart TD
Runtime[Runtime System]
APIs[API Routes]
Docs[OpenAPI Docs]
Sim[Simulator Pages]
Workers[Background Workers]
Validation[Validation and rollout docs]
Admin[Admin / Ops routes]
Runtime --> APIs
Runtime --> Workers
Runtime --> Admin
APIs --> Docs
APIs --> Sim
Runtime --> Validation
subgraph Support["Support layer"]
Docs
Sim
Workers
Validation
Admin
end
```

View File

@@ -1,30 +0,0 @@
# 5.3.2 User Authentication and Session Handling
This diagram separates human user authentication from device-level authentication.
```mermaid
flowchart LR
User[User in Browser]
AuthAPI[/Better Auth Endpoints/]
Session[(session table)]
Users[(users table)]
Accounts[(account table)]
DeviceReg[/Device Registration API/]
DeviceToken[Signed Device Token]
DeviceAPI[/Device Auth Routes/]
User -->|sign up / sign in| AuthAPI
AuthAPI --> Users
AuthAPI --> Accounts
AuthAPI --> Session
Session -->|cookie-backed session| User
User -->|authenticated session| DeviceReg
DeviceReg -->|register browser as camera/client| DeviceToken
DeviceToken --> DeviceAPI
classDef auth fill:#e8f1ff,stroke:#2563eb,stroke-width:2px,color:#111827;
classDef data fill:#fff7e8,stroke:#d97706,stroke-width:2px,color:#111827;
class AuthAPI,DeviceReg,DeviceAPI,DeviceToken auth;
class Session,Users,Accounts data;
```

View File

@@ -1,26 +0,0 @@
# 5.3.3 Device Identity Registration and Presence
This diagram shows how a signed-in user registers a browser as a device and how presence is maintained after realtime connection.
```mermaid
sequenceDiagram
autonumber
participant User as Signed-in User
participant Web as WebApp Controller
participant Devices as POST /devices/register
participant DB as devices + device_links
participant Token as Device Token
participant Socket as Socket.IO Gateway
User->>Web: submit onboarding role + name
Web->>Devices: register device
Devices->>DB: insert devices row
Devices->>DB: auto-link to opposite-role devices if present
Devices-->>Web: return device + deviceToken
Web->>Web: persist saved device record
Web->>Socket: connect with device token
Socket->>DB: mark device online
Web->>Socket: periodic heartbeat
Socket->>DB: update lastSeenAt and status
Socket-->>Web: connection + heartbeat acknowledgements
```

View File

@@ -1,97 +0,0 @@
# 5.3.4 Database Schema and Persistence Model
## Core Entity Relationship Diagram
```mermaid
erDiagram
USERS ||--o{ DEVICES : owns
USERS ||--o{ DEVICE_LINKS : owns
DEVICES ||--o{ DEVICE_LINKS : cameraDeviceId
DEVICES ||--o{ DEVICE_LINKS : clientDeviceId
USERS ||--o{ STREAM_SESSIONS : owns
DEVICES ||--o{ STREAM_SESSIONS : cameraDeviceId
DEVICES ||--o{ STREAM_SESSIONS : requesterDeviceId
USERS {
uuid id PK
varchar email
varchar name
}
DEVICES {
uuid id PK
uuid user_id FK
varchar role
varchar status
timestamp last_seen_at
}
DEVICE_LINKS {
uuid id PK
uuid owner_user_id FK
uuid camera_device_id FK
uuid client_device_id FK
varchar status
}
STREAM_SESSIONS {
uuid id PK
uuid owner_user_id FK
uuid camera_device_id FK
uuid requester_device_id FK
varchar status
varchar reason
}
```
## Media and Event Persistence
```mermaid
erDiagram
USERS ||--o{ EVENTS : owns
DEVICES ||--o{ EVENTS : triggeredBy
STREAM_SESSIONS ||--o{ RECORDINGS : produces
EVENTS ||--o{ RECORDINGS : mayCreate
USERS ||--o{ NOTIFICATIONS : receives
EVENTS ||--o{ NOTIFICATIONS : generates
USERS ||--o{ NOTIFICATION_DELIVERIES : owns
DEVICES ||--o{ NOTIFICATION_DELIVERIES : targets
USERS ||--o{ AUDIT_LOGS : owns
DEVICES ||--o{ AUDIT_LOGS : actor
EVENTS {
uuid id PK
uuid user_id FK
uuid device_id FK
varchar status
timestamp started_at
timestamp ended_at
}
RECORDINGS {
uuid id PK
uuid stream_session_id FK
uuid event_id FK
varchar object_key
varchar bucket
varchar status
}
NOTIFICATIONS {
uuid id PK
uuid event_id FK
uuid user_id FK
varchar channel
varchar status
}
NOTIFICATION_DELIVERIES {
uuid id PK
uuid recipient_device_id FK
varchar type
varchar status
int attempts
}
AUDIT_LOGS {
uuid id PK
uuid owner_user_id FK
uuid actor_device_id FK
varchar action
varchar target_type
varchar target_id
}
```

View File

@@ -1,36 +0,0 @@
# 5.3.5 Device Linking and Command-Oriented Control
This comparison diagram contrasts the older command-driven path with the current simplified stream request path.
```mermaid
flowchart LR
subgraph Legacy["Legacy command-oriented path"]
LClient[Client Device]
LBackend[Backend]
LCmd[(commands table)]
LGateway[Realtime Gateway]
LCamera[Camera Device]
LClient -->|request stream| LBackend
LBackend --> LCmd
LCmd --> LGateway
LGateway -->|command:received| LCamera
LCamera -->|command:ack| LGateway
LGateway --> LBackend
end
subgraph Simple["Simplified linked-device path"]
SClient[Client Device]
SBackend[Backend]
SLinks[(device_links)]
SSession[(stream_sessions)]
SCamera[Camera Device]
SClient -->|request stream| SBackend
SBackend --> SLinks
SBackend --> SSession
SBackend -->|stream:requested| SCamera
SCamera -->|accept| SBackend
SBackend -->|stream:started| SClient
end
```

View File

@@ -1,46 +0,0 @@
# 5.3.6 Stream Sessions and Signalling
## Stream Request and WebRTC Signalling Sequence
```mermaid
sequenceDiagram
autonumber
participant Client as Client Browser
participant Backend as Backend /streams
participant Links as device_links
participant Session as stream_sessions
participant Camera as Camera Browser
participant RT as Socket.IO Relay
Client->>Backend: POST /streams/request
Backend->>Links: verify active camera-client link
Backend->>Session: insert requested session
Backend->>Camera: emit stream:requested
Camera->>Backend: POST /streams/:id/accept
Backend->>Session: update status to streaming
Backend->>Client: emit stream:started
Camera->>RT: webrtc:signal offer
RT->>Client: relay offer
Client->>RT: webrtc:signal answer
RT->>Camera: relay answer
Client->>RT: ICE candidates
RT->>Camera: relay ICE candidates
Camera->>RT: ICE candidates
RT->>Client: relay ICE candidates
```
## Stream Session State Diagram
```mermaid
stateDiagram-v2
[*] --> requested
requested --> streaming: camera accepts
requested --> cancelled: requester cancels
requested --> failed: validation or delivery failure
streaming --> completed: normal end
streaming --> cancelled: manual stop
streaming --> failed: connection or upload failure
completed --> [*]
cancelled --> [*]
failed --> [*]
```

View File

@@ -1,42 +0,0 @@
# 5.3.7 Motion Events Notifications and Audit Trail
## Motion Event Lifecycle
```mermaid
flowchart TD
Trigger[Manual trigger or browser motion detector]
Start[/POST /events/motion/start/]
Event[(events table)]
Links[(device_links)]
RT[Realtime motion:detected]
Push[Queued push notification]
End[/POST /events/:id/motion/end/]
Trigger --> Start
Start --> Event
Start --> Links
Links --> RT
Links --> Push
RT --> End
Push --> End
End --> Event
```
## Activity and Audit Flow
```mermaid
flowchart LR
Camera[Camera Device]
Event[(events)]
Notif[(notifications)]
Delivery[(notification_deliveries)]
Audit[(audit_logs)]
Client[Client Device]
Camera --> Event
Event --> Notif
Event --> Delivery
Event --> Audit
Notif --> Client
Delivery --> Client
```

View File

@@ -1,40 +0,0 @@
# 5.3.8 Recordings and Object Storage
## Recording Pipeline
```mermaid
sequenceDiagram
autonumber
participant Cam as Camera Browser
participant Ctrl as Web Controller
participant Videos as /videos/upload-url
participant MinIO as MinIO Object Storage
participant Rec as /recordings/:id/finalize
participant DB as recordings table
participant Viewer as Client Browser
Cam->>Ctrl: stop local MediaRecorder
Ctrl->>Ctrl: compress recording blob
Ctrl->>Videos: request presigned upload URL
Videos-->>Ctrl: objectKey + uploadUrl
Ctrl->>MinIO: PUT recording blob
Ctrl->>Rec: finalize recording metadata
Rec->>DB: mark recording ready
Viewer->>Rec: GET /recordings/:id/download-url
Rec-->>Viewer: presigned download URL
```
## Storage Architecture
```mermaid
flowchart LR
Backend[Backend Service]
PG[(Postgres)]
MinIO[(MinIO / S3)]
Web[Web Client]
Web -->|stream, event, recording metadata| Backend
Backend -->|users, devices, links, sessions, events, recordings| PG
Backend -->|presigned upload/download + object checks| MinIO
Web -->|PUT / GET via presigned URLs| MinIO
```

View File

@@ -1,53 +0,0 @@
# 5.3.9 Web Application Implementation
## Web Application Architecture
```mermaid
flowchart LR
Routes[Svelte Route Pages]
Controller[controller.js]
Store[store.js]
API[api.js]
Socket[Socket.IO Client]
Backend[Backend APIs]
Routes --> Controller
Controller --> Store
Controller --> API
Controller --> Socket
API --> Backend
Socket --> Backend
subgraph RoutePages["Main route pages"]
Auth[Auth]
Onboarding[Onboarding]
Camera[Camera]
Client[Client]
Activity[Activity]
Settings[Settings]
end
Routes --> RoutePages
```
## UI Flow
```mermaid
flowchart TD
Start[Open WebApp]
Auth[Sign in / Sign up]
Onboarding[Register browser as device]
Role{Chosen role}
Camera[Camera dashboard]
Client[Client dashboard]
Activity[Activity page]
Settings[Settings page]
Start --> Auth --> Onboarding --> Role
Role -->|camera| Camera
Role -->|client| Client
Camera --> Activity
Camera --> Settings
Client --> Activity
Client --> Settings
```

View File

@@ -1,18 +0,0 @@
# Temporary Section 5.3 Diagrams
These temporary files contain Mermaid diagrams for the `5.3` implementation chapter.
## Files
- [5.3.1 Server Bootstrap](./5.3.1-server-bootstrap.md)
- [5.3.2 Authentication and Sessions](./5.3.2-authentication-and-sessions.md)
- [5.3.3 Device Registration and Presence](./5.3.3-device-registration-and-presence.md)
- [5.3.4 Database Schema and Persistence](./5.3.4-database-schema-and-persistence.md)
- [5.3.5 Linking and Control Flow](./5.3.5-linking-and-control-flow.md)
- [5.3.6 Stream Sessions and Signalling](./5.3.6-stream-sessions-and-signalling.md)
- [5.3.7 Motion Events Notifications Audit](./5.3.7-motion-events-notifications-audit.md)
- [5.3.8 Recordings and Object Storage](./5.3.8-recordings-and-object-storage.md)
- [5.3.9 Web Application Implementation](./5.3.9-web-application-implementation.md)
- [5.3.10 Operational and Support Assets](./5.3.10-operational-and-support-assets.md)
These are temporary working diagrams intended for screenshots and report drafting.