From cbbf5da3944a2ffb29278a4156e6ccd62f0bc1d5 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 10 May 2026 16:08:29 -0700 Subject: [PATCH 01/36] docs: add iOS app design --- docs/ios-app-design.md | 334 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 334 insertions(+) create mode 100644 docs/ios-app-design.md diff --git a/docs/ios-app-design.md b/docs/ios-app-design.md new file mode 100644 index 00000000..1e1a00c5 --- /dev/null +++ b/docs/ios-app-design.md @@ -0,0 +1,334 @@ +# HomeSec iOS and iPad App Design + +Last reviewed: 2026-05-10 + +This document is the repo-level source of truth for the first HomeSec iOS and +iPad app. The v1 direction is to package the existing React app in a Capacitor +iOS shell and add narrow native bridges only where iOS capabilities are required. + +## Executive Decision + +Build the first HomeSec iOS/iPad app as a Capacitor-based native shell around +the existing React app. + +The app should not be a remote-only WebView pointed at a hosted HomeSec page. +It should be a native iOS app that loads the built React assets locally, talks +to a configured HomeSec server over HTTPS or VPN, and exposes native features +through explicit bridge modules. + +The existing React app remains the canonical UI for v1: + +- live camera view +- event list and event detail +- recorded clip playback +- camera/settings/setup/system screens +- current HLS preview and MP4 media flows +- current OpenAPI TypeScript client and TanStack Query hooks + +## Locked Decisions + +| Area | Decision | +| --- | --- | +| UI | Reuse the existing React app as the canonical product UI. | +| Native wrapper | Use Capacitor iOS. | +| Capacitor root | Use `ui/` as the Capacitor root. | +| Bundle ID | Use `com.levneiman.homesec`. | +| App name | Use `HomeSec`. | +| Distribution | Personal/internal use first, with room for future TestFlight or public release. | +| Remote access | Single configured server base URL for v1. HTTPS or VPN is recommended. | +| Auth entry | Manual server URL plus pasted HomeSec API token. QR pairing is deferred. | +| Auth disabled behavior | Show a strong warning, but do not hard-block first LAN/VPN iteration. | +| Token storage | Browser mode keeps existing session storage behavior. Native iOS mode must use Keychain. | +| Push notifications | Plain APNs first. Rich notification thumbnails are deferred. | +| Notification route | Open `/events/:clipId?from=notification`. | +| Alert review scope | `alerted == true` is enough. No alert-review or review-state work in this stream. | +| Mobile device registry | Named iOS devices with enable/disable semantics. | +| APNs config | Implement later as a notifier backend under `notifiers`, using `backend: apns_mobile`. | +| Push-to-talk | Keep React parity. Test the WebView path first; add native audio only if needed. | +| Background behavior | Stop live preview and push-to-talk when the app backgrounds. | +| iPad v1 | Same responsive app. No dedicated iPad split view in v1. | +| Face ID | Later milestone. App-level lock on launch/resume. | +| Local cache | Metadata/thumbnails only. No full clip cache by default. | +| Token revocation | Accept global shared-token rotation for v1. Per-device tokens are deferred. | +| Deep links | Custom scheme first. Universal links are deferred. | +| Custom URL scheme | Default to `homesec://`. | +| Privacy posture | No analytics, no third-party crash reporting, no cloud relay in v1. | + +## Goals + +The iOS app should provide feature parity with the current web app: + +1. View live cameras. +2. Review events. +3. Play recorded clips. +4. See AI/VLM summaries, risk, activity type, and detected objects. +5. Navigate from notification to the relevant event. +6. Configure cameras/settings where the current web app supports it. +7. Preserve room for future alert review, dismiss/review state, tuning, and + better VLM explainability without implementing those workflows in this stream. + +The most important iOS-specific loop is: + +```text +Notification received -> open HomeSec -> land on relevant event +-> understand what happened -> play clip -> move to next/previous event +``` + +## Non-Goals For V1 + +These should not block the first iOS app: + +- multi-user RBAC +- OAuth, passkeys, or pairing/QR auth +- Face ID or Touch ID app lock +- HomeKit, Siri, or Apple Watch +- full native SwiftUI UI +- WebRTC live-view migration +- HomeSec-hosted cloud relay +- native iPad split-view UI +- rich notification thumbnails +- universal links +- alert tuning mutation workflows +- alert-review or review-state backend work + +## Architecture + +```mermaid +flowchart LR + subgraph IOS["HomeSec iOS/iPad App"] + Native["Capacitor Native Shell / WKWebView"] + Bridge["Native Bridge: Keychain, Push, Deep Links, Lifecycle"] + Web["Packaged React App: Live, Events, Settings, System"] + Native --> Web + Web <--> Bridge + end + + subgraph Server["HomeSec Server"] + API["FastAPI Control Plane"] + Media["Media, Preview, Talk Token APIs"] + APNS["APNs Mobile Notifier"] + DB["Postgres"] + Storage["Storage Backend"] + end + + Web --> API + Web --> Media + Bridge --> API + APNS --> Apple["Apple Push Notification service"] + API --> DB + Media --> Storage +``` + +### Native Shell Responsibilities + +The native iOS layer should stay small and own only capabilities that the web app +cannot safely or ergonomically own: + +- load packaged React assets +- store and retrieve the API token from Keychain +- store and retrieve the server base URL +- register for APNs and send the device token to HomeSec +- receive notification taps and deep links +- forward routes into the React router +- stop active media sessions on app backgrounding +- later, gate app display through Face ID or Touch ID + +### React Responsibilities + +The React app remains the product surface: + +- routing +- live view +- event list and event detail +- clip playback +- settings/setup/system screens +- API queries and mutations +- mobile layout and error states +- future alert-review UI, when that stream is explicitly in scope + +### Backend Responsibilities + +The backend remains the control plane: + +- auth validation +- camera, event, config, setup, health, runtime APIs +- media, preview, and talk token APIs +- mobile device registry +- APNs notifier +- notification payload generation +- optional thumbnail/media signing later + +## Auth Design + +Current web auth uses a configurable single Bearer token. Browser UI currently +stores the token in `window.sessionStorage` under `homesec.apiKey`, and HTTP +requests send it as: + +```http +Authorization: Bearer +``` + +For v1 iOS, keep single-token auth but move persistent token storage into iOS +Keychain. The long-lived HomeSec API token must not be persisted in WebView +`sessionStorage` when running in native iOS mode. + +Add provider abstractions: + +```typescript +export interface AuthTokenProvider { + getToken(): Promise + setToken(token: string | null): Promise + clearToken(): Promise +} + +export interface ServerBaseUrlProvider { + getBaseUrl(): Promise + setBaseUrl(value: string): Promise + clearBaseUrl(): Promise +} +``` + +Provider selection: + +| Environment | Token provider | Base URL provider | +| --- | --- | --- | +| Browser web app | Existing `sessionStorage` key `homesec.apiKey` | Build-time `VITE_API_BASE_URL`, then optional runtime storage | +| iOS Capacitor app after native bridge lands | Native bridge to Keychain | Native bridge to stored server URL | +| Tests | In-memory provider | In-memory provider | + +Future pairing/QR auth should be designed separately and should not be added to +the M1 app shell. + +M1 introduces the provider contracts and runtime base URL setup path, but it must +not create a new insecure native persistence path for the API token. Until the +Keychain bridge lands in `iOS-06`/`iOS-07`, native-mode setup may validate the +entered server URL and API token and keep them in app runtime state for the +current WebView session, but durable native-mode token persistence belongs to the +Keychain bridge milestone. + +## iOS Setup UX + +Native-mode first launch should support: + +1. User enters server URL. +2. App calls `/api/v1/health`. +3. User enters HomeSec API token. +4. App validates the token against an auth-protected endpoint such as + `/api/v1/cameras`. +5. App stores server URL and API token through the selected providers when the + selected provider has durable storage. Before the native Keychain provider + exists, native mode must avoid durable API-token storage and may retain the + token only for the current app session. +6. App routes to `/live`. + +The setup screen must show actionable errors for invalid URLs and invalid tokens. +It must visibly warn for plain HTTP, and it must show a strong warning if auth +appears disabled. Existing browser `/setup` behavior must remain intact. + +## Push And Deep-Link Design + +Plain APNs comes after the M1 app shell. The eventual APNs payload should include +an app route: + +```json +{ + "aps": { + "alert": { + "title": "Driveway: person detected", + "body": "High-risk event at 9:42 PM." + }, + "sound": "default", + "category": "HOMESEC_EVENT" + }, + "type": "event_alert", + "event_id": "clip_abc123", + "camera": "driveway", + "risk_level": "high", + "activity_type": "person", + "route": "/events/clip_abc123?from=notification" +} +``` + +Custom-scheme links should map like this: + +```text +homesec://events/clip_abc123?from=notification +-> /events/clip_abc123?from=notification +``` + +If setup/auth is required first, React should preserve the pending route and +navigate after successful setup. Invalid routes should fall back safely to +`/live` or `/events`. + +## Implementation Plan + +### iOS M1 - App Shell MVP + +Goal: get a native iOS shell opening the existing React app with runtime server +URL and API token support. + +1. `iOS-00` - Add finalized iOS design doc to repo. +2. `iOS-01` - Introduce API environment and token-provider abstraction. +3. `iOS-02` - Make API client base URL runtime-configurable. +4. `iOS-03` - Add native-mode setup screen for server URL and API token. +5. `iOS-04` - Add Capacitor iOS scaffold rooted in `ui/`. +6. `iOS-05` - Add iOS native runtime detection. + +### iOS M2 - Native Integration And Mobile UX + +1. `iOS-06` - Implement iOS Keychain bridge for token and server URL. +2. `iOS-07` - Wire React auth provider to native Keychain in iOS mode. +3. `iOS-08` - Add app lifecycle handling for background/resume. +4. `iOS-09` - Add custom-scheme deep-link routing. +5. `iOS-10` - Safe-area and bottom-nav hardening. +6. `iOS-11` - Live preview iOS hardening. +7. `iOS-12` - Event detail notification-mode UX. + +### iOS M3 - Plain Push Notifications + +1. `iOS-13` - Add mobile device registry model and repository. +2. `iOS-14` - Add mobile device API routes. +3. `iOS-15` - Add iOS APNs registration in native app. +4. `iOS-16` - Register/update mobile device from React startup. +5. `iOS-17` - Implement `apns_mobile` notifier backend. +6. `iOS-18` - Notification tap opens event detail. + +### iOS M4 - Personal Release Readiness + +1. `iOS-19` - iOS device QA pass. +2. `iOS-20` - Personal release build notes. + +## M1 Validation Expectations + +Use focused validation while developing, then run the relevant repo gates before +publishing or handing off: + +```bash +make check +make ui-check +``` + +For M1 tickets that only touch docs, document if full checks are skipped. For +M1 tickets that touch UI runtime code, run the UI gate at minimum: + +```bash +make ui-check +``` + +For the Capacitor scaffold, also verify: + +```bash +cd ui && pnpm build +cd ui && pnpm ios:sync +``` + +Launching in the simulator is expected for `iOS-04` when local Xcode setup +allows it. Real-device signing is not required for M1. + +## Deferred Follow-Ups + +- rich notification thumbnails with a Notification Service Extension +- pairing/QR auth with revocable per-device tokens +- Face ID or Touch ID app lock +- universal links +- native AVPlayer or native audio bridge, only if WebView media UX is inadequate From 3b68584bcf365d83a79815f551eadea6c7a368e5 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 10 May 2026 16:22:10 -0700 Subject: [PATCH 02/36] feat: add iOS-ready API providers --- ui/src/api/apiKeyStorage.test.ts | 15 ++++ ui/src/api/apiKeyStorage.ts | 29 +++---- ui/src/api/client.ts | 38 ++++++++- ui/src/api/http.test.ts | 67 ++++++++++++++- ui/src/api/http.ts | 38 +++++++-- ui/src/api/serverBaseUrlProvider.test.ts | 76 +++++++++++++++++ ui/src/api/serverBaseUrlProvider.ts | 98 ++++++++++++++++++++++ ui/src/api/tokenProvider.test.ts | 100 +++++++++++++++++++++++ ui/src/api/tokenProvider.ts | 99 ++++++++++++++++++++++ 9 files changed, 532 insertions(+), 28 deletions(-) create mode 100644 ui/src/api/serverBaseUrlProvider.test.ts create mode 100644 ui/src/api/serverBaseUrlProvider.ts create mode 100644 ui/src/api/tokenProvider.test.ts create mode 100644 ui/src/api/tokenProvider.ts diff --git a/ui/src/api/apiKeyStorage.test.ts b/ui/src/api/apiKeyStorage.test.ts index 4d4a3a8f..6215b7bc 100644 --- a/ui/src/api/apiKeyStorage.test.ts +++ b/ui/src/api/apiKeyStorage.test.ts @@ -62,4 +62,19 @@ describe('apiKeyStorage', () => { expect(explicit).toBe('explicit-secret') expect(implicit).toBe('stored-secret') }) + + it('normalizes blank API key values as absent', () => { + // Given: A browser storage area and a whitespace API key + installWindowSessionStorageMock() + + // When: Saving a blank key value + saveApiKey(' ') + const stored = getStoredApiKey() + const hasKey = hasStoredApiKey() + + // Then: Blank keys are treated as missing credentials + expect(stored).toBeNull() + expect(hasKey).toBe(false) + expect(resolveApiKey(' ')).toBeNull() + }) }) diff --git a/ui/src/api/apiKeyStorage.ts b/ui/src/api/apiKeyStorage.ts index 875fd8c6..56b0e990 100644 --- a/ui/src/api/apiKeyStorage.ts +++ b/ui/src/api/apiKeyStorage.ts @@ -1,38 +1,31 @@ import type { ApiRequestOptions } from './generated/client' +import { + BROWSER_AUTH_TOKEN_STORAGE_KEY, + browserAuthTokenProvider, + normalizeAuthToken, +} from './tokenProvider' -const API_KEY_STORAGE_KEY = 'homesec.apiKey' +export const API_KEY_STORAGE_KEY = BROWSER_AUTH_TOKEN_STORAGE_KEY export function saveApiKey(apiKey: string): void { - if (typeof window === 'undefined') { - return - } - - window.sessionStorage.setItem(API_KEY_STORAGE_KEY, apiKey) + browserAuthTokenProvider.setTokenSync(apiKey) } export function getStoredApiKey(): string | null { - if (typeof window === 'undefined') { - return null - } - return window.sessionStorage.getItem(API_KEY_STORAGE_KEY) + return browserAuthTokenProvider.getTokenSync() } export function hasStoredApiKey(): boolean { - const value = getStoredApiKey() - return Boolean(value && value.trim().length > 0) + return getStoredApiKey() !== null } export function clearApiKey(): void { - if (typeof window === 'undefined') { - return - } - - window.sessionStorage.removeItem(API_KEY_STORAGE_KEY) + browserAuthTokenProvider.clearTokenSync() } export function resolveApiKey(explicitApiKey: ApiRequestOptions['apiKey']): string | null { if (explicitApiKey !== undefined) { - return explicitApiKey + return normalizeAuthToken(explicitApiKey) } return getStoredApiKey() diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 889086d5..2e138bd8 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -39,6 +39,9 @@ import type { } from './generated/types' import { JsonHttpClient } from './http' +import { createBrowserServerBaseUrlProvider } from './serverBaseUrlProvider' +import type { ClientServerBaseUrlProvider } from './serverBaseUrlProvider' +import type { AuthTokenProvider } from './tokenProvider' import type { ApiSnapshot, ClipMediaTokenResponsePayload } from './parsing' import { parseCameraListResponse, @@ -72,6 +75,11 @@ import { APIError } from './errors' const DEFAULT_API_BASE_URL = '' +export interface HomeSecApiClientOptions { + authTokenProvider?: AuthTokenProvider + serverBaseUrlProvider?: ClientServerBaseUrlProvider +} + export type HealthSnapshot = ApiSnapshot export type StatsSnapshot = ApiSnapshot export type DiagnosticsSnapshot = ApiSnapshot @@ -97,8 +105,11 @@ export type ClipMediaTokenSnapshot = ApiSnapshot export class HomeSecApiClient implements GeneratedHomeSecClient { private readonly httpClient: JsonHttpClient - constructor(baseUrl = DEFAULT_API_BASE_URL) { - this.httpClient = new JsonHttpClient(baseUrl) + constructor(baseUrl = DEFAULT_API_BASE_URL, options: HomeSecApiClientOptions = {}) { + this.httpClient = new JsonHttpClient(baseUrl, { + authTokenProvider: options.authTokenProvider, + serverBaseUrlProvider: options.serverBaseUrlProvider, + }) } async getCameras(options: ApiRequestOptions = {}): Promise { @@ -602,7 +613,28 @@ export class HomeSecApiClient implements GeneratedHomeSecClient { } } -export const apiClient = new HomeSecApiClient(import.meta.env.VITE_API_BASE_URL ?? DEFAULT_API_BASE_URL) +export const browserServerBaseUrlProvider = createBrowserServerBaseUrlProvider( + import.meta.env.VITE_API_BASE_URL ?? DEFAULT_API_BASE_URL, +) + +export const apiClient = new HomeSecApiClient( + DEFAULT_API_BASE_URL, + { serverBaseUrlProvider: browserServerBaseUrlProvider }, +) export { APIError, isAPIError, isUnauthorizedAPIError } from './errors' export { clearApiKey, getStoredApiKey, hasStoredApiKey, saveApiKey } from './apiKeyStorage' +export { + BROWSER_SERVER_BASE_URL_STORAGE_KEY, + BrowserServerBaseUrlProvider, + createBrowserServerBaseUrlProvider, + normalizeServerBaseUrl, +} from './serverBaseUrlProvider' +export { + BROWSER_AUTH_TOKEN_STORAGE_KEY, + BrowserAuthTokenProvider, + browserAuthTokenProvider, + normalizeAuthToken, +} from './tokenProvider' +export type { AuthTokenProvider } from './tokenProvider' +export type { ClientServerBaseUrlProvider, ServerBaseUrlProvider } from './serverBaseUrlProvider' diff --git a/ui/src/api/http.test.ts b/ui/src/api/http.test.ts index 266778ec..7ec16534 100644 --- a/ui/src/api/http.test.ts +++ b/ui/src/api/http.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { JsonHttpClient } from './http' +import type { ClientServerBaseUrlProvider } from './serverBaseUrlProvider' +import type { AuthTokenProvider } from './tokenProvider' function installWindowSessionStorageMock(): void { const store = new Map() @@ -28,7 +30,7 @@ describe('JsonHttpClient.requestJson', () => { it('serializes query params, auth header, and JSON body', async () => { // Given: A fetch mock and an authenticated POST request with query/body - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' }, @@ -66,7 +68,7 @@ describe('JsonHttpClient.requestJson', () => { // Given: A stored API key and a request without explicit apiKey option installWindowSessionStorageMock() window.sessionStorage.setItem('homesec.apiKey', 'stored-secret') - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' }, @@ -86,6 +88,67 @@ describe('JsonHttpClient.requestJson', () => { }) }) + it('supports an injected auth token provider for native runtime storage', async () => { + // Given: A client backed by a custom token provider + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + const authTokenProvider: AuthTokenProvider = { + getToken: vi.fn().mockResolvedValue('provider-secret'), + setToken: vi.fn(), + clearToken: vi.fn(), + } + const client = new JsonHttpClient('http://localhost:8081', { authTokenProvider }) + + // When: Sending a request without an explicit apiKey option + await client.requestJson('/api/v1/health', {}) + + // Then: Authorization header is derived from the injected provider + expect(authTokenProvider.getToken).toHaveBeenCalledTimes(1) + expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ + headers: { + Accept: 'application/json', + Authorization: 'Bearer provider-secret', + }, + }) + }) + + it('resolves request paths through the base URL provider at call time', async () => { + // Given: A client backed by a runtime-configurable base URL provider + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ) + let baseUrl: string | null = 'http://localhost:8081' + const serverBaseUrlProvider = { + getBaseUrl: vi.fn(async () => baseUrl), + setBaseUrl: vi.fn(async (value: string | null) => { + baseUrl = value + }), + clearBaseUrl: vi.fn(async () => { + baseUrl = null + }), + getBaseUrlSync: (): string | null => baseUrl, + } satisfies ClientServerBaseUrlProvider + const client = new JsonHttpClient('', { serverBaseUrlProvider }) + + // When: The runtime base URL changes after the client is constructed + await client.requestJson('/api/v1/health', {}) + await serverBaseUrlProvider.setBaseUrl('http://192.168.1.10:8081') + const resolvedPath = client.resolvePath('/api/v1/clips') + await client.requestJson('/api/v1/stats', {}) + + // Then: Requests and path resolution use the current provider value + expect(fetchSpy.mock.calls[0]?.[0]).toBe('http://localhost:8081/api/v1/health') + expect(resolvedPath).toBe('http://192.168.1.10:8081/api/v1/clips') + expect(fetchSpy.mock.calls[1]?.[0]).toBe('http://192.168.1.10:8081/api/v1/stats') + }) + it('throws APIError with canonical metadata for non-allowed non-2xx responses', async () => { // Given: A failing endpoint with canonical error envelope vi.spyOn(globalThis, 'fetch').mockResolvedValue( diff --git a/ui/src/api/http.ts b/ui/src/api/http.ts index 0e80430f..ed6b84a2 100644 --- a/ui/src/api/http.ts +++ b/ui/src/api/http.ts @@ -1,7 +1,12 @@ import type { ApiRequestOptions } from './generated/client' -import { resolveApiKey } from './apiKeyStorage' import { APIError, extractAPIErrorEnvelope } from './errors' +import { + browserAuthTokenProvider, + resolveAuthToken, + type AuthTokenProvider, +} from './tokenProvider' +import type { ClientServerBaseUrlProvider } from './serverBaseUrlProvider' type QueryValue = string | number | boolean | null | undefined @@ -17,6 +22,11 @@ export interface JsonResponse { payload: unknown } +export interface JsonHttpClientOptions { + authTokenProvider?: AuthTokenProvider + serverBaseUrlProvider?: ClientServerBaseUrlProvider +} + function joinUrl(baseUrl: string, path: string): string { if (!baseUrl) { return path @@ -72,13 +82,29 @@ async function parseResponsePayload(response: Response): Promise { export class JsonHttpClient { private readonly baseUrl: string + private readonly authTokenProvider: AuthTokenProvider + private readonly serverBaseUrlProvider: ClientServerBaseUrlProvider | undefined - constructor(baseUrl: string) { + constructor(baseUrl: string, options: JsonHttpClientOptions = {}) { this.baseUrl = baseUrl + this.authTokenProvider = options.authTokenProvider ?? browserAuthTokenProvider + this.serverBaseUrlProvider = options.serverBaseUrlProvider + } + + private resolveBaseUrlSync(): string { + return this.serverBaseUrlProvider?.getBaseUrlSync() ?? this.baseUrl + } + + private async resolveBaseUrl(): Promise { + if (!this.serverBaseUrlProvider) { + return this.baseUrl + } + + return (await this.serverBaseUrlProvider.getBaseUrl()) ?? this.baseUrl } resolvePath(path: string): string { - return joinUrl(this.baseUrl, path) + return joinUrl(this.resolveBaseUrlSync(), path) } async requestJson( @@ -86,9 +112,11 @@ export class JsonHttpClient { { signal, apiKey, allowStatuses = [], query, method = 'GET', body }: RequestJsonOptions, ): Promise { const hasJsonBody = body !== undefined - const response = await fetch(joinUrl(this.baseUrl, withQueryString(path, query)), { + const resolvedApiKey = await resolveAuthToken(apiKey, this.authTokenProvider) + const resolvedBaseUrl = await this.resolveBaseUrl() + const response = await fetch(joinUrl(resolvedBaseUrl, withQueryString(path, query)), { method, - headers: buildHeaders(resolveApiKey(apiKey), hasJsonBody), + headers: buildHeaders(resolvedApiKey, hasJsonBody), signal, body: hasJsonBody ? JSON.stringify(body) : undefined, }) diff --git a/ui/src/api/serverBaseUrlProvider.test.ts b/ui/src/api/serverBaseUrlProvider.test.ts new file mode 100644 index 00000000..8ab37608 --- /dev/null +++ b/ui/src/api/serverBaseUrlProvider.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest' + +import { + BROWSER_SERVER_BASE_URL_STORAGE_KEY, + BrowserServerBaseUrlProvider, + normalizeServerBaseUrl, +} from './serverBaseUrlProvider' + +type TestStorage = Pick & { + values: Map +} + +function createStorage(): TestStorage { + const values = new Map() + return { + values, + getItem: (key: string): string | null => values.get(key) ?? null, + setItem: (key: string, value: string): void => { + values.set(key, value) + }, + removeItem: (key: string): void => { + values.delete(key) + }, + } +} + +describe('normalizeServerBaseUrl', () => { + it('normalizes LAN and HTTPS URLs while preserving unset same-origin mode', () => { + // Given: Candidate server base URL values + const lanUrl = ' http://192.168.1.10:8081/// ' + const httpsUrl = 'https://homesec.example.com/' + + // When / Then: URLs are trimmed and empty values stay unset + expect(normalizeServerBaseUrl(lanUrl)).toBe('http://192.168.1.10:8081') + expect(normalizeServerBaseUrl(httpsUrl)).toBe('https://homesec.example.com') + expect(normalizeServerBaseUrl(' ')).toBeNull() + expect(normalizeServerBaseUrl(null)).toBeNull() + }) +}) + +describe('BrowserServerBaseUrlProvider', () => { + it('sets, gets, and clears runtime base URL overrides', async () => { + // Given: A provider with a build-time fallback base URL + const storage = createStorage() + const provider = new BrowserServerBaseUrlProvider('http://localhost:8081/', () => storage) + + // When: Overriding and then clearing the runtime base URL + const fallback = await provider.getBaseUrl() + await provider.setBaseUrl(' https://homesec.example.com/// ') + const override = await provider.getBaseUrl() + const persistedOverride = storage.values.get(BROWSER_SERVER_BASE_URL_STORAGE_KEY) + await provider.clearBaseUrl() + const afterClear = await provider.getBaseUrl() + + // Then: Runtime values are normalized and clearing returns to the fallback + expect(fallback).toBe('http://localhost:8081') + expect(override).toBe('https://homesec.example.com') + expect(persistedOverride).toBe('https://homesec.example.com') + expect(afterClear).toBe('http://localhost:8081') + }) + + it('falls back to the build-time base URL when runtime value is blank', async () => { + // Given: A provider with a stored runtime value and Vite-provided fallback + const storage = createStorage() + const provider = new BrowserServerBaseUrlProvider('http://localhost:8081', () => storage) + await provider.setBaseUrl('http://192.168.1.10:8081') + + // When: Replacing the runtime value with a blank string + await provider.setBaseUrl(' ') + const resolved = await provider.getBaseUrl() + + // Then: Blank runtime values are cleared and the fallback is used + expect(resolved).toBe('http://localhost:8081') + expect(storage.values.has(BROWSER_SERVER_BASE_URL_STORAGE_KEY)).toBe(false) + }) +}) diff --git a/ui/src/api/serverBaseUrlProvider.ts b/ui/src/api/serverBaseUrlProvider.ts new file mode 100644 index 00000000..bf655878 --- /dev/null +++ b/ui/src/api/serverBaseUrlProvider.ts @@ -0,0 +1,98 @@ +export const BROWSER_SERVER_BASE_URL_STORAGE_KEY = 'homesec.serverBaseUrl' + +export interface ServerBaseUrlProvider { + getBaseUrl(): Promise + setBaseUrl(value: string | null): Promise + clearBaseUrl(): Promise +} + +export interface ClientServerBaseUrlProvider extends ServerBaseUrlProvider { + getBaseUrlSync(): string | null +} + +type ServerBaseUrlStorage = Pick + +function getWindowSessionStorage(): ServerBaseUrlStorage | null { + if (typeof window === 'undefined') { + return null + } + + try { + return window.sessionStorage + } catch { + return null + } +} + +export function normalizeServerBaseUrl(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? '' + if (trimmed.length === 0) { + return null + } + + return trimmed.replace(/\/+$/, '') +} + +export class BrowserServerBaseUrlProvider implements ClientServerBaseUrlProvider { + private readonly fallbackBaseUrl: string | null + private readonly getStorage: () => ServerBaseUrlStorage | null + private readonly storageKey: string + + constructor( + fallbackBaseUrl: string | null | undefined, + getStorage: () => ServerBaseUrlStorage | null = getWindowSessionStorage, + storageKey = BROWSER_SERVER_BASE_URL_STORAGE_KEY, + ) { + this.fallbackBaseUrl = normalizeServerBaseUrl(fallbackBaseUrl) + this.getStorage = getStorage + this.storageKey = storageKey + } + + getBaseUrlSync(): string | null { + const storage = this.getStorage() + const stored = storage ? normalizeServerBaseUrl(storage.getItem(this.storageKey)) : null + return stored ?? this.fallbackBaseUrl + } + + setBaseUrlSync(value: string | null): void { + const storage = this.getStorage() + if (!storage) { + return + } + + const normalized = normalizeServerBaseUrl(value) + if (!normalized) { + storage.removeItem(this.storageKey) + return + } + + storage.setItem(this.storageKey, normalized) + } + + clearBaseUrlSync(): void { + const storage = this.getStorage() + if (!storage) { + return + } + + storage.removeItem(this.storageKey) + } + + async getBaseUrl(): Promise { + return this.getBaseUrlSync() + } + + async setBaseUrl(value: string | null): Promise { + this.setBaseUrlSync(value) + } + + async clearBaseUrl(): Promise { + this.clearBaseUrlSync() + } +} + +export function createBrowserServerBaseUrlProvider( + fallbackBaseUrl: string | null | undefined, +): BrowserServerBaseUrlProvider { + return new BrowserServerBaseUrlProvider(fallbackBaseUrl) +} diff --git a/ui/src/api/tokenProvider.test.ts b/ui/src/api/tokenProvider.test.ts new file mode 100644 index 00000000..6df71a36 --- /dev/null +++ b/ui/src/api/tokenProvider.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + BROWSER_AUTH_TOKEN_STORAGE_KEY, + BrowserAuthTokenProvider, + normalizeAuthToken, + resolveAuthToken, + type AuthTokenProvider, +} from './tokenProvider' + +type TestStorage = Pick & { + values: Map +} + +function createStorage(): TestStorage { + const values = new Map() + return { + values, + getItem: (key: string): string | null => values.get(key) ?? null, + setItem: (key: string, value: string): void => { + values.set(key, value) + }, + removeItem: (key: string): void => { + values.delete(key) + }, + } +} + +describe('BrowserAuthTokenProvider', () => { + it('sets, gets, and clears token values from storage', async () => { + // Given: A browser token provider backed by session storage + const storage = createStorage() + const provider = new BrowserAuthTokenProvider(() => storage) + + // When: Persisting and then clearing a token + await provider.setToken(' secret-key ') + const stored = await provider.getToken() + const persisted = storage.values.get(BROWSER_AUTH_TOKEN_STORAGE_KEY) + await provider.clearToken() + const cleared = await provider.getToken() + + // Then: Token values are normalized and removable + expect(stored).toBe('secret-key') + expect(persisted).toBe('secret-key') + expect(cleared).toBeNull() + expect(storage.values.has(BROWSER_AUTH_TOKEN_STORAGE_KEY)).toBe(false) + }) + + it('treats blank token values as absent', async () => { + // Given: A provider with a stored whitespace-only token + const storage = createStorage() + const provider = new BrowserAuthTokenProvider(() => storage) + storage.setItem(BROWSER_AUTH_TOKEN_STORAGE_KEY, ' ') + + // When: Reading and then setting a blank token + const storedBlank = await provider.getToken() + await provider.setToken(' ') + const afterSetBlank = await provider.getToken() + + // Then: Blank tokens are not exposed or persisted + expect(storedBlank).toBeNull() + expect(afterSetBlank).toBeNull() + expect(storage.values.has(BROWSER_AUTH_TOKEN_STORAGE_KEY)).toBe(false) + expect(normalizeAuthToken('\tsecret\n')).toBe('secret') + }) +}) + +describe('resolveAuthToken', () => { + it('prefers explicit request tokens over provider values', async () => { + // Given: A provider with a different stored token + const provider: AuthTokenProvider = { + getToken: vi.fn().mockResolvedValue('stored-secret'), + setToken: vi.fn(), + clearToken: vi.fn(), + } + + // When: Resolving an explicit API token + const resolved = await resolveAuthToken(' explicit-secret ', provider) + + // Then: Explicit values win without consulting storage + expect(resolved).toBe('explicit-secret') + expect(provider.getToken).not.toHaveBeenCalled() + }) + + it('falls back to the configured provider when no request token is supplied', async () => { + // Given: A provider with a stored token + const provider: AuthTokenProvider = { + getToken: vi.fn().mockResolvedValue('stored-secret'), + setToken: vi.fn(), + clearToken: vi.fn(), + } + + // When: Resolving without an explicit request token + const resolved = await resolveAuthToken(undefined, provider) + + // Then: The provider supplies the token + expect(resolved).toBe('stored-secret') + expect(provider.getToken).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/src/api/tokenProvider.ts b/ui/src/api/tokenProvider.ts new file mode 100644 index 00000000..99fd40a5 --- /dev/null +++ b/ui/src/api/tokenProvider.ts @@ -0,0 +1,99 @@ +import type { ApiRequestOptions } from './generated/client' + +export const BROWSER_AUTH_TOKEN_STORAGE_KEY = 'homesec.apiKey' + +export interface AuthTokenProvider { + getToken(): Promise + setToken(token: string | null): Promise + clearToken(): Promise +} + +type AuthTokenStorage = Pick + +function getWindowSessionStorage(): AuthTokenStorage | null { + if (typeof window === 'undefined') { + return null + } + + try { + return window.sessionStorage + } catch { + return null + } +} + +export function normalizeAuthToken(token: string | null | undefined): string | null { + const trimmed = token?.trim() ?? '' + return trimmed.length > 0 ? trimmed : null +} + +export class BrowserAuthTokenProvider implements AuthTokenProvider { + private readonly getStorage: () => AuthTokenStorage | null + private readonly storageKey: string + + constructor( + getStorage: () => AuthTokenStorage | null = getWindowSessionStorage, + storageKey = BROWSER_AUTH_TOKEN_STORAGE_KEY, + ) { + this.getStorage = getStorage + this.storageKey = storageKey + } + + getTokenSync(): string | null { + const storage = this.getStorage() + if (!storage) { + return null + } + + return normalizeAuthToken(storage.getItem(this.storageKey)) + } + + setTokenSync(token: string | null): void { + const storage = this.getStorage() + if (!storage) { + return + } + + const normalized = normalizeAuthToken(token) + if (!normalized) { + storage.removeItem(this.storageKey) + return + } + + storage.setItem(this.storageKey, normalized) + } + + clearTokenSync(): void { + const storage = this.getStorage() + if (!storage) { + return + } + + storage.removeItem(this.storageKey) + } + + async getToken(): Promise { + return this.getTokenSync() + } + + async setToken(token: string | null): Promise { + this.setTokenSync(token) + } + + async clearToken(): Promise { + this.clearTokenSync() + } +} + +export const browserAuthTokenProvider = new BrowserAuthTokenProvider() + +export async function resolveAuthToken( + explicitApiKey: ApiRequestOptions['apiKey'], + provider: AuthTokenProvider = browserAuthTokenProvider, +): Promise { + if (explicitApiKey !== undefined) { + return normalizeAuthToken(explicitApiKey) + } + + return provider.getToken() +} From d475e1470c589f4e651f137a9fc1728cefa47ac5 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 10 May 2026 16:40:24 -0700 Subject: [PATCH 03/36] feat: add API runtime bootstrap --- ui/src/api/runtimeConfig.test.ts | 150 +++++++++++++++++++++++ ui/src/api/runtimeConfig.ts | 41 +++++++ ui/src/api/serverBaseUrlProvider.test.ts | 15 ++- ui/src/api/serverBaseUrlProvider.ts | 13 +- ui/src/app/bootstrap.test.tsx | 43 +++++++ ui/src/app/bootstrap.tsx | 45 +++++++ ui/src/main.tsx | 22 +--- 7 files changed, 301 insertions(+), 28 deletions(-) create mode 100644 ui/src/api/runtimeConfig.test.ts create mode 100644 ui/src/api/runtimeConfig.ts create mode 100644 ui/src/app/bootstrap.test.tsx create mode 100644 ui/src/app/bootstrap.tsx diff --git a/ui/src/api/runtimeConfig.test.ts b/ui/src/api/runtimeConfig.test.ts new file mode 100644 index 00000000..56620319 --- /dev/null +++ b/ui/src/api/runtimeConfig.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { HomeSecApiClient } from './client' +import { + BROWSER_SERVER_BASE_URL_STORAGE_KEY, + BrowserServerBaseUrlProvider, +} from './serverBaseUrlProvider' +import { + initializeApiRuntimeConfig, + WindowApiRuntimeConfigSource, + type ApiRuntimeConfigSource, +} from './runtimeConfig' + +type TestStorage = Pick & { + values: Map +} + +function createStorage(): TestStorage { + const values = new Map() + return { + values, + getItem: (key: string): string | null => values.get(key) ?? null, + setItem: (key: string, value: string): void => { + values.set(key, value) + }, + removeItem: (key: string): void => { + values.delete(key) + }, + } +} + +function sourceWithServerBaseUrl(serverBaseUrl?: string | null): ApiRuntimeConfigSource { + return { + loadRuntimeConfig: () => + serverBaseUrl === undefined + ? {} + : { + serverBaseUrl, + }, + } +} + +describe('initializeApiRuntimeConfig', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('leaves browser fallback configuration intact without extra setup', async () => { + // Given: Browser mode with only a build-time fallback base URL + const storage = createStorage() + const serverBaseUrlProvider = new BrowserServerBaseUrlProvider( + 'http://localhost:8081/', + () => storage, + ) + + // When: Initializing without a runtime server URL + await initializeApiRuntimeConfig({ + runtimeConfigSource: sourceWithServerBaseUrl(), + serverBaseUrlProvider, + }) + + // Then: Existing browser fallback behavior is unchanged + expect(await serverBaseUrlProvider.getBaseUrl()).toBe('http://localhost:8081') + expect(storage.values.has(BROWSER_SERVER_BASE_URL_STORAGE_KEY)).toBe(false) + }) + + it('applies a native-provided LAN base URL before the first API call', async () => { + // Given: Runtime config supplied before the React app mounts + const storage = createStorage() + const serverBaseUrlProvider = new BrowserServerBaseUrlProvider('', () => storage) + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => + new Response( + JSON.stringify({ + status: 'healthy', + pipeline: 'running', + postgres: 'connected', + cameras_online: 1, + bootstrap_mode: false, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ) + + // When: App initialization applies the LAN URL before client use + await initializeApiRuntimeConfig({ + runtimeConfigSource: sourceWithServerBaseUrl(' http://192.168.1.10:8081/// '), + serverBaseUrlProvider, + }) + const client = new HomeSecApiClient('', { serverBaseUrlProvider }) + await client.getHealth() + + // Then: First API call resolves against the runtime-configured server + expect(fetchSpy.mock.calls[0]?.[0]).toBe('http://192.168.1.10:8081/api/v1/health') + }) + + it('supports HTTPS runtime base URLs and empty same-origin mode', async () => { + // Given: A runtime-configurable provider with a build-time fallback + const storage = createStorage() + const serverBaseUrlProvider = new BrowserServerBaseUrlProvider( + 'http://localhost:8081', + () => storage, + ) + const client = new HomeSecApiClient('', { serverBaseUrlProvider }) + + // When: Initializing with HTTPS and then an empty same-origin value + await initializeApiRuntimeConfig({ + runtimeConfigSource: sourceWithServerBaseUrl('https://homesec.example.com/'), + serverBaseUrlProvider, + }) + const httpsUrl = serverBaseUrlProvider.getBaseUrlSync() + + await initializeApiRuntimeConfig({ + runtimeConfigSource: sourceWithServerBaseUrl(' '), + serverBaseUrlProvider, + }) + const sameOriginUrl = serverBaseUrlProvider.getBaseUrlSync() + const sameOriginPath = client.resolvePath('/api/v1/health') + + // Then: HTTPS URLs normalize and empty values override the fallback with same-origin + expect(httpsUrl).toBe('https://homesec.example.com') + expect(sameOriginUrl).toBeNull() + expect(sameOriginPath).toBe('/api/v1/health') + expect(storage.values.get(BROWSER_SERVER_BASE_URL_STORAGE_KEY)).toBe('') + }) +}) + +describe('WindowApiRuntimeConfigSource', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('reads preloaded runtime config from the window object', () => { + // Given: A native shell preloads configuration before the bundle starts + vi.stubGlobal('window', { + __HOMESEC_RUNTIME_CONFIG__: { + serverBaseUrl: 'http://192.168.1.10:8081', + }, + }) + + // When: Loading browser-visible runtime configuration + const config = new WindowApiRuntimeConfigSource().loadRuntimeConfig() + + // Then: The preloaded server URL is exposed to app initialization + expect(config).toEqual({ serverBaseUrl: 'http://192.168.1.10:8081' }) + }) +}) diff --git a/ui/src/api/runtimeConfig.ts b/ui/src/api/runtimeConfig.ts new file mode 100644 index 00000000..0ff5da79 --- /dev/null +++ b/ui/src/api/runtimeConfig.ts @@ -0,0 +1,41 @@ +import { browserServerBaseUrlProvider } from './client' +import type { ClientServerBaseUrlProvider } from './serverBaseUrlProvider' + +export interface ApiRuntimeConfig { + serverBaseUrl?: string | null +} + +export interface ApiRuntimeConfigSource { + loadRuntimeConfig(): ApiRuntimeConfig | Promise +} + +declare global { + interface Window { + __HOMESEC_RUNTIME_CONFIG__?: ApiRuntimeConfig + } +} + +export class WindowApiRuntimeConfigSource implements ApiRuntimeConfigSource { + loadRuntimeConfig(): ApiRuntimeConfig { + if (typeof window === 'undefined') { + return {} + } + + return window.__HOMESEC_RUNTIME_CONFIG__ ?? {} + } +} + +export interface InitializeApiRuntimeConfigOptions { + runtimeConfigSource?: ApiRuntimeConfigSource + serverBaseUrlProvider?: ClientServerBaseUrlProvider +} + +export async function initializeApiRuntimeConfig({ + runtimeConfigSource = new WindowApiRuntimeConfigSource(), + serverBaseUrlProvider = browserServerBaseUrlProvider, +}: InitializeApiRuntimeConfigOptions = {}): Promise { + const config = await runtimeConfigSource.loadRuntimeConfig() + if (Object.prototype.hasOwnProperty.call(config, 'serverBaseUrl')) { + await serverBaseUrlProvider.setBaseUrl(config.serverBaseUrl ?? null) + } +} diff --git a/ui/src/api/serverBaseUrlProvider.test.ts b/ui/src/api/serverBaseUrlProvider.test.ts index 8ab37608..8002a21d 100644 --- a/ui/src/api/serverBaseUrlProvider.test.ts +++ b/ui/src/api/serverBaseUrlProvider.test.ts @@ -59,18 +59,23 @@ describe('BrowserServerBaseUrlProvider', () => { expect(afterClear).toBe('http://localhost:8081') }) - it('falls back to the build-time base URL when runtime value is blank', async () => { + it('distinguishes explicit same-origin override from clearing runtime config', async () => { // Given: A provider with a stored runtime value and Vite-provided fallback const storage = createStorage() const provider = new BrowserServerBaseUrlProvider('http://localhost:8081', () => storage) await provider.setBaseUrl('http://192.168.1.10:8081') - // When: Replacing the runtime value with a blank string + // When: Replacing the runtime value with a blank string and then clearing it await provider.setBaseUrl(' ') - const resolved = await provider.getBaseUrl() + const sameOrigin = await provider.getBaseUrl() + const persistedSameOrigin = storage.values.get(BROWSER_SERVER_BASE_URL_STORAGE_KEY) + await provider.clearBaseUrl() + const afterClear = await provider.getBaseUrl() - // Then: Blank runtime values are cleared and the fallback is used - expect(resolved).toBe('http://localhost:8081') + // Then: Blank runtime values force same-origin; explicit clearing returns to fallback + expect(sameOrigin).toBeNull() + expect(persistedSameOrigin).toBe('') + expect(afterClear).toBe('http://localhost:8081') expect(storage.values.has(BROWSER_SERVER_BASE_URL_STORAGE_KEY)).toBe(false) }) }) diff --git a/ui/src/api/serverBaseUrlProvider.ts b/ui/src/api/serverBaseUrlProvider.ts index bf655878..6e22cf9c 100644 --- a/ui/src/api/serverBaseUrlProvider.ts +++ b/ui/src/api/serverBaseUrlProvider.ts @@ -50,8 +50,12 @@ export class BrowserServerBaseUrlProvider implements ClientServerBaseUrlProvider getBaseUrlSync(): string | null { const storage = this.getStorage() - const stored = storage ? normalizeServerBaseUrl(storage.getItem(this.storageKey)) : null - return stored ?? this.fallbackBaseUrl + const stored = storage?.getItem(this.storageKey) + if (stored !== null && stored !== undefined) { + return normalizeServerBaseUrl(stored) + } + + return this.fallbackBaseUrl } setBaseUrlSync(value: string | null): void { @@ -60,13 +64,12 @@ export class BrowserServerBaseUrlProvider implements ClientServerBaseUrlProvider return } - const normalized = normalizeServerBaseUrl(value) - if (!normalized) { + if (value === null) { storage.removeItem(this.storageKey) return } - storage.setItem(this.storageKey, normalized) + storage.setItem(this.storageKey, normalizeServerBaseUrl(value) ?? '') } clearBaseUrlSync(): void { diff --git a/ui/src/app/bootstrap.test.tsx b/ui/src/app/bootstrap.test.tsx new file mode 100644 index 00000000..42caf9b2 --- /dev/null +++ b/ui/src/app/bootstrap.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from 'vitest' + +import { bootstrapHomeSecApp } from './bootstrap' + +describe('bootstrapHomeSecApp', () => { + it('waits for runtime API configuration before rendering', async () => { + // Given: Runtime configuration that resolves asynchronously + const events: string[] = [] + const rootElement = document.createElement('div') + let finishInitialization: (() => void) | undefined + const initializeRuntimeConfig = vi.fn( + () => + new Promise((resolve) => { + finishInitialization = () => { + events.push('initialized') + resolve() + } + }), + ) + const render = vi.fn(() => { + events.push('rendered') + }) + + // When: Bootstrapping the app before runtime config has finished loading + const bootstrapPromise = bootstrapHomeSecApp({ + rootElement, + initializeRuntimeConfig, + render, + }) + + // Then: Rendering is held until initialization completes + expect(initializeRuntimeConfig).toHaveBeenCalledTimes(1) + expect(render).not.toHaveBeenCalled() + + finishInitialization?.() + await bootstrapPromise + + expect(events).toEqual(['initialized', 'rendered']) + expect(render).toHaveBeenCalledWith(rootElement, expect.anything()) + }) +}) diff --git a/ui/src/app/bootstrap.tsx b/ui/src/app/bootstrap.tsx new file mode 100644 index 00000000..e9c45f8a --- /dev/null +++ b/ui/src/app/bootstrap.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react' + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' + +import App from '../App' +import { initializeApiRuntimeConfig } from '../api/runtimeConfig' +import { QueryProvider } from './providers/QueryProvider' +import { ThemeProvider } from './providers/ThemeProvider' + +export type RenderHomeSecApp = (rootElement: HTMLElement, app: ReactNode) => void + +export interface BootstrapHomeSecAppOptions { + rootElement: HTMLElement + initializeRuntimeConfig?: () => Promise + render?: RenderHomeSecApp +} + +function renderReactApp(rootElement: HTMLElement, app: ReactNode): void { + createRoot(rootElement).render(app) +} + +export function createHomeSecAppElement(): ReactNode { + return ( + + + + + + + + + + ) +} + +export async function bootstrapHomeSecApp({ + rootElement, + initializeRuntimeConfig = initializeApiRuntimeConfig, + render = renderReactApp, +}: BootstrapHomeSecAppOptions): Promise { + await initializeRuntimeConfig() + render(rootElement, createHomeSecAppElement()) +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 9a26ed82..faf18ff2 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,21 +1,7 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import { BrowserRouter } from 'react-router-dom' - -import App from './App' -import { QueryProvider } from './app/providers/QueryProvider' -import { ThemeProvider } from './app/providers/ThemeProvider' +import { bootstrapHomeSecApp } from './app/bootstrap' import './styles/global.css' import './styles/tokens.css' -createRoot(document.getElementById('root')!).render( - - - - - - - - - , -) +void bootstrapHomeSecApp({ + rootElement: document.getElementById('root')!, +}) From 976dc644a104b665754a5f901bdd47656c0f1446 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 10 May 2026 16:54:42 -0700 Subject: [PATCH 04/36] feat: add native iOS setup flow --- .../native-setup/NativeSetupPage.test.tsx | 176 +++++++++++++ .../features/native-setup/NativeSetupPage.tsx | 233 ++++++++++++++++++ ui/src/features/native-setup/nativeSetup.css | 70 ++++++ .../features/native-setup/nativeSetup.test.ts | 51 ++++ ui/src/features/native-setup/nativeSetup.ts | 62 +++++ ui/src/routes/AppRouter.test.tsx | 14 ++ ui/src/routes/AppRouter.tsx | 2 + 7 files changed, 608 insertions(+) create mode 100644 ui/src/features/native-setup/NativeSetupPage.test.tsx create mode 100644 ui/src/features/native-setup/NativeSetupPage.tsx create mode 100644 ui/src/features/native-setup/nativeSetup.css create mode 100644 ui/src/features/native-setup/nativeSetup.test.ts create mode 100644 ui/src/features/native-setup/nativeSetup.ts diff --git a/ui/src/features/native-setup/NativeSetupPage.test.tsx b/ui/src/features/native-setup/NativeSetupPage.test.tsx new file mode 100644 index 00000000..b276f769 --- /dev/null +++ b/ui/src/features/native-setup/NativeSetupPage.test.tsx @@ -0,0 +1,176 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' + +import { BROWSER_AUTH_TOKEN_STORAGE_KEY } from '../../api/tokenProvider' +import { BROWSER_SERVER_BASE_URL_STORAGE_KEY } from '../../api/serverBaseUrlProvider' +import { NativeSetupPage } from './NativeSetupPage' + +const HEALTH_PAYLOAD = { + status: 'healthy', + pipeline: 'running', + postgres: 'connected', + cameras_online: 1, + bootstrap_mode: false, +} + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { 'content-type': 'application/json' }, + }) +} + +function unauthorizedResponse(): Response { + return jsonResponse({ detail: 'Unauthorized', error_code: 'UNAUTHORIZED' }, 401) +} + +function authorizationHeader(call: Parameters[1] | undefined): string | undefined { + const headers = call?.headers + return headers && !Array.isArray(headers) && !(headers instanceof Headers) + ? headers.Authorization + : undefined +} + +function renderNativeSetup(): void { + render( + + + } /> + Live route

} /> +
+
, + ) +} + +describe('NativeSetupPage', () => { + beforeEach(() => { + window.sessionStorage.clear() + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + window.sessionStorage.clear() + }) + + it('validates server and token before saving settings and routing to Live', async () => { + // Given: A reachable HTTP LAN server and an old stored token from another server + const user = userEvent.setup() + window.sessionStorage.setItem(BROWSER_AUTH_TOKEN_STORAGE_KEY, 'old-secret') + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD)) + .mockResolvedValueOnce(unauthorizedResponse()) + .mockResolvedValueOnce(jsonResponse([])) + renderNativeSetup() + + // When: User checks the server URL and submits a valid token + await user.type(screen.getByLabelText('Server URL'), ' http://192.168.1.10:8081/// ') + await user.click(screen.getByRole('button', { name: 'Check server' })) + await screen.findByText('Server reachable') + expect(screen.getByText('Plain HTTP is visible on the network. Prefer HTTPS or VPN for iOS access.')).toBeTruthy() + await user.type(screen.getByLabelText('API token'), ' token-123 ') + await user.click(screen.getByRole('button', { name: 'Save and continue' })) + + // Then: Requests use the runtime base URL, settings are saved, and the app opens Live + await waitFor(() => { + expect(screen.getByText('Live route')).toBeTruthy() + }) + expect(fetchSpy.mock.calls[0]?.[0]).toBe('http://192.168.1.10:8081/api/v1/health') + expect(fetchSpy.mock.calls[1]?.[0]).toBe('http://192.168.1.10:8081/api/v1/cameras') + expect(authorizationHeader(fetchSpy.mock.calls[0]?.[1])).toBeUndefined() + expect(authorizationHeader(fetchSpy.mock.calls[1]?.[1])).toBeUndefined() + expect(fetchSpy.mock.calls[1]?.[1]).toMatchObject({ + headers: { + Accept: 'application/json', + }, + }) + expect(fetchSpy.mock.calls[2]?.[1]).toMatchObject({ + headers: { + Accept: 'application/json', + Authorization: 'Bearer token-123', + }, + }) + expect(window.sessionStorage.getItem(BROWSER_SERVER_BASE_URL_STORAGE_KEY)).toBe( + 'http://192.168.1.10:8081', + ) + expect(window.sessionStorage.getItem(BROWSER_AUTH_TOKEN_STORAGE_KEY)).toBe('token-123') + }) + + it('shows actionable validation errors for bad server URLs and rejected tokens', async () => { + // Given: Setup is rendered with a protected server + const user = userEvent.setup() + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD)) + .mockResolvedValueOnce(unauthorizedResponse()) + .mockResolvedValueOnce(unauthorizedResponse()) + renderNativeSetup() + + // When: User submits an unsupported URL and then a rejected token + await user.type(screen.getByLabelText('Server URL'), 'homesec.local:8081') + await user.click(screen.getByRole('button', { name: 'Check server' })) + await screen.findByText('Only http:// and https:// server URLs are supported.') + await user.clear(screen.getByLabelText('Server URL')) + await user.type(screen.getByLabelText('Server URL'), 'https://homesec.example.com') + await user.click(screen.getByRole('button', { name: 'Check server' })) + await screen.findByText('Server reachable') + await user.type(screen.getByLabelText('API token'), 'wrong-token') + await user.click(screen.getByRole('button', { name: 'Save and continue' })) + + // Then: Invalid states do not persist settings or navigate away + await screen.findByText('API token was rejected. Paste the HomeSec API token and try again.') + expect(fetchSpy).toHaveBeenCalledTimes(3) + expect(window.sessionStorage.getItem(BROWSER_SERVER_BASE_URL_STORAGE_KEY)).toBeNull() + expect(window.sessionStorage.getItem(BROWSER_AUTH_TOKEN_STORAGE_KEY)).toBeNull() + expect(screen.queryByText('Live route')).toBeNull() + }) + + it('clears stale plain HTTP warning when server URL changes', async () => { + // Given: User validated a plain-HTTP server URL + const user = userEvent.setup() + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD)) + .mockResolvedValueOnce(unauthorizedResponse()) + renderNativeSetup() + await user.type(screen.getByLabelText('Server URL'), 'http://192.168.1.10:8081') + await user.click(screen.getByRole('button', { name: 'Check server' })) + await screen.findByText('Plain HTTP is visible on the network. Prefer HTTPS or VPN for iOS access.') + + // When: The server URL field changes + await user.clear(screen.getByLabelText('Server URL')) + await user.type(screen.getByLabelText('Server URL'), 'https://homesec.example.com') + + // Then: Warning state from the previous validated URL is cleared + expect(screen.queryByText('Plain HTTP is visible on the network. Prefer HTTPS or VPN for iOS access.')).toBeNull() + }) + + it('warns and allows continuing when auth-disabled mode is detectable', async () => { + // Given: Camera list succeeds without an API token and an old token is stored + const user = userEvent.setup() + window.sessionStorage.setItem(BROWSER_AUTH_TOKEN_STORAGE_KEY, 'old-secret') + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD)) + .mockResolvedValueOnce(jsonResponse([])) + renderNativeSetup() + + // When: User checks the server and continues without a token + await user.type(screen.getByLabelText('Server URL'), 'https://homesec.example.com') + await user.click(screen.getByRole('button', { name: 'Check server' })) + await screen.findByText(/accepted camera requests without an API token/) + expect((screen.getByLabelText('API token') as HTMLInputElement).disabled).toBe(true) + await user.click(screen.getByRole('button', { name: 'Save and continue' })) + + // Then: The server URL is saved, the old token is cleared, and no token validation is faked + await waitFor(() => { + expect(screen.getByText('Live route')).toBeTruthy() + }) + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(window.sessionStorage.getItem(BROWSER_SERVER_BASE_URL_STORAGE_KEY)).toBe( + 'https://homesec.example.com', + ) + expect(window.sessionStorage.getItem(BROWSER_AUTH_TOKEN_STORAGE_KEY)).toBeNull() + }) +}) diff --git a/ui/src/features/native-setup/NativeSetupPage.tsx b/ui/src/features/native-setup/NativeSetupPage.tsx new file mode 100644 index 00000000..62e0d78a --- /dev/null +++ b/ui/src/features/native-setup/NativeSetupPage.tsx @@ -0,0 +1,233 @@ +import { useState, type FormEvent } from 'react' +import { useNavigate } from 'react-router-dom' + +import { + HomeSecApiClient, + browserAuthTokenProvider, + browserServerBaseUrlProvider, + isAPIError, + isUnauthorizedAPIError, +} from '../../api/client' +import type { AuthTokenProvider, ClientServerBaseUrlProvider } from '../../api/client' +import { Button } from '../../components/ui/Button' +import { validateNativeSetupServerUrl } from './nativeSetup' +import './nativeSetup.css' + +type NativeSetupStep = 'server' | 'token' + +export interface NativeSetupPageProps { + authTokenProvider?: AuthTokenProvider + createClient?: (baseUrl: string) => HomeSecApiClient + serverBaseUrlProvider?: ClientServerBaseUrlProvider +} + +function isAuthFailure(error: unknown): boolean { + return isUnauthorizedAPIError(error) || (isAPIError(error) && error.status === 403) +} + +function describeServerCheckError(error: unknown): string { + if (isAPIError(error)) { + return `Server responded with HTTP ${error.status}. Check the URL and try again.` + } + if (error instanceof Error && error.message.trim().length > 0) { + return `Unable to reach HomeSec: ${error.message}` + } + return 'Unable to reach HomeSec. Check the server URL and network connection.' +} + +function describeTokenError(error: unknown): string { + if (isAuthFailure(error)) { + return 'API token was rejected. Paste the HomeSec API token and try again.' + } + if (isAPIError(error)) { + return `Token validation failed with HTTP ${error.status}. Check server status and try again.` + } + if (error instanceof Error && error.message.trim().length > 0) { + return `Unable to validate token: ${error.message}` + } + return 'Unable to validate the API token. Try again.' +} + +export function NativeSetupPage({ + authTokenProvider = browserAuthTokenProvider, + createClient = (baseUrl: string) => new HomeSecApiClient(baseUrl), + serverBaseUrlProvider = browserServerBaseUrlProvider, +}: NativeSetupPageProps = {}) { + const navigate = useNavigate() + const [serverUrl, setServerUrl] = useState('') + const [apiToken, setApiToken] = useState('') + const [validatedServerUrl, setValidatedServerUrl] = useState(null) + const [isPlainHttp, setIsPlainHttp] = useState(false) + const [authDisabled, setAuthDisabled] = useState(false) + const [serverError, setServerError] = useState(null) + const [tokenError, setTokenError] = useState(null) + const [step, setStep] = useState('server') + const [isCheckingServer, setIsCheckingServer] = useState(false) + const [isSaving, setIsSaving] = useState(false) + + function handleServerUrlChange(value: string): void { + setServerUrl(value) + setValidatedServerUrl(null) + setIsPlainHttp(false) + setAuthDisabled(false) + setServerError(null) + setTokenError(null) + setStep('server') + } + + async function checkServer(event: FormEvent): Promise { + event.preventDefault() + const validation = validateNativeSetupServerUrl(serverUrl) + if (!validation.ok) { + setServerError(validation.message) + setValidatedServerUrl(null) + setStep('server') + return + } + + setIsCheckingServer(true) + setServerError(null) + setTokenError(null) + setIsPlainHttp(false) + setAuthDisabled(false) + try { + const client = createClient(validation.value.serverBaseUrl) + await client.getHealth({ apiKey: null }) + + let acceptsUnauthenticatedRequests = false + try { + await client.getCameras({ apiKey: null }) + acceptsUnauthenticatedRequests = true + } catch (error) { + if (!isAuthFailure(error)) { + acceptsUnauthenticatedRequests = false + } + } + + setValidatedServerUrl(validation.value.serverBaseUrl) + setServerUrl(validation.value.serverBaseUrl) + setIsPlainHttp(validation.value.isPlainHttp) + setAuthDisabled(acceptsUnauthenticatedRequests) + if (acceptsUnauthenticatedRequests) { + setApiToken('') + } + setStep('token') + } catch (error) { + setValidatedServerUrl(null) + setIsPlainHttp(false) + setStep('server') + setServerError(describeServerCheckError(error)) + } finally { + setIsCheckingServer(false) + } + } + + async function saveAndContinue(event: FormEvent): Promise { + event.preventDefault() + const apiKey = apiToken.trim() + if (!validatedServerUrl) { + setTokenError('Check the server URL before continuing.') + return + } + if (!authDisabled && !apiKey) { + setTokenError('API token is required.') + return + } + + setIsSaving(true) + setTokenError(null) + try { + if (!authDisabled && apiKey) { + await createClient(validatedServerUrl).getCameras({ apiKey }) + } + await serverBaseUrlProvider.setBaseUrl(validatedServerUrl) + await authTokenProvider.setToken(authDisabled ? null : apiKey || null) + navigate('/live', { replace: true }) + } catch (error) { + setTokenError(describeTokenError(error)) + } finally { + setIsSaving(false) + } + } + + const tokenInputDisabled = step !== 'token' || authDisabled || isSaving || isCheckingServer + const canSave = step === 'token' && !isCheckingServer && !isSaving + + return ( +
+
+
+

iOS setup

+

+ Connect to HomeSec +

+
+ +
+
+ + handleServerUrlChange(event.target.value)} + disabled={isCheckingServer || isSaving} + /> +
+ {serverError ?

{serverError}

: null} + +
+ + {step === 'token' ? ( +
+ Server reachable +
+ ) : null} + + {isPlainHttp ? ( +
+ Plain HTTP is visible on the network. Prefer HTTPS or VPN for iOS access. +
+ ) : null} + + {authDisabled ? ( +
+ This server accepted camera requests without an API token. Authentication appears + disabled, so the token cannot be verified. +
+ ) : null} + +
+
+ + setApiToken(event.target.value)} + disabled={tokenInputDisabled} + /> +
+ {tokenError ?

{tokenError}

: null} + +
+
+
+ ) +} diff --git a/ui/src/features/native-setup/nativeSetup.css b/ui/src/features/native-setup/nativeSetup.css new file mode 100644 index 00000000..8febddc9 --- /dev/null +++ b/ui/src/features/native-setup/nativeSetup.css @@ -0,0 +1,70 @@ +.native-setup-page { + min-height: 100vh; + padding: var(--space-6); + display: grid; + place-items: center; +} + +.native-setup-panel { + width: min(560px, 100%); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--surface-1) 82%, transparent); + box-shadow: var(--shadow); + backdrop-filter: blur(10px); + padding: var(--space-6); + display: grid; + gap: var(--space-4); +} + +.native-setup-panel__header { + display: grid; + gap: var(--space-2); +} + +.native-setup-panel__title { + margin: 0; + font-size: 1.8rem; +} + +.native-setup-form { + display: grid; + gap: var(--space-3); +} + +.native-setup-form__field { + display: grid; + gap: var(--space-2); +} + +.native-setup-status, +.native-setup-warning { + border: 1px solid color-mix(in srgb, var(--success) 36%, transparent); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--success) 10%, transparent); + color: var(--success); + padding: 0.7rem 0.8rem; +} + +.native-setup-warning { + border-color: color-mix(in srgb, var(--warning) 44%, transparent); + background: color-mix(in srgb, var(--warning) 12%, transparent); + color: var(--warning); +} + +.native-setup-warning--strong { + border-color: color-mix(in srgb, var(--danger) 40%, transparent); + background: color-mix(in srgb, var(--danger) 10%, transparent); + color: var(--danger); +} + +@media (max-width: 640px) { + .native-setup-page { + padding: var(--space-4); + align-items: stretch; + } + + .native-setup-panel { + padding: var(--space-4); + } +} diff --git a/ui/src/features/native-setup/nativeSetup.test.ts b/ui/src/features/native-setup/nativeSetup.test.ts new file mode 100644 index 00000000..f3caddce --- /dev/null +++ b/ui/src/features/native-setup/nativeSetup.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { validateNativeSetupServerUrl } from './nativeSetup' + +describe('validateNativeSetupServerUrl', () => { + it('normalizes HTTPS and LAN URLs while rejecting unsupported input', () => { + // Given: Server URL candidates from the native setup form + const httpsUrl = ' https://homesec.example.com/// ' + const lanUrl = 'http://192.168.1.10:8081/' + const missingScheme = 'homesec.local:8081' + + // When: Validating each value + const httpsResult = validateNativeSetupServerUrl(httpsUrl) + const lanResult = validateNativeSetupServerUrl(lanUrl) + const missingSchemeResult = validateNativeSetupServerUrl(missingScheme) + + // Then: Supported URLs normalize and invalid input returns an actionable message + expect(httpsResult).toEqual({ + ok: true, + value: { + serverBaseUrl: 'https://homesec.example.com', + isPlainHttp: false, + }, + }) + expect(lanResult).toEqual({ + ok: true, + value: { + serverBaseUrl: 'http://192.168.1.10:8081', + isPlainHttp: true, + }, + }) + expect(missingSchemeResult).toEqual({ + ok: false, + message: 'Only http:// and https:// server URLs are supported.', + }) + }) + + it('rejects blank server URLs', () => { + // Given: A blank server URL + const input = ' ' + + // When: Validating setup input + const result = validateNativeSetupServerUrl(input) + + // Then: The setup flow asks for a server URL + expect(result).toEqual({ + ok: false, + message: 'Enter the HomeSec server URL.', + }) + }) +}) diff --git a/ui/src/features/native-setup/nativeSetup.ts b/ui/src/features/native-setup/nativeSetup.ts new file mode 100644 index 00000000..63d9f8e0 --- /dev/null +++ b/ui/src/features/native-setup/nativeSetup.ts @@ -0,0 +1,62 @@ +import { normalizeServerBaseUrl } from '../../api/serverBaseUrlProvider' + +export interface NativeSetupServerUrl { + serverBaseUrl: string + isPlainHttp: boolean +} + +export type NativeSetupServerUrlValidation = + | { + ok: true + value: NativeSetupServerUrl + } + | { + ok: false + message: string + } + +export function validateNativeSetupServerUrl(input: string): NativeSetupServerUrlValidation { + const normalized = normalizeServerBaseUrl(input) + if (!normalized) { + return { + ok: false, + message: 'Enter the HomeSec server URL.', + } + } + + let parsed: URL + try { + parsed = new URL(normalized) + } catch { + return { + ok: false, + message: 'Enter a valid URL that starts with http:// or https://.', + } + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { + ok: false, + message: 'Only http:// and https:// server URLs are supported.', + } + } + + if (!parsed.hostname) { + return { + ok: false, + message: 'Enter a server URL with a host name or IP address.', + } + } + + parsed.hash = '' + parsed.search = '' + parsed.pathname = parsed.pathname.replace(/\/+$/, '') + + return { + ok: true, + value: { + serverBaseUrl: parsed.toString().replace(/\/+$/, ''), + isPlainHttp: parsed.protocol === 'http:', + }, + } +} diff --git a/ui/src/routes/AppRouter.test.tsx b/ui/src/routes/AppRouter.test.tsx index f0ef1f0c..de0db234 100644 --- a/ui/src/routes/AppRouter.test.tsx +++ b/ui/src/routes/AppRouter.test.tsx @@ -46,6 +46,10 @@ vi.mock('../features/setup/SetupPage', () => ({ SetupPage: () =>

Setup Page

, })) +vi.mock('../features/native-setup/NativeSetupPage', () => ({ + NativeSetupPage: () =>

Native Setup Page

, +})) + function LocationProbe() { const location = useLocation() return

{`${location.pathname}${location.search}`}

@@ -104,6 +108,16 @@ describe('AppRouter route cleanup', () => { expect(screen.getByText('System Page')).toBeTruthy() }) + it('renders native setup without mounting app shell queries', () => { + // Given: User opens the native setup route before API settings exist + renderRouter('/native-setup') + + // When / Then: The native setup surface is isolated from shell API queries + expect(screen.getByText('Native Setup Page')).toBeTruthy() + expect(useHealthQueryMock).not.toHaveBeenCalled() + expect(useCamerasQueryMock).not.toHaveBeenCalled() + }) + it('redirects the old cameras route to Settings camera setup', async () => { // Given: User opens the old top-level camera management route renderRouter('/cameras') diff --git a/ui/src/routes/AppRouter.tsx b/ui/src/routes/AppRouter.tsx index 8d0a2337..395df2f7 100644 --- a/ui/src/routes/AppRouter.tsx +++ b/ui/src/routes/AppRouter.tsx @@ -5,6 +5,7 @@ import { CamerasPage } from '../features/cameras/CamerasPage' import { ClipDetailPage } from '../features/clips/ClipDetailPage' import { ClipsPage } from '../features/clips/ClipsPage' import { LivePage } from '../features/live/LivePage' +import { NativeSetupPage } from '../features/native-setup/NativeSetupPage' import { NotFoundPage } from '../features/not-found/NotFoundPage' import { SettingsPage } from '../features/settings/SettingsPage' import { SetupPage } from '../features/setup/SetupPage' @@ -25,6 +26,7 @@ export function AppRouter() { return ( } /> + } /> }> } /> } /> From aa3c5b899c991902f7bf3b7e3a7000292f73e501 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 10 May 2026 17:26:53 -0700 Subject: [PATCH 05/36] feat: add Capacitor iOS scaffold --- docs/ios-app-design.md | 3 +- ui/README.md | 14 + ui/capacitor.config.ts | 9 + ui/eslint.config.js | 8 +- ui/ios/.gitignore | 13 + ui/ios/App/App.xcodeproj/project.pbxproj | 376 +++++++++++ .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 15 + ui/ios/App/App/AppDelegate.swift | 49 ++ .../AppIcon.appiconset/AppIcon-512@2x.png | Bin 0 -> 110522 bytes .../AppIcon.appiconset/Contents.json | 14 + ui/ios/App/App/Assets.xcassets/Contents.json | 6 + .../Splash.imageset/Contents.json | 23 + .../Splash.imageset/splash-2732x2732-1.png | Bin 0 -> 41273 bytes .../Splash.imageset/splash-2732x2732-2.png | Bin 0 -> 41273 bytes .../Splash.imageset/splash-2732x2732.png | Bin 0 -> 41273 bytes .../App/Base.lproj/LaunchScreen.storyboard | 32 + ui/ios/App/App/Base.lproj/Main.storyboard | 19 + ui/ios/App/App/Info.plist | 62 ++ ui/ios/App/CapApp-SPM/.gitignore | 9 + ui/ios/App/CapApp-SPM/Package.resolved | 14 + ui/ios/App/CapApp-SPM/Package.swift | 25 + ui/ios/App/CapApp-SPM/README.md | 5 + .../Sources/CapApp-SPM/CapApp-SPM.swift | 1 + ui/ios/debug.xcconfig | 1 + ui/package.json | 10 + ui/pnpm-lock.yaml | 639 ++++++++++++++++++ ui/tsconfig.node.json | 2 +- 28 files changed, 1354 insertions(+), 3 deletions(-) create mode 100644 ui/capacitor.config.ts create mode 100644 ui/ios/.gitignore create mode 100644 ui/ios/App/App.xcodeproj/project.pbxproj create mode 100644 ui/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 ui/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 ui/ios/App/App/AppDelegate.swift create mode 100644 ui/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png create mode 100644 ui/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 ui/ios/App/App/Assets.xcassets/Contents.json create mode 100644 ui/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json create mode 100644 ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png create mode 100644 ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png create mode 100644 ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png create mode 100644 ui/ios/App/App/Base.lproj/LaunchScreen.storyboard create mode 100644 ui/ios/App/App/Base.lproj/Main.storyboard create mode 100644 ui/ios/App/App/Info.plist create mode 100644 ui/ios/App/CapApp-SPM/.gitignore create mode 100644 ui/ios/App/CapApp-SPM/Package.resolved create mode 100644 ui/ios/App/CapApp-SPM/Package.swift create mode 100644 ui/ios/App/CapApp-SPM/README.md create mode 100644 ui/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift create mode 100644 ui/ios/debug.xcconfig diff --git a/docs/ios-app-design.md b/docs/ios-app-design.md index 1e1a00c5..256c7174 100644 --- a/docs/ios-app-design.md +++ b/docs/ios-app-design.md @@ -318,8 +318,9 @@ make ui-check For the Capacitor scaffold, also verify: ```bash -cd ui && pnpm build +cd ui && pnpm ios:build cd ui && pnpm ios:sync +cd ui && pnpm ios:run ``` Launching in the simulator is expected for `iOS-04` when local Xcode setup diff --git a/ui/README.md b/ui/README.md index b6bb6654..3e725038 100644 --- a/ui/README.md +++ b/ui/README.md @@ -4,6 +4,7 @@ React + TypeScript SPA for HomeSec self-serve control plane. ## Toolchain +- Node.js: `>=22.12.0` for Vite and Capacitor CLI support. - Package manager: `pnpm` (pinned in `packageManager`). - Build/runtime: `Vite + React + TypeScript`. - Router/data: `react-router-dom` + `@tanstack/react-query`. @@ -22,6 +23,19 @@ pnpm check `pnpm check` runs lint, unit tests, typecheck, and production build. +For the iOS shell: + +```bash +pnpm ios:build +pnpm ios:sync +pnpm ios:open +pnpm ios:run +``` + +`ios:build` builds the Vite app and copies web assets into the Capacitor iOS +project. `ios:sync` also updates native dependencies, `ios:open` opens the Xcode +project, and `ios:run` syncs and launches the app through Capacitor. + Make target shortcuts are available too: ```bash diff --git a/ui/capacitor.config.ts b/ui/capacitor.config.ts new file mode 100644 index 00000000..7662a0be --- /dev/null +++ b/ui/capacitor.config.ts @@ -0,0 +1,9 @@ +import type { CapacitorConfig } from '@capacitor/cli' + +const config: CapacitorConfig = { + appId: 'com.levneiman.homesec', + appName: 'HomeSec', + webDir: 'dist', +} + +export default config diff --git a/ui/eslint.config.js b/ui/eslint.config.js index 5e6b472f..55f537bb 100644 --- a/ui/eslint.config.js +++ b/ui/eslint.config.js @@ -6,7 +6,13 @@ import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' export default defineConfig([ - globalIgnores(['dist']), + globalIgnores([ + 'dist', + 'ios/App/App/public', + 'ios/App/CapApp-SPM/.build', + 'ios/DerivedData', + 'ios/capacitor-cordova-ios-plugins', + ]), { files: ['**/*.{ts,tsx}'], extends: [ diff --git a/ui/ios/.gitignore b/ui/ios/.gitignore new file mode 100644 index 00000000..f4702997 --- /dev/null +++ b/ui/ios/.gitignore @@ -0,0 +1,13 @@ +App/build +App/Pods +App/output +App/App/public +DerivedData +xcuserdata + +# Cordova plugins for Capacitor +capacitor-cordova-ios-plugins + +# Generated Config files +App/App/capacitor.config.json +App/App/config.xml diff --git a/ui/ios/App/App.xcodeproj/project.pbxproj b/ui/ios/App/App.xcodeproj/project.pbxproj new file mode 100644 index 00000000..983dbd87 --- /dev/null +++ b/ui/ios/App/App.xcodeproj/project.pbxproj @@ -0,0 +1,376 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 60; + objects = { + +/* Begin PBXBuildFile section */ + 2FAD9763203C412B000D30F8 /* config.xml in Resources */ = {isa = PBXBuildFile; fileRef = 2FAD9762203C412B000D30F8 /* config.xml */; }; + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */ = {isa = PBXBuildFile; productRef = 4D22ABE82AF431CB00220026 /* CapApp-SPM */; }; + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */ = {isa = PBXBuildFile; fileRef = 50379B222058CBB4000EE86E /* capacitor.config.json */; }; + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; }; + 504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; }; + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; + 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2FAD9762203C412B000D30F8 /* config.xml */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = config.xml; sourceTree = ""; }; + 50379B222058CBB4000EE86E /* capacitor.config.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = capacitor.config.json; sourceTree = ""; }; + 504EC3041FED79650016851F /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 504EC3071FED79650016851F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 504EC30C1FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 504EC3011FED79650016851F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 504EC2FB1FED79650016851F = { + isa = PBXGroup; + children = ( + 958DCC722DB07C7200EA8C5F /* debug.xcconfig */, + 504EC3061FED79650016851F /* App */, + 504EC3051FED79650016851F /* Products */, + ); + sourceTree = ""; + }; + 504EC3051FED79650016851F /* Products */ = { + isa = PBXGroup; + children = ( + 504EC3041FED79650016851F /* App.app */, + ); + name = Products; + sourceTree = ""; + }; + 504EC3061FED79650016851F /* App */ = { + isa = PBXGroup; + children = ( + 50379B222058CBB4000EE86E /* capacitor.config.json */, + 504EC3071FED79650016851F /* AppDelegate.swift */, + 504EC30B1FED79650016851F /* Main.storyboard */, + 504EC30E1FED79650016851F /* Assets.xcassets */, + 504EC3101FED79650016851F /* LaunchScreen.storyboard */, + 504EC3131FED79650016851F /* Info.plist */, + 2FAD9762203C412B000D30F8 /* config.xml */, + 50B271D01FEDC1A000F3C39B /* public */, + ); + path = App; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 504EC3031FED79650016851F /* App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */; + buildPhases = ( + 504EC3001FED79650016851F /* Sources */, + 504EC3011FED79650016851F /* Frameworks */, + 504EC3021FED79650016851F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = App; + packageProductDependencies = ( + 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + ); + productName = App; + productReference = 504EC3041FED79650016851F /* App.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 504EC2FC1FED79650016851F /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 0920; + TargetAttributes = { + 504EC3031FED79650016851F = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 504EC2FB1FED79650016851F; + packageReferences = ( + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + ); + productRefGroup = 504EC3051FED79650016851F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 504EC3031FED79650016851F /* App */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 504EC3021FED79650016851F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */, + 50B271D11FEDC1A000F3C39B /* public in Resources */, + 504EC30F1FED79650016851F /* Assets.xcassets in Resources */, + 50379B232058CBB4000EE86E /* capacitor.config.json in Resources */, + 504EC30D1FED79650016851F /* Main.storyboard in Resources */, + 2FAD9763203C412B000D30F8 /* config.xml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 504EC3001FED79650016851F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 504EC30B1FED79650016851F /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC30C1FED79650016851F /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 504EC3101FED79650016851F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 504EC3111FED79650016851F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 504EC3141FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 504EC3151FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_IDENTITY = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 504EC3171FED79650016851F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; + PRODUCT_BUNDLE_IDENTIFIER = com.levneiman.homesec; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 504EC3181FED79650016851F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = App/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.levneiman.homesec; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 504EC2FF1FED79650016851F /* Build configuration list for PBXProject "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3141FED79650016851F /* Debug */, + 504EC3151FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 504EC3171FED79650016851F /* Debug */, + 504EC3181FED79650016851F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "CapApp-SPM"; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { + isa = XCSwiftPackageProductDependency; + package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; + productName = "CapApp-SPM"; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 504EC2FC1FED79650016851F /* Project object */; +} diff --git a/ui/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ui/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/ui/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ui/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ui/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..e5177af3 --- /dev/null +++ b/ui/ios/App/App.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "8c83bfc2b93dabf46cab3f7b6880cdfc9d2326aba44bfcc89616094d70d10fda", + "pins" : [ + { + "identity" : "capacitor-swift-pm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", + "state" : { + "revision" : "1af38be000bb5fcd1d8fec09694115cdcb179695", + "version" : "8.3.3" + } + } + ], + "version" : 3 +} diff --git a/ui/ios/App/App/AppDelegate.swift b/ui/ios/App/App/AppDelegate.swift new file mode 100644 index 00000000..c3cd83b5 --- /dev/null +++ b/ui/ios/App/App/AppDelegate.swift @@ -0,0 +1,49 @@ +import UIKit +import Capacitor + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func applicationWillResignActive(_ application: UIApplication) { + // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. + } + + func applicationDidEnterBackground(_ application: UIApplication) { + // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. + // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. + } + + func applicationWillEnterForeground(_ application: UIApplication) { + // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. + } + + func applicationDidBecomeActive(_ application: UIApplication) { + // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. + } + + func applicationWillTerminate(_ application: UIApplication) { + // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Called when the app was launched with a url. Feel free to add additional processing here, + // but if you want the App API to support tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(app, open: url, options: options) + } + + func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + // Called when the app was launched with an activity, including Universal Links. + // Feel free to add additional processing here, but if you want the App API to support + // tracking app url opens, make sure to keep this call + return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + +} diff --git a/ui/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png b/ui/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..adf6ba01dbe256605c5152ac1fd78ae99aaa2a8d GIT binary patch literal 110522 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 C`SD)> literal 0 HcmV?d00001 diff --git a/ui/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ui/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..9b7d382d --- /dev/null +++ b/ui/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-512@2x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ui/ios/App/App/Assets.xcassets/Contents.json b/ui/ios/App/App/Assets.xcassets/Contents.json new file mode 100644 index 00000000..da4a164c --- /dev/null +++ b/ui/ios/App/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ui/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/ui/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json new file mode 100644 index 00000000..d7d96a67 --- /dev/null +++ b/ui/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "splash-2732x2732-2.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732-1.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "splash-2732x2732.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png new file mode 100644 index 0000000000000000000000000000000000000000..33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf GIT binary patch literal 41273 zcmeHvcT|&E*ykHSBNh;JqzD9IP*5R&N{28KWdx+DfKoy+A~p0*5(h_Mq|A&6C{o0s z4IrrW790>}s47hX2}MA_5F#akkYwMy(b@0Y^X=~0{cF#j_^9ymWWx9d1}tXhO$0N5J<3{VjPZXQ0^5P5g3r0=1E(An$eEP{IDp zMfHmTCfJ)^KSl*%FGX2i_K5QF(7mpJGLkol&;t$lVME;HBm8{*gY_Z|6(GBMV4M4E zq=G!uCB(;2;Ro&m#AvJsh>WhaZ+AWT|*nGeg>(o zrK+x>r>>)?sUiRMr2sxH==u#kTlD#_&jSBusBkkRBtQ>|3=a=i3qPfX3-Uy2=<4bs z)isftnyO$A)!<0~5Vr_b|6s*$7SPyWk07sr5HFm+JlCR|J1#WDPyrk%Zwh_^|C;p= z{t6Qa7&5{w0I8v-&Ycp}@w&&q{Q^RRe4&S5_dsHOv3^+pkYKQ{#=m_7ZsJ04!8dXL z6Vv~G{GT2GLVNMzzt8wD$KvPr?<0ak&V_-~_zK8>**o}hWB?Xviw(ww26`9jKleUTPW*qLzXu&kypOx=Hcbfor21L_yQK~7J@ZY;I5jghNh~f z?q!WLdg{7*n(C_RT6*g0ysj5<*S&5;{@YatTw1XAWsTE%8oGMwr~Xq{&}FW>g}D81 zJ74$EyMYVxa|0*r<>%&!MF#kLD#-r}qn-)Q7Z(IR7#!WH|M>e0CMMQFxEo%+;0M9B zrf20ZoHNnV(ACmX)l}1fhI{d%-Ua{Q5I27h>;<%;0tk+pm)CVY>~(kSDJ?fmRjh}m zuIgz`4_#Gv53TE}ZaOz^Xlb}>VX>z*zkMH#^9bd-z_;&T|3Cb`Rgf1Lf^NS5*LJvp z$@Lw*AH9OXRgL@_I+w74Ut7Lj@{qgexp{EIz)-=1yZ+eg3SWQi^?!{3&usWjENJw9 z;TODa!MGbC;ch|LA3Q;{{;KogZD z`?U!?a^tct_eI`YLxY^uw)y^dMVjIvVJ{Qf3NPt!XGAAj-Q*8!@N4y@?%YKCa(r}$akrvSez9|1lBd<6Ii@Dbo6z(;_O03U(>ZxCoTne3{L zSm>r@hi}9pOg~L$(dZoK@uq4j6O_Nic(an~Wu_drNe`O6*2I{t3p_2(LO`I#BWqjZltZ*$)!bsmZXBi9N@>B>s(W`by^`W$t5K<&Rq;(12&xucPo z9@P*22i3KmKUj>_oqL7WCDJVci;s|<9GEaYtr%; zMJDm$zyV!u-w0g6a1`|k*di)wC;?l?0h=f1(GN=59~fiXr{<@Bo}WqVOj$gHsA5_H zAqdW62ArVV_sFO!8k{pRKK%mMNPv@5{UHwHtP^5vyc;G=?43Z>9YPvbtpY~I0`fhA zT1}sWQFI1Y=Qk*nTk#L7zQEO3kWWj{P3AV6dC_T-=-#K+ZK~^%rcv zok$;gSx_YfU?q22j}k)h(X$crfLe3n2pMR}GNA;oRgR1K=jyr*%fXUY zq$^DGT=;%J^D=XGD4P&$k$45mT3@2QwNJ4ZnFJ#Osn(J0WfLd8G#`VcOG*9;t;v4K zZ=Y!Q0-49xf`4x8D+SUT2ETNx!}HSWy^Yj%?`MtQaI&843Qu{{06<})0fx!7Cuzk> zX34hHSog9VT%fC_G34Ytr~9!PZ^*ui*^w98Hlo!-A}M>&8qa5&%~fY#wnOG;iw_(L z#Rr377_oN_W^oGeCqIBo4r&SKEst-r8XfVFDWd5ko~vi0w*9vU?go}01Foq=t0iZ| zc~4!MB8_A5ct7)`E)v(0*^_2>MXQMw;L! zVPd4w59l>7_`t5)dZZT8eb{pMKL=yfOg71gFk}#5lBCitiJDKVOO6-mowCzo- zxvI%`Bo@l9gSoxE>6vV6*Jz$-8$S?zk7XT53Q~31{$f+M=k)=g`%#?cxb0O>;Ew0j znu<#TbQ7-I0B3o@>D~p~{r@Y0-xf^*whbdR9f3pDPfBxMrH*kQ==#(ty9V7b-5lhEfUDA0vnWX$jKRBGnt~ z4KVEZWqagPgHJN8KDZ82jH-XFRSAo{a`4UhgJrunDJ*epvFh~x@MHlCE`0#5COiH*ZS^W5>|&;Znr4Kv-f9zlzp16-m{ z;isz8ft%VI`!=ada z;yBG=?z7_eg{Mbvc0tb=2&=x7WdJ_a)GO#4r6WEkRL>AN2q+k;B8#`Hl3w1T** z4s{e_0os{9!Gwg&E$Y&7n0hKa0egfPq>x|rS4@wO(3+S)_0YKXJ^K9cc8gl8ea$M+ zqE{{ua7?SH?*P*y3&_O1AcQd265QVZRILG1msNoHg?bmlBVgQb4mK!VfR+sg20BV4 zP+<)mM-BYR2CK%7OZils4bihtZ51dKYNdn;o(K)4HN5a)mpwOpM&9(7ca1v6vy}na zT48S!$anQkvx><*g38VNNNXIC&!uF!7qK?O<@C0K!bm|#K&DWe4q#$W;1{Yt-38J# zosL|4K$;icS*^6w?7csxZ=$(A;aA8DMBAZQh(NSND&lFV4h-WS?Y&idV)jG&t{*Zmq7l`*;h))3u2RCU4@o$9Z=O+@{^t~pgV$aj6jU?XSh0GtYwiID9=44` zg;qBXUs0I44MjwZJj4+JB(D7hR7bU=m{Jb&6FUg7&rqE?$!a(iL`tP2O|sY<#NiUs zsEcBkw;kut^JMAGjFHd}ohUUm_0JVc<<;n`2;JCea8q36g~lxhp4{zD1M1lRX$i%F zQ{{|l92MtGwELC%Vy#=ffRq+FNRVRZ>a*Zj#>f-kk{2nh;4SG7A+2Mn)H}6j(su}i z{RE@j*qT_#@nI@M?!XAJq%e1a&`cg#={-y{=cqg->DN|SUHN<$cdp&0UobNW5Th+- z`3*E&L)p_DJ2{tgs5=;=-J9k0r1T+^^#WLW#1DtXtw-n4SDjAm=h#`o z#Fl-a-xs)+OxV4<=L~Zl;B3}S1#9P?xv@jbOg|4U;{Lb#BfTS#ry%S~)^BpGxoy$= zxQPS|ySI+rXL~9a|69)Pj{`OHIR=Z7fLP#HMqT|d_!vvDU3yKx%z2EqB;34pXEt)w zmpFB4aj`Qf`>oOGt}j>Gx5gLhXm>|H%UJ2CKu;0Gw+k2Sw0+{=$epJ|>EUX+*8M2ts5*cZzCS&brN+{`W0Af2@%W9Nc z`%eF~(7y2Lo*maV5srqoDv^y25EgQhPwh3Be)@FSRSssx;Ui$z%=UZ#(=!9oXBj!y zEU)6#)%2;WX2_4CxzCf45%&jD zhSga{3WdAFottIx?L{ds8-O> z#Z5OaRu&t%?2wH~uPDZp4o-OwTr}g3sr3kti|=duw^um*vQ#^#7oI%yZoA+&!{LJE zgaqYT$+A8BZMuWTR+Fp8#_kAq9oMdGSSm>;F_Tz&1K+vNXG%ZoJ79sh+@UH{zo41@3VK)T-maPlJ$~>@EFl&rwKR#pDt^&Ef&S{8sjnVCgwP);+ zuhzpA+;2uwsf7ZOOGNTsAi-^?F_>*G-yGq~@$Ds1M14y7)5v4N!=OP1p?mTC$S(n07-#s!gT4Sin=ADd8SSY@ygkSDd;;zu- zeqpW?trSPk+=!X7bs~fhEx26si;)T>7qM_%lW)uxvZObkU6shon9A^G`^UIt2W6)E z-@0=X6k466XbM6154CP(wAU%14ALO+7q~tSriG7yy6V%NsaLk2nd9Yh5au3CTm9UL zl)Jn3z7R?|Cz5wEcC*0nZ`;R+ayx;{+LOY!hNOj6T`Hr0bFdW~Si0x-9d~#-yCIe} zXyDGdPDb0`uY2Zf#S!co}0fI(c;WX7el5=)1U~C--L=y{Cp7 zSe9X{LZJ=<^6ojXl^MvT0~luIrc4R$5Ow4Vr2y@TBw0eV+~Qbu zO*m_Jf$TWaX3l#n*s8-5C3bAt=tqwFcnPhmAYy$ns15sJ9znTL&7 zy51R+oyM08h_hm0H{{S`SFTwRR8_%D`ApZl5rVaVe8)@S(Dy;MJd5d2T9;bfzx`f^ z^P4i(xg2ReOG*;HC7va+HJb8SY29{i=T@=mf!9YVrpz?Mr+Zdn93@;;K8f}l8GZi! zfR4J_>r?x)3|ZoB4FKnZ>GTY04M6Mw5vk*8b2%GNtIqkHp7K|J)A+t_~fnv?uNVao>B=>9anCV;w2XsnG0Ttnla%8=8wHTRw>o+Tbj#v5}Qx`NCG>w+#qyJU(bcTe~TS_V3M)*Wd- zcL%O_I8`kx(&jOlW7J+2WlmyYv}=B5Lq*v-zV;d3S;1)idd$Xw!0Ei_TT}I4g%@6% zQZn`;^{(9D=tPRXnJ*{3a`TstUP`-X->W_Sod;57J5F)KWDgX85rf%=vMA<15nHr^ zj9CBQs2%5Eh2O#4tJRFFoZA9zn3uPsT3w0K(K+zfMXc0ZGF&cgp;TFQ+Pa4P52I|r z-fo24!5A#ag#ObZQ{JX9{ds{g4ra`+D50}09Zfz%hfr!l>W|*lrafOw!K9Q2Uu8{$ z;-bGc6jqZMU$TLjqwOrH5^4?j*c7;T|KR3hg>()xLrXRstP3bZhU0n3uR@f_WRLA? z&RPrS-NJfq%6QT$ruy#@I~|1nLj3bB_B^bLsT`^=@OXGuB|lzjaRqmJcITG+ZolKk zR|!YvxYKTRJHYil;?;iG;BW~hicTn#*yC{6bCMosH_K^gIE(UY7?leK`cc*6o}x!j z*2h0TMRCm(Qb*_xGCN~TdL(>SfzhWZUd5#Cdx&-Kf?AnPOGp?sn}i=?n^sfQ%8%d& zlrd48O;?8IPmQ8kdvLEZHk#F1AK56|bdKFj-d4;;FN!!e6FvrLpK7$GK zIZ6F^1B%1&zdFKDcG_G2h>1g4_A>+LJ{#CX&-Ho?yNz`Ay_(NWJti0ZYd;qV z-nQYmYCudq$=Yhh5+aFL(cUHW6 znQfob|9UsYdc0A~+zY_)O+f@yZTTLAi$)jB# zXDa3{{l>b4FxX@K`) zu^RnoFBXVypIA-C#?HH^3bIiW-Aixv$Q)ALNGNRU)X>FEykzuiEnh!z;7x|Oq{xRD zzY2`DD0kL!l2Bw#$`wWe_BgfjmBtU8P_I;s^fdFO(zQmy`{m?xK}pe^l+Cq!ve4?fX+LXPoRQqkId%u9*wq)euW=4nsHO;;o7znw zg7wyOywx$`txj}S5MV{PUhGYeEpKW*SU>hBEbAd-d>_WnUUF>4zT~Hbgt`^G7_SOl zqUIpm$63w~|F)(eV~map1eUws576p8 zXG%^jP1CC?dXm{5&+xVRC=tO=im;C35tu!$QKwkjT^gBibyT3#Q7MZ8mUsbDxKfRi zk?>YRK(YwRWqHDfY~5qJt{zHTiz8H!L(0 zRw*Z}E?eCh3044daZi%WCD2Lb9Jtn&m3Oz{pj)?&>EpFG0-@|{E~Fd085p1CPGp4* zmbDC64E1@`XzGLvS!AM6XxOacXKK&e3f51Q$~~PP&cw(;&Q@g{?$43+kcnvL^ zZcW^zD6~;b3W{-2AkzJE-~l(hymPCqTi~>JEP+~HRQSsbK(W+BC|Fh-=hE$X{8H`oEV%ugZ?}GpVLf!N zRsE7h{wsQhIah~wK;3@tU(h4&K1*@F-^V`)%9#Ir;Zylodb48F(D36E3{FOilXPh)kQb#RSvT(jS zUmgV0{#vEAd&JYPgXUKPc!xJ~NiT|Kg8Mr*Pn8EbgC)fmC4bAB>$&C>kfX@~xhg_A(dj!7V;de`$7A?)s@Cu925UuEtMAXS(oUq4H=h;#Xcg4`r1e z;=DaJMGxC-!L0``$ECJhH+If7a`Dqqa_4|bEuteb=jH2h)RG~q&7+L1{*3vvZ%*xb zw81L6X~m02Z~&CQw8n*Ogv2c0qYawgF`}0&#ePafn%w2gVR8}Hi$N>fVIK@p@j};b zPX1N9VIV5MbxK5u^R8;09xsh}vR4sS*ok{0kYDD$uiJn~ZNJOgrGMZN0=%LhtVdmY zlCIT8$b3LrAXbQBKPY3gqdlUBV|=qqXR4P8a;+;dtE$6tPQ^AOCVLkH&VD#4q;7ff zU5Zg=#cEG|#8f0SSl3q&%}1<&yT*J!I_V;(ToAt;g!HpN`C+A5u*H7BBLYk=OZ1$I zYL0Kd;tn-$FI^@S-?0!XD_2MkKs9_$5&DE&jCd2~kO-2z63^^Kh=xU8ard@iYRf^& z6Ok*FiD?p^biAOS$_!y;OA{)pM-uih`(Qn_2PqfEn6_j2JF5u?D5lvQ%EWqznN-+G zG8Qyh#;x!HC(@^dG{U*Yw5KtBdGoQ5 zOXoA#z>>7hhMwqT$&ks@xvi1nP<+onL2)yG+%{sn>ecm~*pp;`vQ20Qw2vEmLfg1Z zgO?mA2^8{coBL;1M$f}!7<;0~??kiWVZNhtO94OUh$cjU32PQSAhfBP@*}Y~WmG{R z_V*w5dVcV`ZJc!2+#q<^fICxlC<_)LgDkbssaUwYQBoPJkRQsS|IW6p$o+*yVHpVL zoljDPx$6e1{3>ccHg7;&?WUlUZJ+Gz*Xa;X@BFOF#@-KdupIwGYVrBA%g~C*{{=0y zj^+sNRd>epVsFhKEPriIUwVVzCT3#j(9xSuR>P%d$HuG->#31|MGi z{-IP`yZfJ?bvQ4M??ZjY?4G};cXFR~$ANg8O(sSHbVI4O<2yB|6kLWTAu*wtwMWJ8 z%x$gByko-NRn+o6T_T1h8c%lsjRurKDSE3|{hBH56-T@_+J;oqu-@lQZ$9l(NHA&S zG!>oPM;p@sr3JNoHBo7Lx+NG`Z5v*XYmdq8t|A6TBa&a6$_^emW|JY)y8RT5eiVI@ zy>(2K`hbgr!->#FS9!3tG6@!Z!i%)qsMDbX2M{*igW}a{)s#(6FK#7t`F9NdMMZF1 zEJDtDE76bnH7~Atd8;k-D;ZLbI#TgI+lbwqGmC>P6k2Iw1}QOMa{q11ZRvS7NHGf$ zbK`D?+=!3~=F!aK#deTKFn!JF?Oa8W#C`UdUtO~ z7s%WVg%t|JUyRnCEBC|s&~p!6RK|iihKT4*_*#)Jowq-xeq9KsjENK^E2GXiUXKpu zCrB?cc;4sMh|rRaaJLX{5At7dUKbNv^(AhuOYXmUO3<()@w%2iMq9nRy zcuKL$35#5p>=8_G>my`80fJAu8mc_rxZGa1@`F^EX`1-FWfoVK17c!aWtNNpn{ra% z<%Wgc8-C1DP8Md(k~K=NAYc*XUT1dq<5dNLnU@7M$yin(#qyh!aNRrg`de9|vZTgs z=aPeQGPrlA--2Ny@?WHa=_^!$Fuo1F1^lQ+lErLg*Bvlviz=I2wc?~+TAqK;05x2i zph!xMMQezKs)>9aCdL9lxA!Rshy?~#tO9d4*m|fS1fX6arXJ(feVceFDidLlkA4J2 z67d#fdu7rYuiwJZ5A?k0hZ&_s|J-;oma?j`PQ1f@6K2pl$H*S$M9$2455(7$2!ge* z*TZJTjiN@kCG9T3Kvz@ZX248{j?{T9B3Ids>#T^4U9QYEf9H|dEaKSpykPAPBk1*2 zjquirntl7$y2spR#tp!`-1EEBR8sZ_x>3Pc{XTAC!>qA6(%w1M_U01-v-@0>iGn-_ z5q%5R)twznV-djX)OWG^7F2388~Z7#;u(dC$YOTi@StWQQY*UC^<+L&G;qLza`n@k zhOC^x&7(f(#NgCd92T$A_fD^xcLTxzgp{<=7#RI%gCy05lG+JaR4mnbj*r#ihD985 zUtB%vs#cKd75BISg=x_yk0>jt#hg$`(Sxh^f*YZIh1xwpq))Gk@mEL^OCv2$(B+*T za;=MO_xi#Bw*=mHmy|6h<;RS>L3ewdQ^4X72H& z)u(S$POfsQ(TU1fow}1aW}-$;^?S+SDbi=TN_P8mU;p^^em`NNbRdm;eo8SESL)`NQkC{AS*5A<7pB zFQ!|+TwUs3_LU@jPJ%1b#!XXS84!buW;nldCr@PZitRD`G0d>-QgYS-;LYf#?v=1V zyXY5#9iyr3n5FrROIoXdccW{Gxd}lv_-NkX=HE0uEaQ`&tjtU&x~lYI#f^KqNLiG;V;jyIfna}9tag&JSeG(kf7kBq+Oez{*Vl&wP=}j1ELCizgPxa zoxNN%?ctfa`~;-3NjFdEa!mwbPN;S5hUf1bTMIM&W+!>UIR=Zk8t1WFu?+Oy*-%Y zbolyK>Lu7>gE%6*WXlTW*de)tm;&3Zd6;CHljBV%PfAJ8~GJCbloEcg8dAS4## ziQrCssT=In6FYq4;d^!OV56PIg-cbzW6WYGLkx)F2@gYP>?E;TDtd4A>Xo6Urze&v z3D-m%cH|8XIj~z|W6qQdtUN$Pj|jDX+$xXQv~^-FFFslFpT*PTI;1&^x3$t;kEOkS zB5YF*$>GinUe?&WJzP6Oj#2ctDxhih{DXt{JYg&u{7BkcK=TseIGjL+>iP^X(aKMv z`hhxo0r|pE7+99}bZ;tpo4<1)G~>R&;ujUA>GlBI_XhndVb!JC{?1$|bZU61M&mU0HRn*|OxQm(bP zV4Xlu4~u4*Cy$;#=qqw_mc98?Bi(?a36<1RzJoSr*QHvv4!f6H;R&=mZR9vkcmK&G zooIWhvDQy-?p7zx7DqDUF92vz+OsXv8l-nx=U}aL?lyOY^Lu2NcwU-Bad?%@T~IKDT>;Y)-?E2(e?9F~vfy*IgtgdwxBo zyu|7Ll6c94GyVzn@nDx;y>*wmpzC~15r^jm4BoA(lVCD@W_o#MO$I_EmbD(Iv zt%#R%brPX%uLrzVDE8(qnFZb?%ZUCcnBOn>$#898>Cnl{r>`@_b);w`>>fdLzyhL) zSBJ^6%k|``AMUdk@P+SpmXZ$0NQxAXc~avIk3nw0ya91sLIR^4l|o=L1NGB4IR$7{ z8$~mz!X>%@qhYW)={>UBX672O`UTdO$ucnm+QEwJ3)5Y$oQs`=+(p#=S0kXy1$XHP=5>Ha zwWm-UlLvqg;F-sl*@&5rFKL8O2kpYyG{Y;xd%X?@1k0Kug-qg zwT%m&$G#}ocTV})2bYmHz#B}~9ztM5LsFns5xiU2P!fb1p~k_E^POp74A|RP=Tpat?4`T-SBmGyQoH~Vf zQ8Kld?0*WFYuIquiL1zfu}!>4RU^TU+c&;%RbM5%C_Vq^7skSqi+}|?%6Eerh zMQ`XcnJikGG}P@axXjA)W{u1psZ zd{M&0f4C=Zv>`!XGj?+XiX4Ofe0^trjfFAk>{Ux#(fLH3Xg&NgA&IjA7nu#@I*Z^{ z?g<;wbcF8TnSn>PLX21Mi$R?iX5%E4nX|Uq(uno|n5z+RT8M-4zFsro6kMsOxa)%#sL0 zujcQOY?;=}`bd!7tXO|*fQ|(+5Yj0{8>hC9RSgOaQt}mJ*LxA4`f^f8>lxEtkotU= z`&Cis9Kuh6_4!Uvea+QY*OKTj0c>cDO0aVi+9!b;e;-`<3OTBfF&eCLaL+0id%h%f zE2b~#ZZ%$c``DL$P6bM}0#5QOx4u2Fw30zto1zZ-bMF-LpQR5OB?-A(zcE%(5eMnp zN7iMG%U+0JBl^T`;yX)~yq(W0rb&y@>L8S4M0xeC`_vC9$XG=el7__gJUMIRgh2bs zQ}no3^@{ZBaX|E(KpyXO4rgJ0q-wsrx;&~fa*h3SRWryp2VxJ%fwcWE7!R4rfmA?wveyhr;mPwc#C^T~p<% zE$AwQnCcOART=B)BbERfJe1p>5gS-d?v@*zdR2MoY)POc_fP;xk%GKu(iN;+R_i?# zQZEx&vB!UOy9k7=HUQc+ZGb|)r4-RE6x7)>1cDv!OA`P6OU~Fx8%4{)AHnpQdy1na z>S}A&{(Su9_T)KU-xIvP%`w!ln1#c%t>gqPLDxkNQpp=VXdq1Ve+Um)pWAL4yASi` zF5L;Pd;~gwhx~B74YnjOm>_<}1Js`td(;iT=5#S^6KO(^9gi(yS_a$9_C9NB_OhY%h0HQEdjo=m(jzU!nZY%Rw z?w{P3>shEY1Ge}b`S3t4!FLLNDDXpp9|{lx{7~SB0zVY^p}<9euOGig0$(-rlZtPy zz)z+43FKE_;Pd0Rpx{>(_(hkmzQE^4K0osL5ki0;3j9#uhXOwoKm_;#i7$}8QUQNg z;O`3jU4g$V@OK6N6$E|-=_?cR1=4?6Vc#|ARQe$B*9vbgOc%~sqMx31{pr5}7WQ#E literal 0 HcmV?d00001 diff --git a/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png new file mode 100644 index 0000000000000000000000000000000000000000..33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf GIT binary patch literal 41273 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 literal 0 HcmV?d00001 diff --git a/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png new file mode 100644 index 0000000000000000000000000000000000000000..33ea6c970f2df1db62a624a55e5bbcc4ee07bbdf GIT binary patch literal 41273 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 literal 0 HcmV?d00001 diff --git a/ui/ios/App/App/Base.lproj/LaunchScreen.storyboard b/ui/ios/App/App/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..e7ae5d78 --- /dev/null +++ b/ui/ios/App/App/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/ios/App/App/Base.lproj/Main.storyboard b/ui/ios/App/App/Base.lproj/Main.storyboard new file mode 100644 index 00000000..b44df7be --- /dev/null +++ b/ui/ios/App/App/Base.lproj/Main.storyboard @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ui/ios/App/App/Info.plist b/ui/ios/App/App/Info.plist new file mode 100644 index 00000000..aeb90075 --- /dev/null +++ b/ui/ios/App/App/Info.plist @@ -0,0 +1,62 @@ + + + + + CAPACITOR_DEBUG + $(CAPACITOR_DEBUG) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + HomeSec + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + + NSAllowsLocalNetworking + + + NSLocalNetworkUsageDescription + HomeSec connects to your HomeSec server on your local network. + NSMicrophoneUsageDescription + HomeSec uses the microphone for push-to-talk camera audio. + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ui/ios/App/CapApp-SPM/.gitignore b/ui/ios/App/CapApp-SPM/.gitignore new file mode 100644 index 00000000..3b298120 --- /dev/null +++ b/ui/ios/App/CapApp-SPM/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +DerivedData/ +.swiftpm/config/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/ui/ios/App/CapApp-SPM/Package.resolved b/ui/ios/App/CapApp-SPM/Package.resolved new file mode 100644 index 00000000..7e636d22 --- /dev/null +++ b/ui/ios/App/CapApp-SPM/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "capacitor-swift-pm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", + "state" : { + "revision" : "1af38be000bb5fcd1d8fec09694115cdcb179695", + "version" : "8.3.3" + } + } + ], + "version" : 2 +} diff --git a/ui/ios/App/CapApp-SPM/Package.swift b/ui/ios/App/CapApp-SPM/Package.swift new file mode 100644 index 00000000..2a382419 --- /dev/null +++ b/ui/ios/App/CapApp-SPM/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 5.9 +import PackageDescription + +// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands +let package = Package( + name: "CapApp-SPM", + platforms: [.iOS(.v15)], + products: [ + .library( + name: "CapApp-SPM", + targets: ["CapApp-SPM"]) + ], + dependencies: [ + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.3") + ], + targets: [ + .target( + name: "CapApp-SPM", + dependencies: [ + .product(name: "Capacitor", package: "capacitor-swift-pm"), + .product(name: "Cordova", package: "capacitor-swift-pm") + ] + ) + ] +) diff --git a/ui/ios/App/CapApp-SPM/README.md b/ui/ios/App/CapApp-SPM/README.md new file mode 100644 index 00000000..03964db9 --- /dev/null +++ b/ui/ios/App/CapApp-SPM/README.md @@ -0,0 +1,5 @@ +# CapApp-SPM + +This package is used to host SPM dependencies for your Capacitor project + +Do not modify the contents of it or there may be unintended consequences. diff --git a/ui/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/ui/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift new file mode 100644 index 00000000..945afec8 --- /dev/null +++ b/ui/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift @@ -0,0 +1 @@ +public let isCapacitorApp = true diff --git a/ui/ios/debug.xcconfig b/ui/ios/debug.xcconfig new file mode 100644 index 00000000..53ce18de --- /dev/null +++ b/ui/ios/debug.xcconfig @@ -0,0 +1 @@ +CAPACITOR_DEBUG = true diff --git a/ui/package.json b/ui/package.json index 140f99e1..5b0a123c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,6 +4,9 @@ "version": "0.0.0", "type": "module", "packageManager": "pnpm@10.15.1", + "engines": { + "node": ">=22.12.0" + }, "scripts": { "dev": "vite", "api:generate": "node ./scripts/api_codegen.mjs generate", @@ -13,11 +16,17 @@ "test:watch": "vitest", "test:e2e": "playwright test", "build": "pnpm typecheck && vite build", + "ios:build": "pnpm build && cap copy ios", + "ios:sync": "pnpm build && cap sync ios", + "ios:open": "cap open ios", + "ios:run": "pnpm ios:sync && cap run ios", "lint": "eslint .", "check": "pnpm api:check && pnpm lint && pnpm test && pnpm build", "preview": "vite preview" }, "dependencies": { + "@capacitor/core": "^8.3.3", + "@capacitor/ios": "^8.3.3", "@tanstack/react-query": "^5.90.21", "hls.js": "^1.6.16", "react": "^19.2.0", @@ -25,6 +34,7 @@ "react-router-dom": "^7.13.0" }, "devDependencies": { + "@capacitor/cli": "^8.3.3", "@eslint/js": "^9.39.1", "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^6.9.1", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 0cbef57d..a82d63d2 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@capacitor/core': + specifier: ^8.3.3 + version: 8.3.3 + '@capacitor/ios': + specifier: ^8.3.3 + version: 8.3.3(@capacitor/core@8.3.3) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.4) @@ -24,6 +30,9 @@ importers: specifier: ^7.13.0 version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) devDependencies: + '@capacitor/cli': + specifier: ^8.3.3 + version: 8.3.3 '@eslint/js': specifier: ^9.39.1 version: 9.39.2 @@ -189,6 +198,19 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@capacitor/cli@8.3.3': + resolution: {integrity: sha512-FHebL02KEyU5vs+Os5s1yZuE8QT3FzxoO4nZLywGk7Ny957E6gOujKouGKsnKYq01eAWWJGGV/Fv04rY27tSsw==} + engines: {node: '>=22.0.0'} + hasBin: true + + '@capacitor/core@8.3.3': + resolution: {integrity: sha512-xx1FIriZQ5jwqEkZwmWQHfXNEn5a9ZLOtdFoJXslh0ian6T/EU+QJtRmZw3KRsmcUV6p5ufczBrzF1rVP8Nu3A==} + + '@capacitor/ios@8.3.3': + resolution: {integrity: sha512-BHlTOxarrvkaqDdlTxKwip+dQmfQSnfctizpheR7SWp/VIlR0HcPpYzWMTiVbHQLq3nLRUdeZO1wSPVGvxzAPA==} + peerDependencies: + '@capacitor/core': ^8.3.0 + '@csstools/color-helpers@6.0.1': resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==} engines: {node: '>=20.19.0'} @@ -439,6 +461,42 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@ionic/cli-framework-output@2.2.8': + resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-array@2.1.6': + resolution: {integrity: sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-fs@3.1.7': + resolution: {integrity: sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-object@2.1.6': + resolution: {integrity: sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-process@2.1.12': + resolution: {integrity: sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-stream@3.1.7': + resolution: {integrity: sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-subprocess@3.0.1': + resolution: {integrity: sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==} + engines: {node: '>=16.0.0'} + + '@ionic/utils-terminal@2.3.5': + resolution: {integrity: sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==} + engines: {node: '>=16.0.0'} + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -662,6 +720,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/fs-extra@8.1.5': + resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -676,6 +737,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/slice-ansi@4.0.0': + resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==} + '@typescript-eslint/eslint-plugin@8.55.0': resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -770,6 +834,10 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -817,9 +885,24 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -827,17 +910,32 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + big-integer@1.6.52: + resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} + engines: {node: '>=0.6'} + + bplist-parser@0.3.2: + resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==} + engines: {node: '>= 5.10.0'} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + browserslist@4.28.1: resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -856,6 +954,10 @@ packages: change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -866,6 +968,10 @@ packages: colorette@1.4.0: resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -913,6 +1019,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -926,6 +1036,13 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + elementtree@0.1.7: + resolution: {integrity: sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==} + engines: {node: '>= 0.4.0'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -934,6 +1051,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -1022,6 +1143,9 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1046,6 +1170,14 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1064,6 +1196,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -1072,6 +1208,9 @@ packages: resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} engines: {node: '>=18'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + happy-dom@15.11.7: resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==} engines: {node: '>=18.0.0'} @@ -1125,10 +1264,26 @@ packages: resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} engines: {node: '>=18'} + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@4.1.3: + resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -1136,6 +1291,10 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1181,9 +1340,20 @@ packages: engines: {node: '>=6'} hasBin: true + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -1216,6 +1386,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1227,6 +1401,14 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1235,6 +1417,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + native-run@2.0.3: + resolution: {integrity: sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==} + engines: {node: '>=16.0.0'} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1244,6 +1431,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + openapi-typescript@7.13.0: resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} hasBin: true @@ -1262,6 +1453,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -1281,9 +1475,16 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1301,6 +1502,10 @@ packages: engines: {node: '>=18'} hasBin: true + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -1317,6 +1522,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1354,6 +1563,10 @@ packages: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -1366,11 +1579,26 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + sax@1.1.4: + resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} @@ -1401,16 +1629,41 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-indent@3.0.0: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} @@ -1430,6 +1683,13 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tar@7.5.15: + resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + engines: {node: '>=18'} + + through2@4.0.2: + resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1460,12 +1720,19 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -1493,6 +1760,14 @@ packages: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1502,6 +1777,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1614,16 +1892,36 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + yaml-ast-parser@0.0.43: resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} @@ -1631,6 +1929,9 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1782,6 +2083,36 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@capacitor/cli@8.3.3': + dependencies: + '@ionic/cli-framework-output': 2.2.8 + '@ionic/utils-subprocess': 3.0.1 + '@ionic/utils-terminal': 2.3.5 + commander: 12.1.0 + debug: 4.4.3(supports-color@10.2.2) + env-paths: 2.2.1 + fs-extra: 11.3.5 + kleur: 4.1.5 + native-run: 2.0.3 + open: 8.4.2 + plist: 3.1.1 + prompts: 2.4.2 + rimraf: 6.1.3 + semver: 7.7.4 + tar: 7.5.15 + tslib: 2.8.1 + xml2js: 0.6.2 + transitivePeerDependencies: + - supports-color + + '@capacitor/core@8.3.3': + dependencies: + tslib: 2.8.1 + + '@capacitor/ios@8.3.3(@capacitor/core@8.3.3)': + dependencies: + '@capacitor/core': 8.3.3 + '@csstools/color-helpers@6.0.1': {} '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -1941,6 +2272,86 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@ionic/cli-framework-output@2.2.8': + dependencies: + '@ionic/utils-terminal': 2.3.5 + debug: 4.4.3(supports-color@10.2.2) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-array@2.1.6': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-fs@3.1.7': + dependencies: + '@types/fs-extra': 8.1.5 + debug: 4.4.3(supports-color@10.2.2) + fs-extra: 9.1.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-object@2.1.6': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-process@2.1.12': + dependencies: + '@ionic/utils-object': 2.1.6 + '@ionic/utils-terminal': 2.3.5 + debug: 4.4.3(supports-color@10.2.2) + signal-exit: 3.0.7 + tree-kill: 1.2.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-stream@3.1.7': + dependencies: + debug: 4.4.3(supports-color@10.2.2) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-subprocess@3.0.1': + dependencies: + '@ionic/utils-array': 2.1.6 + '@ionic/utils-fs': 3.1.7 + '@ionic/utils-process': 2.1.12 + '@ionic/utils-stream': 3.1.7 + '@ionic/utils-terminal': 2.3.5 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@10.2.2) + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@ionic/utils-terminal@2.3.5': + dependencies: + '@types/slice-ansi': 4.0.0 + debug: 4.4.3(supports-color@10.2.2) + signal-exit: 3.0.7 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + tslib: 2.8.1 + untildify: 4.0.0 + wrap-ansi: 7.0.0 + transitivePeerDependencies: + - supports-color + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2139,6 +2550,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/fs-extra@8.1.5': + dependencies: + '@types/node': 24.10.13 + '@types/json-schema@7.0.15': {} '@types/node@24.10.13': @@ -2153,6 +2568,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/slice-ansi@4.0.0': {} + '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2295,6 +2712,8 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@xmldom/xmldom@0.9.10': {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -2330,14 +2749,28 @@ snapshots: assertion-error@2.0.1: {} + astral-regex@2.0.0: {} + + at-least-node@1.0.0: {} + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + baseline-browser-mapping@2.9.19: {} bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 + big-integer@1.6.52: {} + + bplist-parser@0.3.2: + dependencies: + big-integer: 1.6.52 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -2347,6 +2780,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.9.19 @@ -2355,6 +2792,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-crc32@0.2.13: {} + callsites@3.1.0: {} caniuse-lite@1.0.30001769: {} @@ -2368,6 +2807,8 @@ snapshots: change-case@5.4.4: {} + chownr@3.0.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2376,6 +2817,8 @@ snapshots: colorette@1.4.0: {} + commander@12.1.0: {} + concat-map@0.0.1: {} convert-source-map@2.0.0: {} @@ -2421,6 +2864,8 @@ snapshots: deep-is@0.1.4: {} + define-lazy-prop@2.0.0: {} + dequal@2.0.3: {} dom-accessibility-api@0.5.16: {} @@ -2429,10 +2874,18 @@ snapshots: electron-to-chromium@1.5.286: {} + elementtree@0.1.7: + dependencies: + sax: 1.1.4 + + emoji-regex@8.0.0: {} + entities@4.5.0: {} entities@6.0.1: {} + env-paths@2.2.1: {} + es-module-lexer@1.7.0: {} esbuild@0.27.3: @@ -2563,6 +3016,10 @@ snapshots: fast-uri@3.1.0: {} + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -2583,6 +3040,19 @@ snapshots: flatted@3.3.3: {} + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + fsevents@2.3.2: optional: true @@ -2595,10 +3065,18 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + globals@14.0.0: {} globals@16.5.0: {} + graceful-fs@4.2.11: {} + happy-dom@15.11.7: dependencies: entities: 4.5.0 @@ -2650,14 +3128,26 @@ snapshots: index-to-position@1.2.0: {} + inherits@2.0.4: {} + + ini@4.1.3: {} + + is-docker@2.2.1: {} + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 is-potential-custom-element-name@1.0.1: {} + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isexe@2.0.0: {} js-levenshtein@1.1.6: {} @@ -2706,10 +3196,20 @@ snapshots: json5@2.2.3: {} + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 + kleur@3.0.3: {} + + kleur@4.1.5: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -2737,6 +3237,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -2749,16 +3253,44 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + ms@2.1.3: {} nanoid@3.3.11: {} + native-run@2.0.3: + dependencies: + '@ionic/utils-fs': 3.1.7 + '@ionic/utils-terminal': 2.3.5 + bplist-parser: 0.3.2 + debug: 4.4.3(supports-color@10.2.2) + elementtree: 0.1.7 + ini: 4.1.3 + plist: 3.1.1 + split2: 4.2.0 + through2: 4.0.2 + tslib: 2.8.1 + yauzl: 2.10.0 + transitivePeerDependencies: + - supports-color + natural-compare@1.4.0: {} node-releases@2.0.27: {} obug@2.1.1: {} + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + openapi-typescript@7.13.0(typescript@5.9.3): dependencies: '@redocly/openapi-core': 1.34.6(supports-color@10.2.2) @@ -2786,6 +3318,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2804,8 +3338,15 @@ snapshots: path-key@3.1.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.2.6 + minipass: 7.1.3 + pathe@2.0.3: {} + pend@1.2.0: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -2818,6 +3359,12 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + pluralize@8.0.0: {} postcss@8.5.6: @@ -2834,6 +3381,11 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + punycode@2.3.1: {} react-dom@19.2.4(react@19.2.4): @@ -2861,6 +3413,12 @@ snapshots: react@19.2.4: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -2870,6 +3428,11 @@ snapshots: resolve-from@4.0.0: {} + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + rollup@4.57.1: dependencies: '@types/estree': 1.0.8 @@ -2901,6 +3464,12 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 + safe-buffer@5.2.1: {} + + sax@1.1.4: {} + + sax@1.6.0: {} + saxes@6.0.0: dependencies: xmlchars: 2.2.0 @@ -2921,12 +3490,38 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + + sisteransi@1.0.5: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + source-map-js@1.2.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} std-env@3.10.0: {} + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-indent@3.0.0: dependencies: min-indent: 1.0.1 @@ -2941,6 +3536,18 @@ snapshots: symbol-tree@3.2.4: {} + tar@7.5.15: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + through2@4.0.2: + dependencies: + readable-stream: 3.6.2 + tinybench@2.9.0: {} tinyexec@1.0.2: {} @@ -2966,10 +3573,14 @@ snapshots: dependencies: punycode: 2.3.1 + tree-kill@1.2.2: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 + tslib@2.8.1: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -2993,6 +3604,10 @@ snapshots: undici@7.22.0: {} + universalify@2.0.1: {} + + untildify@4.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -3003,6 +3618,8 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + vite@7.3.1(@types/node@24.10.13): dependencies: esbuild: 0.27.3 @@ -3085,16 +3702,38 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + xml-name-validator@5.0.0: {} + xml2js@0.6.2: + dependencies: + sax: 1.6.0 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xmlbuilder@15.1.1: {} + xmlchars@2.2.0: {} yallist@3.1.1: {} + yallist@5.0.0: {} + yaml-ast-parser@0.0.43: {} yargs-parser@21.1.1: {} + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.3.6): diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json index 8a67f62f..6ac41954 100644 --- a/ui/tsconfig.node.json +++ b/ui/tsconfig.node.json @@ -22,5 +22,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "capacitor.config.ts"] } From f2287d64d6d894bb26073f81e91e088c19931e7d Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 10 May 2026 17:43:33 -0700 Subject: [PATCH 06/36] feat: add iOS native runtime detection --- ui/src/app/nativeRuntime.test.ts | 61 ++++++++++++++++++++++++++++++++ ui/src/app/nativeRuntime.ts | 9 +++++ 2 files changed, 70 insertions(+) create mode 100644 ui/src/app/nativeRuntime.test.ts create mode 100644 ui/src/app/nativeRuntime.ts diff --git a/ui/src/app/nativeRuntime.test.ts b/ui/src/app/nativeRuntime.test.ts new file mode 100644 index 00000000..d99e1653 --- /dev/null +++ b/ui/src/app/nativeRuntime.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const capacitorMock = vi.hoisted(() => ({ + getPlatform: vi.fn(() => 'web'), + isNativePlatform: vi.fn(() => false), +})) + +vi.mock('@capacitor/core', () => ({ + Capacitor: capacitorMock, +})) + +import { isIOSNativeApp, isNativeApp } from './nativeRuntime' + +describe('native runtime detection', () => { + beforeEach(() => { + capacitorMock.getPlatform.mockReturnValue('web') + capacitorMock.isNativePlatform.mockReturnValue(false) + }) + + it('reports browser mode as non-native', () => { + // Given: Capacitor is running on the web platform + capacitorMock.getPlatform.mockReturnValue('web') + capacitorMock.isNativePlatform.mockReturnValue(false) + + // When: The app checks the runtime mode + const native = isNativeApp() + const iosNative = isIOSNativeApp() + + // Then: Browser mode is not treated as native iOS + expect(native).toBe(false) + expect(iosNative).toBe(false) + }) + + it('reports iOS Capacitor mode as native iOS', () => { + // Given: Capacitor is running inside the iOS native shell + capacitorMock.getPlatform.mockReturnValue('ios') + capacitorMock.isNativePlatform.mockReturnValue(true) + + // When: The app checks the runtime mode + const native = isNativeApp() + const iosNative = isIOSNativeApp() + + // Then: The iOS native shell is detected + expect(native).toBe(true) + expect(iosNative).toBe(true) + }) + + it('does not report non-iOS native platforms as iOS', () => { + // Given: Capacitor is running on a different native platform + capacitorMock.getPlatform.mockReturnValue('android') + capacitorMock.isNativePlatform.mockReturnValue(true) + + // When: The app checks the runtime mode + const native = isNativeApp() + const iosNative = isIOSNativeApp() + + // Then: Native and iOS-native detection remain distinct + expect(native).toBe(true) + expect(iosNative).toBe(false) + }) +}) diff --git a/ui/src/app/nativeRuntime.ts b/ui/src/app/nativeRuntime.ts new file mode 100644 index 00000000..521377e1 --- /dev/null +++ b/ui/src/app/nativeRuntime.ts @@ -0,0 +1,9 @@ +import { Capacitor } from '@capacitor/core' + +export function isNativeApp(): boolean { + return Capacitor.isNativePlatform() +} + +export function isIOSNativeApp(): boolean { + return isNativeApp() && Capacitor.getPlatform() === 'ios' +} From 96e30f7fd9242fb32ff21d97f2af08d35882347e Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 10 May 2026 19:07:21 -0700 Subject: [PATCH 07/36] fix: harden iOS native setup flow --- ui/README.md | 4 +- ui/ios/App/App/Info.plist | 2 - ui/package.json | 2 +- ui/src/api/apiKeyStorage.test.ts | 26 ++++ ui/src/api/apiKeyStorage.ts | 14 +- ui/src/api/client.ts | 23 ++- ui/src/api/hooks/useClipMediaUrl.ts | 4 +- ui/src/api/tokenProvider.test.ts | 91 ++++++++++- ui/src/api/tokenProvider.ts | 93 ++++++++++- ui/src/app/nativeRuntime.ts | 10 +- .../native-setup/NativeSetupPage.test.tsx | 144 ++++++++++++++++-- .../features/native-setup/NativeSetupPage.tsx | 33 +++- .../features/native-setup/nativeSetup.test.ts | 38 +++++ ui/src/features/native-setup/nativeSetup.ts | 63 ++++++++ ui/src/routes/AppRouter.test.tsx | 116 +++++++++++++- ui/src/routes/AppRouter.tsx | 23 ++- ui/src/runtime/nativeRuntime.ts | 9 ++ 17 files changed, 653 insertions(+), 42 deletions(-) create mode 100644 ui/src/runtime/nativeRuntime.ts diff --git a/ui/README.md b/ui/README.md index 3e725038..243e18ad 100644 --- a/ui/README.md +++ b/ui/README.md @@ -33,8 +33,8 @@ pnpm ios:run ``` `ios:build` builds the Vite app and copies web assets into the Capacitor iOS -project. `ios:sync` also updates native dependencies, `ios:open` opens the Xcode -project, and `ios:run` syncs and launches the app through Capacitor. +project. `ios:sync` also updates native dependencies, `ios:open` syncs and opens +the Xcode project, and `ios:run` syncs and launches the app through Capacitor. Make target shortcuts are available too: diff --git a/ui/ios/App/App/Info.plist b/ui/ios/App/App/Info.plist index aeb90075..c1e6eeae 100644 --- a/ui/ios/App/App/Info.plist +++ b/ui/ios/App/App/Info.plist @@ -26,8 +26,6 @@ NSAppTransportSecurity - NSAllowsArbitraryLoadsInWebContent - NSAllowsLocalNetworking diff --git a/ui/package.json b/ui/package.json index 5b0a123c..c84043bb 100644 --- a/ui/package.json +++ b/ui/package.json @@ -18,7 +18,7 @@ "build": "pnpm typecheck && vite build", "ios:build": "pnpm build && cap copy ios", "ios:sync": "pnpm build && cap sync ios", - "ios:open": "cap open ios", + "ios:open": "pnpm ios:sync && cap open ios", "ios:run": "pnpm ios:sync && cap run ios", "lint": "eslint .", "check": "pnpm api:check && pnpm lint && pnpm test && pnpm build", diff --git a/ui/src/api/apiKeyStorage.test.ts b/ui/src/api/apiKeyStorage.test.ts index 6215b7bc..f99d1f6d 100644 --- a/ui/src/api/apiKeyStorage.test.ts +++ b/ui/src/api/apiKeyStorage.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { + API_KEY_STORAGE_KEY, clearApiKey, getStoredApiKey, hasStoredApiKey, @@ -77,4 +78,29 @@ describe('apiKeyStorage', () => { expect(hasKey).toBe(false) expect(resolveApiKey(' ')).toBeNull() }) + + it('uses the native runtime token provider when iOS mode is active', async () => { + // Given: The runtime is loaded in native iOS mode with browser session storage available + vi.resetModules() + installWindowSessionStorageMock() + vi.doMock('../runtime/nativeRuntime', () => ({ + isIOSNativeApp: () => true, + })) + const nativeApiKeyStorage = await import('./apiKeyStorage') + const tokenProvider = await import('./tokenProvider') + + // When: The shared auth recovery helpers save an API key + nativeApiKeyStorage.saveApiKey(' native-secret ') + const stored = nativeApiKeyStorage.getStoredApiKey() + const ready = tokenProvider.isRuntimeAuthSessionReady() + nativeApiKeyStorage.clearApiKey() + + // Then: The iOS API client token source is updated without writing WebView storage + expect(stored).toBe('native-secret') + expect(ready).toBe(true) + expect(tokenProvider.nativeAuthTokenProvider.getTokenSync()).toBeNull() + expect(window.sessionStorage.getItem(API_KEY_STORAGE_KEY)).toBeNull() + + vi.doUnmock('../runtime/nativeRuntime') + }) }) diff --git a/ui/src/api/apiKeyStorage.ts b/ui/src/api/apiKeyStorage.ts index 56b0e990..3f4ada1e 100644 --- a/ui/src/api/apiKeyStorage.ts +++ b/ui/src/api/apiKeyStorage.ts @@ -1,18 +1,23 @@ import type { ApiRequestOptions } from './generated/client' import { BROWSER_AUTH_TOKEN_STORAGE_KEY, - browserAuthTokenProvider, + clearRuntimeAuthSessionReady, + markRuntimeAuthSessionReady, normalizeAuthToken, + runtimeAuthTokenProvider, } from './tokenProvider' export const API_KEY_STORAGE_KEY = BROWSER_AUTH_TOKEN_STORAGE_KEY export function saveApiKey(apiKey: string): void { - browserAuthTokenProvider.setTokenSync(apiKey) + runtimeAuthTokenProvider.setTokenSync(apiKey) + if (getStoredApiKey()) { + markRuntimeAuthSessionReady() + } } export function getStoredApiKey(): string | null { - return browserAuthTokenProvider.getTokenSync() + return runtimeAuthTokenProvider.getTokenSync() } export function hasStoredApiKey(): boolean { @@ -20,7 +25,8 @@ export function hasStoredApiKey(): boolean { } export function clearApiKey(): void { - browserAuthTokenProvider.clearTokenSync() + runtimeAuthTokenProvider.clearTokenSync() + clearRuntimeAuthSessionReady() } export function resolveApiKey(explicitApiKey: ApiRequestOptions['apiKey']): string | null { diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 2e138bd8..4f30bf73 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -41,6 +41,10 @@ import type { import { JsonHttpClient } from './http' import { createBrowserServerBaseUrlProvider } from './serverBaseUrlProvider' import type { ClientServerBaseUrlProvider } from './serverBaseUrlProvider' +import { + hasAuthToken, + runtimeAuthTokenProvider, +} from './tokenProvider' import type { AuthTokenProvider } from './tokenProvider' import type { ApiSnapshot, ClipMediaTokenResponsePayload } from './parsing' import { @@ -619,9 +623,16 @@ export const browserServerBaseUrlProvider = createBrowserServerBaseUrlProvider( export const apiClient = new HomeSecApiClient( DEFAULT_API_BASE_URL, - { serverBaseUrlProvider: browserServerBaseUrlProvider }, + { + authTokenProvider: runtimeAuthTokenProvider, + serverBaseUrlProvider: browserServerBaseUrlProvider, + }, ) +export function hasConfiguredApiToken(): boolean { + return hasAuthToken(runtimeAuthTokenProvider) +} + export { APIError, isAPIError, isUnauthorizedAPIError } from './errors' export { clearApiKey, getStoredApiKey, hasStoredApiKey, saveApiKey } from './apiKeyStorage' export { @@ -631,10 +642,18 @@ export { normalizeServerBaseUrl, } from './serverBaseUrlProvider' export { + BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY, BROWSER_AUTH_TOKEN_STORAGE_KEY, BrowserAuthTokenProvider, browserAuthTokenProvider, + clearRuntimeAuthSessionReady, + hasAuthToken, + InMemoryAuthTokenProvider, + isRuntimeAuthSessionReady, + markRuntimeAuthSessionReady, + nativeAuthTokenProvider, normalizeAuthToken, + runtimeAuthTokenProvider, } from './tokenProvider' -export type { AuthTokenProvider } from './tokenProvider' +export type { AuthTokenProvider, SyncAuthTokenProvider } from './tokenProvider' export type { ClientServerBaseUrlProvider, ServerBaseUrlProvider } from './serverBaseUrlProvider' diff --git a/ui/src/api/hooks/useClipMediaUrl.ts b/ui/src/api/hooks/useClipMediaUrl.ts index b04c033c..d1672ec2 100644 --- a/ui/src/api/hooks/useClipMediaUrl.ts +++ b/ui/src/api/hooks/useClipMediaUrl.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect } from 'react' import { useQuery } from '@tanstack/react-query' -import { apiClient, hasStoredApiKey } from '../client' +import { apiClient, hasConfiguredApiToken } from '../client' import type { ClipMediaTokenSnapshot } from '../client' import { QUERY_KEYS } from './queryKeys' @@ -45,7 +45,7 @@ export function computeTokenRefreshDelayMs( export function useClipMediaUrl(clipId: string | undefined): ClipMediaUrlState { const directMediaUrl = clipId ? apiClient.resolvePath(buildDirectMediaPath(clipId)) : null - const shouldRequestToken = Boolean(clipId) && hasStoredApiKey() + const shouldRequestToken = Boolean(clipId) && hasConfiguredApiToken() const tokenQuery = useQuery({ queryKey: QUERY_KEYS.clipMediaToken(clipId), diff --git a/ui/src/api/tokenProvider.test.ts b/ui/src/api/tokenProvider.test.ts index 6df71a36..03acb05f 100644 --- a/ui/src/api/tokenProvider.test.ts +++ b/ui/src/api/tokenProvider.test.ts @@ -1,8 +1,10 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { + BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY, BROWSER_AUTH_TOKEN_STORAGE_KEY, BrowserAuthTokenProvider, + InMemoryAuthTokenProvider, normalizeAuthToken, resolveAuthToken, type AuthTokenProvider, @@ -26,6 +28,26 @@ function createStorage(): TestStorage { } } +function installWindowSessionStorageMock(): TestStorage { + const storage = createStorage() + vi.stubGlobal('window', { + sessionStorage: { + getItem: storage.getItem, + setItem: storage.setItem, + removeItem: storage.removeItem, + clear: (): void => { + storage.values.clear() + }, + }, + }) + return storage +} + +afterEach(() => { + vi.unstubAllGlobals() + vi.doUnmock('../runtime/nativeRuntime') +}) + describe('BrowserAuthTokenProvider', () => { it('sets, gets, and clears token values from storage', async () => { // Given: A browser token provider backed by session storage @@ -65,6 +87,73 @@ describe('BrowserAuthTokenProvider', () => { }) }) +describe('InMemoryAuthTokenProvider', () => { + it('keeps token values in memory only', async () => { + // Given: A native-runtime token provider without browser storage + const provider = new InMemoryAuthTokenProvider() + + // When: Persisting and then clearing a token + await provider.setToken(' native-secret ') + const stored = await provider.getToken() + await provider.clearToken() + const cleared = await provider.getToken() + + // Then: Token values are normalized without depending on session storage + expect(stored).toBe('native-secret') + expect(provider.getTokenSync()).toBeNull() + expect(cleared).toBeNull() + }) +}) + +describe('runtime auth session readiness', () => { + it('persists auth-disabled readiness without a token in native iOS mode', async () => { + // Given: The runtime is loaded in native iOS mode with browser session storage available + vi.resetModules() + const storage = installWindowSessionStorageMock() + vi.doMock('../runtime/nativeRuntime', () => ({ + isIOSNativeApp: () => true, + })) + const tokenProvider = await import('./tokenProvider') + + // When: Setup marks an auth-disabled server as ready + tokenProvider.markRuntimeAuthSessionReady({ persistAuthDisabled: true }) + const readyBeforeReload = tokenProvider.isRuntimeAuthSessionReady() + vi.resetModules() + const reloadedTokenProvider = await import('./tokenProvider') + const readyAfterReload = reloadedTokenProvider.isRuntimeAuthSessionReady() + + // Then: Readiness survives reload without persisting an API token + expect(readyBeforeReload).toBe(true) + expect(readyAfterReload).toBe(true) + expect(storage.values.get(BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY)).toBe('true') + expect(reloadedTokenProvider.nativeAuthTokenProvider.getTokenSync()).toBeNull() + }) + + it('does not persist protected native sessions after reload', async () => { + // Given: The runtime is loaded in native iOS mode with browser session storage available + vi.resetModules() + const storage = installWindowSessionStorageMock() + vi.doMock('../runtime/nativeRuntime', () => ({ + isIOSNativeApp: () => true, + })) + const tokenProvider = await import('./tokenProvider') + + // When: Setup marks a protected server as ready with an in-memory token + await tokenProvider.runtimeAuthTokenProvider.setToken('native-token') + tokenProvider.markRuntimeAuthSessionReady({ persistAuthDisabled: false }) + const readyBeforeReload = tokenProvider.isRuntimeAuthSessionReady() + vi.resetModules() + const reloadedTokenProvider = await import('./tokenProvider') + const readyAfterReload = reloadedTokenProvider.isRuntimeAuthSessionReady() + + // Then: Token-backed readiness remains memory-only + expect(readyBeforeReload).toBe(true) + expect(readyAfterReload).toBe(false) + expect(storage.values.has(BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY)).toBe(false) + expect(reloadedTokenProvider.nativeAuthTokenProvider.getTokenSync()).toBeNull() + }) +}) + describe('resolveAuthToken', () => { it('prefers explicit request tokens over provider values', async () => { // Given: A provider with a different stored token diff --git a/ui/src/api/tokenProvider.ts b/ui/src/api/tokenProvider.ts index 99fd40a5..e716bd09 100644 --- a/ui/src/api/tokenProvider.ts +++ b/ui/src/api/tokenProvider.ts @@ -1,6 +1,10 @@ import type { ApiRequestOptions } from './generated/client' +import { isIOSNativeApp } from '../runtime/nativeRuntime' + export const BROWSER_AUTH_TOKEN_STORAGE_KEY = 'homesec.apiKey' +export const BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY = + 'homesec.authDisabledSessionReady' export interface AuthTokenProvider { getToken(): Promise @@ -8,6 +12,12 @@ export interface AuthTokenProvider { clearToken(): Promise } +export interface SyncAuthTokenProvider extends AuthTokenProvider { + getTokenSync(): string | null + setTokenSync(token: string | null): void + clearTokenSync(): void +} + type AuthTokenStorage = Pick function getWindowSessionStorage(): AuthTokenStorage | null { @@ -27,7 +37,7 @@ export function normalizeAuthToken(token: string | null | undefined): string | n return trimmed.length > 0 ? trimmed : null } -export class BrowserAuthTokenProvider implements AuthTokenProvider { +export class BrowserAuthTokenProvider implements SyncAuthTokenProvider { private readonly getStorage: () => AuthTokenStorage | null private readonly storageKey: string @@ -85,7 +95,88 @@ export class BrowserAuthTokenProvider implements AuthTokenProvider { } } +export class InMemoryAuthTokenProvider implements SyncAuthTokenProvider { + private token: string | null = null + + getTokenSync(): string | null { + return this.token + } + + setTokenSync(token: string | null): void { + this.token = normalizeAuthToken(token) + } + + clearTokenSync(): void { + this.token = null + } + + async getToken(): Promise { + return this.getTokenSync() + } + + async setToken(token: string | null): Promise { + this.setTokenSync(token) + } + + async clearToken(): Promise { + this.clearTokenSync() + } +} + export const browserAuthTokenProvider = new BrowserAuthTokenProvider() +export const nativeAuthTokenProvider = new InMemoryAuthTokenProvider() +export const runtimeAuthTokenProvider: SyncAuthTokenProvider = isIOSNativeApp() + ? nativeAuthTokenProvider + : browserAuthTokenProvider +let nativeAuthSessionReady = false + +export function hasAuthToken(provider: SyncAuthTokenProvider): boolean { + return provider.getTokenSync() !== null +} + +function hasPersistedAuthDisabledSessionReady(): boolean { + return getWindowSessionStorage()?.getItem(BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY) === 'true' +} + +function persistAuthDisabledSessionReady(ready: boolean): void { + const storage = getWindowSessionStorage() + if (!storage) { + return + } + + if (ready) { + storage.setItem(BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY, 'true') + return + } + + storage.removeItem(BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY) +} + +export function markRuntimeAuthSessionReady(options: { persistAuthDisabled?: boolean } = {}): void { + if (isIOSNativeApp()) { + nativeAuthSessionReady = true + persistAuthDisabledSessionReady(Boolean(options.persistAuthDisabled)) + } +} + +export function clearRuntimeAuthSessionReady(): void { + if (isIOSNativeApp()) { + nativeAuthSessionReady = false + persistAuthDisabledSessionReady(false) + } +} + +export function isRuntimeAuthSessionReady(): boolean { + if (!isIOSNativeApp()) { + return true + } + + return ( + nativeAuthSessionReady || + hasAuthToken(runtimeAuthTokenProvider) || + hasPersistedAuthDisabledSessionReady() + ) +} export async function resolveAuthToken( explicitApiKey: ApiRequestOptions['apiKey'], diff --git a/ui/src/app/nativeRuntime.ts b/ui/src/app/nativeRuntime.ts index 521377e1..f18ba2fb 100644 --- a/ui/src/app/nativeRuntime.ts +++ b/ui/src/app/nativeRuntime.ts @@ -1,9 +1 @@ -import { Capacitor } from '@capacitor/core' - -export function isNativeApp(): boolean { - return Capacitor.isNativePlatform() -} - -export function isIOSNativeApp(): boolean { - return isNativeApp() && Capacitor.getPlatform() === 'ios' -} +export { isIOSNativeApp, isNativeApp } from '../runtime/nativeRuntime' diff --git a/ui/src/features/native-setup/NativeSetupPage.test.tsx b/ui/src/features/native-setup/NativeSetupPage.test.tsx index b276f769..5923fce5 100644 --- a/ui/src/features/native-setup/NativeSetupPage.test.tsx +++ b/ui/src/features/native-setup/NativeSetupPage.test.tsx @@ -1,13 +1,17 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { cleanup, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' -import { BROWSER_AUTH_TOKEN_STORAGE_KEY } from '../../api/tokenProvider' +import { + BROWSER_AUTH_TOKEN_STORAGE_KEY, + InMemoryAuthTokenProvider, +} from '../../api/tokenProvider' import { BROWSER_SERVER_BASE_URL_STORAGE_KEY } from '../../api/serverBaseUrlProvider' -import { NativeSetupPage } from './NativeSetupPage' +import { NativeSetupPage, type NativeSetupPageProps } from './NativeSetupPage' const HEALTH_PAYLOAD = { status: 'healthy', @@ -35,15 +39,41 @@ function authorizationHeader(call: Parameters[1] | undefined): str : undefined } -function renderNativeSetup(): void { +function createTestQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) +} + +function renderNativeSetup( + props: NativeSetupPageProps = {}, + queryClient: QueryClient = createTestQueryClient(), + setupState: unknown = undefined, +): QueryClient { + const initialEntry = + setupState === undefined + ? '/native-setup' + : { + pathname: '/native-setup', + state: setupState, + } + render( - - - } /> - Live route

} /> -
-
, + + + + } /> + Live route

} /> + Event route

} /> +
+
+
, ) + return queryClient } describe('NativeSetupPage', () => { @@ -147,6 +177,102 @@ describe('NativeSetupPage', () => { expect(screen.queryByText('Plain HTTP is visible on the network. Prefer HTTPS or VPN for iOS access.')).toBeNull() }) + it('clears stale token input when the validated server URL changes', async () => { + // Given: User validated a protected server and entered its API token + const user = userEvent.setup() + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD)) + .mockResolvedValueOnce(unauthorizedResponse()) + renderNativeSetup() + await user.type(screen.getByLabelText('Server URL'), 'https://homesec.example.com') + await user.click(screen.getByRole('button', { name: 'Check server' })) + await screen.findByText('Server reachable') + await user.type(screen.getByLabelText('API token'), 'token-for-first-server') + + // When: The server URL field changes before saving + await user.clear(screen.getByLabelText('Server URL')) + await user.type(screen.getByLabelText('Server URL'), 'https://homesec-new.example.com') + + // Then: The previous server token cannot be saved against the new server + expect((screen.getByLabelText('API token') as HTMLInputElement).value).toBe('') + }) + + it('can save tokens through an in-memory provider without writing browser token storage', async () => { + // Given: Native setup uses an in-memory token provider + const user = userEvent.setup() + const authTokenProvider = new InMemoryAuthTokenProvider() + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD)) + .mockResolvedValueOnce(unauthorizedResponse()) + .mockResolvedValueOnce(jsonResponse([])) + renderNativeSetup({ authTokenProvider }) + + // When: User validates and saves a protected server + await user.type(screen.getByLabelText('Server URL'), 'https://homesec.example.com') + await user.click(screen.getByRole('button', { name: 'Check server' })) + await screen.findByText('Server reachable') + await user.type(screen.getByLabelText('API token'), 'native-token') + await user.click(screen.getByRole('button', { name: 'Save and continue' })) + + // Then: The token is available to API clients through the provider only + await waitFor(() => { + expect(screen.getByText('Live route')).toBeTruthy() + }) + expect(await authTokenProvider.getToken()).toBe('native-token') + expect(window.sessionStorage.getItem(BROWSER_AUTH_TOKEN_STORAGE_KEY)).toBeNull() + }) + + it('clears cached API data after saving a server URL', async () => { + // Given: Cached data from a previous HomeSec server + const user = userEvent.setup() + const queryClient = createTestQueryClient() + queryClient.setQueryData(['cameras'], [{ name: 'old-server-camera' }]) + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD)) + .mockResolvedValueOnce(unauthorizedResponse()) + .mockResolvedValueOnce(jsonResponse([])) + renderNativeSetup({}, queryClient) + + // When: User validates and saves a new server + await user.type(screen.getByLabelText('Server URL'), 'https://homesec.example.com') + await user.click(screen.getByRole('button', { name: 'Check server' })) + await screen.findByText('Server reachable') + await user.type(screen.getByLabelText('API token'), 'token-123') + await user.click(screen.getByRole('button', { name: 'Save and continue' })) + + // Then: Server-agnostic React Query cache entries cannot leak across servers + await waitFor(() => { + expect(screen.getByText('Live route')).toBeTruthy() + }) + expect(queryClient.getQueryData(['cameras'])).toBeUndefined() + }) + + it('returns to the requested route after native setup succeeds', async () => { + // Given: Setup was opened by a guard for an event deep link + const user = userEvent.setup() + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD)) + .mockResolvedValueOnce(unauthorizedResponse()) + .mockResolvedValueOnce(jsonResponse([])) + renderNativeSetup( + {}, + createTestQueryClient(), + { nativeSetupReturnTo: '/events/clip-42?camera=front' }, + ) + + // When: User completes setup + await user.type(screen.getByLabelText('Server URL'), 'https://homesec.example.com') + await user.click(screen.getByRole('button', { name: 'Check server' })) + await screen.findByText('Server reachable') + await user.type(screen.getByLabelText('API token'), 'token-123') + await user.click(screen.getByRole('button', { name: 'Save and continue' })) + + // Then: The original route intent wins over the default Live destination + await waitFor(() => { + expect(screen.getByText('Event route')).toBeTruthy() + }) + }) + it('warns and allows continuing when auth-disabled mode is detectable', async () => { // Given: Camera list succeeds without an API token and an old token is stored const user = userEvent.setup() diff --git a/ui/src/features/native-setup/NativeSetupPage.tsx b/ui/src/features/native-setup/NativeSetupPage.tsx index 62e0d78a..a3762d15 100644 --- a/ui/src/features/native-setup/NativeSetupPage.tsx +++ b/ui/src/features/native-setup/NativeSetupPage.tsx @@ -1,12 +1,14 @@ import { useState, type FormEvent } from 'react' -import { useNavigate } from 'react-router-dom' +import { useQueryClient } from '@tanstack/react-query' +import { useLocation, useNavigate } from 'react-router-dom' import { HomeSecApiClient, - browserAuthTokenProvider, browserServerBaseUrlProvider, isAPIError, isUnauthorizedAPIError, + markRuntimeAuthSessionReady, + runtimeAuthTokenProvider, } from '../../api/client' import type { AuthTokenProvider, ClientServerBaseUrlProvider } from '../../api/client' import { Button } from '../../components/ui/Button' @@ -48,12 +50,32 @@ function describeTokenError(error: unknown): string { return 'Unable to validate the API token. Try again.' } +function nativeSetupReturnTo(state: unknown): string { + if (!state || typeof state !== 'object') { + return '/live' + } + + const returnTo = (state as { nativeSetupReturnTo?: unknown }).nativeSetupReturnTo + if ( + typeof returnTo !== 'string' || + !returnTo.startsWith('/') || + returnTo.startsWith('//') || + returnTo === '/native-setup' + ) { + return '/live' + } + + return returnTo +} + export function NativeSetupPage({ - authTokenProvider = browserAuthTokenProvider, + authTokenProvider = runtimeAuthTokenProvider, createClient = (baseUrl: string) => new HomeSecApiClient(baseUrl), serverBaseUrlProvider = browserServerBaseUrlProvider, }: NativeSetupPageProps = {}) { const navigate = useNavigate() + const location = useLocation() + const queryClient = useQueryClient() const [serverUrl, setServerUrl] = useState('') const [apiToken, setApiToken] = useState('') const [validatedServerUrl, setValidatedServerUrl] = useState(null) @@ -67,6 +89,7 @@ export function NativeSetupPage({ function handleServerUrlChange(value: string): void { setServerUrl(value) + setApiToken('') setValidatedServerUrl(null) setIsPlainHttp(false) setAuthDisabled(false) @@ -142,7 +165,9 @@ export function NativeSetupPage({ } await serverBaseUrlProvider.setBaseUrl(validatedServerUrl) await authTokenProvider.setToken(authDisabled ? null : apiKey || null) - navigate('/live', { replace: true }) + markRuntimeAuthSessionReady({ persistAuthDisabled: authDisabled }) + queryClient.clear() + navigate(nativeSetupReturnTo(location.state), { replace: true }) } catch (error) { setTokenError(describeTokenError(error)) } finally { diff --git a/ui/src/features/native-setup/nativeSetup.test.ts b/ui/src/features/native-setup/nativeSetup.test.ts index f3caddce..9f9bb5a1 100644 --- a/ui/src/features/native-setup/nativeSetup.test.ts +++ b/ui/src/features/native-setup/nativeSetup.test.ts @@ -7,11 +7,15 @@ describe('validateNativeSetupServerUrl', () => { // Given: Server URL candidates from the native setup form const httpsUrl = ' https://homesec.example.com/// ' const lanUrl = 'http://192.168.1.10:8081/' + const localHostUrl = 'http://homesec.local:8081/' + const singleLabelUrl = 'http://homesec:8081/' const missingScheme = 'homesec.local:8081' // When: Validating each value const httpsResult = validateNativeSetupServerUrl(httpsUrl) const lanResult = validateNativeSetupServerUrl(lanUrl) + const localHostResult = validateNativeSetupServerUrl(localHostUrl) + const singleLabelResult = validateNativeSetupServerUrl(singleLabelUrl) const missingSchemeResult = validateNativeSetupServerUrl(missingScheme) // Then: Supported URLs normalize and invalid input returns an actionable message @@ -29,12 +33,46 @@ describe('validateNativeSetupServerUrl', () => { isPlainHttp: true, }, }) + expect(localHostResult).toEqual({ + ok: true, + value: { + serverBaseUrl: 'http://homesec.local:8081', + isPlainHttp: true, + }, + }) + expect(singleLabelResult).toEqual({ + ok: true, + value: { + serverBaseUrl: 'http://homesec:8081', + isPlainHttp: true, + }, + }) expect(missingSchemeResult).toEqual({ ok: false, message: 'Only http:// and https:// server URLs are supported.', }) }) + it('rejects public plain-HTTP hosts', () => { + // Given: Plain-HTTP URLs outside the local network + const publicHostname = 'http://homesec.example.com' + const publicIp = 'http://8.8.8.8:8081' + + // When: Validating setup input + const hostnameResult = validateNativeSetupServerUrl(publicHostname) + const ipResult = validateNativeSetupServerUrl(publicIp) + + // Then: The setup flow requires HTTPS for public hosts + expect(hostnameResult).toEqual({ + ok: false, + message: 'Plain HTTP is only supported for local network HomeSec servers. Use HTTPS for public hosts.', + }) + expect(ipResult).toEqual({ + ok: false, + message: 'Plain HTTP is only supported for local network HomeSec servers. Use HTTPS for public hosts.', + }) + }) + it('rejects blank server URLs', () => { // Given: A blank server URL const input = ' ' diff --git a/ui/src/features/native-setup/nativeSetup.ts b/ui/src/features/native-setup/nativeSetup.ts index 63d9f8e0..644ddbfd 100644 --- a/ui/src/features/native-setup/nativeSetup.ts +++ b/ui/src/features/native-setup/nativeSetup.ts @@ -15,6 +15,62 @@ export type NativeSetupServerUrlValidation = message: string } +function isPrivateIPv4Address(hostname: string): boolean { + const octets = hostname.split('.').map((part) => Number(part)) + if ( + octets.length !== 4 || + octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255) + ) { + return false + } + + const [first = 0, second = 0] = octets + return ( + first === 10 || + first === 127 || + (first === 100 && second >= 64 && second <= 127) || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) + ) +} + +function isPrivateIPv6Address(hostname: string): boolean { + const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase() + if (normalized === '::1') { + return true + } + + const [firstHextetRaw] = normalized.split(':') + const firstHextet = Number.parseInt(firstHextetRaw ?? '', 16) + if (Number.isNaN(firstHextet)) { + return false + } + + return (firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xffc0) === 0xfe80 +} + +function isLocalPlainHttpHost(hostname: string): boolean { + const normalized = hostname.toLowerCase() + if ( + normalized === 'localhost' || + normalized.endsWith('.localhost') || + normalized.endsWith('.local') + ) { + return true + } + + if (normalized.includes(':')) { + return isPrivateIPv6Address(normalized) + } + + if (isPrivateIPv4Address(normalized)) { + return true + } + + return !normalized.includes('.') +} + export function validateNativeSetupServerUrl(input: string): NativeSetupServerUrlValidation { const normalized = normalizeServerBaseUrl(input) if (!normalized) { @@ -48,6 +104,13 @@ export function validateNativeSetupServerUrl(input: string): NativeSetupServerUr } } + if (parsed.protocol === 'http:' && !isLocalPlainHttpHost(parsed.hostname)) { + return { + ok: false, + message: 'Plain HTTP is only supported for local network HomeSec servers. Use HTTPS for public hosts.', + } + } + parsed.hash = '' parsed.search = '' parsed.pathname = parsed.pathname.replace(/\/+$/, '') diff --git a/ui/src/routes/AppRouter.test.tsx b/ui/src/routes/AppRouter.test.tsx index de0db234..1ad04241 100644 --- a/ui/src/routes/AppRouter.test.tsx +++ b/ui/src/routes/AppRouter.test.tsx @@ -1,14 +1,33 @@ // @vitest-environment happy-dom -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, useLocation } from 'react-router-dom' import { ThemeProvider } from '../app/providers/ThemeProvider' import { AppRouter } from './AppRouter' -const useHealthQueryMock = vi.fn() -const useCamerasQueryMock = vi.fn() +const routeMocks = vi.hoisted(() => ({ + getBaseUrlSync: vi.fn<() => string | null>(() => null), + isRuntimeAuthSessionReady: vi.fn<() => boolean>(() => true), + isIOSNativeApp: vi.fn<() => boolean>(() => false), + useCamerasQuery: vi.fn(), + useHealthQuery: vi.fn(), +})) + +const useHealthQueryMock = routeMocks.useHealthQuery +const useCamerasQueryMock = routeMocks.useCamerasQuery + +vi.mock('../api/client', () => ({ + browserServerBaseUrlProvider: { + getBaseUrlSync: () => routeMocks.getBaseUrlSync(), + }, + isRuntimeAuthSessionReady: () => routeMocks.isRuntimeAuthSessionReady(), +})) + +vi.mock('../runtime/nativeRuntime', () => ({ + isIOSNativeApp: () => routeMocks.isIOSNativeApp(), +})) vi.mock('../api/hooks/useHealthQuery', () => ({ useHealthQuery: () => useHealthQueryMock(), @@ -47,7 +66,16 @@ vi.mock('../features/setup/SetupPage', () => ({ })) vi.mock('../features/native-setup/NativeSetupPage', () => ({ - NativeSetupPage: () =>

Native Setup Page

, + NativeSetupPage: () => { + const location = useLocation() + const state = location.state as { nativeSetupReturnTo?: string } | null + return ( + <> +

Native Setup Page

+

{state?.nativeSetupReturnTo ?? ''}

+ + ) + }, })) function LocationProbe() { @@ -76,10 +104,19 @@ function renderRouter(initialPath: string) { } describe('AppRouter route cleanup', () => { + beforeEach(() => { + routeMocks.getBaseUrlSync.mockReturnValue(null) + routeMocks.isRuntimeAuthSessionReady.mockReturnValue(true) + routeMocks.isIOSNativeApp.mockReturnValue(false) + }) + afterEach(() => { cleanup() useHealthQueryMock.mockReset() useCamerasQueryMock.mockReset() + routeMocks.getBaseUrlSync.mockReset() + routeMocks.isRuntimeAuthSessionReady.mockReset() + routeMocks.isIOSNativeApp.mockReset() }) it('redirects the root route to Live', async () => { @@ -118,6 +155,77 @@ describe('AppRouter route cleanup', () => { expect(useCamerasQueryMock).not.toHaveBeenCalled() }) + it('redirects iOS shell routes to native setup until a server URL is configured', async () => { + // Given: The iOS shell starts without a configured HomeSec server URL + routeMocks.isIOSNativeApp.mockReturnValue(true) + routeMocks.getBaseUrlSync.mockReturnValue(null) + + // When: User opens the default shell route + renderRouter('/live') + + // Then: The setup route is reached before shell API queries mount + await waitFor(() => { + expect(screen.getByTestId('location').textContent).toBe('/native-setup') + }) + expect(screen.getByText('Native Setup Page')).toBeTruthy() + expect(screen.getByTestId('native-setup-return-to').textContent).toBe('/live') + expect(useHealthQueryMock).not.toHaveBeenCalled() + expect(useCamerasQueryMock).not.toHaveBeenCalled() + }) + + it('redirects iOS shell routes to native setup when the auth session was lost', async () => { + // Given: The iOS shell retained a server URL but lost in-memory auth state + routeMocks.isIOSNativeApp.mockReturnValue(true) + routeMocks.getBaseUrlSync.mockReturnValue('https://homesec.example.com') + routeMocks.isRuntimeAuthSessionReady.mockReturnValue(false) + + // When: User opens a protected shell route after a WebView reload + renderRouter('/events') + + // Then: The setup route is reached before unauthenticated API queries mount + await waitFor(() => { + expect(screen.getByTestId('location').textContent).toBe('/native-setup') + }) + expect(screen.getByText('Native Setup Page')).toBeTruthy() + expect(screen.getByTestId('native-setup-return-to').textContent).toBe('/events') + expect(useHealthQueryMock).not.toHaveBeenCalled() + expect(useCamerasQueryMock).not.toHaveBeenCalled() + }) + + it('preserves native setup return intent for deep links with filters', async () => { + // Given: The iOS shell needs setup before opening a deep-linked event + routeMocks.isIOSNativeApp.mockReturnValue(true) + routeMocks.getBaseUrlSync.mockReturnValue('https://homesec.example.com') + routeMocks.isRuntimeAuthSessionReady.mockReturnValue(false) + + // When: User opens a protected route with list context + renderRouter('/events/clip-42?camera=front') + + // Then: Setup receives enough state to return to the intended route + await waitFor(() => { + expect(screen.getByTestId('location').textContent).toBe('/native-setup') + }) + expect(screen.getByTestId('native-setup-return-to').textContent).toBe( + '/events/clip-42?camera=front', + ) + }) + + it('allows iOS shell routes after a server URL is configured', async () => { + // Given: The iOS shell already has a HomeSec server URL + routeMocks.isIOSNativeApp.mockReturnValue(true) + routeMocks.getBaseUrlSync.mockReturnValue('https://homesec.example.com') + routeMocks.isRuntimeAuthSessionReady.mockReturnValue(true) + + // When: User opens the native shell route + renderRouter('/live') + + // Then: The app shell renders instead of returning to setup + await waitFor(() => { + expect(screen.getByTestId('location').textContent).toBe('/live') + }) + expect(screen.getByText('Live Page')).toBeTruthy() + }) + it('redirects the old cameras route to Settings camera setup', async () => { // Given: User opens the old top-level camera management route renderRouter('/cameras') diff --git a/ui/src/routes/AppRouter.tsx b/ui/src/routes/AppRouter.tsx index 395df2f7..2d8447aa 100644 --- a/ui/src/routes/AppRouter.tsx +++ b/ui/src/routes/AppRouter.tsx @@ -1,6 +1,8 @@ import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom' +import { browserServerBaseUrlProvider, isRuntimeAuthSessionReady } from '../api/client' import { AppShell } from '../app/layout/AppShell' +import { isIOSNativeApp } from '../runtime/nativeRuntime' import { CamerasPage } from '../features/cameras/CamerasPage' import { ClipDetailPage } from '../features/clips/ClipDetailPage' import { ClipsPage } from '../features/clips/ClipsPage' @@ -22,12 +24,31 @@ function RedirectClipDetailToEvent() { return } +function NativeSetupGuard() { + const location = useLocation() + + if ( + isIOSNativeApp() && + (!browserServerBaseUrlProvider.getBaseUrlSync() || !isRuntimeAuthSessionReady()) + ) { + return ( + + ) + } + + return +} + export function AppRouter() { return ( } /> } /> - }> + }> } /> } /> } /> diff --git a/ui/src/runtime/nativeRuntime.ts b/ui/src/runtime/nativeRuntime.ts new file mode 100644 index 00000000..521377e1 --- /dev/null +++ b/ui/src/runtime/nativeRuntime.ts @@ -0,0 +1,9 @@ +import { Capacitor } from '@capacitor/core' + +export function isNativeApp(): boolean { + return Capacitor.isNativePlatform() +} + +export function isIOSNativeApp(): boolean { + return isNativeApp() && Capacitor.getPlatform() === 'ios' +} From c62256a5d4ee6bf845549ba07763fb3348507eff Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 18:38:30 -0700 Subject: [PATCH 08/36] feat: persist iOS auth in Keychain --- ui/ios/App/App.xcodeproj/project.pbxproj | 12 + ui/ios/App/App/Base.lproj/Main.storyboard | 2 +- ui/ios/App/App/HomeSecAuthPlugin.swift | 226 ++++++++++++++++++ .../App/App/HomeSecBridgeViewController.swift | 10 + ui/ios/App/App/HomeSecKeychainStore.swift | 86 +++++++ ui/src/api/apiKeyStorage.test.ts | 38 ++- ui/src/api/apiKeyStorage.ts | 16 +- ui/src/api/client.ts | 23 +- ui/src/api/homeSecAuthPlugin.ts | 23 ++ ui/src/api/runtimeConfig.ts | 5 +- ui/src/api/serverBaseUrlProvider.test.ts | 42 ++++ ui/src/api/serverBaseUrlProvider.ts | 48 ++++ ui/src/api/tokenProvider.test.ts | 100 +++++++- ui/src/api/tokenProvider.ts | 114 ++++++++- ui/src/features/cameras/CamerasPage.tsx | 4 +- ui/src/features/clips/ClipDetailPage.tsx | 4 +- ui/src/features/clips/ClipsPage.tsx | 4 +- ui/src/features/live/LivePage.tsx | 4 +- .../features/native-setup/NativeSetupPage.tsx | 8 +- ui/src/features/system/SystemPage.tsx | 4 +- ui/src/routes/AppRouter.test.tsx | 2 +- ui/src/routes/AppRouter.tsx | 4 +- 22 files changed, 728 insertions(+), 51 deletions(-) create mode 100644 ui/ios/App/App/HomeSecAuthPlugin.swift create mode 100644 ui/ios/App/App/HomeSecBridgeViewController.swift create mode 100644 ui/ios/App/App/HomeSecKeychainStore.swift create mode 100644 ui/src/api/homeSecAuthPlugin.ts diff --git a/ui/ios/App/App.xcodeproj/project.pbxproj b/ui/ios/App/App.xcodeproj/project.pbxproj index 983dbd87..7af6b7bf 100644 --- a/ui/ios/App/App.xcodeproj/project.pbxproj +++ b/ui/ios/App/App.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + AB6585AB036645ACB3F18713 /* HomeSecKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BAEE612533413E98770C40 /* HomeSecKeychainStore.swift */; }; + 66567A6DF7C547C188064737 /* HomeSecAuthPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A484C75B28C044178789F867 /* HomeSecAuthPlugin.swift */; }; + 9B5C5B337D684A7AA00B985B /* HomeSecBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1874C85077A0477C9265DAB5 /* HomeSecBridgeViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -28,6 +31,9 @@ 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; + 27BAEE612533413E98770C40 /* HomeSecKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecKeychainStore.swift; sourceTree = ""; }; + A484C75B28C044178789F867 /* HomeSecAuthPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecAuthPlugin.swift; sourceTree = ""; }; + 1874C85077A0477C9265DAB5 /* HomeSecBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecBridgeViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -64,6 +70,9 @@ children = ( 50379B222058CBB4000EE86E /* capacitor.config.json */, 504EC3071FED79650016851F /* AppDelegate.swift */, + 1874C85077A0477C9265DAB5 /* HomeSecBridgeViewController.swift */, + A484C75B28C044178789F867 /* HomeSecAuthPlugin.swift */, + 27BAEE612533413E98770C40 /* HomeSecKeychainStore.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, @@ -156,6 +165,9 @@ buildActionMask = 2147483647; files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, + 9B5C5B337D684A7AA00B985B /* HomeSecBridgeViewController.swift in Sources */, + 66567A6DF7C547C188064737 /* HomeSecAuthPlugin.swift in Sources */, + AB6585AB036645ACB3F18713 /* HomeSecKeychainStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ui/ios/App/App/Base.lproj/Main.storyboard b/ui/ios/App/App/Base.lproj/Main.storyboard index b44df7be..4bfea4c3 100644 --- a/ui/ios/App/App/Base.lproj/Main.storyboard +++ b/ui/ios/App/App/Base.lproj/Main.storyboard @@ -11,7 +11,7 @@ - + diff --git a/ui/ios/App/App/HomeSecAuthPlugin.swift b/ui/ios/App/App/HomeSecAuthPlugin.swift new file mode 100644 index 00000000..4b901453 --- /dev/null +++ b/ui/ios/App/App/HomeSecAuthPlugin.swift @@ -0,0 +1,226 @@ +import Capacitor +import Foundation + +enum HomeSecAuthPluginError: LocalizedError { + case invalidServerBaseUrl(String) + case missingValue(String) + case serverBaseUrlRequired + + var errorDescription: String? { + switch self { + case .invalidServerBaseUrl(let value): + return "Invalid HomeSec server URL: \(value)" + case .missingValue(let field): + return "\(field) is required." + case .serverBaseUrlRequired: + return "Set the HomeSec server URL before storing an API token." + } + } +} + +@objc(HomeSecAuthPlugin) +public class HomeSecAuthPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "HomeSecAuthPlugin" + public let jsName = "HomeSecAuth" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "getServerBaseUrl", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setServerBaseUrl", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearServerBaseUrl", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getApiToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setApiToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearApiToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getAuthDisabledReady", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setAuthDisabledReady", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearAuthDisabledReady", returnType: CAPPluginReturnPromise), + ] + + private let authDisabledReadyPrefix = "auth-disabled-ready:" + private let keychain = HomeSecKeychainStore() + private let serverBaseUrlAccount = "server-base-url" + private let tokenAccountPrefix = "api-token:" + + @objc func getServerBaseUrl(_ call: CAPPluginCall) { + resolveStoredValue(call, account: serverBaseUrlAccount) + } + + @objc func setServerBaseUrl(_ call: CAPPluginCall) { + do { + let value = try requiredString(call, key: "value") + let normalized = try normalizedServerBaseUrl(value) + let previousOrigin = try currentServerOrigin() + if let previousOrigin, previousOrigin != normalized { + try keychain.delete(account: "\(tokenAccountPrefix)\(previousOrigin)") + try keychain.delete(account: "\(authDisabledReadyPrefix)\(previousOrigin)") + } + try keychain.set(normalized, account: serverBaseUrlAccount) + call.resolve() + } catch { + reject(call, error: error) + } + } + + @objc func clearServerBaseUrl(_ call: CAPPluginCall) { + do { + if let tokenAccount = try currentApiTokenAccount() { + try keychain.delete(account: tokenAccount) + } + if let authDisabledAccount = try currentAuthDisabledReadyAccount() { + try keychain.delete(account: authDisabledAccount) + } + try keychain.delete(account: serverBaseUrlAccount) + call.resolve() + } catch { + reject(call, error: error) + } + } + + @objc func getApiToken(_ call: CAPPluginCall) { + do { + guard let account = try currentApiTokenAccount() else { + call.resolve(["value": NSNull()]) + return + } + resolveStoredValue(call, account: account) + } catch { + reject(call, error: error) + } + } + + @objc func setApiToken(_ call: CAPPluginCall) { + do { + let value = try requiredString(call, key: "value") + let account = try requiredApiTokenAccount() + try keychain.set(value, account: account) + call.resolve() + } catch { + reject(call, error: error) + } + } + + @objc func clearApiToken(_ call: CAPPluginCall) { + do { + if let account = try currentApiTokenAccount() { + try keychain.delete(account: account) + } + call.resolve() + } catch { + reject(call, error: error) + } + } + + @objc func getAuthDisabledReady(_ call: CAPPluginCall) { + do { + guard let account = try currentAuthDisabledReadyAccount() else { + call.resolve(["value": false]) + return + } + call.resolve(["value": try keychain.read(account: account) == "true"]) + } catch { + reject(call, error: error) + } + } + + @objc func setAuthDisabledReady(_ call: CAPPluginCall) { + do { + let ready = call.getBool("value", false) + let account = try requiredAuthDisabledReadyAccount() + if ready { + try keychain.set("true", account: account) + } else { + try keychain.delete(account: account) + } + call.resolve() + } catch { + reject(call, error: error) + } + } + + @objc func clearAuthDisabledReady(_ call: CAPPluginCall) { + do { + if let account = try currentAuthDisabledReadyAccount() { + try keychain.delete(account: account) + } + call.resolve() + } catch { + reject(call, error: error) + } + } + + private func resolveStoredValue(_ call: CAPPluginCall, account: String) { + do { + if let value = try keychain.read(account: account) { + call.resolve(["value": value]) + } else { + call.resolve(["value": NSNull()]) + } + } catch { + reject(call, error: error) + } + } + + private func requiredString(_ call: CAPPluginCall, key: String) throws -> String { + guard let value = call.getString(key), !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw HomeSecAuthPluginError.missingValue(key) + } + return value + } + + private func normalizedServerBaseUrl(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard + var components = URLComponents(string: trimmed), + let scheme = components.scheme?.lowercased(), + (scheme == "http" || scheme == "https"), + components.host != nil + else { + throw HomeSecAuthPluginError.invalidServerBaseUrl(value) + } + components.scheme = scheme + components.path = "" + components.query = nil + components.fragment = nil + guard let normalized = components.string else { + throw HomeSecAuthPluginError.invalidServerBaseUrl(value) + } + return normalized + } + + private func currentServerOrigin() throws -> String? { + guard let serverBaseUrl = try keychain.read(account: serverBaseUrlAccount) else { + return nil + } + return try normalizedServerBaseUrl(serverBaseUrl) + } + + private func currentApiTokenAccount() throws -> String? { + guard let origin = try currentServerOrigin() else { + return nil + } + return "\(tokenAccountPrefix)\(origin)" + } + + private func requiredApiTokenAccount() throws -> String { + guard let account = try currentApiTokenAccount() else { + throw HomeSecAuthPluginError.serverBaseUrlRequired + } + return account + } + + private func currentAuthDisabledReadyAccount() throws -> String? { + guard let origin = try currentServerOrigin() else { + return nil + } + return "\(authDisabledReadyPrefix)\(origin)" + } + + private func requiredAuthDisabledReadyAccount() throws -> String { + guard let account = try currentAuthDisabledReadyAccount() else { + throw HomeSecAuthPluginError.serverBaseUrlRequired + } + return account + } + + private func reject(_ call: CAPPluginCall, error: Error) { + call.reject(error.localizedDescription, "HOMESEC_AUTH_STORAGE_ERROR", error) + } +} diff --git a/ui/ios/App/App/HomeSecBridgeViewController.swift b/ui/ios/App/App/HomeSecBridgeViewController.swift new file mode 100644 index 00000000..a796f0b8 --- /dev/null +++ b/ui/ios/App/App/HomeSecBridgeViewController.swift @@ -0,0 +1,10 @@ +import Capacitor +import UIKit + +@objc(HomeSecBridgeViewController) +class HomeSecBridgeViewController: CAPBridgeViewController { + override func capacitorDidLoad() { + super.capacitorDidLoad() + bridge?.registerPluginInstance(HomeSecAuthPlugin()) + } +} diff --git a/ui/ios/App/App/HomeSecKeychainStore.swift b/ui/ios/App/App/HomeSecKeychainStore.swift new file mode 100644 index 00000000..94e6e034 --- /dev/null +++ b/ui/ios/App/App/HomeSecKeychainStore.swift @@ -0,0 +1,86 @@ +import Foundation +import Security + +enum HomeSecKeychainError: LocalizedError { + case invalidStoredValue(account: String) + case unexpectedStatus(operation: String, status: OSStatus) + + var errorDescription: String? { + switch self { + case .invalidStoredValue(let account): + return "Stored Keychain value for \(account) is not valid UTF-8." + case .unexpectedStatus(let operation, let status): + return "Keychain \(operation) failed with status \(status)." + } + } +} + +final class HomeSecKeychainStore { + private let service: String + + init(service: String = "homesec") { + self.service = service + } + + func read(account: String) throws -> String? { + var query = baseQuery(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return nil + } + guard status == errSecSuccess else { + throw HomeSecKeychainError.unexpectedStatus(operation: "read", status: status) + } + guard + let data = result as? Data, + let value = String(data: data, encoding: .utf8) + else { + throw HomeSecKeychainError.invalidStoredValue(account: account) + } + + return value + } + + func set(_ value: String, account: String) throws { + let data = Data(value.utf8) + let query = baseQuery(account: account) + let updateStatus = SecItemUpdate( + query as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + + if updateStatus == errSecSuccess { + return + } + if updateStatus != errSecItemNotFound { + throw HomeSecKeychainError.unexpectedStatus(operation: "update", status: updateStatus) + } + + var addQuery = query + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw HomeSecKeychainError.unexpectedStatus(operation: "add", status: addStatus) + } + } + + func delete(account: String) throws { + let status = SecItemDelete(baseQuery(account: account) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw HomeSecKeychainError.unexpectedStatus(operation: "delete", status: status) + } + } + + private func baseQuery(account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } +} diff --git a/ui/src/api/apiKeyStorage.test.ts b/ui/src/api/apiKeyStorage.test.ts index f99d1f6d..172774f2 100644 --- a/ui/src/api/apiKeyStorage.test.ts +++ b/ui/src/api/apiKeyStorage.test.ts @@ -30,18 +30,20 @@ function installWindowSessionStorageMock(): void { describe('apiKeyStorage', () => { afterEach(() => { vi.unstubAllGlobals() + vi.doUnmock('./homeSecAuthPlugin') + vi.doUnmock('../runtime/nativeRuntime') }) - it('saves, loads, and clears API key values', () => { + it('saves, loads, and clears API key values', async () => { // Given: A client API key installWindowSessionStorageMock() const apiKey = 'secret-key' // When: Saving and reading key from session storage - saveApiKey(apiKey) + await saveApiKey(apiKey) const stored = getStoredApiKey() const hasBeforeClear = hasStoredApiKey() - clearApiKey() + await clearApiKey() const cleared = getStoredApiKey() // Then: Key persistence and clear behavior are consistent @@ -50,10 +52,10 @@ describe('apiKeyStorage', () => { expect(cleared).toBeNull() }) - it('prefers explicit apiKey and falls back to storage when omitted', () => { + it('prefers explicit apiKey and falls back to storage when omitted', async () => { // Given: A stored API key and an explicit override key installWindowSessionStorageMock() - saveApiKey('stored-secret') + await saveApiKey('stored-secret') // When: Resolving keys with explicit and implicit values const explicit = resolveApiKey('explicit-secret') @@ -64,12 +66,12 @@ describe('apiKeyStorage', () => { expect(implicit).toBe('stored-secret') }) - it('normalizes blank API key values as absent', () => { + it('normalizes blank API key values as absent', async () => { // Given: A browser storage area and a whitespace API key installWindowSessionStorageMock() // When: Saving a blank key value - saveApiKey(' ') + await saveApiKey(' ') const stored = getStoredApiKey() const hasKey = hasStoredApiKey() @@ -83,6 +85,20 @@ describe('apiKeyStorage', () => { // Given: The runtime is loaded in native iOS mode with browser session storage available vi.resetModules() installWindowSessionStorageMock() + const nativeAuthPlugin = { + getServerBaseUrl: vi.fn(async () => ({ value: 'https://homesec.example.com' })), + setServerBaseUrl: vi.fn(async () => {}), + clearServerBaseUrl: vi.fn(async () => {}), + getApiToken: vi.fn(async () => ({ value: null })), + setApiToken: vi.fn(async () => {}), + clearApiToken: vi.fn(async () => {}), + getAuthDisabledReady: vi.fn(async () => ({ value: false })), + setAuthDisabledReady: vi.fn(async () => {}), + clearAuthDisabledReady: vi.fn(async () => {}), + } + vi.doMock('./homeSecAuthPlugin', () => ({ + homeSecAuthPlugin: nativeAuthPlugin, + })) vi.doMock('../runtime/nativeRuntime', () => ({ isIOSNativeApp: () => true, })) @@ -90,17 +106,17 @@ describe('apiKeyStorage', () => { const tokenProvider = await import('./tokenProvider') // When: The shared auth recovery helpers save an API key - nativeApiKeyStorage.saveApiKey(' native-secret ') + await nativeApiKeyStorage.saveApiKey(' native-secret ') const stored = nativeApiKeyStorage.getStoredApiKey() const ready = tokenProvider.isRuntimeAuthSessionReady() - nativeApiKeyStorage.clearApiKey() + await nativeApiKeyStorage.clearApiKey() // Then: The iOS API client token source is updated without writing WebView storage + expect(nativeAuthPlugin.setApiToken).toHaveBeenCalledWith({ value: 'native-secret' }) + expect(nativeAuthPlugin.clearApiToken).toHaveBeenCalledTimes(1) expect(stored).toBe('native-secret') expect(ready).toBe(true) expect(tokenProvider.nativeAuthTokenProvider.getTokenSync()).toBeNull() expect(window.sessionStorage.getItem(API_KEY_STORAGE_KEY)).toBeNull() - - vi.doUnmock('../runtime/nativeRuntime') }) }) diff --git a/ui/src/api/apiKeyStorage.ts b/ui/src/api/apiKeyStorage.ts index 3f4ada1e..4d40a73c 100644 --- a/ui/src/api/apiKeyStorage.ts +++ b/ui/src/api/apiKeyStorage.ts @@ -1,18 +1,18 @@ import type { ApiRequestOptions } from './generated/client' import { BROWSER_AUTH_TOKEN_STORAGE_KEY, - clearRuntimeAuthSessionReady, - markRuntimeAuthSessionReady, + clearPersistedRuntimeAuthSessionReady, normalizeAuthToken, + persistRuntimeAuthSessionReady, runtimeAuthTokenProvider, } from './tokenProvider' export const API_KEY_STORAGE_KEY = BROWSER_AUTH_TOKEN_STORAGE_KEY -export function saveApiKey(apiKey: string): void { - runtimeAuthTokenProvider.setTokenSync(apiKey) +export async function saveApiKey(apiKey: string): Promise { + await runtimeAuthTokenProvider.setToken(apiKey) if (getStoredApiKey()) { - markRuntimeAuthSessionReady() + await persistRuntimeAuthSessionReady() } } @@ -24,9 +24,9 @@ export function hasStoredApiKey(): boolean { return getStoredApiKey() !== null } -export function clearApiKey(): void { - runtimeAuthTokenProvider.clearTokenSync() - clearRuntimeAuthSessionReady() +export async function clearApiKey(): Promise { + await runtimeAuthTokenProvider.clearToken() + await clearPersistedRuntimeAuthSessionReady() } export function resolveApiKey(explicitApiKey: ApiRequestOptions['apiKey']): string | null { diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 4f30bf73..354b43b6 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -38,11 +38,14 @@ import type { StatsResponse, } from './generated/types' +import { isIOSNativeApp } from '../runtime/nativeRuntime' import { JsonHttpClient } from './http' import { createBrowserServerBaseUrlProvider } from './serverBaseUrlProvider' +import { NativeServerBaseUrlProvider } from './serverBaseUrlProvider' import type { ClientServerBaseUrlProvider } from './serverBaseUrlProvider' import { hasAuthToken, + nativeAuthTokenProvider, runtimeAuthTokenProvider, } from './tokenProvider' import type { AuthTokenProvider } from './tokenProvider' @@ -620,15 +623,30 @@ export class HomeSecApiClient implements GeneratedHomeSecClient { export const browserServerBaseUrlProvider = createBrowserServerBaseUrlProvider( import.meta.env.VITE_API_BASE_URL ?? DEFAULT_API_BASE_URL, ) +export const nativeServerBaseUrlProvider = new NativeServerBaseUrlProvider() +export const runtimeServerBaseUrlProvider: ClientServerBaseUrlProvider = isIOSNativeApp() + ? nativeServerBaseUrlProvider + : browserServerBaseUrlProvider export const apiClient = new HomeSecApiClient( DEFAULT_API_BASE_URL, { authTokenProvider: runtimeAuthTokenProvider, - serverBaseUrlProvider: browserServerBaseUrlProvider, + serverBaseUrlProvider: runtimeServerBaseUrlProvider, }, ) +export async function hydrateRuntimeApiProviders(): Promise { + if (!isIOSNativeApp()) { + return + } + + await Promise.all([ + nativeAuthTokenProvider.hydrate(), + nativeServerBaseUrlProvider.hydrate(), + ]) +} + export function hasConfiguredApiToken(): boolean { return hasAuthToken(runtimeAuthTokenProvider) } @@ -639,6 +657,7 @@ export { BROWSER_SERVER_BASE_URL_STORAGE_KEY, BrowserServerBaseUrlProvider, createBrowserServerBaseUrlProvider, + NativeServerBaseUrlProvider, normalizeServerBaseUrl, } from './serverBaseUrlProvider' export { @@ -651,8 +670,10 @@ export { InMemoryAuthTokenProvider, isRuntimeAuthSessionReady, markRuntimeAuthSessionReady, + NativeAuthTokenProvider, nativeAuthTokenProvider, normalizeAuthToken, + persistRuntimeAuthSessionReady, runtimeAuthTokenProvider, } from './tokenProvider' export type { AuthTokenProvider, SyncAuthTokenProvider } from './tokenProvider' diff --git a/ui/src/api/homeSecAuthPlugin.ts b/ui/src/api/homeSecAuthPlugin.ts new file mode 100644 index 00000000..47849eef --- /dev/null +++ b/ui/src/api/homeSecAuthPlugin.ts @@ -0,0 +1,23 @@ +import { registerPlugin } from '@capacitor/core' + +export interface HomeSecAuthStoredValue { + value: string | null +} + +export interface HomeSecAuthStoredFlag { + value: boolean +} + +export interface HomeSecAuthPlugin { + getServerBaseUrl(): Promise + setServerBaseUrl(input: { value: string }): Promise + clearServerBaseUrl(): Promise + getApiToken(): Promise + setApiToken(input: { value: string }): Promise + clearApiToken(): Promise + getAuthDisabledReady(): Promise + setAuthDisabledReady(input: { value: boolean }): Promise + clearAuthDisabledReady(): Promise +} + +export const homeSecAuthPlugin = registerPlugin('HomeSecAuth') diff --git a/ui/src/api/runtimeConfig.ts b/ui/src/api/runtimeConfig.ts index 0ff5da79..56e7ae2f 100644 --- a/ui/src/api/runtimeConfig.ts +++ b/ui/src/api/runtimeConfig.ts @@ -1,4 +1,4 @@ -import { browserServerBaseUrlProvider } from './client' +import { hydrateRuntimeApiProviders, runtimeServerBaseUrlProvider } from './client' import type { ClientServerBaseUrlProvider } from './serverBaseUrlProvider' export interface ApiRuntimeConfig { @@ -32,8 +32,9 @@ export interface InitializeApiRuntimeConfigOptions { export async function initializeApiRuntimeConfig({ runtimeConfigSource = new WindowApiRuntimeConfigSource(), - serverBaseUrlProvider = browserServerBaseUrlProvider, + serverBaseUrlProvider = runtimeServerBaseUrlProvider, }: InitializeApiRuntimeConfigOptions = {}): Promise { + await hydrateRuntimeApiProviders() const config = await runtimeConfigSource.loadRuntimeConfig() if (Object.prototype.hasOwnProperty.call(config, 'serverBaseUrl')) { await serverBaseUrlProvider.setBaseUrl(config.serverBaseUrl ?? null) diff --git a/ui/src/api/serverBaseUrlProvider.test.ts b/ui/src/api/serverBaseUrlProvider.test.ts index 8002a21d..07d9a4ba 100644 --- a/ui/src/api/serverBaseUrlProvider.test.ts +++ b/ui/src/api/serverBaseUrlProvider.test.ts @@ -3,13 +3,34 @@ import { describe, expect, it } from 'vitest' import { BROWSER_SERVER_BASE_URL_STORAGE_KEY, BrowserServerBaseUrlProvider, + NativeServerBaseUrlProvider, normalizeServerBaseUrl, } from './serverBaseUrlProvider' +import type { HomeSecAuthPlugin } from './homeSecAuthPlugin' type TestStorage = Pick & { values: Map } +function createNativePluginMock(initialBaseUrl: string | null = null): HomeSecAuthPlugin { + let baseUrl = initialBaseUrl + return { + getServerBaseUrl: async () => ({ value: baseUrl }), + setServerBaseUrl: async ({ value }) => { + baseUrl = value + }, + clearServerBaseUrl: async () => { + baseUrl = null + }, + getApiToken: async () => ({ value: null }), + setApiToken: async () => {}, + clearApiToken: async () => {}, + getAuthDisabledReady: async () => ({ value: false }), + setAuthDisabledReady: async () => {}, + clearAuthDisabledReady: async () => {}, + } +} + function createStorage(): TestStorage { const values = new Map() return { @@ -79,3 +100,24 @@ describe('BrowserServerBaseUrlProvider', () => { expect(storage.values.has(BROWSER_SERVER_BASE_URL_STORAGE_KEY)).toBe(false) }) }) + +describe('NativeServerBaseUrlProvider', () => { + it('hydrates, updates, and clears server URL values through the native bridge', async () => { + // Given: A native bridge with a stored HomeSec server URL + const plugin = createNativePluginMock(' http://192.168.1.10:8081/// ') + const provider = new NativeServerBaseUrlProvider(plugin) + + // When: Hydrating, updating, and clearing the native URL cache + await provider.hydrate() + const hydrated = provider.getBaseUrlSync() + await provider.setBaseUrl('https://homesec.example.com/') + const updated = await provider.getBaseUrl() + await provider.clearBaseUrl() + const cleared = provider.getBaseUrlSync() + + // Then: Values are normalized and remain available synchronously after hydration + expect(hydrated).toBe('http://192.168.1.10:8081') + expect(updated).toBe('https://homesec.example.com') + expect(cleared).toBeNull() + }) +}) diff --git a/ui/src/api/serverBaseUrlProvider.ts b/ui/src/api/serverBaseUrlProvider.ts index 6e22cf9c..3a204086 100644 --- a/ui/src/api/serverBaseUrlProvider.ts +++ b/ui/src/api/serverBaseUrlProvider.ts @@ -1,3 +1,5 @@ +import { homeSecAuthPlugin, type HomeSecAuthPlugin } from './homeSecAuthPlugin' + export const BROWSER_SERVER_BASE_URL_STORAGE_KEY = 'homesec.serverBaseUrl' export interface ServerBaseUrlProvider { @@ -94,6 +96,52 @@ export class BrowserServerBaseUrlProvider implements ClientServerBaseUrlProvider } } +export class NativeServerBaseUrlProvider implements ClientServerBaseUrlProvider { + private baseUrl: string | null = null + private hydrated = false + private readonly plugin: HomeSecAuthPlugin + + constructor(plugin: HomeSecAuthPlugin = homeSecAuthPlugin) { + this.plugin = plugin + } + + async hydrate(): Promise { + const result = await this.plugin.getServerBaseUrl() + this.baseUrl = normalizeServerBaseUrl(result.value) + this.hydrated = true + } + + getBaseUrlSync(): string | null { + return this.baseUrl + } + + async getBaseUrl(): Promise { + if (!this.hydrated) { + await this.hydrate() + } + + return this.getBaseUrlSync() + } + + async setBaseUrl(value: string | null): Promise { + const normalized = normalizeServerBaseUrl(value) + if (normalized) { + await this.plugin.setServerBaseUrl({ value: normalized }) + } else { + await this.plugin.clearServerBaseUrl() + } + + this.baseUrl = normalized + this.hydrated = true + } + + async clearBaseUrl(): Promise { + await this.plugin.clearServerBaseUrl() + this.baseUrl = null + this.hydrated = true + } +} + export function createBrowserServerBaseUrlProvider( fallbackBaseUrl: string | null | undefined, ): BrowserServerBaseUrlProvider { diff --git a/ui/src/api/tokenProvider.test.ts b/ui/src/api/tokenProvider.test.ts index 03acb05f..bcde7ade 100644 --- a/ui/src/api/tokenProvider.test.ts +++ b/ui/src/api/tokenProvider.test.ts @@ -5,15 +5,43 @@ import { BROWSER_AUTH_TOKEN_STORAGE_KEY, BrowserAuthTokenProvider, InMemoryAuthTokenProvider, + NativeAuthTokenProvider, normalizeAuthToken, resolveAuthToken, type AuthTokenProvider, } from './tokenProvider' +import type { HomeSecAuthPlugin } from './homeSecAuthPlugin' type TestStorage = Pick & { values: Map } +function createNativePluginMock( + initial: { token?: string | null; authDisabledReady?: boolean } = {}, +): HomeSecAuthPlugin { + let token = initial.token ?? null + let authDisabledReady = initial.authDisabledReady ?? false + return { + getServerBaseUrl: vi.fn(async () => ({ value: 'https://homesec.example.com' })), + setServerBaseUrl: vi.fn(async () => {}), + clearServerBaseUrl: vi.fn(async () => {}), + getApiToken: vi.fn(async () => ({ value: token })), + setApiToken: vi.fn(async ({ value }) => { + token = value + }), + clearApiToken: vi.fn(async () => { + token = null + }), + getAuthDisabledReady: vi.fn(async () => ({ value: authDisabledReady })), + setAuthDisabledReady: vi.fn(async ({ value }) => { + authDisabledReady = value + }), + clearAuthDisabledReady: vi.fn(async () => { + authDisabledReady = false + }), + } +} + function createStorage(): TestStorage { const values = new Map() return { @@ -105,21 +133,65 @@ describe('InMemoryAuthTokenProvider', () => { }) }) +describe('NativeAuthTokenProvider', () => { + it('hydrates token and auth-disabled readiness from the native bridge', async () => { + // Given: A native bridge with stored token and auth-disabled state + const plugin = createNativePluginMock({ + token: ' native-secret ', + authDisabledReady: true, + }) + const provider = new NativeAuthTokenProvider(plugin) + + // When: Hydrating the native provider + await provider.hydrate() + + // Then: Token and readiness are cached for synchronous route guards + expect(provider.getTokenSync()).toBe('native-secret') + expect(provider.isAuthDisabledReadySync()).toBe(true) + }) + + it('sets and clears native token values through the bridge', async () => { + // Given: A native provider backed by a bridge plugin + const plugin = createNativePluginMock() + const provider = new NativeAuthTokenProvider(plugin) + + // When: Persisting then clearing a native token + await provider.setToken(' native-secret ') + const stored = provider.getTokenSync() + await provider.clearToken() + const cleared = provider.getTokenSync() + + // Then: Writes go through the native bridge and update the sync cache + expect(plugin.setApiToken).toHaveBeenCalledWith({ value: 'native-secret' }) + expect(stored).toBe('native-secret') + expect(plugin.clearApiToken).toHaveBeenCalledTimes(1) + expect(cleared).toBeNull() + }) +}) + describe('runtime auth session readiness', () => { it('persists auth-disabled readiness without a token in native iOS mode', async () => { - // Given: The runtime is loaded in native iOS mode with browser session storage available + // Given: The runtime is loaded in native iOS mode with a native auth bridge vi.resetModules() const storage = installWindowSessionStorageMock() + const nativePlugin = createNativePluginMock() + vi.doMock('./homeSecAuthPlugin', () => ({ + homeSecAuthPlugin: nativePlugin, + })) vi.doMock('../runtime/nativeRuntime', () => ({ isIOSNativeApp: () => true, })) const tokenProvider = await import('./tokenProvider') - // When: Setup marks an auth-disabled server as ready - tokenProvider.markRuntimeAuthSessionReady({ persistAuthDisabled: true }) + // When: Setup persists an auth-disabled server as ready + await tokenProvider.persistRuntimeAuthSessionReady({ persistAuthDisabled: true }) const readyBeforeReload = tokenProvider.isRuntimeAuthSessionReady() vi.resetModules() + vi.doMock('./homeSecAuthPlugin', () => ({ + homeSecAuthPlugin: nativePlugin, + })) const reloadedTokenProvider = await import('./tokenProvider') + await reloadedTokenProvider.nativeAuthTokenProvider.hydrate() const readyAfterReload = reloadedTokenProvider.isRuntimeAuthSessionReady() // Then: Readiness survives reload without persisting an API token @@ -129,28 +201,36 @@ describe('runtime auth session readiness', () => { expect(reloadedTokenProvider.nativeAuthTokenProvider.getTokenSync()).toBeNull() }) - it('does not persist protected native sessions after reload', async () => { - // Given: The runtime is loaded in native iOS mode with browser session storage available + it('hydrates protected native sessions after reload', async () => { + // Given: The runtime is loaded in native iOS mode with a native auth bridge vi.resetModules() const storage = installWindowSessionStorageMock() + const nativePlugin = createNativePluginMock() + vi.doMock('./homeSecAuthPlugin', () => ({ + homeSecAuthPlugin: nativePlugin, + })) vi.doMock('../runtime/nativeRuntime', () => ({ isIOSNativeApp: () => true, })) const tokenProvider = await import('./tokenProvider') - // When: Setup marks a protected server as ready with an in-memory token + // When: Setup persists a protected server token through native storage await tokenProvider.runtimeAuthTokenProvider.setToken('native-token') - tokenProvider.markRuntimeAuthSessionReady({ persistAuthDisabled: false }) + await tokenProvider.persistRuntimeAuthSessionReady({ persistAuthDisabled: false }) const readyBeforeReload = tokenProvider.isRuntimeAuthSessionReady() vi.resetModules() + vi.doMock('./homeSecAuthPlugin', () => ({ + homeSecAuthPlugin: nativePlugin, + })) const reloadedTokenProvider = await import('./tokenProvider') + await reloadedTokenProvider.nativeAuthTokenProvider.hydrate() const readyAfterReload = reloadedTokenProvider.isRuntimeAuthSessionReady() - // Then: Token-backed readiness remains memory-only + // Then: Token-backed readiness survives reload through the native bridge expect(readyBeforeReload).toBe(true) - expect(readyAfterReload).toBe(false) + expect(readyAfterReload).toBe(true) expect(storage.values.has(BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY)).toBe(false) - expect(reloadedTokenProvider.nativeAuthTokenProvider.getTokenSync()).toBeNull() + expect(reloadedTokenProvider.nativeAuthTokenProvider.getTokenSync()).toBe('native-token') }) }) diff --git a/ui/src/api/tokenProvider.ts b/ui/src/api/tokenProvider.ts index e716bd09..e1e2c7cc 100644 --- a/ui/src/api/tokenProvider.ts +++ b/ui/src/api/tokenProvider.ts @@ -1,6 +1,7 @@ import type { ApiRequestOptions } from './generated/client' import { isIOSNativeApp } from '../runtime/nativeRuntime' +import { homeSecAuthPlugin, type HomeSecAuthPlugin } from './homeSecAuthPlugin' export const BROWSER_AUTH_TOKEN_STORAGE_KEY = 'homesec.apiKey' export const BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY = @@ -123,8 +124,92 @@ export class InMemoryAuthTokenProvider implements SyncAuthTokenProvider { } } +export class NativeAuthTokenProvider implements SyncAuthTokenProvider { + private authDisabledReady = false + private hydrated = false + private readonly plugin: HomeSecAuthPlugin + private token: string | null = null + + constructor(plugin: HomeSecAuthPlugin = homeSecAuthPlugin) { + this.plugin = plugin + } + + async hydrate(): Promise { + const [tokenResult, authDisabledResult] = await Promise.all([ + this.plugin.getApiToken(), + this.plugin.getAuthDisabledReady(), + ]) + this.token = normalizeAuthToken(tokenResult.value) + this.authDisabledReady = authDisabledResult.value + this.hydrated = true + } + + getTokenSync(): string | null { + return this.token + } + + setTokenSync(token: string | null): void { + this.token = normalizeAuthToken(token) + this.hydrated = true + } + + clearTokenSync(): void { + this.token = null + this.hydrated = true + } + + async getToken(): Promise { + if (!this.hydrated) { + await this.hydrate() + } + + return this.getTokenSync() + } + + async setToken(token: string | null): Promise { + const normalized = normalizeAuthToken(token) + if (normalized) { + await this.plugin.setApiToken({ value: normalized }) + } else { + await this.plugin.clearApiToken() + } + + this.token = normalized + this.hydrated = true + } + + async clearToken(): Promise { + await this.plugin.clearApiToken() + this.clearTokenSync() + } + + isAuthDisabledReadySync(): boolean { + return this.authDisabledReady + } + + async setAuthDisabledReady(ready: boolean): Promise { + if (ready) { + await this.plugin.setAuthDisabledReady({ value: true }) + } else { + await this.plugin.clearAuthDisabledReady() + } + + this.authDisabledReady = ready + this.hydrated = true + } + + setAuthDisabledReadySync(ready: boolean): void { + this.authDisabledReady = ready + this.hydrated = true + } + + clearAuthDisabledReadySync(): void { + this.setAuthDisabledReadySync(false) + } +} + export const browserAuthTokenProvider = new BrowserAuthTokenProvider() -export const nativeAuthTokenProvider = new InMemoryAuthTokenProvider() +export const nativeAuthTokenProvider = new NativeAuthTokenProvider() export const runtimeAuthTokenProvider: SyncAuthTokenProvider = isIOSNativeApp() ? nativeAuthTokenProvider : browserAuthTokenProvider @@ -155,13 +240,39 @@ function persistAuthDisabledSessionReady(ready: boolean): void { export function markRuntimeAuthSessionReady(options: { persistAuthDisabled?: boolean } = {}): void { if (isIOSNativeApp()) { nativeAuthSessionReady = true + nativeAuthTokenProvider.setAuthDisabledReadySync(Boolean(options.persistAuthDisabled)) + persistAuthDisabledSessionReady(Boolean(options.persistAuthDisabled)) + } +} + +export async function persistRuntimeAuthSessionReady( + options: { persistAuthDisabled?: boolean } = {}, +): Promise { + if (isIOSNativeApp()) { + nativeAuthSessionReady = true + await nativeAuthTokenProvider.setAuthDisabledReady(Boolean(options.persistAuthDisabled)) persistAuthDisabledSessionReady(Boolean(options.persistAuthDisabled)) + return } + + persistAuthDisabledSessionReady(Boolean(options.persistAuthDisabled)) +} + +export async function clearPersistedRuntimeAuthSessionReady(): Promise { + if (isIOSNativeApp()) { + nativeAuthSessionReady = false + await nativeAuthTokenProvider.setAuthDisabledReady(false) + persistAuthDisabledSessionReady(false) + return + } + + persistAuthDisabledSessionReady(false) } export function clearRuntimeAuthSessionReady(): void { if (isIOSNativeApp()) { nativeAuthSessionReady = false + nativeAuthTokenProvider.clearAuthDisabledReadySync() persistAuthDisabledSessionReady(false) } } @@ -174,6 +285,7 @@ export function isRuntimeAuthSessionReady(): boolean { return ( nativeAuthSessionReady || hasAuthToken(runtimeAuthTokenProvider) || + nativeAuthTokenProvider.isAuthDisabledReadySync() || hasPersistedAuthDisabledSessionReady() ) } diff --git a/ui/src/features/cameras/CamerasPage.tsx b/ui/src/features/cameras/CamerasPage.tsx index 7fd90cd3..6c6981c1 100644 --- a/ui/src/features/cameras/CamerasPage.tsx +++ b/ui/src/features/cameras/CamerasPage.tsx @@ -42,12 +42,12 @@ export function CamerasPage() { } async function submitApiKey(apiKey: string): Promise { - saveApiKey(apiKey) + await saveApiKey(apiKey) await refreshAll() } async function clearStoredApiKey(): Promise { - clearApiKey() + await clearApiKey() await refreshAll() } diff --git a/ui/src/features/clips/ClipDetailPage.tsx b/ui/src/features/clips/ClipDetailPage.tsx index 579101bc..5c255d2c 100644 --- a/ui/src/features/clips/ClipDetailPage.tsx +++ b/ui/src/features/clips/ClipDetailPage.tsx @@ -123,14 +123,14 @@ export function ClipDetailPage() { }, [mediaQuery.mediaUrl]) async function submitApiKey(apiKey: string): Promise { - saveApiKey(apiKey) + await saveApiKey(apiKey) playbackRefreshAttempts.current = 0 await clipQuery.refetch() await mediaQuery.refresh() } async function clearStoredApiKey(): Promise { - clearApiKey() + await clearApiKey() await clipQuery.refetch() } diff --git a/ui/src/features/clips/ClipsPage.tsx b/ui/src/features/clips/ClipsPage.tsx index 68368e01..68048e4a 100644 --- a/ui/src/features/clips/ClipsPage.tsx +++ b/ui/src/features/clips/ClipsPage.tsx @@ -487,12 +487,12 @@ export function ClipsPage() { } async function submitApiKey(apiKey: string): Promise { - saveApiKey(apiKey) + await saveApiKey(apiKey) await Promise.all([clipsQuery.refetch(), camerasQuery.refetch()]) } async function clearStoredApiKey(): Promise { - clearApiKey() + await clearApiKey() await Promise.all([clipsQuery.refetch(), camerasQuery.refetch()]) } diff --git a/ui/src/features/live/LivePage.tsx b/ui/src/features/live/LivePage.tsx index 6b3b6f27..aa139ea6 100644 --- a/ui/src/features/live/LivePage.tsx +++ b/ui/src/features/live/LivePage.tsx @@ -38,12 +38,12 @@ export function LivePage() { } async function submitApiKey(apiKey: string): Promise { - saveApiKey(apiKey) + await saveApiKey(apiKey) await camerasQuery.refetch() } async function clearStoredApiKey(): Promise { - clearApiKey() + await clearApiKey() await camerasQuery.refetch() } diff --git a/ui/src/features/native-setup/NativeSetupPage.tsx b/ui/src/features/native-setup/NativeSetupPage.tsx index a3762d15..10c43d28 100644 --- a/ui/src/features/native-setup/NativeSetupPage.tsx +++ b/ui/src/features/native-setup/NativeSetupPage.tsx @@ -4,11 +4,11 @@ import { useLocation, useNavigate } from 'react-router-dom' import { HomeSecApiClient, - browserServerBaseUrlProvider, isAPIError, isUnauthorizedAPIError, - markRuntimeAuthSessionReady, + persistRuntimeAuthSessionReady, runtimeAuthTokenProvider, + runtimeServerBaseUrlProvider, } from '../../api/client' import type { AuthTokenProvider, ClientServerBaseUrlProvider } from '../../api/client' import { Button } from '../../components/ui/Button' @@ -71,7 +71,7 @@ function nativeSetupReturnTo(state: unknown): string { export function NativeSetupPage({ authTokenProvider = runtimeAuthTokenProvider, createClient = (baseUrl: string) => new HomeSecApiClient(baseUrl), - serverBaseUrlProvider = browserServerBaseUrlProvider, + serverBaseUrlProvider = runtimeServerBaseUrlProvider, }: NativeSetupPageProps = {}) { const navigate = useNavigate() const location = useLocation() @@ -165,7 +165,7 @@ export function NativeSetupPage({ } await serverBaseUrlProvider.setBaseUrl(validatedServerUrl) await authTokenProvider.setToken(authDisabled ? null : apiKey || null) - markRuntimeAuthSessionReady({ persistAuthDisabled: authDisabled }) + await persistRuntimeAuthSessionReady({ persistAuthDisabled: authDisabled }) queryClient.clear() navigate(nativeSetupReturnTo(location.state), { replace: true }) } catch (error) { diff --git a/ui/src/features/system/SystemPage.tsx b/ui/src/features/system/SystemPage.tsx index e481edf1..d33a8e72 100644 --- a/ui/src/features/system/SystemPage.tsx +++ b/ui/src/features/system/SystemPage.tsx @@ -48,12 +48,12 @@ export function SystemPage() { } async function submitApiKey(apiKey: string): Promise { - saveApiKey(apiKey) + await saveApiKey(apiKey) await Promise.all([statsQuery.refetch(), backupStatusQuery.refetch()]) } async function clearStoredApiKey(): Promise { - clearApiKey() + await clearApiKey() await Promise.all([statsQuery.refetch(), backupStatusQuery.refetch()]) } diff --git a/ui/src/routes/AppRouter.test.tsx b/ui/src/routes/AppRouter.test.tsx index 1ad04241..e1e9fabc 100644 --- a/ui/src/routes/AppRouter.test.tsx +++ b/ui/src/routes/AppRouter.test.tsx @@ -19,7 +19,7 @@ const useHealthQueryMock = routeMocks.useHealthQuery const useCamerasQueryMock = routeMocks.useCamerasQuery vi.mock('../api/client', () => ({ - browserServerBaseUrlProvider: { + runtimeServerBaseUrlProvider: { getBaseUrlSync: () => routeMocks.getBaseUrlSync(), }, isRuntimeAuthSessionReady: () => routeMocks.isRuntimeAuthSessionReady(), diff --git a/ui/src/routes/AppRouter.tsx b/ui/src/routes/AppRouter.tsx index 2d8447aa..970336ef 100644 --- a/ui/src/routes/AppRouter.tsx +++ b/ui/src/routes/AppRouter.tsx @@ -1,6 +1,6 @@ import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom' -import { browserServerBaseUrlProvider, isRuntimeAuthSessionReady } from '../api/client' +import { isRuntimeAuthSessionReady, runtimeServerBaseUrlProvider } from '../api/client' import { AppShell } from '../app/layout/AppShell' import { isIOSNativeApp } from '../runtime/nativeRuntime' import { CamerasPage } from '../features/cameras/CamerasPage' @@ -29,7 +29,7 @@ function NativeSetupGuard() { if ( isIOSNativeApp() && - (!browserServerBaseUrlProvider.getBaseUrlSync() || !isRuntimeAuthSessionReady()) + (!runtimeServerBaseUrlProvider.getBaseUrlSync() || !isRuntimeAuthSessionReady()) ) { return ( Date: Sat, 13 Jun 2026 18:56:08 -0700 Subject: [PATCH 09/36] feat: handle iOS app lifecycle media --- ui/ios/App/CapApp-SPM/Package.swift | 6 +- ui/package.json | 1 + ui/pnpm-lock.yaml | 12 ++ .../cameras/hooks/useCameraPreview.test.tsx | 103 ++++++++++++++++++ .../cameras/hooks/useCameraPreview.ts | 60 ++++++++-- .../cameras/hooks/usePushToTalk.test.tsx | 80 ++++++++++++++ .../features/cameras/hooks/usePushToTalk.ts | 51 ++++++++- ui/src/runtime/nativeAppLifecycle.ts | 84 ++++++++++++++ 8 files changed, 379 insertions(+), 18 deletions(-) create mode 100644 ui/src/runtime/nativeAppLifecycle.ts diff --git a/ui/ios/App/CapApp-SPM/Package.swift b/ui/ios/App/CapApp-SPM/Package.swift index 2a382419..d0f1ca5d 100644 --- a/ui/ios/App/CapApp-SPM/Package.swift +++ b/ui/ios/App/CapApp-SPM/Package.swift @@ -11,14 +11,16 @@ let package = Package( targets: ["CapApp-SPM"]) ], dependencies: [ - .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.3") + .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.3"), + .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.3/node_modules/@capacitor/app") ], targets: [ .target( name: "CapApp-SPM", dependencies: [ .product(name: "Capacitor", package: "capacitor-swift-pm"), - .product(name: "Cordova", package: "capacitor-swift-pm") + .product(name: "Cordova", package: "capacitor-swift-pm"), + .product(name: "CapacitorApp", package: "CapacitorApp") ] ) ] diff --git a/ui/package.json b/ui/package.json index c84043bb..ce9e4fcd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -25,6 +25,7 @@ "preview": "vite preview" }, "dependencies": { + "@capacitor/app": "8.1.0", "@capacitor/core": "^8.3.3", "@capacitor/ios": "^8.3.3", "@tanstack/react-query": "^5.90.21", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index a82d63d2..1ea2480f 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@capacitor/app': + specifier: 8.1.0 + version: 8.1.0(@capacitor/core@8.3.3) '@capacitor/core': specifier: ^8.3.3 version: 8.3.3 @@ -198,6 +201,11 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@capacitor/app@8.1.0': + resolution: {integrity: sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@capacitor/cli@8.3.3': resolution: {integrity: sha512-FHebL02KEyU5vs+Os5s1yZuE8QT3FzxoO4nZLywGk7Ny957E6gOujKouGKsnKYq01eAWWJGGV/Fv04rY27tSsw==} engines: {node: '>=22.0.0'} @@ -2083,6 +2091,10 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@capacitor/app@8.1.0(@capacitor/core@8.3.3)': + dependencies: + '@capacitor/core': 8.3.3 + '@capacitor/cli@8.3.3': dependencies: '@ionic/cli-framework-output': 2.2.8 diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index 865c12f9..0406d6a6 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -5,11 +5,44 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { afterEach, describe, expect, it, vi } from 'vitest' +type MockNativeLifecycleState = { + isActive: boolean + pauseCount: number + resumeCount: number +} + +const nativeLifecycleMock = vi.hoisted(() => ({ + state: { + isActive: true, + pauseCount: 0, + resumeCount: 0, + } as MockNativeLifecycleState, +})) + +vi.mock('../../../runtime/nativeAppLifecycle', () => ({ + useNativeAppLifecycleState: () => nativeLifecycleMock.state, +})) + import { apiClient } from '../../../api/client' import { useCameraPreview } from './useCameraPreview' const PREVIEW_TEST_NOW_MS = Date.parse('2026-04-23T12:00:00.000Z') +function resetNativeLifecycleState() { + nativeLifecycleMock.state = { + isActive: true, + pauseCount: 0, + resumeCount: 0, + } +} + +function setNativeLifecycleState(nextState: Partial) { + nativeLifecycleMock.state = { + ...nativeLifecycleMock.state, + ...nextState, + } +} + function freezePreviewClock() { vi.spyOn(Date, 'now').mockReturnValue(PREVIEW_TEST_NOW_MS) } @@ -33,6 +66,7 @@ function createWrapper() { describe('useCameraPreview', () => { afterEach(() => { + resetNativeLifecycleState() vi.restoreAllMocks() vi.useRealTimers() }) @@ -615,4 +649,73 @@ describe('useCameraPreview', () => { }) expect(ensurePreviewActive).toHaveBeenCalledTimes(1) }) + + it('stops active preview on native background and refreshes status on resume', async () => { + // Given: An active native app preview session + freezePreviewClock() + const getPreviewStatus = vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + vi.spyOn(apiClient, 'ensureCameraPreviewActive').mockResolvedValue({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-1', + token_expires_at: '2026-04-23T12:00:10.000Z', + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({ + accepted: true, + state: 'stopping', + httpStatus: 202, + }) + + const { result, rerender } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-1') + }) + + // When: iOS backgrounds the app while preview is attached + setNativeLifecycleState({ isActive: false, pauseCount: 1 }) + rerender() + + // Then: The hook stops preview and suppresses background status requests + await waitFor(() => { + expect(stopPreview).toHaveBeenCalledWith('front') + expect(result.current.session).toBeNull() + }) + const statusCallsAfterPause = getPreviewStatus.mock.calls.length + await act(async () => { + await expect(result.current.refreshStatus()).resolves.toBeNull() + }) + expect(getPreviewStatus).toHaveBeenCalledTimes(statusCallsAfterPause) + + // When: iOS resumes the app + setNativeLifecycleState({ isActive: true, resumeCount: 1 }) + rerender() + + // Then: The hook refreshes preview status without auto-attaching a new session + await waitFor(() => { + expect(getPreviewStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterPause) + }) + expect(result.current.session).toBeNull() + }) }) diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index ba4d7d91..7de07498 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -8,6 +8,7 @@ import { type PreviewStopSnapshot, } from '../../../api/client' import { QUERY_KEYS } from '../../../api/hooks/queryKeys' +import { useNativeAppLifecycleState } from '../../../runtime/nativeAppLifecycle' const PREVIEW_STATUS_REFRESH_MS = 5_000 const PREVIEW_TOKEN_REFRESH_LEEWAY_MS = 5_000 @@ -37,6 +38,7 @@ interface StoredPreviewSession { export function useCameraPreview(cameraName: string): CameraPreviewState { const queryClient = useQueryClient() + const nativeLifecycle = useNativeAppLifecycleState() const [sessionState, setSessionState] = useState(null) const [refreshError, setRefreshError] = useState(null) const sessionStateRef = useRef(null) @@ -85,7 +87,8 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { return nextStatus }, staleTime: PREVIEW_STATUS_REFRESH_MS, - refetchInterval: sessionState ? PREVIEW_STATUS_REFRESH_MS : false, + enabled: nativeLifecycle.isActive, + refetchInterval: nativeLifecycle.isActive && sessionState ? PREVIEW_STATUS_REFRESH_MS : false, }) const stopMutation = useMutation({ @@ -96,9 +99,22 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) }) }, }) + const refetchStatus = statusQuery.refetch + const stopPreview = stopMutation.mutateAsync const session = sessionState?.snapshot ?? null + const stop = useCallback(async () => { + try { + await stopPreview() + } catch { + return + } + }, [stopPreview]) + const refreshSession = useCallback(async () => { + if (!nativeLifecycle.isActive) { + return + } try { const nextSession = await apiClient.ensureCameraPreviewActive(cameraName) setRefreshError(null) @@ -107,10 +123,26 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { } catch (nextError) { setRefreshError(nextError as Error) } - }, [cameraName, queryClient, storeSession]) + }, [cameraName, nativeLifecycle.isActive, queryClient, storeSession]) + + useEffect(() => { + if (nativeLifecycle.isActive || sessionStateRef.current === null) { + return + } + + void stop() + }, [nativeLifecycle.isActive, stop]) + + useEffect(() => { + if (!nativeLifecycle.isActive || nativeLifecycle.resumeCount === 0) { + return + } + + void refetchStatus() + }, [nativeLifecycle.isActive, nativeLifecycle.resumeCount, refetchStatus]) useEffect(() => { - if (session?.token_expires_at == null || stopMutation.isPending) { + if (!nativeLifecycle.isActive || session?.token_expires_at == null || stopMutation.isPending) { return } @@ -141,7 +173,13 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { return () => { window.clearTimeout(timeoutId) } - }, [session?.token_expires_at, refreshError, refreshSession, stopMutation.isPending]) + }, [ + nativeLifecycle.isActive, + session?.token_expires_at, + refreshError, + refreshSession, + stopMutation.isPending, + ]) const warning = session?.warning @@ -169,21 +207,21 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { isStarting: startMutation.isPending, isStopping: stopMutation.isPending, start: async () => { - try { - await startMutation.mutateAsync() - } catch { + if (!nativeLifecycle.isActive) { return } - }, - stop: async () => { try { - await stopMutation.mutateAsync() + await startMutation.mutateAsync() } catch { return } }, + stop, refreshStatus: async () => { - const result = await statusQuery.refetch() + if (!nativeLifecycle.isActive) { + return null + } + const result = await refetchStatus() return result.data ?? null }, } diff --git a/ui/src/features/cameras/hooks/usePushToTalk.test.tsx b/ui/src/features/cameras/hooks/usePushToTalk.test.tsx index 66a47ec0..30242ba0 100644 --- a/ui/src/features/cameras/hooks/usePushToTalk.test.tsx +++ b/ui/src/features/cameras/hooks/usePushToTalk.test.tsx @@ -3,12 +3,45 @@ import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +type MockNativeLifecycleState = { + isActive: boolean + pauseCount: number + resumeCount: number +} + +const nativeLifecycleMock = vi.hoisted(() => ({ + state: { + isActive: true, + pauseCount: 0, + resumeCount: 0, + } as MockNativeLifecycleState, +})) + +vi.mock('../../../runtime/nativeAppLifecycle', () => ({ + useNativeAppLifecycleState: () => nativeLifecycleMock.state, +})) + import { apiClient } from '../../../api/client' import type { TalkSessionResponse, TalkStatusResponse } from '../../../api/generated/types' import { usePushToTalk } from './usePushToTalk' type TalkStatusSnapshot = TalkStatusResponse & { httpStatus: number } +function resetNativeLifecycleState() { + nativeLifecycleMock.state = { + isActive: true, + pauseCount: 0, + resumeCount: 0, + } +} + +function setNativeLifecycleState(nextState: Partial) { + nativeLifecycleMock.state = { + ...nativeLifecycleMock.state, + ...nextState, + } +} + const idleStatus: TalkStatusResponse = { camera_name: 'front', enabled: true, @@ -242,6 +275,7 @@ describe('usePushToTalk', () => { }) afterEach(() => { + resetNativeLifecycleState() cleanup() vi.restoreAllMocks() }) @@ -756,4 +790,50 @@ describe('usePushToTalk', () => { expect(lastGainNode?.gain.value).toBe(0) expect(lastGainNode?.connect).toHaveBeenCalled() }) + + it('stops active talk on native background and refreshes status on resume', async () => { + // Given: An active native app push-to-talk stream + const { stream, track } = createMediaStream() + installBrowserFakes(vi.fn().mockResolvedValue(stream)) + const { result, rerender } = renderHook(() => usePushToTalk('front')) + await waitFor(() => expect(result.current.canStart).toBe(true)) + void act(() => { + void result.current.start() + }) + await waitFor(() => expect(sockets).toHaveLength(1)) + sockets[0].open() + sockets[0].message(JSON.stringify({ type: 'ready' })) + await waitFor(() => expect(result.current.isStreaming).toBe(true)) + + // When: iOS backgrounds the app during an active talk session + setNativeLifecycleState({ isActive: false, pauseCount: 1 }) + rerender() + + // Then: The hook sends the stop frame, tears down media, and stops the backend session + await waitFor(() => { + expect(apiClient.stopCameraTalkSession).toHaveBeenCalledWith('front', 'tk_123') + }) + expect(sockets[0].sent).toContain(JSON.stringify({ type: 'stop' })) + expect(sockets[0].lastClose).toEqual({ code: 1000, reason: 'Talk stopped' }) + expect(track.stop).toHaveBeenCalled() + await waitFor(() => expect(result.current.isStreaming).toBe(false)) + expect(result.current.canStart).toBe(false) + const statusCallsAfterPause = vi.mocked(apiClient.getCameraTalkStatus).mock.calls.length + + await act(async () => { + await result.current.refreshStatus() + }) + expect(apiClient.getCameraTalkStatus).toHaveBeenCalledTimes(statusCallsAfterPause) + + // When: iOS resumes the app + setNativeLifecycleState({ isActive: true, resumeCount: 1 }) + rerender() + + // Then: The hook refreshes talk status for the foregrounded route + await waitFor(() => { + expect(vi.mocked(apiClient.getCameraTalkStatus).mock.calls.length).toBeGreaterThan( + statusCallsAfterPause, + ) + }) + }) }) diff --git a/ui/src/features/cameras/hooks/usePushToTalk.ts b/ui/src/features/cameras/hooks/usePushToTalk.ts index a59f013b..87fea136 100644 --- a/ui/src/features/cameras/hooks/usePushToTalk.ts +++ b/ui/src/features/cameras/hooks/usePushToTalk.ts @@ -13,6 +13,7 @@ import type { TalkState, TalkStatusResponse, } from '../../../api/generated/types' +import { useNativeAppLifecycleState } from '../../../runtime/nativeAppLifecycle' const DEFAULT_TALK_INPUT: TalkInputFormat = { codec: 'pcm_s16le', @@ -236,6 +237,8 @@ function nextStatusFromState( } export function usePushToTalk(cameraName: string): PushToTalkState { + const nativeLifecycle = useNativeAppLifecycleState() + const nativeLifecycleRef = useRef(nativeLifecycle) const [status, setStatus] = useState(null) const [session, setSession] = useState(null) const [error, setError] = useState(null) @@ -255,6 +258,10 @@ export function usePushToTalk(cameraName: string): PushToTalkState { const pendingSessionIdRef = useRef(null) const statusRequestGenerationRef = useRef(0) + useEffect(() => { + nativeLifecycleRef.current = nativeLifecycle + }, [nativeLifecycle]) + const cleanupSocketAndAudio = useCallback(async () => { const socket = socketRef.current socketRef.current = null @@ -269,6 +276,9 @@ export function usePushToTalk(cameraName: string): PushToTalkState { }, []) const refreshStatus = useCallback(async () => { + if (!nativeLifecycleRef.current.isActive) { + return + } const generation = statusRequestGenerationRef.current + 1 statusRequestGenerationRef.current = generation setIsPending(true) @@ -420,7 +430,7 @@ export function usePushToTalk(cameraName: string): PushToTalkState { ) const start = useCallback(async () => { - if (startInFlightRef.current || isStreaming || isStopping) { + if (!nativeLifecycleRef.current.isActive || startInFlightRef.current || isStreaming || isStopping) { return } const generation = startGenerationRef.current + 1 @@ -530,7 +540,13 @@ export function usePushToTalk(cameraName: string): PushToTalkState { setIsStarting(false) } } - }, [cameraName, cleanupSocketAndAudio, isStopping, isStreaming, openTalkSocket]) + }, [ + cameraName, + cleanupSocketAndAudio, + isStopping, + isStreaming, + openTalkSocket, + ]) const stop = useCallback(async () => { const activeSession = sessionRef.current @@ -588,7 +604,9 @@ export function usePushToTalk(cameraName: string): PushToTalkState { setIsStreaming(false) setIsStarting(false) setIsStopping(false) - void refreshStatus() + if (nativeLifecycleRef.current.isActive) { + void refreshStatus() + } return () => { mountedRef.current = false statusRequestGenerationRef.current += 1 @@ -608,9 +626,32 @@ export function usePushToTalk(cameraName: string): PushToTalkState { } }, [cameraName, cleanupSocketAndAudio, refreshStatus]) + useEffect(() => { + if (nativeLifecycle.isActive) { + return + } + + void stop() + }, [nativeLifecycle.isActive, stop]) + + useEffect(() => { + if (!nativeLifecycle.isActive || nativeLifecycle.resumeCount === 0) { + return + } + + void refreshStatus() + }, [nativeLifecycle.isActive, nativeLifecycle.resumeCount, refreshStatus]) + const canStart = useMemo( - () => !isPending && !isStarting && !isStopping && !isStreaming && statusAllowsStart(status, cameraName), - [cameraName, isPending, isStarting, isStopping, isStreaming, status], + () => ( + nativeLifecycle.isActive + && !isPending + && !isStarting + && !isStopping + && !isStreaming + && statusAllowsStart(status, cameraName) + ), + [cameraName, isPending, isStarting, isStopping, isStreaming, nativeLifecycle.isActive, status], ) return { diff --git a/ui/src/runtime/nativeAppLifecycle.ts b/ui/src/runtime/nativeAppLifecycle.ts new file mode 100644 index 00000000..791ad904 --- /dev/null +++ b/ui/src/runtime/nativeAppLifecycle.ts @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react' +import { App } from '@capacitor/app' +import type { PluginListenerHandle } from '@capacitor/core' + +import { isIOSNativeApp } from './nativeRuntime' + +export interface NativeAppLifecycleState { + isActive: boolean + pauseCount: number + resumeCount: number +} + +const ACTIVE_BROWSER_LIFECYCLE_STATE: NativeAppLifecycleState = { + isActive: true, + pauseCount: 0, + resumeCount: 0, +} + +export function useNativeAppLifecycleState(): NativeAppLifecycleState { + const isIOS = isIOSNativeApp() + const [state, setState] = useState(ACTIVE_BROWSER_LIFECYCLE_STATE) + + useEffect(() => { + if (!isIOS) { + return + } + + let cancelled = false + const handles: PluginListenerHandle[] = [] + + const trackHandle = async (listener: Promise): Promise => { + const handle = await listener.catch(() => null) + if (handle === null) { + return + } + if (cancelled) { + void handle.remove() + return + } + handles.push(handle) + } + + void App.getState() + .then((appState) => { + if (!cancelled) { + setState((previous) => ({ ...previous, isActive: appState.isActive })) + } + }) + .catch(() => {}) + + void trackHandle( + App.addListener('appStateChange', (appState) => { + setState((previous) => ({ ...previous, isActive: appState.isActive })) + }), + ) + void trackHandle( + App.addListener('pause', () => { + setState((previous) => ({ + ...previous, + isActive: false, + pauseCount: previous.pauseCount + 1, + })) + }), + ) + void trackHandle( + App.addListener('resume', () => { + setState((previous) => ({ + ...previous, + isActive: true, + resumeCount: previous.resumeCount + 1, + })) + }), + ) + + return () => { + cancelled = true + handles.forEach((handle) => { + void handle.remove() + }) + } + }, [isIOS]) + + return isIOS ? state : ACTIVE_BROWSER_LIFECYCLE_STATE +} From dc68b2a43150492e6c0fad475902bfa28b71b356 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 19:09:04 -0700 Subject: [PATCH 10/36] fix: close iOS media lifecycle races --- .../cameras/hooks/useCameraPreview.test.tsx | 138 +++++++++++++++++- .../cameras/hooks/useCameraPreview.ts | 62 +++++--- .../cameras/hooks/usePushToTalk.test.tsx | 35 ++++- .../features/cameras/hooks/usePushToTalk.ts | 28 +++- ui/src/runtime/nativeAppLifecycle.ts | 16 +- 5 files changed, 250 insertions(+), 29 deletions(-) diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index 0406d6a6..f47c9043 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' type MockNativeLifecycleState = { isActive: boolean + isBackgrounded: boolean pauseCount: number resumeCount: number } @@ -14,6 +15,7 @@ type MockNativeLifecycleState = { const nativeLifecycleMock = vi.hoisted(() => ({ state: { isActive: true, + isBackgrounded: false, pauseCount: 0, resumeCount: 0, } as MockNativeLifecycleState, @@ -31,6 +33,7 @@ const PREVIEW_TEST_NOW_MS = Date.parse('2026-04-23T12:00:00.000Z') function resetNativeLifecycleState() { nativeLifecycleMock.state = { isActive: true, + isBackgrounded: false, pauseCount: 0, resumeCount: 0, } @@ -47,6 +50,22 @@ function freezePreviewClock() { vi.spyOn(Date, 'now').mockReturnValue(PREVIEW_TEST_NOW_MS) } +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve + reject = nextReject + }) + return { promise, resolve, reject } +} + function createWrapper() { const queryClient = new QueryClient({ defaultOptions: { @@ -694,7 +713,7 @@ describe('useCameraPreview', () => { }) // When: iOS backgrounds the app while preview is attached - setNativeLifecycleState({ isActive: false, pauseCount: 1 }) + setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 }) rerender() // Then: The hook stops preview and suppresses background status requests @@ -709,7 +728,7 @@ describe('useCameraPreview', () => { expect(getPreviewStatus).toHaveBeenCalledTimes(statusCallsAfterPause) // When: iOS resumes the app - setNativeLifecycleState({ isActive: true, resumeCount: 1 }) + setNativeLifecycleState({ isActive: true, isBackgrounded: false, resumeCount: 1 }) rerender() // Then: The hook refreshes preview status without auto-attaching a new session @@ -718,4 +737,119 @@ describe('useCameraPreview', () => { }) expect(result.current.session).toBeNull() }) + + it('stops preview start that resolves after native background', async () => { + // Given: Preview start is still in flight when native iOS backgrounds the app + const previewStart = deferred>>() + vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 0, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + vi.spyOn(apiClient, 'ensureCameraPreviewActive').mockReturnValue(previewStart.promise) + const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({ + accepted: true, + state: 'stopping', + httpStatus: 202, + }) + + const { result, rerender } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + + let startPromise!: Promise + act(() => { + startPromise = result.current.start() + }) + await waitFor(() => { + expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledWith('front') + }) + + // When: The app backgrounds before the preview start response resolves + setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 }) + rerender() + await act(async () => { + previewStart.resolve({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-1', + token_expires_at: '2026-04-23T12:00:10.000Z', + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + await startPromise + }) + + // Then: The late preview session is stopped instead of being stored while backgrounded + await waitFor(() => { + expect(stopPreview).toHaveBeenCalledWith('front') + expect(result.current.session).toBeNull() + expect(result.current.playlistUrl).toBeNull() + }) + }) + + it('does not stop preview on transient native inactive transitions before background', async () => { + // Given: Preview is attached while iOS is active + vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + vi.spyOn(apiClient, 'ensureCameraPreviewActive').mockResolvedValue({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-1', + token_expires_at: '2026-04-23T12:00:10.000Z', + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({ + accepted: true, + state: 'stopping', + httpStatus: 202, + }) + + const { result, rerender } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-1') + }) + + // When: iOS becomes inactive without the pause/background event + setNativeLifecycleState({ isActive: false, isBackgrounded: false }) + rerender() + await act(async () => { + await Promise.resolve() + }) + + // Then: Preview remains attached until a real background pause is observed + expect(stopPreview).not.toHaveBeenCalled() + expect(result.current.session?.token).toBe('preview-token-1') + }) }) diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index 7de07498..a696b7cb 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { @@ -36,14 +36,24 @@ interface StoredPreviewSession { statusRequestSeq: number } +interface PreviewActivation { + pauseCountAtRequest: number + snapshot: PreviewSessionSnapshot +} + export function useCameraPreview(cameraName: string): CameraPreviewState { const queryClient = useQueryClient() const nativeLifecycle = useNativeAppLifecycleState() + const nativeLifecycleRef = useRef(nativeLifecycle) const [sessionState, setSessionState] = useState(null) const [refreshError, setRefreshError] = useState(null) const sessionStateRef = useRef(null) const statusRequestSeqRef = useRef(0) + useLayoutEffect(() => { + nativeLifecycleRef.current = nativeLifecycle + }, [nativeLifecycle]) + const storeSession = useCallback((nextSession: PreviewSessionSnapshot) => { const nextState = { snapshot: nextSession, @@ -59,12 +69,30 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { setSessionState(null) }, []) - const startMutation = useMutation({ - mutationFn: () => apiClient.ensureCameraPreviewActive(cameraName), - onSuccess: async (nextSession) => { + const storeActivationIfCurrent = useCallback(async (activation: PreviewActivation) => { + const currentLifecycle = nativeLifecycleRef.current + if ( + currentLifecycle.isBackgrounded + || currentLifecycle.pauseCount !== activation.pauseCountAtRequest + ) { + clearSession() + await apiClient.stopCameraPreview(cameraName).catch(() => {}) + return + } + + storeSession(activation.snapshot) + await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) }) + }, [cameraName, clearSession, queryClient, storeSession]) + + const startMutation = useMutation({ + mutationFn: async () => { + const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount + const snapshot = await apiClient.ensureCameraPreviewActive(cameraName) + return { pauseCountAtRequest, snapshot } + }, + onSuccess: async (activation) => { setRefreshError(null) - storeSession(nextSession) - await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) }) + await storeActivationIfCurrent(activation) }, }) @@ -112,26 +140,26 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { }, [stopPreview]) const refreshSession = useCallback(async () => { - if (!nativeLifecycle.isActive) { + if (nativeLifecycle.isBackgrounded) { return } try { - const nextSession = await apiClient.ensureCameraPreviewActive(cameraName) + const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount + const snapshot = await apiClient.ensureCameraPreviewActive(cameraName) setRefreshError(null) - storeSession(nextSession) - await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) }) + await storeActivationIfCurrent({ pauseCountAtRequest, snapshot }) } catch (nextError) { setRefreshError(nextError as Error) } - }, [cameraName, nativeLifecycle.isActive, queryClient, storeSession]) + }, [cameraName, nativeLifecycle.isBackgrounded, storeActivationIfCurrent]) useEffect(() => { - if (nativeLifecycle.isActive || sessionStateRef.current === null) { + if (!nativeLifecycle.isBackgrounded || sessionStateRef.current === null) { return } void stop() - }, [nativeLifecycle.isActive, stop]) + }, [nativeLifecycle.isBackgrounded, stop]) useEffect(() => { if (!nativeLifecycle.isActive || nativeLifecycle.resumeCount === 0) { @@ -142,7 +170,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { }, [nativeLifecycle.isActive, nativeLifecycle.resumeCount, refetchStatus]) useEffect(() => { - if (!nativeLifecycle.isActive || session?.token_expires_at == null || stopMutation.isPending) { + if (nativeLifecycle.isBackgrounded || session?.token_expires_at == null || stopMutation.isPending) { return } @@ -174,7 +202,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { window.clearTimeout(timeoutId) } }, [ - nativeLifecycle.isActive, + nativeLifecycle.isBackgrounded, session?.token_expires_at, refreshError, refreshSession, @@ -207,7 +235,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { isStarting: startMutation.isPending, isStopping: stopMutation.isPending, start: async () => { - if (!nativeLifecycle.isActive) { + if (!nativeLifecycle.isActive || nativeLifecycle.isBackgrounded) { return } try { @@ -218,7 +246,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { }, stop, refreshStatus: async () => { - if (!nativeLifecycle.isActive) { + if (nativeLifecycle.isBackgrounded) { return null } const result = await refetchStatus() diff --git a/ui/src/features/cameras/hooks/usePushToTalk.test.tsx b/ui/src/features/cameras/hooks/usePushToTalk.test.tsx index 30242ba0..ecda728f 100644 --- a/ui/src/features/cameras/hooks/usePushToTalk.test.tsx +++ b/ui/src/features/cameras/hooks/usePushToTalk.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' type MockNativeLifecycleState = { isActive: boolean + isBackgrounded: boolean pauseCount: number resumeCount: number } @@ -12,6 +13,7 @@ type MockNativeLifecycleState = { const nativeLifecycleMock = vi.hoisted(() => ({ state: { isActive: true, + isBackgrounded: false, pauseCount: 0, resumeCount: 0, } as MockNativeLifecycleState, @@ -30,6 +32,7 @@ type TalkStatusSnapshot = TalkStatusResponse & { httpStatus: number } function resetNativeLifecycleState() { nativeLifecycleMock.state = { isActive: true, + isBackgrounded: false, pauseCount: 0, resumeCount: 0, } @@ -806,7 +809,7 @@ describe('usePushToTalk', () => { await waitFor(() => expect(result.current.isStreaming).toBe(true)) // When: iOS backgrounds the app during an active talk session - setNativeLifecycleState({ isActive: false, pauseCount: 1 }) + setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 }) rerender() // Then: The hook sends the stop frame, tears down media, and stops the backend session @@ -826,7 +829,7 @@ describe('usePushToTalk', () => { expect(apiClient.getCameraTalkStatus).toHaveBeenCalledTimes(statusCallsAfterPause) // When: iOS resumes the app - setNativeLifecycleState({ isActive: true, resumeCount: 1 }) + setNativeLifecycleState({ isActive: true, isBackgrounded: false, resumeCount: 1 }) rerender() // Then: The hook refreshes talk status for the foregrounded route @@ -836,4 +839,32 @@ describe('usePushToTalk', () => { ) }) }) + + it('does not stop active talk on transient native inactive transitions before background', async () => { + // Given: An active push-to-talk stream while iOS is active + const { stream, track } = createMediaStream() + installBrowserFakes(vi.fn().mockResolvedValue(stream)) + const { result, rerender } = renderHook(() => usePushToTalk('front')) + await waitFor(() => expect(result.current.canStart).toBe(true)) + void act(() => { + void result.current.start() + }) + await waitFor(() => expect(sockets).toHaveLength(1)) + sockets[0].open() + sockets[0].message(JSON.stringify({ type: 'ready' })) + await waitFor(() => expect(result.current.isStreaming).toBe(true)) + + // When: iOS becomes inactive without the pause/background event + setNativeLifecycleState({ isActive: false, isBackgrounded: false }) + rerender() + await act(async () => { + await Promise.resolve() + }) + + // Then: The talk stream remains active until actual backgrounding + expect(apiClient.stopCameraTalkSession).not.toHaveBeenCalled() + expect(sockets[0].readyState).toBe(FakeWebSocket.OPEN) + expect(track.stop).not.toHaveBeenCalled() + expect(result.current.isStreaming).toBe(true) + }) }) diff --git a/ui/src/features/cameras/hooks/usePushToTalk.ts b/ui/src/features/cameras/hooks/usePushToTalk.ts index 87fea136..b4ae6daf 100644 --- a/ui/src/features/cameras/hooks/usePushToTalk.ts +++ b/ui/src/features/cameras/hooks/usePushToTalk.ts @@ -276,7 +276,7 @@ export function usePushToTalk(cameraName: string): PushToTalkState { }, []) const refreshStatus = useCallback(async () => { - if (!nativeLifecycleRef.current.isActive) { + if (nativeLifecycleRef.current.isBackgrounded) { return } const generation = statusRequestGenerationRef.current + 1 @@ -430,7 +430,13 @@ export function usePushToTalk(cameraName: string): PushToTalkState { ) const start = useCallback(async () => { - if (!nativeLifecycleRef.current.isActive || startInFlightRef.current || isStreaming || isStopping) { + if ( + !nativeLifecycleRef.current.isActive + || nativeLifecycleRef.current.isBackgrounded + || startInFlightRef.current + || isStreaming + || isStopping + ) { return } const generation = startGenerationRef.current + 1 @@ -604,7 +610,7 @@ export function usePushToTalk(cameraName: string): PushToTalkState { setIsStreaming(false) setIsStarting(false) setIsStopping(false) - if (nativeLifecycleRef.current.isActive) { + if (!nativeLifecycleRef.current.isBackgrounded) { void refreshStatus() } return () => { @@ -627,12 +633,12 @@ export function usePushToTalk(cameraName: string): PushToTalkState { }, [cameraName, cleanupSocketAndAudio, refreshStatus]) useEffect(() => { - if (nativeLifecycle.isActive) { + if (!nativeLifecycle.isBackgrounded) { return } void stop() - }, [nativeLifecycle.isActive, stop]) + }, [nativeLifecycle.isBackgrounded, stop]) useEffect(() => { if (!nativeLifecycle.isActive || nativeLifecycle.resumeCount === 0) { @@ -645,13 +651,23 @@ export function usePushToTalk(cameraName: string): PushToTalkState { const canStart = useMemo( () => ( nativeLifecycle.isActive + && !nativeLifecycle.isBackgrounded && !isPending && !isStarting && !isStopping && !isStreaming && statusAllowsStart(status, cameraName) ), - [cameraName, isPending, isStarting, isStopping, isStreaming, nativeLifecycle.isActive, status], + [ + cameraName, + isPending, + isStarting, + isStopping, + isStreaming, + nativeLifecycle.isActive, + nativeLifecycle.isBackgrounded, + status, + ], ) return { diff --git a/ui/src/runtime/nativeAppLifecycle.ts b/ui/src/runtime/nativeAppLifecycle.ts index 791ad904..cfcc8038 100644 --- a/ui/src/runtime/nativeAppLifecycle.ts +++ b/ui/src/runtime/nativeAppLifecycle.ts @@ -6,12 +6,14 @@ import { isIOSNativeApp } from './nativeRuntime' export interface NativeAppLifecycleState { isActive: boolean + isBackgrounded: boolean pauseCount: number resumeCount: number } const ACTIVE_BROWSER_LIFECYCLE_STATE: NativeAppLifecycleState = { isActive: true, + isBackgrounded: false, pauseCount: 0, resumeCount: 0, } @@ -43,20 +45,29 @@ export function useNativeAppLifecycleState(): NativeAppLifecycleState { void App.getState() .then((appState) => { if (!cancelled) { - setState((previous) => ({ ...previous, isActive: appState.isActive })) + setState((previous) => ({ + ...previous, + isActive: appState.isActive, + isBackgrounded: appState.isActive ? false : previous.isBackgrounded, + })) } }) .catch(() => {}) void trackHandle( App.addListener('appStateChange', (appState) => { - setState((previous) => ({ ...previous, isActive: appState.isActive })) + setState((previous) => ({ + ...previous, + isActive: appState.isActive, + isBackgrounded: appState.isActive ? false : previous.isBackgrounded, + })) }), ) void trackHandle( App.addListener('pause', () => { setState((previous) => ({ ...previous, + isBackgrounded: true, isActive: false, pauseCount: previous.pauseCount + 1, })) @@ -66,6 +77,7 @@ export function useNativeAppLifecycleState(): NativeAppLifecycleState { App.addListener('resume', () => { setState((previous) => ({ ...previous, + isBackgrounded: false, isActive: true, resumeCount: previous.resumeCount + 1, })) From 153a30b18e916359738205b470ca94ada364a6db Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 19:18:40 -0700 Subject: [PATCH 11/36] fix: ignore stale iOS preview activations --- .../cameras/hooks/useCameraPreview.test.tsx | 80 +++++++++++++++++++ .../cameras/hooks/useCameraPreview.ts | 21 ++++- 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index f47c9043..9447cc0d 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -799,6 +799,86 @@ describe('useCameraPreview', () => { }) }) + it('does not let a stale preview start stop a newer resumed preview', async () => { + // Given: A preview start begins before background and a newer start succeeds after resume + const staleStart = deferred>>() + vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 0, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + vi.spyOn(apiClient, 'ensureCameraPreviewActive') + .mockReturnValueOnce(staleStart.promise) + .mockResolvedValueOnce({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-new', + token_expires_at: '2026-04-23T12:00:10.000Z', + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-new', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({ + accepted: true, + state: 'stopping', + httpStatus: 202, + }) + + const { result, rerender } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + + let staleStartPromise!: Promise + act(() => { + staleStartPromise = result.current.start() + }) + await waitFor(() => { + expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledTimes(1) + }) + + setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 }) + rerender() + setNativeLifecycleState({ isActive: true, isBackgrounded: false, resumeCount: 1 }) + rerender() + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-new') + }) + + // When: The stale pre-background start resolves after the newer resumed start + await act(async () => { + staleStart.resolve({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-stale', + token_expires_at: '2026-04-23T12:00:10.000Z', + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-stale', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + await staleStartPromise + }) + + // Then: The stale completion is ignored without clearing or stopping the newer session + expect(stopPreview).not.toHaveBeenCalled() + expect(result.current.session?.token).toBe('preview-token-new') + expect(result.current.playlistUrl).toContain('preview-token-new') + }) + it('does not stop preview on transient native inactive transitions before background', async () => { // Given: Preview is attached while iOS is active vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index a696b7cb..5250fb72 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -37,6 +37,7 @@ interface StoredPreviewSession { } interface PreviewActivation { + activationSeq: number pauseCountAtRequest: number snapshot: PreviewSessionSnapshot } @@ -49,6 +50,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const [refreshError, setRefreshError] = useState(null) const sessionStateRef = useRef(null) const statusRequestSeqRef = useRef(0) + const activationRequestSeqRef = useRef(0) useLayoutEffect(() => { nativeLifecycleRef.current = nativeLifecycle @@ -70,13 +72,20 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { }, []) const storeActivationIfCurrent = useCallback(async (activation: PreviewActivation) => { + const isLatestActivation = activation.activationSeq === activationRequestSeqRef.current const currentLifecycle = nativeLifecycleRef.current if ( currentLifecycle.isBackgrounded || currentLifecycle.pauseCount !== activation.pauseCountAtRequest ) { - clearSession() - await apiClient.stopCameraPreview(cameraName).catch(() => {}) + if (isLatestActivation) { + clearSession() + await apiClient.stopCameraPreview(cameraName).catch(() => {}) + } + return + } + + if (!isLatestActivation) { return } @@ -86,9 +95,11 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const startMutation = useMutation({ mutationFn: async () => { + const activationSeq = activationRequestSeqRef.current + 1 + activationRequestSeqRef.current = activationSeq const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount const snapshot = await apiClient.ensureCameraPreviewActive(cameraName) - return { pauseCountAtRequest, snapshot } + return { activationSeq, pauseCountAtRequest, snapshot } }, onSuccess: async (activation) => { setRefreshError(null) @@ -144,10 +155,12 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { return } try { + const activationSeq = activationRequestSeqRef.current + 1 + activationRequestSeqRef.current = activationSeq const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount const snapshot = await apiClient.ensureCameraPreviewActive(cameraName) setRefreshError(null) - await storeActivationIfCurrent({ pauseCountAtRequest, snapshot }) + await storeActivationIfCurrent({ activationSeq, pauseCountAtRequest, snapshot }) } catch (nextError) { setRefreshError(nextError as Error) } From 4dc8e2c2c55cc9aa8ff70ab50042853a69a720d9 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 19:30:05 -0700 Subject: [PATCH 12/36] fix: serialize iOS preview stops --- .../cameras/hooks/useCameraPreview.test.tsx | 96 +++++++++++++++++++ .../cameras/hooks/useCameraPreview.ts | 69 ++++++++++--- 2 files changed, 150 insertions(+), 15 deletions(-) diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index 9447cc0d..954ce812 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -879,6 +879,102 @@ describe('useCameraPreview', () => { expect(result.current.playlistUrl).toContain('preview-token-new') }) + it('does not start a resumed preview while a background stop is still pending', async () => { + // Given: A preview session is active and background stop has not completed yet + freezePreviewClock() + const backgroundStop = deferred>>() + vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + const ensurePreviewActive = vi + .spyOn(apiClient, 'ensureCameraPreviewActive') + .mockResolvedValueOnce({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-old', + token_expires_at: '2026-04-23T12:00:10.000Z', + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-old', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + .mockResolvedValueOnce({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-new', + token_expires_at: '2026-04-23T12:00:10.000Z', + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-new', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockReturnValue(backgroundStop.promise) + + const { result, rerender } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-old') + }) + + setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 }) + rerender() + await waitFor(() => { + expect(stopPreview).toHaveBeenCalledWith('front') + expect(result.current.isStopping).toBe(true) + }) + + // When: The app resumes and a start is requested before the background stop resolves + setNativeLifecycleState({ isActive: true, isBackgrounded: false, resumeCount: 1 }) + rerender() + await act(async () => { + await result.current.start() + }) + + // Then: The hook waits for the stop to settle instead of racing a new attach against it + expect(ensurePreviewActive).toHaveBeenCalledTimes(1) + expect(result.current.session?.token).toBe('preview-token-old') + + await act(async () => { + backgroundStop.resolve({ + accepted: true, + state: 'stopping', + httpStatus: 202, + }) + await Promise.resolve() + }) + await waitFor(() => { + expect(result.current.session).toBeNull() + }) + + // When: The user starts preview after the old background stop is settled + await act(async () => { + await result.current.start() + }) + + // Then: A new preview session can attach normally + await waitFor(() => { + expect(ensurePreviewActive).toHaveBeenCalledTimes(2) + expect(result.current.session?.token).toBe('preview-token-new') + expect(result.current.playlistUrl).toContain('preview-token-new') + }) + }) + it('does not stop preview on transient native inactive transitions before background', async () => { // Given: Preview is attached while iOS is active vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index 5250fb72..d0c2dcb4 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -42,6 +42,10 @@ interface PreviewActivation { snapshot: PreviewSessionSnapshot } +interface PreviewStopRequest { + requestSeq: number +} + export function useCameraPreview(cameraName: string): CameraPreviewState { const queryClient = useQueryClient() const nativeLifecycle = useNativeAppLifecycleState() @@ -50,7 +54,8 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const [refreshError, setRefreshError] = useState(null) const sessionStateRef = useRef(null) const statusRequestSeqRef = useRef(0) - const activationRequestSeqRef = useRef(0) + const sessionRequestSeqRef = useRef(0) + const stopInFlightSeqRef = useRef(null) useLayoutEffect(() => { nativeLifecycleRef.current = nativeLifecycle @@ -71,8 +76,26 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { setSessionState(null) }, []) + const beginSessionRequest = useCallback(() => { + const requestSeq = sessionRequestSeqRef.current + 1 + sessionRequestSeqRef.current = requestSeq + return requestSeq + }, []) + + const beginStopRequest = useCallback(() => { + const requestSeq = beginSessionRequest() + stopInFlightSeqRef.current = requestSeq + return requestSeq + }, [beginSessionRequest]) + + const finishStopRequest = useCallback((requestSeq: number) => { + if (stopInFlightSeqRef.current === requestSeq) { + stopInFlightSeqRef.current = null + } + }, []) + const storeActivationIfCurrent = useCallback(async (activation: PreviewActivation) => { - const isLatestActivation = activation.activationSeq === activationRequestSeqRef.current + const isLatestActivation = activation.activationSeq === sessionRequestSeqRef.current const currentLifecycle = nativeLifecycleRef.current if ( currentLifecycle.isBackgrounded @@ -80,7 +103,14 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { ) { if (isLatestActivation) { clearSession() - await apiClient.stopCameraPreview(cameraName).catch(() => {}) + const stopRequestSeq = beginStopRequest() + try { + await apiClient.stopCameraPreview(cameraName) + } catch { + return + } finally { + finishStopRequest(stopRequestSeq) + } } return } @@ -91,12 +121,11 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { storeSession(activation.snapshot) await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) }) - }, [cameraName, clearSession, queryClient, storeSession]) + }, [beginStopRequest, cameraName, clearSession, finishStopRequest, queryClient, storeSession]) const startMutation = useMutation({ mutationFn: async () => { - const activationSeq = activationRequestSeqRef.current + 1 - activationRequestSeqRef.current = activationSeq + const activationSeq = beginSessionRequest() const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount const snapshot = await apiClient.ensureCameraPreviewActive(cameraName) return { activationSeq, pauseCountAtRequest, snapshot } @@ -130,33 +159,39 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { refetchInterval: nativeLifecycle.isActive && sessionState ? PREVIEW_STATUS_REFRESH_MS : false, }) - const stopMutation = useMutation({ + const stopMutation = useMutation({ mutationFn: () => apiClient.stopCameraPreview(cameraName), - onSuccess: async () => { + onSuccess: async (_snapshot, request) => { + if (request.requestSeq !== sessionRequestSeqRef.current) { + return + } setRefreshError(null) clearSession() await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) }) }, + onSettled: (_snapshot, _error, request) => { + finishStopRequest(request.requestSeq) + }, }) const refetchStatus = statusQuery.refetch const stopPreview = stopMutation.mutateAsync const session = sessionState?.snapshot ?? null const stop = useCallback(async () => { + const requestSeq = beginStopRequest() try { - await stopPreview() + await stopPreview({ requestSeq }) } catch { return } - }, [stopPreview]) + }, [beginStopRequest, stopPreview]) const refreshSession = useCallback(async () => { - if (nativeLifecycle.isBackgrounded) { + if (nativeLifecycle.isBackgrounded || stopInFlightSeqRef.current !== null) { return } try { - const activationSeq = activationRequestSeqRef.current + 1 - activationRequestSeqRef.current = activationSeq + const activationSeq = beginSessionRequest() const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount const snapshot = await apiClient.ensureCameraPreviewActive(cameraName) setRefreshError(null) @@ -164,7 +199,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { } catch (nextError) { setRefreshError(nextError as Error) } - }, [cameraName, nativeLifecycle.isBackgrounded, storeActivationIfCurrent]) + }, [beginSessionRequest, cameraName, nativeLifecycle.isBackgrounded, storeActivationIfCurrent]) useEffect(() => { if (!nativeLifecycle.isBackgrounded || sessionStateRef.current === null) { @@ -248,7 +283,11 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { isStarting: startMutation.isPending, isStopping: stopMutation.isPending, start: async () => { - if (!nativeLifecycle.isActive || nativeLifecycle.isBackgrounded) { + if ( + !nativeLifecycle.isActive + || nativeLifecycle.isBackgrounded + || stopInFlightSeqRef.current !== null + ) { return } try { From f5a5b2f2711b6db2aad09e6aa7553bbf6f70cd2f Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 19:45:10 -0700 Subject: [PATCH 13/36] feat: add iOS deep link routing --- ui/ios/App/App/Info.plist | 11 ++ ui/src/app/bootstrap.tsx | 2 + ui/src/runtime/nativeDeepLinkRoutes.ts | 45 ++++++ ui/src/runtime/nativeDeepLinks.test.tsx | 190 ++++++++++++++++++++++++ ui/src/runtime/nativeDeepLinks.tsx | 81 ++++++++++ 5 files changed, 329 insertions(+) create mode 100644 ui/src/runtime/nativeDeepLinkRoutes.ts create mode 100644 ui/src/runtime/nativeDeepLinks.test.tsx create mode 100644 ui/src/runtime/nativeDeepLinks.tsx diff --git a/ui/ios/App/App/Info.plist b/ui/ios/App/App/Info.plist index c1e6eeae..d68c7c18 100644 --- a/ui/ios/App/App/Info.plist +++ b/ui/ios/App/App/Info.plist @@ -20,6 +20,17 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + com.levneiman.homesec + CFBundleURLSchemes + + homesec + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) LSRequiresIPhoneOS diff --git a/ui/src/app/bootstrap.tsx b/ui/src/app/bootstrap.tsx index e9c45f8a..a7dbebf9 100644 --- a/ui/src/app/bootstrap.tsx +++ b/ui/src/app/bootstrap.tsx @@ -8,6 +8,7 @@ import App from '../App' import { initializeApiRuntimeConfig } from '../api/runtimeConfig' import { QueryProvider } from './providers/QueryProvider' import { ThemeProvider } from './providers/ThemeProvider' +import { NativeDeepLinkRouter } from '../runtime/nativeDeepLinks' export type RenderHomeSecApp = (rootElement: HTMLElement, app: ReactNode) => void @@ -27,6 +28,7 @@ export function createHomeSecAppElement(): ReactNode { + diff --git a/ui/src/runtime/nativeDeepLinkRoutes.ts b/ui/src/runtime/nativeDeepLinkRoutes.ts new file mode 100644 index 00000000..02f4cb83 --- /dev/null +++ b/ui/src/runtime/nativeDeepLinkRoutes.ts @@ -0,0 +1,45 @@ +const HOMESEC_DEEP_LINK_SCHEME = 'homesec:' +const DEFAULT_DEEP_LINK_ROUTE = '/live' + +const ALLOWED_DEEP_LINK_ROUTE_PREFIXES = [ + '/live', + '/events', + '/settings', + '/system', + '/cameras', + '/clips', + '/dashboard', + '/home', +] + +function routeIsAllowed(route: string): boolean { + return ALLOWED_DEEP_LINK_ROUTE_PREFIXES.some((prefix) => { + return route === prefix || route.startsWith(`${prefix}/`) + }) +} + +function routeFromUrl(url: URL): string { + const pathSegments = [url.hostname, url.pathname.replace(/^\/+/, '')].filter(Boolean) + const pathname = `/${pathSegments.join('/')}`.replace(/\/{2,}/g, '/') + return `${pathname}${url.search}${url.hash}` +} + +export function parseNativeDeepLinkRoute(rawUrl: string): string | null { + let url: URL + try { + url = new URL(rawUrl) + } catch { + return null + } + + if (url.protocol !== HOMESEC_DEEP_LINK_SCHEME) { + return null + } + + const route = routeFromUrl(url) + if (!routeIsAllowed(route)) { + return DEFAULT_DEEP_LINK_ROUTE + } + + return route +} diff --git a/ui/src/runtime/nativeDeepLinks.test.tsx b/ui/src/runtime/nativeDeepLinks.test.tsx new file mode 100644 index 00000000..a6b8978f --- /dev/null +++ b/ui/src/runtime/nativeDeepLinks.test.tsx @@ -0,0 +1,190 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' + +const nativeRuntimeMock = vi.hoisted(() => ({ + isIOSNativeApp: vi.fn<() => boolean>(() => false), +})) + +vi.mock('./nativeRuntime', () => ({ + isIOSNativeApp: () => nativeRuntimeMock.isIOSNativeApp(), +})) + +import { parseNativeDeepLinkRoute } from './nativeDeepLinkRoutes' +import { NativeDeepLinkRouter } from './nativeDeepLinks' + +type DeepLinkEvent = { + url?: string | null +} + +function LocationProbe() { + const location = useLocation() + return

{`${location.pathname}${location.search}${location.hash}`}

+} + +function createNativeDeepLinkApp(launchUrl?: string | null) { + let appUrlOpenListener: ((event: DeepLinkEvent) => void) | null = null + const remove = vi.fn() + const app = { + getLaunchUrl: vi.fn(async () => (launchUrl === undefined ? null : { url: launchUrl })), + addListener: vi.fn(async ( + eventName: 'appUrlOpen', + listenerFunc: (event: DeepLinkEvent) => void, + ) => { + expect(eventName).toBe('appUrlOpen') + appUrlOpenListener = listenerFunc + return { remove } + }), + } + + return { + app, + emitUrlOpen(rawUrl: string) { + appUrlOpenListener?.({ url: rawUrl }) + }, + remove, + } +} + +function renderNativeDeepLinkRouter( + app: ReturnType['app'], + initialPath = '/live', +) { + render( + + + + } /> + + , + ) +} + +describe('parseNativeDeepLinkRoute', () => { + it('translates homesec event links into React routes', () => { + // Given: A notification deep link with route and source context + const route = parseNativeDeepLinkRoute('homesec://events/test-id?from=notification') + + // Then: The custom scheme is stripped and the React route is preserved + expect(route).toBe('/events/test-id?from=notification') + }) + + it('preserves triple-slash path links, query strings, and hashes', () => { + // Given: A custom-scheme URL using path form instead of host form + const route = parseNativeDeepLinkRoute('homesec:///events/clip-42?camera=front#summary') + + // Then: The parser keeps the route details needed by React Router + expect(route).toBe('/events/clip-42?camera=front#summary') + }) + + it('ignores non-HomeSec URLs', () => { + // Given: A URL that was not issued for the HomeSec app scheme + const route = parseNativeDeepLinkRoute('https://homesec.example.com/events/test-id') + + // Then: The native listener leaves unrelated URLs alone + expect(route).toBeNull() + }) + + it('falls back safely for unsupported HomeSec routes', () => { + // Given: A HomeSec-scheme URL that does not map to a known app route + const route = parseNativeDeepLinkRoute('homesec://admin/secrets?token=leak') + + // Then: The app opens a safe default route instead of an arbitrary path + expect(route).toBe('/live') + }) +}) + +describe('NativeDeepLinkRouter', () => { + beforeEach(() => { + nativeRuntimeMock.isIOSNativeApp.mockReturnValue(true) + }) + + afterEach(() => { + cleanup() + nativeRuntimeMock.isIOSNativeApp.mockReset() + }) + + it('routes a cold-start launch URL into the React app', async () => { + // Given: iOS launched the app from a notification deep link + const { app } = createNativeDeepLinkApp('homesec://events/test-id?from=notification') + + // When: The deep-link router mounts + renderNativeDeepLinkRouter(app, '/live') + + // Then: The launch URL becomes the active React route + await waitFor(() => { + expect(screen.getByTestId('location').textContent).toBe( + '/events/test-id?from=notification', + ) + }) + expect(app.getLaunchUrl).toHaveBeenCalledTimes(1) + expect(app.addListener).toHaveBeenCalledTimes(1) + }) + + it('routes warm appUrlOpen events into the React app', async () => { + // Given: The app is already open and listening for URL events + const nativeApp = createNativeDeepLinkApp() + renderNativeDeepLinkRouter(nativeApp.app, '/live') + await waitFor(() => { + expect(nativeApp.app.addListener).toHaveBeenCalledTimes(1) + }) + + // When: iOS sends a custom-scheme URL to the running app + await act(async () => { + nativeApp.emitUrlOpen('homesec://events/clip-99?from=notification') + }) + + // Then: React Router navigates to the event detail route + expect(screen.getByTestId('location').textContent).toBe( + '/events/clip-99?from=notification', + ) + }) + + it('falls back to Live for unsupported HomeSec appUrlOpen routes', async () => { + // Given: The app receives an invalid route under the HomeSec scheme + const nativeApp = createNativeDeepLinkApp() + renderNativeDeepLinkRouter(nativeApp.app, '/events') + await waitFor(() => { + expect(nativeApp.app.addListener).toHaveBeenCalledTimes(1) + }) + + // When: The invalid route opens + await act(async () => { + nativeApp.emitUrlOpen('homesec://admin') + }) + + // Then: The app navigates to a safe default route + expect(screen.getByTestId('location').textContent).toBe('/live') + }) + + it('does not register native listeners outside iOS native mode', () => { + // Given: The React app is running in the browser + nativeRuntimeMock.isIOSNativeApp.mockReturnValue(false) + const { app } = createNativeDeepLinkApp('homesec://events/test-id') + + // When: The deep-link router mounts + renderNativeDeepLinkRouter(app, '/live') + + // Then: Capacitor deep-link APIs are not invoked + expect(app.getLaunchUrl).not.toHaveBeenCalled() + expect(app.addListener).not.toHaveBeenCalled() + expect(screen.getByTestId('location').textContent).toBe('/live') + }) + + it('removes the appUrlOpen listener on unmount', async () => { + // Given: The deep-link router registered a native listener + const nativeApp = createNativeDeepLinkApp() + renderNativeDeepLinkRouter(nativeApp.app, '/live') + await waitFor(() => { + expect(nativeApp.app.addListener).toHaveBeenCalledTimes(1) + }) + + // When: React unmounts the router + cleanup() + + // Then: The native listener is removed + expect(nativeApp.remove).toHaveBeenCalledTimes(1) + }) +}) diff --git a/ui/src/runtime/nativeDeepLinks.tsx b/ui/src/runtime/nativeDeepLinks.tsx new file mode 100644 index 00000000..0d11b3b3 --- /dev/null +++ b/ui/src/runtime/nativeDeepLinks.tsx @@ -0,0 +1,81 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { App } from '@capacitor/app' +import type { PluginListenerHandle } from '@capacitor/core' + +import { parseNativeDeepLinkRoute } from './nativeDeepLinkRoutes' +import { isIOSNativeApp } from './nativeRuntime' + +interface NativeDeepLinkEvent { + url?: string | null +} + +interface NativeDeepLinkApp { + getLaunchUrl: () => Promise + addListener: ( + eventName: 'appUrlOpen', + listenerFunc: (event: NativeDeepLinkEvent) => void, + ) => Promise +} + +export function NativeDeepLinkRouter({ app = App }: { app?: NativeDeepLinkApp }) { + const navigate = useNavigate() + const navigateRef = useRef(navigate) + const isIOS = isIOSNativeApp() + + useEffect(() => { + navigateRef.current = navigate + }, [navigate]) + + const navigateToDeepLink = useCallback(( + rawUrl: string | null | undefined, + options: { replace: boolean }, + ) => { + if (!rawUrl) { + return + } + const route = parseNativeDeepLinkRoute(rawUrl) + if (route === null) { + return + } + navigateRef.current(route, { replace: options.replace }) + }, []) + + useEffect(() => { + if (!isIOS) { + return + } + + let cancelled = false + let handle: PluginListenerHandle | null = null + + void app.getLaunchUrl() + .then((event) => { + if (!cancelled) { + navigateToDeepLink(event?.url, { replace: true }) + } + }) + .catch(() => {}) + + void app.addListener('appUrlOpen', (event) => { + navigateToDeepLink(event.url, { replace: false }) + }) + .then((nextHandle) => { + if (cancelled) { + void nextHandle.remove() + return + } + handle = nextHandle + }) + .catch(() => {}) + + return () => { + cancelled = true + if (handle !== null) { + void handle.remove() + } + } + }, [app, isIOS, navigateToDeepLink]) + + return null +} From d56789779ff4707e06631c92f1404022e701e1f1 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 19:54:43 -0700 Subject: [PATCH 14/36] fix: harden iOS deep link routing --- ui/src/runtime/nativeDeepLinkRoutes.ts | 11 ++- ui/src/runtime/nativeDeepLinks.test.tsx | 103 +++++++++++++++++++++++- ui/src/runtime/nativeDeepLinks.tsx | 3 + 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/ui/src/runtime/nativeDeepLinkRoutes.ts b/ui/src/runtime/nativeDeepLinkRoutes.ts index 02f4cb83..4fb949ea 100644 --- a/ui/src/runtime/nativeDeepLinkRoutes.ts +++ b/ui/src/runtime/nativeDeepLinkRoutes.ts @@ -18,10 +18,9 @@ function routeIsAllowed(route: string): boolean { }) } -function routeFromUrl(url: URL): string { +function pathnameFromUrl(url: URL): string { const pathSegments = [url.hostname, url.pathname.replace(/^\/+/, '')].filter(Boolean) - const pathname = `/${pathSegments.join('/')}`.replace(/\/{2,}/g, '/') - return `${pathname}${url.search}${url.hash}` + return `/${pathSegments.join('/')}`.replace(/\/{2,}/g, '/') } export function parseNativeDeepLinkRoute(rawUrl: string): string | null { @@ -36,10 +35,10 @@ export function parseNativeDeepLinkRoute(rawUrl: string): string | null { return null } - const route = routeFromUrl(url) - if (!routeIsAllowed(route)) { + const pathname = pathnameFromUrl(url) + if (!routeIsAllowed(pathname)) { return DEFAULT_DEEP_LINK_ROUTE } - return route + return `${pathname}${url.search}${url.hash}` } diff --git a/ui/src/runtime/nativeDeepLinks.test.tsx b/ui/src/runtime/nativeDeepLinks.test.tsx index a6b8978f..be4f47e4 100644 --- a/ui/src/runtime/nativeDeepLinks.test.tsx +++ b/ui/src/runtime/nativeDeepLinks.test.tsx @@ -19,6 +19,14 @@ type DeepLinkEvent = { url?: string | null } +type TestNativeDeepLinkApp = { + getLaunchUrl: () => Promise + addListener: ( + eventName: 'appUrlOpen', + listenerFunc: (event: DeepLinkEvent) => void, + ) => Promise<{ remove: () => Promise }> +} + function LocationProbe() { const location = useLocation() return

{`${location.pathname}${location.search}${location.hash}`}

@@ -26,7 +34,7 @@ function LocationProbe() { function createNativeDeepLinkApp(launchUrl?: string | null) { let appUrlOpenListener: ((event: DeepLinkEvent) => void) | null = null - const remove = vi.fn() + const remove = vi.fn(async () => {}) const app = { getLaunchUrl: vi.fn(async () => (launchUrl === undefined ? null : { url: launchUrl })), addListener: vi.fn(async ( @@ -48,8 +56,39 @@ function createNativeDeepLinkApp(launchUrl?: string | null) { } } +function createDeferredNativeDeepLinkApp() { + let appUrlOpenListener: ((event: DeepLinkEvent) => void) | null = null + let resolveListener: ((handle: { remove: () => Promise }) => void) | null = null + const remove = vi.fn(async () => {}) + const listenerPromise = new Promise<{ remove: () => Promise }>((resolve) => { + resolveListener = resolve + }) + const app = { + getLaunchUrl: vi.fn(async () => null), + addListener: vi.fn(( + eventName: 'appUrlOpen', + listenerFunc: (event: DeepLinkEvent) => void, + ) => { + expect(eventName).toBe('appUrlOpen') + appUrlOpenListener = listenerFunc + return listenerPromise + }), + } + + return { + app, + emitUrlOpen(rawUrl: string) { + appUrlOpenListener?.({ url: rawUrl }) + }, + resolveListener() { + resolveListener?.({ remove }) + }, + remove, + } +} + function renderNativeDeepLinkRouter( - app: ReturnType['app'], + app: TestNativeDeepLinkApp, initialPath = '/live', ) { render( @@ -62,6 +101,30 @@ function renderNativeDeepLinkRouter( ) } +function renderToggleableNativeDeepLinkRouter( + app: TestNativeDeepLinkApp, + initialPath = '/live', +) { + function Harness({ enabled }: { enabled: boolean }) { + return ( + + {enabled ? : null} + + } /> + + + ) + } + + const result = render() + return { + ...result, + disableRouter() { + result.rerender() + }, + } +} + describe('parseNativeDeepLinkRoute', () => { it('translates homesec event links into React routes', () => { // Given: A notification deep link with route and source context @@ -79,6 +142,22 @@ describe('parseNativeDeepLinkRoute', () => { expect(route).toBe('/events/clip-42?camera=front#summary') }) + it('preserves query strings on allowed top-level routes', () => { + // Given: A custom-scheme URL for a top-level route with query context + const route = parseNativeDeepLinkRoute('homesec://events?from=notification') + + // Then: Route validation accepts the pathname before preserving the query + expect(route).toBe('/events?from=notification') + }) + + it('preserves hashes on allowed top-level routes', () => { + // Given: A custom-scheme URL for a top-level route with a hash target + const route = parseNativeDeepLinkRoute('homesec://live#camera-front') + + // Then: Route validation accepts the pathname before preserving the hash + expect(route).toBe('/live#camera-front') + }) + it('ignores non-HomeSec URLs', () => { // Given: A URL that was not issued for the HomeSec app scheme const route = parseNativeDeepLinkRoute('https://homesec.example.com/events/test-id') @@ -187,4 +266,24 @@ describe('NativeDeepLinkRouter', () => { // Then: The native listener is removed expect(nativeApp.remove).toHaveBeenCalledTimes(1) }) + + it('ignores stale appUrlOpen events after cleanup', async () => { + // Given: Native listener registration captured a callback but has not resolved yet + const nativeApp = createDeferredNativeDeepLinkApp() + const view = renderToggleableNativeDeepLinkRouter(nativeApp.app, '/live') + await waitFor(() => { + expect(nativeApp.app.addListener).toHaveBeenCalledTimes(1) + }) + + // When: React removes the deep-link router before the native listener resolves + view.disableRouter() + await act(async () => { + nativeApp.emitUrlOpen('homesec://events/stale') + nativeApp.resolveListener() + }) + + // Then: The stale native callback does not navigate after cleanup + expect(screen.getByTestId('location').textContent).toBe('/live') + expect(nativeApp.remove).toHaveBeenCalledTimes(1) + }) }) diff --git a/ui/src/runtime/nativeDeepLinks.tsx b/ui/src/runtime/nativeDeepLinks.tsx index 0d11b3b3..53ed4330 100644 --- a/ui/src/runtime/nativeDeepLinks.tsx +++ b/ui/src/runtime/nativeDeepLinks.tsx @@ -58,6 +58,9 @@ export function NativeDeepLinkRouter({ app = App }: { app?: NativeDeepLinkApp }) .catch(() => {}) void app.addListener('appUrlOpen', (event) => { + if (cancelled) { + return + } navigateToDeepLink(event.url, { replace: false }) }) .then((nextHandle) => { From 02c0d62a1753876bedda68e8982ecafcd5a1f5e1 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 20:12:09 -0700 Subject: [PATCH 15/36] fix: harden iOS safe-area layout --- ui/src/styles/global.css | 82 +++++++++++++++++++++++++++++++++++----- ui/src/styles/tokens.css | 8 ++++ 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css index f641e138..22e2696e 100644 --- a/ui/src/styles/global.css +++ b/ui/src/styles/global.css @@ -11,6 +11,10 @@ body, min-height: 100%; } +html { + scroll-padding-bottom: var(--mobile-content-bottom-inset); +} + body { min-width: 320px; font-family: var(--font-sans); @@ -32,10 +36,13 @@ a:hover { } .app-shell { + --app-shell-inline-padding: clamp(var(--space-3), 2.5vw, var(--space-6)); + position: relative; display: grid; grid-template-rows: auto 1fr; min-height: 100vh; + min-height: 100dvh; } .app-shell__background { @@ -56,7 +63,11 @@ a:hover { border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--surface-1) 94%, transparent); backdrop-filter: blur(12px); - padding: var(--space-3) clamp(var(--space-3), 2.5vw, var(--space-6)); + padding: + calc(var(--space-3) + var(--safe-area-inset-top)) + calc(var(--app-shell-inline-padding) + var(--safe-area-inset-right)) + var(--space-3) + calc(var(--app-shell-inline-padding) + var(--safe-area-inset-left)); } .app-shell__brand-link { @@ -148,9 +159,12 @@ a:hover { .app-shell__content { position: relative; z-index: 1; - width: min(1220px, calc(100% - 3rem)); + width: min( + 1220px, + calc(100% - 3rem - var(--safe-area-inset-left) - var(--safe-area-inset-right)) + ); margin: 0 auto; - padding: var(--space-6) 0 var(--space-7); + padding: var(--space-6) 0 var(--mobile-content-bottom-inset); } .mobile-bottom-nav { @@ -283,6 +297,8 @@ a:hover { } .field-label { + display: grid; + gap: 0.4rem; min-width: 0; color: var(--text-secondary); font-size: 0.85rem; @@ -291,6 +307,7 @@ a:hover { .input { width: 100%; min-width: 0; + min-height: 2.75rem; border: 1px solid var(--line); border-radius: var(--radius-sm); background: color-mix(in srgb, var(--surface-1) 85%, transparent); @@ -1557,7 +1574,10 @@ a:hover { } .app-shell__content { - width: min(100% - 2rem, 1180px); + width: min( + 1180px, + calc(100% - 2rem - var(--safe-area-inset-left) - var(--safe-area-inset-right)) + ); padding-top: var(--space-5); } @@ -1575,12 +1595,26 @@ a:hover { } @media (max-width: 620px) { + .app-shell { + --app-shell-inline-padding: var(--space-4); + --mobile-content-bottom-inset: calc( + var(--mobile-bottom-nav-height) + + var(--mobile-bottom-nav-gap) + + var(--safe-area-inset-bottom) + + var(--space-5) + ); + } + .app-shell__nav { display: none; } .app-shell__topbar { - padding: var(--space-3) var(--space-4); + padding: + calc(var(--space-3) + var(--safe-area-inset-top)) + calc(var(--app-shell-inline-padding) + var(--safe-area-inset-right)) + var(--space-3) + calc(var(--app-shell-inline-padding) + var(--safe-area-inset-left)); } .app-shell__header { @@ -1605,35 +1639,50 @@ a:hover { } .app-shell__content { - padding-bottom: calc(var(--space-7) + 4.5rem); + padding-bottom: var(--mobile-content-bottom-inset); } .mobile-bottom-nav { position: fixed; - left: var(--space-3); - right: var(--space-3); - bottom: var(--space-3); + left: calc(var(--mobile-bottom-nav-gap) + var(--safe-area-inset-left)); + right: calc(var(--mobile-bottom-nav-gap) + var(--safe-area-inset-right)); + bottom: calc(var(--mobile-bottom-nav-gap) + var(--safe-area-inset-bottom)); z-index: 10; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.35rem; + min-height: var(--mobile-bottom-nav-height); border: 1px solid var(--line); border-radius: var(--radius-md); background: color-mix(in srgb, var(--surface-1) 94%, transparent); box-shadow: var(--shadow); padding: 0.35rem; backdrop-filter: blur(10px); + transform: translateY(0); + transition: + transform var(--duration-normal) ease, + opacity var(--duration-fast) ease; } .mobile-nav-link { display: grid; place-items: center; min-height: 3rem; + min-width: 0; border: 1px solid transparent; border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 0.82rem; font-weight: 600; + overflow-wrap: anywhere; + padding: 0.35rem 0.45rem; + } + + .input, + .button, + .media-panel__viewport, + .camera-preview__viewport { + scroll-margin-bottom: calc(var(--mobile-content-bottom-inset) + var(--space-3)); } .mobile-nav-link--active { @@ -1755,3 +1804,18 @@ a:hover { justify-content: flex-start; } } + +@supports selector(body:has(input:focus)) { + @media (max-width: 620px) { + body:has(input:focus, textarea:focus, select:focus) .mobile-bottom-nav { + display: none; + opacity: 0; + pointer-events: none; + transform: translateY(calc( + 100% + + var(--mobile-bottom-nav-gap) + + var(--safe-area-inset-bottom) + )); + } + } +} diff --git a/ui/src/styles/tokens.css b/ui/src/styles/tokens.css index bdf8b18b..f460a333 100644 --- a/ui/src/styles/tokens.css +++ b/ui/src/styles/tokens.css @@ -14,6 +14,14 @@ --space-6: 2rem; --space-7: 3rem; + --safe-area-inset-top: env(safe-area-inset-top, 0px); + --safe-area-inset-right: env(safe-area-inset-right, 0px); + --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-inset-left: env(safe-area-inset-left, 0px); + --mobile-bottom-nav-height: 3.75rem; + --mobile-bottom-nav-gap: var(--space-3); + --mobile-content-bottom-inset: var(--space-7); + --duration-fast: 130ms; --duration-normal: 220ms; From 2529cdf1da0d54e5d752322ba1ac4dab3a6733d6 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 20:28:08 -0700 Subject: [PATCH 16/36] fix: close iOS safe-area review gaps --- .github/workflows/ci.yml | 6 + ui/.gitignore | 2 + ui/e2e/mobile-layout.spec.ts | 323 +++++++++++++++++++ ui/index.html | 2 +- ui/playwright.config.ts | 23 ++ ui/src/app/layout/AppShell.test.tsx | 27 +- ui/src/app/layout/AppShell.tsx | 44 ++- ui/src/features/native-setup/nativeSetup.css | 17 +- ui/src/styles/global.css | 17 +- ui/src/styles/tokens.css | 11 + 10 files changed, 460 insertions(+), 12 deletions(-) create mode 100644 ui/e2e/mobile-layout.spec.ts create mode 100644 ui/playwright.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61147895..c77ed68b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,12 @@ jobs: - name: UI check run: pnpm --dir ui check + - name: Install Playwright Chromium + run: pnpm --dir ui exec playwright install --with-deps chromium + + - name: UI e2e + run: pnpm --dir ui test:e2e + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: diff --git a/ui/.gitignore b/ui/.gitignore index 253cc7d5..f5d336cc 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -10,6 +10,8 @@ lerna-debug.log* node_modules dist dist-ssr +test-results +playwright-report *.local # Editor directories and files diff --git a/ui/e2e/mobile-layout.spec.ts b/ui/e2e/mobile-layout.spec.ts new file mode 100644 index 00000000..11e25704 --- /dev/null +++ b/ui/e2e/mobile-layout.spec.ts @@ -0,0 +1,323 @@ +import { expect, test, type Page } from '@playwright/test' + +const MOBILE_VIEWPORT = { width: 320, height: 700 } +const DESKTOP_VIEWPORT = { width: 1280, height: 800 } + +const camera = { + name: 'front_door', + enabled: true, + healthy: true, + last_heartbeat: 1_797_187_200, + source_backend: 'rtsp', + source_config: {}, +} + +const clip = { + id: 'test-id', + camera: 'front_door', + status: 'done', + created_at: '2026-06-14T02:30:00Z', + activity_type: 'package', + risk_level: 'medium', + summary: 'Package delivery at the front door.', + detected_objects: ['person', 'package'], + storage_uri: 'dropbox:/clips/test-id.mp4', + view_url: null, + alerted: true, +} + +async function mockHomeSecApi(page: Page): Promise { + await page.route('**/api/v1/**', async (route) => { + const request = route.request() + const url = new URL(request.url()) + const path = url.pathname + const method = request.method() + + const fulfillJson = (payload: unknown, status = 200) => route.fulfill({ + status, + contentType: 'application/json', + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': '*', + 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', + }, + body: JSON.stringify(payload), + }) + + if (method === 'OPTIONS') { + await route.fulfill({ + status: 204, + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': '*', + 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS', + }, + }) + return + } + + if (path === '/api/v1/setup/status') { + await fulfillJson({ + state: 'complete', + has_cameras: true, + pipeline_running: true, + auth_configured: false, + }) + return + } + + if (path === '/api/v1/health') { + await fulfillJson({ + status: 'healthy', + bootstrap_mode: false, + pipeline: 'running', + postgres: 'ok', + cameras_online: 1, + }) + return + } + + if (path === '/api/v1/stats') { + await fulfillJson({ + clips_today: 3, + alerts_today: 1, + cameras_total: 1, + cameras_online: 1, + uptime_seconds: 18_000, + }) + return + } + + if (path === '/api/v1/maintenance/postgres-backups/status') { + await fulfillJson({ + enabled: true, + available: true, + running: false, + last_attempted_at: '2026-06-14T02:00:00Z', + last_success_at: '2026-06-14T02:00:00Z', + last_error: null, + last_local_path: '/backups/homesec.sql', + last_uploaded_uri: null, + next_run_at: '2026-06-15T02:00:00Z', + pending_remote_delete_count: 0, + unavailable_reason: null, + }) + return + } + + if (path === '/api/v1/cameras') { + await fulfillJson([camera]) + return + } + + if (path === '/api/v1/preview/cameras/front_door') { + await fulfillJson({ + camera_name: 'front_door', + enabled: true, + state: 'idle', + viewer_count: 0, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + }) + return + } + + if (path === '/api/v1/talk/cameras/front_door') { + await fulfillJson({ + camera_name: 'front_door', + enabled: true, + policy_enabled: true, + capability: 'supported', + state: 'idle', + active_session_id: null, + supported_codecs: ['pcm_s16le'], + offered_codecs: ['pcm_s16le'], + selected_codec: 'pcm_s16le', + backend: 'rtsp', + backend_reason: null, + last_error: null, + }) + return + } + + if (path === '/api/v1/clips' && method === 'GET') { + await fulfillJson({ + clips: [clip], + limit: 25, + next_cursor: null, + has_more: false, + }) + return + } + + if (path === '/api/v1/clips/test-id/media-token' && method === 'POST') { + await fulfillJson({ + media_url: '/event-video.mp4', + tokenized: false, + expires_at: null, + }) + return + } + + if (path === '/api/v1/clips/test-id' && method === 'GET') { + await fulfillJson(clip) + return + } + + await fulfillJson({ detail: `Unhandled ${method} ${path}` }, 404) + }) +} + +async function openApp(page: Page, path: string): Promise { + await page.goto(path) + await page.getByRole('main').waitFor() +} + +async function expectNoHorizontalOverflow(page: Page): Promise { + const metrics = await page.evaluate(() => ({ + viewportWidth: window.innerWidth, + htmlScrollWidth: document.documentElement.scrollWidth, + bodyScrollWidth: document.body.scrollWidth, + })) + + expect(metrics.htmlScrollWidth).toBeLessThanOrEqual(metrics.viewportWidth) + expect(metrics.bodyScrollWidth).toBeLessThanOrEqual(metrics.viewportWidth) +} + +async function expectMobileBottomNavClearance(page: Page): Promise { + const nav = page.locator('.mobile-bottom-nav') + const navBox = await nav.boundingBox() + const metrics = await page.evaluate(() => { + const content = document.querySelector('.app-shell__content') + const styles = content ? getComputedStyle(content) : null + return { + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + contentPaddingBottom: styles ? Number.parseFloat(styles.paddingBottom) : 0, + scrollPaddingBottom: Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom), + } + }) + + expect(navBox).not.toBeNull() + expect(navBox?.x ?? -1).toBeGreaterThanOrEqual(0) + expect((navBox?.x ?? 0) + (navBox?.width ?? 0)).toBeLessThanOrEqual(metrics.viewportWidth) + expect((navBox?.y ?? 0) + (navBox?.height ?? 0)).toBeLessThanOrEqual(metrics.viewportHeight) + expect(metrics.contentPaddingBottom).toBeGreaterThan(80) + expect(metrics.scrollPaddingBottom).toBeGreaterThan(80) +} + +async function expectElementAboveMobileNav(page: Page, selector: string): Promise { + const target = page.locator(selector).first() + await target.scrollIntoViewIfNeeded() + const targetBox = await target.boundingBox() + const navBox = await page.locator('.mobile-bottom-nav').boundingBox() + + expect(targetBox).not.toBeNull() + expect(navBox).not.toBeNull() + expect(targetBox?.y ?? 0).toBeGreaterThanOrEqual(0) + expect((targetBox?.y ?? 0) + (targetBox?.height ?? 0)).toBeLessThanOrEqual(navBox?.y ?? 0) +} + +test.beforeEach(async ({ page }) => { + await mockHomeSecApi(page) +}) + +test.describe('iOS M1 mobile layout hardening', () => { + test.use({ viewport: MOBILE_VIEWPORT }) + + for (const route of ['/live', '/events', '/events/test-id', '/settings', '/system']) { + test(`${route} has no horizontal overflow and keeps bottom nav clear`, async ({ page }) => { + // Given: The HomeSec app is opened at iPhone width with API responses mocked + await openApp(page, route) + + // When: The rendered route is measured in a real browser layout engine + await expect(page.getByRole('heading').first()).toBeVisible() + + // Then: Page content stays within the viewport and reserves room for fixed nav + await expectNoHorizontalOverflow(page) + await expectMobileBottomNavClearance(page) + }) + } + + test('keeps live preview controls above the bottom nav', async ({ page }) => { + // Given: Live view renders a camera preview at iPhone width + await openApp(page, '/live') + + // When: The preview viewport is scrolled into view + await expect(page.getByText('Start live view to watch this camera.')).toBeVisible() + + // Then: The preview surface remains above the fixed bottom nav + await expectElementAboveMobileNav(page, '.camera-preview__viewport') + }) + + test('keeps event video controls above the bottom nav', async ({ page }) => { + // Given: Event detail renders a video panel at iPhone width + await openApp(page, '/events/test-id') + + // When: The event video panel is scrolled into view + await expect(page.locator('.clip-detail-video')).toBeVisible() + + // Then: The media viewport remains above the fixed bottom nav + await expectElementAboveMobileNav(page, '.clip-detail-media .media-panel__viewport') + }) + + test('hides bottom nav while a form control is focused', async ({ page }) => { + // Given: Events exposes a mobile filter form control + await openApp(page, '/events') + + // When: The Camera filter receives focus as it would with the iOS keyboard + await page.getByRole('combobox', { name: 'Camera' }).focus() + + // Then: The fixed bottom nav is removed from the focus layout + await expect(page.locator('.mobile-bottom-nav')).toHaveCSS('display', 'none') + }) + + test('keeps native setup inside safe-area-aware viewport padding', async ({ page }) => { + // Given: Native setup bypasses AppShell and renders its own mobile page + await page.goto('/native-setup') + + // When: The setup page is measured at iPhone width + await expect(page.getByRole('heading', { name: 'Connect to HomeSec' })).toBeVisible() + const metrics = await page.locator('.native-setup-page').evaluate((element) => { + const styles = getComputedStyle(element) + return { + minHeight: styles.minHeight, + paddingTop: Number.parseFloat(styles.paddingTop), + paddingBottom: Number.parseFloat(styles.paddingBottom), + scrollPaddingBottom: Number.parseFloat(styles.scrollPaddingBottom), + htmlScrollWidth: document.documentElement.scrollWidth, + viewportWidth: window.innerWidth, + } + }) + + // Then: Setup has dynamic viewport sizing and no mobile horizontal overflow + expect(metrics.minHeight).toBe('700px') + expect(metrics.paddingTop).toBeGreaterThanOrEqual(16) + expect(metrics.paddingBottom).toBeGreaterThanOrEqual(16) + expect(metrics.scrollPaddingBottom).toBeGreaterThanOrEqual(16) + expect(metrics.htmlScrollWidth).toBeLessThanOrEqual(metrics.viewportWidth) + }) +}) + +test.describe('desktop layout regression guard', () => { + test.use({ viewport: DESKTOP_VIEWPORT }) + + test('keeps desktop primary nav in the topbar instead of the mobile fixed nav', async ({ page }) => { + // Given: The app is opened at desktop width + await openApp(page, '/live') + + // When: Navigation CSS is inspected in a real browser + const desktopNavDisplay = await page.locator('.app-shell__nav').evaluate((element) => + getComputedStyle(element).display + ) + const mobileNavDisplay = await page.locator('.mobile-bottom-nav').evaluate((element) => + getComputedStyle(element).display + ) + + // Then: Desktop keeps the topbar nav visible and the mobile nav hidden + expect(desktopNavDisplay).toBe('flex') + expect(mobileNavDisplay).toBe('none') + await expectNoHorizontalOverflow(page) + }) +}) diff --git a/ui/index.html b/ui/index.html index 592716ba..c10642eb 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,7 +3,7 @@ - + ui diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 00000000..9a21ca88 --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + reporter: 'list', + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry', + }, + webServer: { + command: 'pnpm dev --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + reuseExistingServer: false, + timeout: 120_000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/ui/src/app/layout/AppShell.test.tsx b/ui/src/app/layout/AppShell.test.tsx index 541af7fb..1e519ba8 100644 --- a/ui/src/app/layout/AppShell.test.tsx +++ b/ui/src/app/layout/AppShell.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen, within } from '@testing-library/react' +import { cleanup, render, screen, waitFor, within } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router-dom' import type { CameraResponse } from '../../api/generated/types' @@ -43,7 +43,15 @@ function renderShell( }> Live route

} /> Events route

} /> - Settings route

} /> + + Server URL + + + )} + /> System route

} />
@@ -114,4 +122,19 @@ describe('AppShell navigation', () => { expect(systemStatus.getAttribute('href')).toBe('/system') expect(systemStatus.className).not.toContain('app-shell__header-status--nominal') }) + + it('marks the shell while a form control is focused for mobile keyboard layout', async () => { + // Given: App shell is mounted on a route with a form field + renderShell('/settings') + + // When: A form control receives focus + screen.getByRole('textbox', { name: 'Server URL' }).focus() + + // Then: The shell exposes focus state for CSS that hides fixed mobile nav + await waitFor(() => { + expect(document.querySelector('.app-shell')?.className).toContain( + 'app-shell--form-control-focused', + ) + }) + }) }) diff --git a/ui/src/app/layout/AppShell.tsx b/ui/src/app/layout/AppShell.tsx index da8cc8eb..29511c4d 100644 --- a/ui/src/app/layout/AppShell.tsx +++ b/ui/src/app/layout/AppShell.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react' import { NavLink, Outlet } from 'react-router-dom' import { useCamerasQuery } from '../../api/hooks/useCamerasQuery' @@ -19,10 +20,17 @@ const MOBILE_NAV_LINKS: readonly MobileBottomNavLink[] = [ { to: '/settings', label: 'Settings' }, ] +const FORM_CONTROL_SELECTOR = 'input, textarea, select' + function navLinkClassName({ isActive }: { isActive: boolean }): string { return isActive ? 'nav-link nav-link--active' : 'nav-link' } +function documentHasFocusedFormControl(): boolean { + return document.activeElement instanceof HTMLElement && + document.activeElement.matches(FORM_CONTROL_SELECTOR) +} + function systemStatusText(status: string | undefined, isError: boolean): string { if (isError) { return 'System needs attention' @@ -38,6 +46,7 @@ function systemStatusText(status: string | undefined, isError: boolean): string export function AppShell() { const { theme, toggleTheme } = useTheme() + const [isFormControlFocused, setIsFormControlFocused] = useState(false) const healthQuery = useHealthQuery() const camerasQuery = useCamerasQuery() const cameraIssue = cameraIssueSummary(camerasQuery.data) @@ -45,9 +54,42 @@ export function AppShell() { const systemStatusClassName = !cameraIssue && !healthQuery.isError && healthQuery.data?.status === 'healthy' ? 'app-shell__header-status app-shell__header-status--nominal' : 'app-shell__header-status' + const appShellClassName = isFormControlFocused + ? 'app-shell app-shell--form-control-focused' + : 'app-shell' + + useEffect(() => { + let focusOutTimer: number | undefined + + const syncFocusedControlState = () => { + setIsFormControlFocused(documentHasFocusedFormControl()) + } + + const queueFocusedControlStateSync = () => { + if (focusOutTimer !== undefined) { + window.clearTimeout(focusOutTimer) + } + focusOutTimer = window.setTimeout(() => { + focusOutTimer = undefined + syncFocusedControlState() + }, 0) + } + + document.addEventListener('focusin', syncFocusedControlState) + document.addEventListener('focusout', queueFocusedControlStateSync) + syncFocusedControlState() + + return () => { + if (focusOutTimer !== undefined) { + window.clearTimeout(focusOutTimer) + } + document.removeEventListener('focusin', syncFocusedControlState) + document.removeEventListener('focusout', queueFocusedControlStateSync) + } + }, []) return ( -
+
diff --git a/ui/src/features/native-setup/nativeSetup.css b/ui/src/features/native-setup/nativeSetup.css index 8febddc9..8cddee5c 100644 --- a/ui/src/features/native-setup/nativeSetup.css +++ b/ui/src/features/native-setup/nativeSetup.css @@ -1,8 +1,16 @@ .native-setup-page { min-height: 100vh; - padding: var(--space-6); + min-height: 100dvh; + overflow-y: auto; + padding: + calc(var(--space-6) + var(--safe-area-inset-top)) + calc(var(--space-6) + var(--safe-area-inset-right)) + calc(var(--space-6) + var(--safe-area-inset-bottom)) + calc(var(--space-6) + var(--safe-area-inset-left)); display: grid; place-items: center; + scroll-padding-bottom: calc(var(--space-6) + var(--safe-area-inset-bottom)); + -webkit-overflow-scrolling: touch; } .native-setup-panel { @@ -60,8 +68,13 @@ @media (max-width: 640px) { .native-setup-page { - padding: var(--space-4); + padding: + calc(var(--space-4) + var(--safe-area-inset-top)) + calc(var(--space-4) + var(--safe-area-inset-right)) + calc(var(--space-4) + var(--safe-area-inset-bottom)) + calc(var(--space-4) + var(--safe-area-inset-left)); align-items: stretch; + scroll-padding-bottom: calc(var(--space-4) + var(--safe-area-inset-bottom)); } .native-setup-panel { diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css index 22e2696e..11b35c72 100644 --- a/ui/src/styles/global.css +++ b/ui/src/styles/global.css @@ -1597,12 +1597,6 @@ a:hover { @media (max-width: 620px) { .app-shell { --app-shell-inline-padding: var(--space-4); - --mobile-content-bottom-inset: calc( - var(--mobile-bottom-nav-height) - + var(--mobile-bottom-nav-gap) - + var(--safe-area-inset-bottom) - + var(--space-5) - ); } .app-shell__nav { @@ -1664,6 +1658,17 @@ a:hover { opacity var(--duration-fast) ease; } + .app-shell--form-control-focused .mobile-bottom-nav { + display: none; + opacity: 0; + pointer-events: none; + transform: translateY(calc( + 100% + + var(--mobile-bottom-nav-gap) + + var(--safe-area-inset-bottom) + )); + } + .mobile-nav-link { display: grid; place-items: center; diff --git a/ui/src/styles/tokens.css b/ui/src/styles/tokens.css index f460a333..710872e7 100644 --- a/ui/src/styles/tokens.css +++ b/ui/src/styles/tokens.css @@ -44,6 +44,17 @@ --gradient-end: #f5f9ff; } +@media (max-width: 620px) { + :root { + --mobile-content-bottom-inset: calc( + var(--mobile-bottom-nav-height) + + var(--mobile-bottom-nav-gap) + + var(--safe-area-inset-bottom) + + var(--space-5) + ); + } +} + :root[data-theme='dark'] { --surface-0: #0c111a; --surface-1: #141c28; From 49501f703400be44db3512f77187a7f8d06f82f1 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 20:38:34 -0700 Subject: [PATCH 17/36] test: expand iOS layout e2e coverage --- .github/workflows/ci.yml | 4 +- ui/e2e/mobile-layout.spec.ts | 117 ++++++++++++++++++++++++++++------- ui/playwright.config.ts | 4 ++ 3 files changed, 102 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c77ed68b..fed1a724 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,8 +90,8 @@ jobs: - name: UI check run: pnpm --dir ui check - - name: Install Playwright Chromium - run: pnpm --dir ui exec playwright install --with-deps chromium + - name: Install Playwright browsers + run: pnpm --dir ui exec playwright install --with-deps chromium webkit - name: UI e2e run: pnpm --dir ui test:e2e diff --git a/ui/e2e/mobile-layout.spec.ts b/ui/e2e/mobile-layout.spec.ts index 11e25704..a73a809b 100644 --- a/ui/e2e/mobile-layout.spec.ts +++ b/ui/e2e/mobile-layout.spec.ts @@ -2,6 +2,21 @@ import { expect, test, type Page } from '@playwright/test' const MOBILE_VIEWPORT = { width: 320, height: 700 } const DESKTOP_VIEWPORT = { width: 1280, height: 800 } +const SHELL_ROUTES = [ + '/live', + '/events', + '/events/test-id', + '/settings', + '/settings/cameras', + '/system', +] as const + +const SAFE_AREA_OVERRIDES = { + top: '8px', + right: '12px', + bottom: '34px', + left: '12px', +} as const const camera = { name: 'front_door', @@ -110,6 +125,18 @@ async function mockHomeSecApi(page: Page): Promise { return } + if (path === '/api/v1/runtime/status') { + await fulfillJson({ + state: 'idle', + generation: 3, + reload_in_progress: false, + active_config_version: 'cfg-v3', + last_reload_at: null, + last_reload_error: null, + }) + return + } + if (path === '/api/v1/preview/cameras/front_door') { await fulfillJson({ camera_name: 'front_door', @@ -174,6 +201,16 @@ async function openApp(page: Page, path: string): Promise { await page.getByRole('main').waitFor() } +async function applySafeAreaOverrides(page: Page): Promise { + await page.evaluate((tokens) => { + const root = document.documentElement + root.style.setProperty('--safe-area-inset-top', tokens.top) + root.style.setProperty('--safe-area-inset-right', tokens.right) + root.style.setProperty('--safe-area-inset-bottom', tokens.bottom) + root.style.setProperty('--safe-area-inset-left', tokens.left) + }, SAFE_AREA_OVERRIDES) +} + async function expectNoHorizontalOverflow(page: Page): Promise { const metrics = await page.evaluate(() => ({ viewportWidth: window.innerWidth, @@ -226,7 +263,7 @@ test.beforeEach(async ({ page }) => { test.describe('iOS M1 mobile layout hardening', () => { test.use({ viewport: MOBILE_VIEWPORT }) - for (const route of ['/live', '/events', '/events/test-id', '/settings', '/system']) { + for (const route of SHELL_ROUTES) { test(`${route} has no horizontal overflow and keeps bottom nav clear`, async ({ page }) => { // Given: The HomeSec app is opened at iPhone width with API responses mocked await openApp(page, route) @@ -240,6 +277,41 @@ test.describe('iOS M1 mobile layout hardening', () => { }) } + test('honors nonzero safe-area insets for mobile shell chrome', async ({ page }) => { + // Given: The shell renders with simulated iPhone notch and home-indicator insets + await openApp(page, '/live') + await applySafeAreaOverrides(page) + + // When: The topbar, content, and fixed bottom nav are measured after re-layout + const metrics = await page.evaluate(() => { + const topbar = document.querySelector('.app-shell__topbar') + const content = document.querySelector('.app-shell__content') + const nav = document.querySelector('.mobile-bottom-nav') + const topbarStyles = topbar ? getComputedStyle(topbar) : null + const contentStyles = content ? getComputedStyle(content) : null + const navBox = nav?.getBoundingClientRect() + + return { + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + topbarPaddingTop: topbarStyles ? Number.parseFloat(topbarStyles.paddingTop) : 0, + contentPaddingBottom: contentStyles ? Number.parseFloat(contentStyles.paddingBottom) : 0, + scrollPaddingBottom: Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom), + navLeft: navBox?.left ?? -1, + navRight: navBox?.right ?? -1, + navBottom: navBox?.bottom ?? -1, + } + }) + + // Then: Safe-area tokens move chrome away from each unsafe viewport edge + expect(metrics.topbarPaddingTop).toBeGreaterThanOrEqual(20) + expect(metrics.contentPaddingBottom).toBeGreaterThanOrEqual(120) + expect(metrics.scrollPaddingBottom).toBeGreaterThanOrEqual(120) + expect(metrics.navLeft).toBeGreaterThanOrEqual(24) + expect(metrics.viewportWidth - metrics.navRight).toBeGreaterThanOrEqual(24) + expect(metrics.viewportHeight - metrics.navBottom).toBeGreaterThanOrEqual(46) + }) + test('keeps live preview controls above the bottom nav', async ({ page }) => { // Given: Live view renders a camera preview at iPhone width await openApp(page, '/live') @@ -274,8 +346,9 @@ test.describe('iOS M1 mobile layout hardening', () => { }) test('keeps native setup inside safe-area-aware viewport padding', async ({ page }) => { - // Given: Native setup bypasses AppShell and renders its own mobile page + // Given: Native setup bypasses AppShell and renders with simulated iPhone safe-area insets await page.goto('/native-setup') + await applySafeAreaOverrides(page) // When: The setup page is measured at iPhone width await expect(page.getByRole('heading', { name: 'Connect to HomeSec' })).toBeVisible() @@ -293,9 +366,9 @@ test.describe('iOS M1 mobile layout hardening', () => { // Then: Setup has dynamic viewport sizing and no mobile horizontal overflow expect(metrics.minHeight).toBe('700px') - expect(metrics.paddingTop).toBeGreaterThanOrEqual(16) - expect(metrics.paddingBottom).toBeGreaterThanOrEqual(16) - expect(metrics.scrollPaddingBottom).toBeGreaterThanOrEqual(16) + expect(metrics.paddingTop).toBeGreaterThanOrEqual(24) + expect(metrics.paddingBottom).toBeGreaterThanOrEqual(50) + expect(metrics.scrollPaddingBottom).toBeGreaterThanOrEqual(50) expect(metrics.htmlScrollWidth).toBeLessThanOrEqual(metrics.viewportWidth) }) }) @@ -303,21 +376,23 @@ test.describe('iOS M1 mobile layout hardening', () => { test.describe('desktop layout regression guard', () => { test.use({ viewport: DESKTOP_VIEWPORT }) - test('keeps desktop primary nav in the topbar instead of the mobile fixed nav', async ({ page }) => { - // Given: The app is opened at desktop width - await openApp(page, '/live') + for (const route of SHELL_ROUTES) { + test(`${route} keeps desktop nav in the topbar`, async ({ page }) => { + // Given: The app is opened at desktop width + await openApp(page, route) - // When: Navigation CSS is inspected in a real browser - const desktopNavDisplay = await page.locator('.app-shell__nav').evaluate((element) => - getComputedStyle(element).display - ) - const mobileNavDisplay = await page.locator('.mobile-bottom-nav').evaluate((element) => - getComputedStyle(element).display - ) - - // Then: Desktop keeps the topbar nav visible and the mobile nav hidden - expect(desktopNavDisplay).toBe('flex') - expect(mobileNavDisplay).toBe('none') - await expectNoHorizontalOverflow(page) - }) + // When: Navigation CSS is inspected in a real browser + const desktopNavDisplay = await page.locator('.app-shell__nav').evaluate((element) => + getComputedStyle(element).display + ) + const mobileNavDisplay = await page.locator('.mobile-bottom-nav').evaluate((element) => + getComputedStyle(element).display + ) + + // Then: Desktop keeps the topbar nav visible and the mobile nav hidden + expect(desktopNavDisplay).toBe('flex') + expect(mobileNavDisplay).toBe('none') + await expectNoHorizontalOverflow(page) + }) + } }) diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index 9a21ca88..53ff1b2a 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -19,5 +19,9 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, + { + name: 'webkit-iphone', + use: { ...devices['iPhone 15'] }, + }, ], }) From 05991bc99c91ddde109fd73eebeaa093633ffea8 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 20:52:22 -0700 Subject: [PATCH 18/36] fix: harden iOS live preview playback --- .../components/CameraPreviewPanel.test.tsx | 76 ++++++++++++++++++- .../cameras/components/CameraPreviewPanel.tsx | 44 ++++++++++- 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx index e9f1975e..b63d99ab 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx @@ -19,6 +19,7 @@ const { hlsIsSupportedMock, hlsLoadSourceMock, hlsOnMock, + isIOSNativeAppMock, } = vi.hoisted(() => ({ useCameraPreviewMock: vi.fn(), usePushToTalkMock: vi.fn(), @@ -28,6 +29,7 @@ const { hlsOnMock: vi.fn(), hlsDestroyMock: vi.fn(), hlsIsSupportedMock: vi.fn(() => true), + isIOSNativeAppMock: vi.fn(() => false), })) vi.mock('../hooks/useCameraPreview', () => ({ @@ -38,6 +40,10 @@ vi.mock('../hooks/usePushToTalk', () => ({ usePushToTalk: (...args: unknown[]) => usePushToTalkMock(...args), })) +vi.mock('../../../runtime/nativeRuntime', () => ({ + isIOSNativeApp: () => isIOSNativeAppMock(), +})) + vi.mock('hls.js', () => { function MockHls(this: Record) { hlsConstructMock() @@ -134,6 +140,8 @@ describe('CameraPreviewPanel', () => { hlsDestroyMock.mockReset() hlsIsSupportedMock.mockReset() hlsIsSupportedMock.mockReturnValue(true) + isIOSNativeAppMock.mockReset() + isIOSNativeAppMock.mockReturnValue(false) mockIdlePushToTalk() vi.spyOn(globalThis, 'fetch').mockResolvedValue( new Response('#EXTM3U', { @@ -288,15 +296,15 @@ describe('CameraPreviewPanel', () => { }) }) - it('uses hls.js before native HLS when both playback paths are available', async () => { - // Given: Safari-like native HLS support and hls.js support are both available + it('uses hls.js before native HLS in browser mode when both playback paths are available', async () => { + // Given: Browser mode has hls.js support and Safari-like native HLS support vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('maybe') mockReadyPreviewSession() // When: Rendering the preview panel render() - // Then: The player takes the hls.js path instead of short-circuiting to native HLS + // Then: Browser mode takes the hls.js path instead of short-circuiting to native HLS await waitFor(() => { expect(hlsConstructMock).toHaveBeenCalledTimes(1) expect(hlsLoadSourceMock).toHaveBeenCalledWith(DEFAULT_PLAYLIST_URL) @@ -304,6 +312,27 @@ describe('CameraPreviewPanel', () => { }) }) + it('uses native HLS before hls.js inside the iOS native app', async () => { + // Given: The Capacitor iOS app can play HLS natively and hls.js is also present + isIOSNativeAppMock.mockReturnValue(true) + vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('maybe') + mockReadyPreviewSession() + + // When: Rendering the preview panel + const { container } = render() + + // Then: The player assigns the playlist directly to the inline video element + await waitFor(() => { + const video = container.querySelector('video') + expect(video?.getAttribute('src')).toBe(DEFAULT_PLAYLIST_URL) + expect(hlsConstructMock).not.toHaveBeenCalled() + expect(video?.muted).toBe(true) + expect(video?.autoplay).toBe(true) + expect(video?.playsInline).toBe(true) + expect(video?.getAttribute('webkit-playsinline')).toBe('') + }) + }) + it('falls back to native HLS when hls.js is unavailable', async () => { // Given: hls.js cannot run but the browser supports native HLS playback hlsIsSupportedMock.mockReturnValue(false) @@ -321,6 +350,47 @@ describe('CameraPreviewPanel', () => { }) }) + it('shows an actionable iOS playback error when native HLS fails', async () => { + // Given: The iOS native app has an active preview assigned through native HLS + isIOSNativeAppMock.mockReturnValue(true) + vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('maybe') + mockReadyPreviewSession() + const { container } = render() + const video = await waitFor(() => { + const currentVideo = container.querySelector('video') + expect(currentVideo?.getAttribute('src')).toBe(DEFAULT_PLAYLIST_URL) + return currentVideo + }) + + // When: WKWebView reports a native media playback error + video?.dispatchEvent(new Event('error')) + + // Then: The live view replaces the blank player with homeowner-actionable recovery copy + await waitFor(() => { + expect(screen.getByText( + 'Live preview could not play in the iOS app. Stop and start live view; if it keeps failing, check server or VPN reachability.', + )).toBeTruthy() + }) + }) + + it('shows an actionable iOS unsupported-player error', async () => { + // Given: The iOS native app cannot use hls.js or native HLS + isIOSNativeAppMock.mockReturnValue(true) + hlsIsSupportedMock.mockReturnValue(false) + vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('') + mockReadyPreviewSession() + + // When: Rendering the preview panel + render() + + // Then: The placeholder explains the iOS playback limitation instead of staying blank + await waitFor(() => { + expect(screen.getByText( + 'This iOS app cannot play the live preview stream. Check the HomeSec preview configuration and try again.', + )).toBeTruthy() + }) + }) + it('renders attached previews with only a fullscreen playback control', async () => { // Given: A ready preview session with playable live media mockReadyPreviewSession() diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.tsx index b9953115..34356abf 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.tsx @@ -4,6 +4,7 @@ import Hls from 'hls.js' import { isAPIError } from '../../../api/client' import { Button } from '../../../components/ui/Button' import { StatusBadge } from '../../../components/ui/StatusBadge' +import { isIOSNativeApp } from '../../../runtime/nativeRuntime' import { describeUnknownError } from '../../shared/errorPresentation' import { useCameraPreview } from '../hooks/useCameraPreview' import { PushToTalkControl } from './PushToTalkControl' @@ -12,6 +13,7 @@ const PLAYLIST_POLL_DELAY_MS = 500 const PLAYLIST_POLL_MAX_ATTEMPTS = 12 const PLAYBACK_RETRY_DELAY_MS = 1000 const PREVIEW_DISPLAY_STATUS_STATES = new Set(['starting', 'ready', 'degraded', 'stopping']) +const HLS_MIME_TYPES = ['application/vnd.apple.mpegurl', 'application/x-mpegURL'] type WebKitFullscreenDocument = Document & { webkitExitFullscreen?: () => Promise | void @@ -68,6 +70,22 @@ function previewLabel(state: string | undefined): string { } } +function canPlayNativeHls(video: HTMLVideoElement): boolean { + return HLS_MIME_TYPES.some((mimeType) => video.canPlayType(mimeType) !== '') +} + +function previewPlaybackFailureMessage(isIOSNative: boolean): string { + return isIOSNative + ? 'Live preview could not play in the iOS app. Stop and start live view; if it keeps failing, check server or VPN reachability.' + : 'Preview playback failed. Stop and start live view.' +} + +function previewUnsupportedMessage(isIOSNative: boolean): string { + return isIOSNative + ? 'This iOS app cannot play the live preview stream. Check the HomeSec preview configuration and try again.' + : 'This browser cannot play the live preview stream.' +} + function startLabel(statusState: string | undefined): string { if (statusState === 'ready' || statusState === 'degraded' || statusState === 'starting') { return 'Show live view' @@ -131,6 +149,7 @@ export function CameraPreviewPanel({ const [playlistReady, setPlaylistReady] = useState(false) const [isPreviewFullscreen, setIsPreviewFullscreen] = useState(false) const [playerError, setPlayerError] = useState(null) + const isIOSNative = isIOSNativeApp() const effectiveState = session && (!status || !PREVIEW_DISPLAY_STATUS_STATES.has(status.state)) ? session.state @@ -286,6 +305,15 @@ export function CameraPreviewPanel({ } } + const handleVideoError = (): void => { + setPlayerError(previewPlaybackFailureMessage(isIOSNative)) + keepPlaybackActive = false + clearResumeTimeout() + clearResumeInterval() + hls?.destroy() + hls = null + } + const cleanupPlayback = (): void => { keepPlaybackActive = false clearResumeTimeout() @@ -298,6 +326,7 @@ export function CameraPreviewPanel({ video.removeEventListener('canplaythrough', requestPlayback) video.removeEventListener('stalled', requestPlayback) video.removeEventListener('waiting', requestPlayback) + video.removeEventListener('error', handleVideoError) document.removeEventListener('visibilitychange', handleVisibilityChange) hls?.destroy() video.pause() @@ -313,8 +342,15 @@ export function CameraPreviewPanel({ video.addEventListener('canplaythrough', requestPlayback) video.addEventListener('stalled', requestPlayback) video.addEventListener('waiting', requestPlayback) + video.addEventListener('error', handleVideoError) document.addEventListener('visibilitychange', handleVisibilityChange) + if (isIOSNative && canPlayNativeHls(video)) { + video.src = playlistUrl + startPlaybackMonitor() + return cleanupPlayback + } + if (Hls.isSupported()) { hls = new Hls({ enableWorker: true, @@ -329,7 +365,7 @@ export function CameraPreviewPanel({ if (!data.fatal) { return } - setPlayerError('Preview playback failed. Restart preview.') + setPlayerError(previewPlaybackFailureMessage(isIOSNative)) keepPlaybackActive = false clearResumeTimeout() clearResumeInterval() @@ -342,16 +378,16 @@ export function CameraPreviewPanel({ return cleanupPlayback } - if (video.canPlayType('application/vnd.apple.mpegurl')) { + if (canPlayNativeHls(video)) { video.src = playlistUrl startPlaybackMonitor() return cleanupPlayback } - setPlayerError('This browser cannot play the live preview stream.') + setPlayerError(previewUnsupportedMessage(isIOSNative)) return cleanupPlayback - }, [playlistReady, playlistUrl]) + }, [isIOSNative, playlistReady, playlistUrl]) const toggleFullscreen = async (): Promise => { const viewport = viewportRef.current From cbf04a7440d90c82bac587213ba38d33951ce16c Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 22:50:08 -0700 Subject: [PATCH 19/36] fix: release failed iOS live preview media --- .../components/CameraPreviewPanel.test.tsx | 3 +++ .../cameras/components/CameraPreviewPanel.tsx | 20 +++++++++++-------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx index b63d99ab..78a14bc1 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx @@ -370,6 +370,9 @@ describe('CameraPreviewPanel', () => { expect(screen.getByText( 'Live preview could not play in the iOS app. Stop and start live view; if it keeps failing, check server or VPN reachability.', )).toBeTruthy() + expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled() + expect(HTMLMediaElement.prototype.load).toHaveBeenCalled() + expect(video?.hasAttribute('src')).toBe(false) }) }) diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.tsx index 34356abf..c1db7e90 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.tsx @@ -305,13 +305,21 @@ export function CameraPreviewPanel({ } } + const releaseMediaElement = (): void => { + hls?.destroy() + hls = null + video.pause() + video.removeAttribute('src') + video.load() + } + const handleVideoError = (): void => { setPlayerError(previewPlaybackFailureMessage(isIOSNative)) keepPlaybackActive = false clearResumeTimeout() clearResumeInterval() - hls?.destroy() - hls = null + video.removeEventListener('error', handleVideoError) + releaseMediaElement() } const cleanupPlayback = (): void => { @@ -328,10 +336,7 @@ export function CameraPreviewPanel({ video.removeEventListener('waiting', requestPlayback) video.removeEventListener('error', handleVideoError) document.removeEventListener('visibilitychange', handleVisibilityChange) - hls?.destroy() - video.pause() - video.removeAttribute('src') - video.load() + releaseMediaElement() } video.addEventListener('pause', requestPlayback) @@ -369,8 +374,7 @@ export function CameraPreviewPanel({ keepPlaybackActive = false clearResumeTimeout() clearResumeInterval() - hls?.destroy() - hls = null + releaseMediaElement() }) startPlaybackMonitor() From 8611d0e6170de3971be5cb70721a7eaf4b257b29 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 23:02:37 -0700 Subject: [PATCH 20/36] fix: release live preview on stop failures --- .../components/CameraPreviewPanel.test.tsx | 36 +++++- .../cameras/components/CameraPreviewPanel.tsx | 114 +++++++++--------- .../cameras/hooks/useCameraPreview.test.tsx | 64 +++++++++- .../cameras/hooks/useCameraPreview.ts | 3 +- 4 files changed, 153 insertions(+), 64 deletions(-) diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx index 78a14bc1..bb3b524b 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { APIError } from '../../../api/client' @@ -296,6 +296,38 @@ describe('CameraPreviewPanel', () => { }) }) + it('tears down playback listeners when hls.js reports a fatal error', async () => { + // Given: Browser HLS playback is active and hls.js has registered a fatal-error handler + let fatalErrorHandler: ((event: unknown, data: { fatal: boolean }) => void) | null = null + hlsOnMock.mockImplementation((event: string, handler: unknown) => { + if (event === 'error' && typeof handler === 'function') { + fatalErrorHandler = handler as (event: unknown, data: { fatal: boolean }) => void + } + }) + const removeDocumentListener = vi.spyOn(document, 'removeEventListener') + mockReadyPreviewSession() + render() + + await waitFor(() => { + expect(hlsOnMock).toHaveBeenCalledWith('error', expect.any(Function)) + expect(fatalErrorHandler).not.toBeNull() + }) + + // When: hls.js reports a fatal playback failure + act(() => { + fatalErrorHandler?.('error', { fatal: true }) + }) + + // Then: The player shows recovery copy and tears down document/video resources immediately + await waitFor(() => { + expect(screen.getByText('Preview playback failed. Stop and start live view.')).toBeTruthy() + expect(removeDocumentListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) + expect(hlsDestroyMock).toHaveBeenCalled() + expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled() + expect(HTMLMediaElement.prototype.load).toHaveBeenCalled() + }) + }) + it('uses hls.js before native HLS in browser mode when both playback paths are available', async () => { // Given: Browser mode has hls.js support and Safari-like native HLS support vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('maybe') @@ -355,6 +387,7 @@ describe('CameraPreviewPanel', () => { isIOSNativeAppMock.mockReturnValue(true) vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('maybe') mockReadyPreviewSession() + const removeDocumentListener = vi.spyOn(document, 'removeEventListener') const { container } = render() const video = await waitFor(() => { const currentVideo = container.querySelector('video') @@ -373,6 +406,7 @@ describe('CameraPreviewPanel', () => { expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled() expect(HTMLMediaElement.prototype.load).toHaveBeenCalled() expect(video?.hasAttribute('src')).toBe(false) + expect(removeDocumentListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) }) }) diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.tsx index c1db7e90..1506b9e2 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.tsx @@ -233,25 +233,27 @@ export function CameraPreviewPanel({ }, []) useEffect(() => { - const video = videoRef.current - if (!video || !playlistUrl || !playlistReady) { + const videoElement = videoRef.current + if (!videoElement || !playlistUrl || !playlistReady) { return } + const activeVideo: HTMLVideoElement = videoElement let hls: Hls | null = null let keepPlaybackActive = true + let playbackCleanedUp = false let resumeTimeoutId: number | null = null let resumeIntervalId: number | null = null setPlayerError(null) - video.muted = true - video.defaultMuted = true - video.autoplay = true - video.playsInline = true - video.setAttribute('autoplay', '') - video.setAttribute('muted', '') - video.setAttribute('playsinline', '') - video.setAttribute('webkit-playsinline', '') + activeVideo.muted = true + activeVideo.defaultMuted = true + activeVideo.autoplay = true + activeVideo.playsInline = true + activeVideo.setAttribute('autoplay', '') + activeVideo.setAttribute('muted', '') + activeVideo.setAttribute('playsinline', '') + activeVideo.setAttribute('webkit-playsinline', '') const clearResumeTimeout = (): void => { if (resumeTimeoutId === null) { @@ -279,10 +281,10 @@ export function CameraPreviewPanel({ if (!keepPlaybackActive) { return } - if (!video.paused && !video.ended) { + if (!activeVideo.paused && !activeVideo.ended) { return } - void video.play().catch(() => {}) + void activeVideo.play().catch(() => {}) }, 0) } @@ -293,7 +295,7 @@ export function CameraPreviewPanel({ if (!keepPlaybackActive || document.visibilityState === 'hidden') { return } - if (video.paused || video.ended) { + if (activeVideo.paused || activeVideo.ended) { requestPlayback() } }, PLAYBACK_RETRY_DELAY_MS) @@ -305,55 +307,55 @@ export function CameraPreviewPanel({ } } - const releaseMediaElement = (): void => { + function releaseMediaElement(): void { hls?.destroy() hls = null - video.pause() - video.removeAttribute('src') - video.load() + activeVideo.pause() + activeVideo.removeAttribute('src') + activeVideo.load() } - const handleVideoError = (): void => { - setPlayerError(previewPlaybackFailureMessage(isIOSNative)) + function teardownPlayback(): void { + if (playbackCleanedUp) { + return + } + playbackCleanedUp = true keepPlaybackActive = false clearResumeTimeout() clearResumeInterval() - video.removeEventListener('error', handleVideoError) + activeVideo.removeEventListener('pause', requestPlayback) + activeVideo.removeEventListener('ended', requestPlayback) + activeVideo.removeEventListener('loadedmetadata', requestPlayback) + activeVideo.removeEventListener('loadeddata', requestPlayback) + activeVideo.removeEventListener('canplay', requestPlayback) + activeVideo.removeEventListener('canplaythrough', requestPlayback) + activeVideo.removeEventListener('stalled', requestPlayback) + activeVideo.removeEventListener('waiting', requestPlayback) + activeVideo.removeEventListener('error', handleVideoError) + document.removeEventListener('visibilitychange', handleVisibilityChange) releaseMediaElement() } - const cleanupPlayback = (): void => { - keepPlaybackActive = false - clearResumeTimeout() - clearResumeInterval() - video.removeEventListener('pause', requestPlayback) - video.removeEventListener('ended', requestPlayback) - video.removeEventListener('loadedmetadata', requestPlayback) - video.removeEventListener('loadeddata', requestPlayback) - video.removeEventListener('canplay', requestPlayback) - video.removeEventListener('canplaythrough', requestPlayback) - video.removeEventListener('stalled', requestPlayback) - video.removeEventListener('waiting', requestPlayback) - video.removeEventListener('error', handleVideoError) - document.removeEventListener('visibilitychange', handleVisibilityChange) - releaseMediaElement() + function handleVideoError(): void { + setPlayerError(previewPlaybackFailureMessage(isIOSNative)) + teardownPlayback() } - video.addEventListener('pause', requestPlayback) - video.addEventListener('ended', requestPlayback) - video.addEventListener('loadedmetadata', requestPlayback) - video.addEventListener('loadeddata', requestPlayback) - video.addEventListener('canplay', requestPlayback) - video.addEventListener('canplaythrough', requestPlayback) - video.addEventListener('stalled', requestPlayback) - video.addEventListener('waiting', requestPlayback) - video.addEventListener('error', handleVideoError) + activeVideo.addEventListener('pause', requestPlayback) + activeVideo.addEventListener('ended', requestPlayback) + activeVideo.addEventListener('loadedmetadata', requestPlayback) + activeVideo.addEventListener('loadeddata', requestPlayback) + activeVideo.addEventListener('canplay', requestPlayback) + activeVideo.addEventListener('canplaythrough', requestPlayback) + activeVideo.addEventListener('stalled', requestPlayback) + activeVideo.addEventListener('waiting', requestPlayback) + activeVideo.addEventListener('error', handleVideoError) document.addEventListener('visibilitychange', handleVisibilityChange) - if (isIOSNative && canPlayNativeHls(video)) { - video.src = playlistUrl + if (isIOSNative && canPlayNativeHls(activeVideo)) { + activeVideo.src = playlistUrl startPlaybackMonitor() - return cleanupPlayback + return teardownPlayback } if (Hls.isSupported()) { @@ -362,7 +364,7 @@ export function CameraPreviewPanel({ lowLatencyMode: true, }) hls.loadSource(playlistUrl) - hls.attachMedia(video) + hls.attachMedia(activeVideo) hls.on(Hls.Events.MANIFEST_PARSED, () => { startPlaybackMonitor() }) @@ -371,26 +373,24 @@ export function CameraPreviewPanel({ return } setPlayerError(previewPlaybackFailureMessage(isIOSNative)) - keepPlaybackActive = false - clearResumeTimeout() - clearResumeInterval() - releaseMediaElement() + teardownPlayback() }) startPlaybackMonitor() - return cleanupPlayback + return teardownPlayback } - if (canPlayNativeHls(video)) { - video.src = playlistUrl + if (canPlayNativeHls(activeVideo)) { + activeVideo.src = playlistUrl startPlaybackMonitor() - return cleanupPlayback + return teardownPlayback } setPlayerError(previewUnsupportedMessage(isIOSNative)) + teardownPlayback() - return cleanupPlayback + return teardownPlayback }, [isIOSNative, playlistReady, playlistUrl]) const toggleFullscreen = async (): Promise => { diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index 954ce812..09fd37da 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -687,7 +687,7 @@ describe('useCameraPreview', () => { state: 'ready', viewer_count: 1, token: 'preview-token-1', - token_expires_at: '2026-04-23T12:00:10.000Z', + token_expires_at: null, playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1', idle_timeout_s: 30, warning: null, @@ -738,6 +738,60 @@ describe('useCameraPreview', () => { expect(result.current.session).toBeNull() }) + it('clears local preview when a stop request rejects', async () => { + // Given: An active preview session whose server-side stop request will fail + vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + vi.spyOn(apiClient, 'ensureCameraPreviewActive').mockResolvedValue({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-1', + token_expires_at: null, + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + const stopPreview = vi + .spyOn(apiClient, 'stopCameraPreview') + .mockRejectedValue(new Error('stop failed')) + + const { result } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.playlistUrl).toContain('preview-token-1') + }) + + // When: The user stops preview and the backend request rejects + await act(async () => { + await expect(result.current.stop()).resolves.toBeUndefined() + }) + + // Then: Local media is detached even though the server-side stop failed + await waitFor(() => { + expect(stopPreview).toHaveBeenCalledWith('front') + expect(result.current.session).toBeNull() + expect(result.current.playlistUrl).toBeNull() + expect(result.current.error?.message).toBe('stop failed') + }) + }) + it('stops preview start that resolves after native background', async () => { // Given: Preview start is still in flight when native iOS backgrounds the app const previewStart = deferred>>() @@ -819,7 +873,7 @@ describe('useCameraPreview', () => { state: 'ready', viewer_count: 1, token: 'preview-token-new', - token_expires_at: '2026-04-23T12:00:10.000Z', + token_expires_at: null, playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-new', idle_timeout_s: 30, warning: null, @@ -864,7 +918,7 @@ describe('useCameraPreview', () => { state: 'ready', viewer_count: 1, token: 'preview-token-stale', - token_expires_at: '2026-04-23T12:00:10.000Z', + token_expires_at: null, playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-stale', idle_timeout_s: 30, warning: null, @@ -948,7 +1002,7 @@ describe('useCameraPreview', () => { // Then: The hook waits for the stop to settle instead of racing a new attach against it expect(ensurePreviewActive).toHaveBeenCalledTimes(1) - expect(result.current.session?.token).toBe('preview-token-old') + expect(result.current.session).toBeNull() await act(async () => { backgroundStop.resolve({ @@ -992,7 +1046,7 @@ describe('useCameraPreview', () => { state: 'ready', viewer_count: 1, token: 'preview-token-1', - token_expires_at: '2026-04-23T12:00:10.000Z', + token_expires_at: null, playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1', idle_timeout_s: 30, warning: null, diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index d0c2dcb4..3c5ee1f6 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -179,12 +179,13 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const stop = useCallback(async () => { const requestSeq = beginStopRequest() + clearSession() try { await stopPreview({ requestSeq }) } catch { return } - }, [beginStopRequest, stopPreview]) + }, [beginStopRequest, clearSession, stopPreview]) const refreshSession = useCallback(async () => { if (nativeLifecycle.isBackgrounded || stopInFlightSeqRef.current !== null) { From ce56f51642362cf9961e61f33d6f109675e11b9e Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 23:10:14 -0700 Subject: [PATCH 21/36] fix: stop stale background preview activations --- .../cameras/hooks/useCameraPreview.test.tsx | 84 +++++++++++++++++++ .../cameras/hooks/useCameraPreview.ts | 33 +++++--- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index 09fd37da..165180bc 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -853,6 +853,90 @@ describe('useCameraPreview', () => { }) }) + it('stops stale activation that resolves after native background stop', async () => { + // Given: An active preview and a second activation request still in flight + const lateActivation = deferred>>() + vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + vi.spyOn(apiClient, 'ensureCameraPreviewActive') + .mockResolvedValueOnce({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-old', + token_expires_at: null, + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-old', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + .mockReturnValueOnce(lateActivation.promise) + const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({ + accepted: true, + state: 'stopping', + httpStatus: 202, + }) + + const { result, rerender } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-old') + }) + + let lateActivationPromise!: Promise + act(() => { + lateActivationPromise = result.current.start() + }) + await waitFor(() => { + expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledTimes(2) + }) + + // When: iOS backgrounds, completes its normal stop, then the late activation resolves + setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 }) + rerender() + await waitFor(() => { + expect(stopPreview).toHaveBeenCalledTimes(1) + expect(result.current.session).toBeNull() + }) + + await act(async () => { + lateActivation.resolve({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-late', + token_expires_at: null, + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-late', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + await lateActivationPromise + }) + + // Then: The late server activation is cleaned up while the app remains backgrounded + await waitFor(() => { + expect(stopPreview).toHaveBeenCalledTimes(2) + expect(result.current.session).toBeNull() + expect(result.current.playlistUrl).toBeNull() + }) + }) + it('does not let a stale preview start stop a newer resumed preview', async () => { // Given: A preview start begins before background and a newer start succeeds after resume const staleStart = deferred>>() diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index 3c5ee1f6..766e9f53 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -97,20 +97,27 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const storeActivationIfCurrent = useCallback(async (activation: PreviewActivation) => { const isLatestActivation = activation.activationSeq === sessionRequestSeqRef.current const currentLifecycle = nativeLifecycleRef.current - if ( - currentLifecycle.isBackgrounded - || currentLifecycle.pauseCount !== activation.pauseCountAtRequest - ) { + + const stopLateActivation = async (): Promise => { + clearSession() + const stopRequestSeq = beginStopRequest() + try { + await apiClient.stopCameraPreview(cameraName) + } catch { + return + } finally { + finishStopRequest(stopRequestSeq) + } + } + + if (currentLifecycle.isBackgrounded) { + await stopLateActivation() + return + } + + if (currentLifecycle.pauseCount !== activation.pauseCountAtRequest) { if (isLatestActivation) { - clearSession() - const stopRequestSeq = beginStopRequest() - try { - await apiClient.stopCameraPreview(cameraName) - } catch { - return - } finally { - finishStopRequest(stopRequestSeq) - } + await stopLateActivation() } return } From 2bae5051a41ec1ec546c7bad760a0a3276c101a8 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 23:21:06 -0700 Subject: [PATCH 22/36] fix: clear stale preview recovery errors --- .../components/CameraPreviewPanel.test.tsx | 16 ++++- .../cameras/components/CameraPreviewPanel.tsx | 6 +- .../cameras/hooks/useCameraPreview.test.tsx | 70 +++++++++++++++++++ .../cameras/hooks/useCameraPreview.ts | 12 +++- 4 files changed, 96 insertions(+), 8 deletions(-) diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx index bb3b524b..b4905905 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx @@ -94,7 +94,10 @@ function mockIdlePushToTalk(overrides: Record = {}) { }) } -function mockReadyPreviewSession(playlistUrl: string = DEFAULT_PLAYLIST_URL) { +function mockReadyPreviewSession( + playlistUrl: string = DEFAULT_PLAYLIST_URL, + overrides: Record = {}, +) { useCameraPreviewMock.mockReturnValue({ status: { camera_name: 'front', @@ -126,6 +129,7 @@ function mockReadyPreviewSession(playlistUrl: string = DEFAULT_PLAYLIST_URL) { start: vi.fn(), stop: vi.fn(), refreshStatus: vi.fn(), + ...overrides, }) } @@ -386,7 +390,9 @@ describe('CameraPreviewPanel', () => { // Given: The iOS native app has an active preview assigned through native HLS isIOSNativeAppMock.mockReturnValue(true) vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('maybe') - mockReadyPreviewSession() + mockReadyPreviewSession(DEFAULT_PLAYLIST_URL, { + warning: 'Preview degraded: stale playlist warning', + }) const removeDocumentListener = vi.spyOn(document, 'removeEventListener') const { container } = render() const video = await waitFor(() => { @@ -403,6 +409,7 @@ describe('CameraPreviewPanel', () => { expect(screen.getByText( 'Live preview could not play in the iOS app. Stop and start live view; if it keeps failing, check server or VPN reachability.', )).toBeTruthy() + expect(screen.queryByText('Preview degraded: stale playlist warning')).toBeNull() expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled() expect(HTMLMediaElement.prototype.load).toHaveBeenCalled() expect(video?.hasAttribute('src')).toBe(false) @@ -415,7 +422,9 @@ describe('CameraPreviewPanel', () => { isIOSNativeAppMock.mockReturnValue(true) hlsIsSupportedMock.mockReturnValue(false) vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('') - mockReadyPreviewSession() + mockReadyPreviewSession(DEFAULT_PLAYLIST_URL, { + warning: 'Preview degraded: stale playlist warning', + }) // When: Rendering the preview panel render() @@ -425,6 +434,7 @@ describe('CameraPreviewPanel', () => { expect(screen.getByText( 'This iOS app cannot play the live preview stream. Check the HomeSec preview configuration and try again.', )).toBeTruthy() + expect(screen.queryByText('Preview degraded: stale playlist warning')).toBeNull() }) }) diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.tsx index 1506b9e2..fd02206a 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.tsx @@ -417,12 +417,12 @@ export function CameraPreviewPanel({ } const statusMessage = useMemo(() => { - if (warning) { - return warning - } if (playerError) { return playerError } + if (warning) { + return warning + } if (error) { if (isAPIError(error) && error.errorCode === 'PREVIEW_MEDIA_UNAVAILABLE') { return 'Preview media is still starting.' diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index 165180bc..dfa2ebb0 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -792,6 +792,76 @@ describe('useCameraPreview', () => { }) }) + it('clears a stale stop error after preview restarts successfully', async () => { + // Given: A stop failure has detached the local preview and left a user-visible error + vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + vi.spyOn(apiClient, 'ensureCameraPreviewActive') + .mockResolvedValueOnce({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-1', + token_expires_at: null, + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + .mockResolvedValueOnce({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-2', + token_expires_at: null, + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-2', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + vi.spyOn(apiClient, 'stopCameraPreview').mockRejectedValue(new Error('stop failed')) + + const { result } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-1') + }) + await act(async () => { + await result.current.stop() + }) + await waitFor(() => { + expect(result.current.error?.message).toBe('stop failed') + expect(result.current.session).toBeNull() + }) + + // When: A later preview start succeeds + await act(async () => { + await result.current.start() + }) + + // Then: The stale stop error is cleared once the new session is accepted + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-2') + expect(result.current.error).toBeNull() + expect(result.current.playlistUrl).toContain('preview-token-2') + }) + }) + it('stops preview start that resolves after native background', async () => { // Given: Preview start is still in flight when native iOS backgrounds the app const previewStart = deferred>>() diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index 766e9f53..8ff9c1f5 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -52,6 +52,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const nativeLifecycleRef = useRef(nativeLifecycle) const [sessionState, setSessionState] = useState(null) const [refreshError, setRefreshError] = useState(null) + const [stopError, setStopError] = useState(null) const sessionStateRef = useRef(null) const statusRequestSeqRef = useRef(0) const sessionRequestSeqRef = useRef(0) @@ -67,6 +68,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { receivedAtMs: Date.now(), statusRequestSeq: statusRequestSeqRef.current, } + setStopError(null) sessionStateRef.current = nextState setSessionState(nextState) }, []) @@ -158,6 +160,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { ) { clearSession() setRefreshError(null) + setStopError(null) } return nextStatus }, @@ -173,6 +176,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { return } setRefreshError(null) + setStopError(null) clearSession() await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) }) }, @@ -187,9 +191,13 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const stop = useCallback(async () => { const requestSeq = beginStopRequest() clearSession() + setStopError(null) try { await stopPreview({ requestSeq }) - } catch { + } catch (nextError) { + if (requestSeq === sessionRequestSeqRef.current) { + setStopError(nextError as Error) + } return } }, [beginStopRequest, clearSession, stopPreview]) @@ -273,7 +281,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const playlistUrl = session ? apiClient.resolvePath(session.playlist_url) : null const error = (startMutation.error - ?? stopMutation.error + ?? stopError ?? refreshError ?? statusQuery.error ?? null) as Error | null From 391c8b831a4d3a66f4ebd8ccee4fc4553ca548db Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 23:29:47 -0700 Subject: [PATCH 23/36] fix: clean stale preview activation after stop --- .../cameras/hooks/useCameraPreview.test.tsx | 85 +++++++++++++++++++ .../cameras/hooks/useCameraPreview.ts | 12 +++ 2 files changed, 97 insertions(+) diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index dfa2ebb0..01317720 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -862,6 +862,91 @@ describe('useCameraPreview', () => { }) }) + it('stops stale activation that resolves after explicit stop', async () => { + // Given: A preview session is active and a replacement activation is in flight + const lateActivation = deferred>>() + vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + vi.spyOn(apiClient, 'ensureCameraPreviewActive') + .mockResolvedValueOnce({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-old', + token_expires_at: null, + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-old', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + .mockReturnValueOnce(lateActivation.promise) + const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({ + accepted: true, + state: 'stopping', + httpStatus: 202, + }) + + const { result } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-old') + }) + + let lateActivationPromise!: Promise + act(() => { + lateActivationPromise = result.current.start() + }) + await waitFor(() => { + expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledTimes(2) + }) + + // When: The user stops preview before the late activation resolves + await act(async () => { + await result.current.stop() + }) + await waitFor(() => { + expect(stopPreview).toHaveBeenCalledTimes(1) + expect(result.current.session).toBeNull() + }) + await act(async () => { + lateActivation.resolve({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-late', + token_expires_at: null, + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-late', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + await lateActivationPromise + }) + + // Then: The stale activation is stopped instead of leaving the backend preview active + await waitFor(() => { + expect(stopPreview).toHaveBeenCalledTimes(2) + expect(result.current.session).toBeNull() + expect(result.current.playlistUrl).toBeNull() + expect(result.current.error).toBeNull() + }) + }) + it('stops preview start that resolves after native background', async () => { // Given: Preview start is still in flight when native iOS backgrounds the app const previewStart = deferred>>() diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index 8ff9c1f5..fd31afb1 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -57,6 +57,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const statusRequestSeqRef = useRef(0) const sessionRequestSeqRef = useRef(0) const stopInFlightSeqRef = useRef(null) + const latestStopRequestSeqRef = useRef(0) useLayoutEffect(() => { nativeLifecycleRef.current = nativeLifecycle @@ -87,6 +88,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const beginStopRequest = useCallback(() => { const requestSeq = beginSessionRequest() stopInFlightSeqRef.current = requestSeq + latestStopRequestSeqRef.current = requestSeq return requestSeq }, [beginSessionRequest]) @@ -98,6 +100,10 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const storeActivationIfCurrent = useCallback(async (activation: PreviewActivation) => { const isLatestActivation = activation.activationSeq === sessionRequestSeqRef.current + const wasSupersededByLatestStop = + !isLatestActivation + && activation.activationSeq < latestStopRequestSeqRef.current + && sessionRequestSeqRef.current === latestStopRequestSeqRef.current const currentLifecycle = nativeLifecycleRef.current const stopLateActivation = async (): Promise => { @@ -105,6 +111,9 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const stopRequestSeq = beginStopRequest() try { await apiClient.stopCameraPreview(cameraName) + setRefreshError(null) + setStopError(null) + await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) }) } catch { return } finally { @@ -125,6 +134,9 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { } if (!isLatestActivation) { + if (wasSupersededByLatestStop) { + await stopLateActivation() + } return } From 1aeaf3d5bf2da5d594ab6220e1433190b05850cc Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 23:37:55 -0700 Subject: [PATCH 24/36] fix: surface live preview cleanup failures --- .../components/CameraPreviewPanel.test.tsx | 33 ++++++++ .../cameras/components/CameraPreviewPanel.tsx | 6 +- .../cameras/hooks/useCameraPreview.test.tsx | 84 +++++++++++++++++++ .../cameras/hooks/useCameraPreview.ts | 4 +- 4 files changed, 123 insertions(+), 4 deletions(-) diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx index b4905905..bcae1268 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx @@ -438,6 +438,39 @@ describe('CameraPreviewPanel', () => { }) }) + it('surfaces preview hook errors before stale warning text', () => { + // Given: The hook reports a stop failure while status still carries an older warning + useCameraPreviewMock.mockReturnValue({ + status: { + camera_name: 'front', + enabled: true, + state: 'degraded', + viewer_count: 0, + degraded_reason: 'Preview degraded: stale runtime warning', + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }, + session: null, + playlistUrl: null, + warning: 'Preview degraded: stale runtime warning', + error: new Error('stop failed'), + isPending: false, + isStarting: false, + isStopping: false, + start: vi.fn(), + stop: vi.fn(), + refreshStatus: vi.fn(), + }) + + // When: Rendering the preview panel after local media has been cleared + render() + + // Then: The actionable hook failure is shown instead of the stale degraded warning + expect(screen.getByText('stop failed')).toBeTruthy() + expect(screen.queryByText('Preview degraded: stale runtime warning')).toBeNull() + }) + it('renders attached previews with only a fullscreen playback control', async () => { // Given: A ready preview session with playable live media mockReadyPreviewSession() diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.tsx index fd02206a..69e6b14a 100644 --- a/ui/src/features/cameras/components/CameraPreviewPanel.tsx +++ b/ui/src/features/cameras/components/CameraPreviewPanel.tsx @@ -420,15 +420,15 @@ export function CameraPreviewPanel({ if (playerError) { return playerError } - if (warning) { - return warning - } if (error) { if (isAPIError(error) && error.errorCode === 'PREVIEW_MEDIA_UNAVAILABLE') { return 'Preview media is still starting.' } return describeUnknownError(error) } + if (warning) { + return warning + } if (playlistUrl && !playlistReady) { return 'Starting live view.' } diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index 01317720..6bdb1fc9 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -947,6 +947,90 @@ describe('useCameraPreview', () => { }) }) + it('surfaces cleanup failure when stale activation resolves after explicit stop', async () => { + // Given: A preview session is active and a replacement activation is in flight + const lateActivation = deferred>>() + vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + vi.spyOn(apiClient, 'ensureCameraPreviewActive') + .mockResolvedValueOnce({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-old', + token_expires_at: null, + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-old', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + .mockReturnValueOnce(lateActivation.promise) + const stopPreview = vi + .spyOn(apiClient, 'stopCameraPreview') + .mockResolvedValueOnce({ + accepted: true, + state: 'stopping', + httpStatus: 202, + }) + .mockRejectedValueOnce(new Error('late cleanup stop failed')) + + const { result } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-old') + }) + + let lateActivationPromise!: Promise + act(() => { + lateActivationPromise = result.current.start() + }) + await waitFor(() => { + expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledTimes(2) + }) + + // When: Explicit stop succeeds, but cleanup for the late activation fails + await act(async () => { + await result.current.stop() + }) + await act(async () => { + lateActivation.resolve({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-late', + token_expires_at: null, + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-late', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + await lateActivationPromise + }) + + // Then: The cleanup failure is exposed while local media remains detached + await waitFor(() => { + expect(stopPreview).toHaveBeenCalledTimes(2) + expect(result.current.session).toBeNull() + expect(result.current.playlistUrl).toBeNull() + expect(result.current.error?.message).toBe('late cleanup stop failed') + }) + }) + it('stops preview start that resolves after native background', async () => { // Given: Preview start is still in flight when native iOS backgrounds the app const previewStart = deferred>>() diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index fd31afb1..aa4aac39 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -114,7 +114,9 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { setRefreshError(null) setStopError(null) await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) }) - } catch { + } catch (nextError) { + setStopError(nextError as Error) + await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) }) return } finally { finishStopRequest(stopRequestSeq) From 423eb6e5fc66c3408ce511e645ff0413a4d9dad6 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 23:46:49 -0700 Subject: [PATCH 25/36] fix: ignore stale preview renewal failures --- .../cameras/hooks/useCameraPreview.test.tsx | 97 +++++++++++++++++++ .../cameras/hooks/useCameraPreview.ts | 12 ++- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index 6bdb1fc9..7c822d24 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -529,6 +529,103 @@ describe('useCameraPreview', () => { expect(result.current.error).toBeNull() }) + it('ignores stale token renewal failures after explicit stop', async () => { + // Given: A ready preview session with an in-flight token renewal + freezePreviewClock() + const realSetTimeout = window.setTimeout.bind(window) + const realClearTimeout = window.clearTimeout.bind(window) + let runScheduledRefresh: (() => void) | null = null + vi.spyOn(window, 'setTimeout').mockImplementation(((handler, timeout, ...args) => { + if (timeout === 5_000) { + runScheduledRefresh = () => { + if (typeof handler !== 'function') { + throw new Error('Expected refresh timer handler to be a function') + } + handler(...args) + } + return 99 + } + return realSetTimeout(handler, timeout, ...args) + }) as typeof window.setTimeout) + vi.spyOn(window, 'clearTimeout').mockImplementation(((timeoutId) => { + if (timeoutId === 99) { + return + } + realClearTimeout(timeoutId) + }) as typeof window.clearTimeout) + const renewal = deferred>>() + vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + const ensurePreviewActive = vi + .spyOn(apiClient, 'ensureCameraPreviewActive') + .mockResolvedValueOnce({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-1', + token_expires_at: '2026-04-23T12:00:10.000Z', + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + .mockReturnValueOnce(renewal.promise) + vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({ + accepted: true, + state: 'stopping', + httpStatus: 202, + }) + + const { result } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-1') + expect(runScheduledRefresh).not.toBeNull() + }) + await act(async () => { + runScheduledRefresh?.() + await Promise.resolve() + }) + await waitFor(() => { + expect(ensurePreviewActive).toHaveBeenCalledTimes(2) + }) + + // When: The user stops preview before the renewal request rejects + await act(async () => { + await result.current.stop() + }) + await waitFor(() => { + expect(result.current.session).toBeNull() + expect(result.current.error).toBeNull() + }) + await act(async () => { + renewal.reject(new Error('token refresh failed after stop')) + await Promise.resolve() + }) + + // Then: The stale renewal failure cannot repopulate preview errors + await waitFor(() => { + expect(result.current.session).toBeNull() + expect(result.current.playlistUrl).toBeNull() + expect(result.current.error).toBeNull() + }) + }) + it('drops stale preview sessions after a newer terminal runtime status', async () => { // Given: A started preview session whose follow-up status says the runtime has already failed it freezePreviewClock() diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index aa4aac39..01eff540 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -220,14 +220,18 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { if (nativeLifecycle.isBackgrounded || stopInFlightSeqRef.current !== null) { return } + const activationSeq = beginSessionRequest() + const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount try { - const activationSeq = beginSessionRequest() - const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount const snapshot = await apiClient.ensureCameraPreviewActive(cameraName) - setRefreshError(null) + if (activationSeq === sessionRequestSeqRef.current) { + setRefreshError(null) + } await storeActivationIfCurrent({ activationSeq, pauseCountAtRequest, snapshot }) } catch (nextError) { - setRefreshError(nextError as Error) + if (activationSeq === sessionRequestSeqRef.current && !nativeLifecycleRef.current.isBackgrounded) { + setRefreshError(nextError as Error) + } } }, [beginSessionRequest, cameraName, nativeLifecycle.isBackgrounded, storeActivationIfCurrent]) From a95e1917ae8f725af04888676200bc50d93cdfe9 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 13 Jun 2026 23:54:57 -0700 Subject: [PATCH 26/36] fix: guard stale preview start errors --- .../cameras/hooks/useCameraPreview.test.tsx | 171 ++++++++++++++++++ .../cameras/hooks/useCameraPreview.ts | 34 +++- 2 files changed, 198 insertions(+), 7 deletions(-) diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index 7c822d24..94499f25 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -124,6 +124,62 @@ describe('useCameraPreview', () => { }) }) + it('ignores stale preview start failures after explicit stop', async () => { + // Given: A preview activation is still in flight + const previewStart = deferred>>() + vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 0, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + vi.spyOn(apiClient, 'ensureCameraPreviewActive').mockReturnValue(previewStart.promise) + vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({ + accepted: true, + state: 'stopping', + httpStatus: 202, + }) + + const { result } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + + let startPromise!: Promise + act(() => { + startPromise = result.current.start() + }) + await waitFor(() => { + expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledWith('front') + }) + + // When: The user stops preview before the start request rejects + await act(async () => { + await result.current.stop() + }) + await waitFor(() => { + expect(result.current.session).toBeNull() + expect(result.current.error).toBeNull() + }) + await act(async () => { + previewStart.reject(new Error('preview failed after stop')) + await startPromise + }) + + // Then: The stale start failure cannot repopulate preview errors + await waitFor(() => { + expect(result.current.session).toBeNull() + expect(result.current.playlistUrl).toBeNull() + expect(result.current.error).toBeNull() + }) + }) + it('keeps a fresh preview session when the follow-up status refetch fails', async () => { // Given: An idle camera whose preview start succeeds but the invalidated status refresh fails freezePreviewClock() @@ -626,6 +682,121 @@ describe('useCameraPreview', () => { }) }) + it('ignores stale token renewal failures after terminal status detaches preview', async () => { + // Given: A ready preview session with an in-flight token renewal + freezePreviewClock() + const realSetTimeout = window.setTimeout.bind(window) + const realClearTimeout = window.clearTimeout.bind(window) + let runScheduledRefresh: (() => void) | null = null + vi.spyOn(window, 'setTimeout').mockImplementation(((handler, timeout, ...args) => { + if (timeout === 5_000) { + runScheduledRefresh = () => { + if (typeof handler !== 'function') { + throw new Error('Expected refresh timer handler to be a function') + } + handler(...args) + } + return 99 + } + return realSetTimeout(handler, timeout, ...args) + }) as typeof window.setTimeout) + vi.spyOn(window, 'clearTimeout').mockImplementation(((timeoutId) => { + if (timeoutId === 99) { + return + } + realClearTimeout(timeoutId) + }) as typeof window.clearTimeout) + const renewal = deferred>>() + vi.spyOn(apiClient, 'getCameraPreviewStatus') + .mockResolvedValueOnce({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + .mockResolvedValueOnce({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + .mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'error', + viewer_count: 0, + degraded_reason: null, + last_error: 'runtime worker exited with code 137', + idle_shutdown_at: null, + httpStatus: 200, + }) + const ensurePreviewActive = vi + .spyOn(apiClient, 'ensureCameraPreviewActive') + .mockResolvedValueOnce({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-1', + token_expires_at: '2026-04-23T12:00:10.000Z', + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + .mockReturnValueOnce(renewal.promise) + + const { result } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-1') + expect(runScheduledRefresh).not.toBeNull() + }) + await act(async () => { + runScheduledRefresh?.() + await Promise.resolve() + }) + await waitFor(() => { + expect(ensurePreviewActive).toHaveBeenCalledTimes(2) + }) + + // When: A status refresh detaches the preview before renewal rejects + await act(async () => { + await result.current.refreshStatus() + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('error') + expect(result.current.session).toBeNull() + expect(result.current.error).toBeNull() + }) + await act(async () => { + renewal.reject(new Error('token refresh failed after terminal status')) + await Promise.resolve() + }) + + // Then: The stale renewal failure cannot hide the backend terminal status warning + await waitFor(() => { + expect(result.current.session).toBeNull() + expect(result.current.playlistUrl).toBeNull() + expect(result.current.warning).toBe('runtime worker exited with code 137') + expect(result.current.error).toBeNull() + }) + }) + it('drops stale preview sessions after a newer terminal runtime status', async () => { // Given: A started preview session whose follow-up status says the runtime has already failed it freezePreviewClock() diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index 01eff540..6684db23 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -42,6 +42,11 @@ interface PreviewActivation { snapshot: PreviewSessionSnapshot } +interface PreviewActivationRequest { + activationSeq: number + pauseCountAtRequest: number +} + interface PreviewStopRequest { requestSeq: number } @@ -51,6 +56,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const nativeLifecycle = useNativeAppLifecycleState() const nativeLifecycleRef = useRef(nativeLifecycle) const [sessionState, setSessionState] = useState(null) + const [startError, setStartError] = useState(null) const [refreshError, setRefreshError] = useState(null) const [stopError, setStopError] = useState(null) const sessionStateRef = useRef(null) @@ -70,6 +76,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { statusRequestSeq: statusRequestSeqRef.current, } setStopError(null) + setStartError(null) sessionStateRef.current = nextState setSessionState(nextState) }, []) @@ -146,17 +153,23 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) }) }, [beginStopRequest, cameraName, clearSession, finishStopRequest, queryClient, storeSession]) - const startMutation = useMutation({ - mutationFn: async () => { - const activationSeq = beginSessionRequest() - const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount + const startMutation = useMutation({ + mutationFn: async ({ activationSeq, pauseCountAtRequest }) => { const snapshot = await apiClient.ensureCameraPreviewActive(cameraName) return { activationSeq, pauseCountAtRequest, snapshot } }, onSuccess: async (activation) => { - setRefreshError(null) + if (activation.activationSeq === sessionRequestSeqRef.current) { + setStartError(null) + setRefreshError(null) + } await storeActivationIfCurrent(activation) }, + onError: (nextError, activation) => { + if (activation.activationSeq === sessionRequestSeqRef.current && !nativeLifecycleRef.current.isBackgrounded) { + setStartError(nextError) + } + }, }) const statusQuery = useQuery({ @@ -172,7 +185,9 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { && (nextStatus.enabled === false || (!PREVIEW_SESSION_ACTIVE_STATES.has(nextStatus.state) && !startMutation.isPending)) ) { + beginSessionRequest() clearSession() + setStartError(null) setRefreshError(null) setStopError(null) } @@ -189,6 +204,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { if (request.requestSeq !== sessionRequestSeqRef.current) { return } + setStartError(null) setRefreshError(null) setStopError(null) clearSession() @@ -205,6 +221,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { const stop = useCallback(async () => { const requestSeq = beginStopRequest() clearSession() + setStartError(null) setStopError(null) try { await stopPreview({ requestSeq }) @@ -298,7 +315,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { ?? null const playlistUrl = session ? apiClient.resolvePath(session.playlist_url) : null - const error = (startMutation.error + const error = (startError ?? stopError ?? refreshError ?? statusQuery.error @@ -324,8 +341,11 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { ) { return } + const activationSeq = beginSessionRequest() + const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount + setStartError(null) try { - await startMutation.mutateAsync() + await startMutation.mutateAsync({ activationSeq, pauseCountAtRequest }) } catch { return } From ffc70a0b1144fd215226b52b353bb4d2ba397f46 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 14 Jun 2026 00:02:12 -0700 Subject: [PATCH 27/36] fix: clean stale preview renewals --- .../cameras/hooks/useCameraPreview.test.tsx | 130 ++++++++++++++++++ .../cameras/hooks/useCameraPreview.ts | 8 +- 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx index 94499f25..155ee656 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx +++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx @@ -797,6 +797,136 @@ describe('useCameraPreview', () => { }) }) + it('stops stale token renewal successes after terminal status detaches preview', async () => { + // Given: A ready preview session with an in-flight token renewal + freezePreviewClock() + const realSetTimeout = window.setTimeout.bind(window) + const realClearTimeout = window.clearTimeout.bind(window) + let runScheduledRefresh: (() => void) | null = null + vi.spyOn(window, 'setTimeout').mockImplementation(((handler, timeout, ...args) => { + if (timeout === 5_000) { + runScheduledRefresh = () => { + if (typeof handler !== 'function') { + throw new Error('Expected refresh timer handler to be a function') + } + handler(...args) + } + return 99 + } + return realSetTimeout(handler, timeout, ...args) + }) as typeof window.setTimeout) + vi.spyOn(window, 'clearTimeout').mockImplementation(((timeoutId) => { + if (timeoutId === 99) { + return + } + realClearTimeout(timeoutId) + }) as typeof window.clearTimeout) + const renewal = deferred>>() + vi.spyOn(apiClient, 'getCameraPreviewStatus') + .mockResolvedValueOnce({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + .mockResolvedValueOnce({ + camera_name: 'front', + enabled: true, + state: 'ready', + viewer_count: 1, + degraded_reason: null, + last_error: null, + idle_shutdown_at: null, + httpStatus: 200, + }) + .mockResolvedValue({ + camera_name: 'front', + enabled: true, + state: 'error', + viewer_count: 0, + degraded_reason: null, + last_error: 'runtime worker exited with code 137', + idle_shutdown_at: null, + httpStatus: 200, + }) + const ensurePreviewActive = vi + .spyOn(apiClient, 'ensureCameraPreviewActive') + .mockResolvedValueOnce({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-1', + token_expires_at: '2026-04-23T12:00:10.000Z', + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + .mockReturnValueOnce(renewal.promise) + const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({ + accepted: true, + state: 'stopping', + httpStatus: 202, + }) + + const { result } = renderHook(() => useCameraPreview('front'), { + wrapper: createWrapper(), + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('ready') + }) + await act(async () => { + await result.current.start() + }) + await waitFor(() => { + expect(result.current.session?.token).toBe('preview-token-1') + expect(runScheduledRefresh).not.toBeNull() + }) + await act(async () => { + runScheduledRefresh?.() + await Promise.resolve() + }) + await waitFor(() => { + expect(ensurePreviewActive).toHaveBeenCalledTimes(2) + }) + + // When: A status refresh detaches the preview before renewal succeeds + await act(async () => { + await result.current.refreshStatus() + }) + await waitFor(() => { + expect(result.current.status?.state).toBe('error') + expect(result.current.session).toBeNull() + }) + await act(async () => { + renewal.resolve({ + camera_name: 'front', + state: 'ready', + viewer_count: 1, + token: 'preview-token-2', + token_expires_at: '2026-04-23T12:01:10.000Z', + playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-2', + idle_timeout_s: 30, + warning: null, + httpStatus: 200, + }) + await Promise.resolve() + }) + + // Then: The stale renewal is cleaned up without reattaching local media + await waitFor(() => { + expect(stopPreview).toHaveBeenCalledWith('front') + expect(result.current.session).toBeNull() + expect(result.current.playlistUrl).toBeNull() + expect(result.current.warning).toBe('runtime worker exited with code 137') + expect(result.current.error).toBeNull() + }) + }) + it('drops stale preview sessions after a newer terminal runtime status', async () => { // Given: A started preview session whose follow-up status says the runtime has already failed it freezePreviewClock() diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts index 6684db23..784e19ae 100644 --- a/ui/src/features/cameras/hooks/useCameraPreview.ts +++ b/ui/src/features/cameras/hooks/useCameraPreview.ts @@ -99,6 +99,12 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { return requestSeq }, [beginSessionRequest]) + const beginCleanupBoundary = useCallback(() => { + const requestSeq = beginSessionRequest() + latestStopRequestSeqRef.current = requestSeq + return requestSeq + }, [beginSessionRequest]) + const finishStopRequest = useCallback((requestSeq: number) => { if (stopInFlightSeqRef.current === requestSeq) { stopInFlightSeqRef.current = null @@ -185,7 +191,7 @@ export function useCameraPreview(cameraName: string): CameraPreviewState { && (nextStatus.enabled === false || (!PREVIEW_SESSION_ACTIVE_STATES.has(nextStatus.state) && !startMutation.isPending)) ) { - beginSessionRequest() + beginCleanupBoundary() clearSession() setStartError(null) setRefreshError(null) From a714f86e49cecef73c4820cff151f7ebea98a714 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 14 Jun 2026 00:25:20 -0700 Subject: [PATCH 28/36] fix: harden notification event detail UX --- ui/src/features/clips/ClipDetailPage.test.tsx | 97 ++++++++++++++++++- ui/src/features/clips/ClipDetailPage.tsx | 69 ++++++++++--- ui/src/styles/global.css | 13 +++ 3 files changed, 161 insertions(+), 18 deletions(-) diff --git a/ui/src/features/clips/ClipDetailPage.test.tsx b/ui/src/features/clips/ClipDetailPage.test.tsx index 018be1ac..96a1c9e1 100644 --- a/ui/src/features/clips/ClipDetailPage.test.tsx +++ b/ui/src/features/clips/ClipDetailPage.test.tsx @@ -5,7 +5,7 @@ import { cleanup, render, screen, within } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MemoryRouter, Route, Routes } from 'react-router-dom' -import type { ClipListSnapshot } from '../../api/client' +import { APIError, type ClipListSnapshot } from '../../api/client' import type { ClipResponse } from '../../api/generated/types' import { QUERY_KEYS } from '../../api/hooks/queryKeys' import type { useClipMediaUrl } from '../../api/hooks/useClipMediaUrl' @@ -45,10 +45,14 @@ function renderDetail({ route = '/events/clip-2?detected=any', clip = makeClip('clip-2'), cachedClips, + clipQuery = {}, + mediaQuery = {}, }: { route?: string - clip?: ClipResponse + clip?: ClipResponse | undefined cachedClips?: ClipResponse[] + clipQuery?: Partial> + mediaQuery?: Partial> } = {}) { const queryClient = new QueryClient({ defaultOptions: { @@ -72,6 +76,7 @@ function renderDetail({ isFetching: false, error: null, refetch: vi.fn().mockResolvedValue(undefined), + ...clipQuery, } as unknown as ReturnType) useClipMediaUrlMock.mockReturnValue({ @@ -81,6 +86,7 @@ function renderDetail({ isPending: false, error: null, refresh: vi.fn().mockResolvedValue('/api/v1/clips/clip-2/media'), + ...mediaQuery, } as unknown as ReturnType) render( @@ -119,7 +125,7 @@ describe('ClipDetailPage', () => { expect(screen.getByRole('heading', { name: 'Package Drop' })).toBeTruthy() expect(screen.getByText('Package left near the front door.')).toBeTruthy() expect(screen.getByText('person, package')).toBeTruthy() - expect(screen.getByRole('link', { name: 'Back to events' }).getAttribute('href')).toBe( + expect(screen.getByRole('link', { name: 'Back to Events' }).getAttribute('href')).toBe( '/events?detected=any', ) expect(screen.getByRole('link', { name: 'Previous event' }).getAttribute('href')).toBe( @@ -145,4 +151,89 @@ describe('ClipDetailPage', () => { expect(previous.getAttribute('aria-disabled')).toBe('true') expect(next.getAttribute('aria-disabled')).toBe('true') }) + + it('opens notification deep links on the event detail page with a list fallback', () => { + // Given: iOS opens an event detail route from a notification + renderDetail({ + route: '/events/clip-2?from=notification', + }) + + // When: The event detail renders + const backToEvents = screen.getByRole('link', { name: 'Back to Events' }) + + // Then: The detail content opens and the list fallback drops notification-only routing state + expect(screen.getByRole('heading', { name: 'Package Drop' })).toBeTruthy() + expect(screen.getByText('Package left near the front door.')).toBeTruthy() + expect(backToEvents.getAttribute('href')).toBe('/events') + expect(useClipMediaUrlMock).toHaveBeenCalledWith('clip-2') + }) + + it('strips notification source state from neighbor navigation', () => { + // Given: A notification opens an event with cached neighboring events + renderDetail({ + route: '/events/clip-2?from=notification&detected=any', + cachedClips: [ + makeClip('clip-1', { summary: 'Earlier package event.' }), + makeClip('clip-2'), + makeClip('clip-3', { summary: 'Next package event.' }), + ], + }) + + // When: Navigating around the cached event window + const previous = screen.getByRole('link', { name: 'Previous event' }) + const next = screen.getByRole('link', { name: 'Next event' }) + + // Then: Neighbor routes preserve list filters without carrying notification-only source state + expect(previous.getAttribute('href')).toBe('/events/clip-1?detected=any') + expect(next.getAttribute('href')).toBe('/events/clip-3?detected=any') + }) + + it('shows a notification-specific fallback when a cached event no longer exists', () => { + // Given: A notification points at an event that the API no longer has but React Query has cached + const missingEventError = new APIError( + 'Clip not found', + 404, + { detail: 'Clip not found' }, + 'CLIP_NOT_FOUND', + ) + renderDetail({ + route: '/events/deleted-clip?from=notification', + clip: makeClip('deleted-clip', { summary: 'Stale cached summary.' }), + clipQuery: { + error: missingEventError, + }, + }) + + // When: The detail route handles the missing event response + const fallback = screen.getByRole('heading', { name: 'Event no longer available' }) + + // Then: The page stays useful instead of rendering a blank detail view + expect(fallback).toBeTruthy() + expect(screen.getByText(/opened from this notification is no longer available/i)).toBeTruthy() + expect(screen.getByRole('link', { name: 'Back to Events' }).getAttribute('href')).toBe('/events') + expect(screen.queryByText('Event video')).toBeNull() + expect(screen.queryByText('Stale cached summary.')).toBeNull() + expect(useClipMediaUrlMock).toHaveBeenCalledWith(undefined) + }) + + it('keeps event metadata visible when playback is unavailable', () => { + // Given: Event metadata loads but the media URL cannot be prepared + renderDetail({ + mediaQuery: { + mediaUrl: null, + error: new Error('Media file is missing'), + }, + }) + + // When: The detail page renders the unavailable playback state + const summary = screen.getByLabelText('Event summary') + + // Then: Review metadata remains visible alongside the playback problem + expect(screen.getByText('Event video is not available for playback.')).toBeTruthy() + expect(within(summary).getByRole('heading', { name: 'Package Drop' })).toBeTruthy() + expect(within(summary).getByText('Package left near the front door.')).toBeTruthy() + expect(within(summary).getByText('front_door')).toBeTruthy() + expect(within(summary).getByText('High')).toBeTruthy() + expect(within(summary).getByText('Media file is missing')).toBeTruthy() + }) }) diff --git a/ui/src/features/clips/ClipDetailPage.tsx b/ui/src/features/clips/ClipDetailPage.tsx index 5c255d2c..c58b1894 100644 --- a/ui/src/features/clips/ClipDetailPage.tsx +++ b/ui/src/features/clips/ClipDetailPage.tsx @@ -4,6 +4,7 @@ import { Link, useLocation, useParams } from 'react-router-dom' import { clearApiKey, + isAPIError, isUnauthorizedAPIError, saveApiKey, type ClipListSnapshot, @@ -42,11 +43,43 @@ interface NeighborEvents { } function eventDetailPath(clipId: string, routeSearch: string): string { - return `/events/${encodeURIComponent(clipId)}${routeSearch}` + return `/events/${encodeURIComponent(clipId)}${eventNavigationSearch(routeSearch)}` +} + +function eventNavigationSearch(routeSearch: string): string { + const params = new URLSearchParams(routeSearch) + params.delete('from') + const search = params.toString() + return search ? `?${search}` : '' } function eventListPath(routeSearch: string): string { - return `/events${routeSearch}` + return `/events${eventNavigationSearch(routeSearch)}` +} + +function isNotificationOpen(searchParams: URLSearchParams): boolean { + return searchParams.get('from') === 'notification' +} + +function isMissingEventError(error: unknown): boolean { + return isAPIError(error) && error.status === 404 +} + +function describeClipLoadError(error: unknown, openedFromNotification: boolean): string { + if (isMissingEventError(error)) { + return openedFromNotification + ? 'The event opened from this notification is no longer available. It may have been deleted or cleaned up.' + : 'This event is no longer available. It may have been deleted or cleaned up.' + } + + return describeClipError(error) +} + +function clipLoadErrorTitle(error: unknown, openedFromNotification: boolean): string { + if (isMissingEventError(error)) { + return 'Event no longer available' + } + return openedFromNotification ? 'Notification event could not load' : 'Event could not load' } function findClipWindow( @@ -92,15 +125,19 @@ export function ClipDetailPage() { const location = useLocation() const queryClient = useQueryClient() const clipQuery = useClipQuery(clipId) - const mediaQuery = useClipMediaUrl(clipId) + const missingEvent = isMissingEventError(clipQuery.error) + const clip = missingEvent ? undefined : clipQuery.data + const mediaQuery = useClipMediaUrl(clip?.id) + const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]) + const openedFromNotification = isNotificationOpen(searchParams) const unauthorized = isUnauthorizedAPIError(clipQuery.error) - const clip = clipQuery.data const externalLink = clip ? resolveClipExternalLink(clip) : null const viewUrlLink = clip ? resolveClipViewLink(clip) : null const externalStorageLink = externalLink && externalLink !== viewUrlLink ? externalLink : null + const backToEventsPath = eventListPath(location.search) const listQuery = useMemo( - () => parseClipsQuery(new URLSearchParams(location.search)), - [location.search], + () => parseClipsQuery(searchParams), + [searchParams], ) const clipWindow = useMemo( () => findClipWindow(queryClient, clipId, listQuery), @@ -159,9 +196,14 @@ export function ClipDetailPage() { {clip ? `${clip.camera} - ${formatTimestamp(clip.created_at)}` : 'Recorded security event'}

- +
+ + Back to Events + + +
{!clipId ? ( @@ -169,7 +211,7 @@ export function ClipDetailPage() { title="Invalid event request" description={( <> - Missing event ID. Return to events. + Missing event ID. Return to events. )} tone="error" @@ -196,8 +238,8 @@ export function ClipDetailPage() { {clipQuery.error && !unauthorized ? ( ) : null} @@ -252,9 +294,6 @@ export function ClipDetailPage() {

- - Back to events - {neighbors.previous ? ( Date: Sun, 14 Jun 2026 00:49:51 -0700 Subject: [PATCH 29/36] feat: add mobile device registry --- ...7b0b1fbfc69b_add_mobile_device_registry.py | 70 ++++++ src/homesec/models/mobile.py | 56 +++++ .../repository/mobile_device_repository.py | 191 ++++++++++++++++ src/homesec/state/postgres.py | 50 ++++ .../homesec/test_mobile_device_repository.py | 215 ++++++++++++++++++ 5 files changed, 582 insertions(+) create mode 100644 alembic/versions/7b0b1fbfc69b_add_mobile_device_registry.py create mode 100644 src/homesec/models/mobile.py create mode 100644 src/homesec/repository/mobile_device_repository.py create mode 100644 tests/homesec/test_mobile_device_repository.py diff --git a/alembic/versions/7b0b1fbfc69b_add_mobile_device_registry.py b/alembic/versions/7b0b1fbfc69b_add_mobile_device_registry.py new file mode 100644 index 00000000..32fc0948 --- /dev/null +++ b/alembic/versions/7b0b1fbfc69b_add_mobile_device_registry.py @@ -0,0 +1,70 @@ +"""add mobile device registry + +Revision ID: 7b0b1fbfc69b +Revises: d936851f725a +Create Date: 2026-06-14 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "7b0b1fbfc69b" +down_revision: str | None = "d936851f725a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "mobile_devices", + sa.Column("id", sa.Text(), nullable=False), + sa.Column("platform", sa.Text(), nullable=False), + sa.Column("apns_token_hash", sa.Text(), nullable=False), + sa.Column("apns_token_encrypted", sa.Text(), nullable=False), + sa.Column("apns_environment", sa.Text(), nullable=False), + sa.Column("bundle_id", sa.Text(), nullable=False), + sa.Column("device_name", sa.Text(), nullable=True), + sa.Column("app_version", sa.Text(), nullable=True), + sa.Column("enabled", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_push_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_push_error", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("apns_token_hash", name="uq_mobile_devices_apns_token_hash"), + ) + op.create_index("idx_mobile_devices_enabled", "mobile_devices", ["enabled"], unique=False) + op.create_index( + "idx_mobile_devices_platform_environment", + "mobile_devices", + ["platform", "apns_environment"], + unique=False, + ) + op.create_index( + "idx_mobile_devices_updated_at_desc", + "mobile_devices", + [sa.literal_column("updated_at DESC")], + unique=False, + ) + + +def downgrade() -> None: + op.drop_index("idx_mobile_devices_updated_at_desc", table_name="mobile_devices") + op.drop_index("idx_mobile_devices_platform_environment", table_name="mobile_devices") + op.drop_index("idx_mobile_devices_enabled", table_name="mobile_devices") + op.drop_table("mobile_devices") diff --git a/src/homesec/models/mobile.py b/src/homesec/models/mobile.py new file mode 100644 index 00000000..24d86edd --- /dev/null +++ b/src/homesec/models/mobile.py @@ -0,0 +1,56 @@ +"""Mobile device registration models.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, Field, field_validator + +MobilePlatform = Literal["ios"] +APNSEnvironment = Literal["sandbox", "production"] + + +class MobileDeviceRegistration(BaseModel): + """Registration payload for an iOS APNs device.""" + + platform: MobilePlatform = "ios" + apns_token: str = Field(min_length=1) + apns_environment: APNSEnvironment + bundle_id: str = Field(min_length=1) + device_name: str | None = None + app_version: str | None = None + + @field_validator("apns_token", "bundle_id") + @classmethod + def _strip_required_text(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("value must not be blank") + return normalized + + +class MobileDeviceUpdate(BaseModel): + """Mutable fields for a registered mobile device.""" + + device_name: str | None = None + app_version: str | None = None + enabled: bool | None = None + + +class MobileDeviceRecord(BaseModel): + """Public mobile device record without raw APNs registration material.""" + + id: str + platform: MobilePlatform + apns_environment: APNSEnvironment + bundle_id: str + device_name: str | None = None + app_version: str | None = None + enabled: bool + token_fingerprint: str + created_at: datetime + updated_at: datetime + last_seen_at: datetime | None = None + last_push_at: datetime | None = None + last_push_error: str | None = None diff --git a/src/homesec/repository/mobile_device_repository.py b/src/homesec/repository/mobile_device_repository.py new file mode 100644 index 00000000..be19a677 --- /dev/null +++ b/src/homesec/repository/mobile_device_repository.py @@ -0,0 +1,191 @@ +"""Repository for iOS mobile device registrations.""" + +from __future__ import annotations + +import hashlib +import secrets +from collections.abc import Mapping +from datetime import datetime, timezone +from typing import Any, cast + +from sqlalchemy import Table, select, update +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncEngine + +from homesec.models.mobile import MobileDeviceRecord, MobileDeviceRegistration, MobileDeviceUpdate +from homesec.state.postgres import MobileDevice + + +def hash_apns_token(apns_token: str) -> str: + """Return the stable lookup hash for an APNs token.""" + normalized = _normalize_apns_token(apns_token) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +class MobileDeviceRepository: + """Persistence boundary for mobile APNs device registrations.""" + + def __init__(self, engine: AsyncEngine) -> None: + self._engine = engine + + async def register_device( + self, + registration: MobileDeviceRegistration, + *, + now: datetime | None = None, + ) -> MobileDeviceRecord: + """Create or update a device by APNs token hash.""" + recorded_at = _utc_now() if now is None else now + token = _normalize_apns_token(registration.apns_token) + token_hash = hash_apns_token(token) + + table = cast(Table, MobileDevice.__table__) + insert_stmt = pg_insert(table).values( + id=_new_device_id(), + platform=registration.platform, + apns_token_hash=token_hash, + # No encryption utility exists yet. Keep the raw token confined to + # this internal column until the APNs sender ticket adds key management. + apns_token_encrypted=token, + apns_environment=registration.apns_environment, + bundle_id=registration.bundle_id, + device_name=registration.device_name, + app_version=registration.app_version, + enabled=True, + created_at=recorded_at, + updated_at=recorded_at, + last_seen_at=recorded_at, + ) + upsert_stmt = insert_stmt.on_conflict_do_update( + index_elements=[table.c.apns_token_hash], + set_={ + "platform": insert_stmt.excluded.platform, + "apns_token_encrypted": insert_stmt.excluded.apns_token_encrypted, + "apns_environment": insert_stmt.excluded.apns_environment, + "bundle_id": insert_stmt.excluded.bundle_id, + "device_name": insert_stmt.excluded.device_name, + "app_version": insert_stmt.excluded.app_version, + "updated_at": recorded_at, + "last_seen_at": recorded_at, + }, + ) + returning_stmt = upsert_stmt.returning(*_device_record_columns()) + + async with self._engine.begin() as conn: + row = cast(Mapping[str, Any], (await conn.execute(returning_stmt)).mappings().one()) + + return _device_record_from_mapping(row) + + async def get_device(self, device_id: str) -> MobileDeviceRecord | None: + """Return one mobile device record without raw APNs token material.""" + stmt = select(*_device_record_columns()).where(MobileDevice.id == device_id) + async with self._engine.connect() as conn: + row = cast( + Mapping[str, Any] | None, (await conn.execute(stmt)).mappings().one_or_none() + ) + if row is None: + return None + return _device_record_from_mapping(row) + + async def list_devices(self, *, include_disabled: bool = False) -> list[MobileDeviceRecord]: + """List mobile device records without raw APNs token material.""" + stmt = select(*_device_record_columns()) + if not include_disabled: + stmt = stmt.where(MobileDevice.enabled.is_(True)) + stmt = stmt.order_by(MobileDevice.updated_at.desc(), MobileDevice.id.asc()) + + async with self._engine.connect() as conn: + rows = (await conn.execute(stmt)).mappings().all() + + return [_device_record_from_mapping(cast(Mapping[str, Any], row)) for row in rows] + + async def update_device( + self, + device_id: str, + patch: MobileDeviceUpdate, + *, + now: datetime | None = None, + ) -> MobileDeviceRecord | None: + """Update mutable device metadata and enabled state.""" + changes = patch.model_dump(exclude_unset=True) + if changes.get("enabled") is None: + changes.pop("enabled", None) + if not changes: + return await self.get_device(device_id) + + changes["updated_at"] = _utc_now() if now is None else now + stmt = ( + update(MobileDevice) + .where(MobileDevice.id == device_id) + .values(**changes) + .returning(*_device_record_columns()) + ) + async with self._engine.begin() as conn: + row = cast( + Mapping[str, Any] | None, (await conn.execute(stmt)).mappings().one_or_none() + ) + if row is None: + return None + return _device_record_from_mapping(row) + + async def disable_device( + self, + device_id: str, + *, + now: datetime | None = None, + ) -> MobileDeviceRecord | None: + """Disable a mobile device without deleting its registration history.""" + return await self.update_device( + device_id, + MobileDeviceUpdate(enabled=False), + now=now, + ) + + +def _device_record_columns() -> tuple[Any, ...]: + return ( + MobileDevice.id, + MobileDevice.platform, + MobileDevice.apns_token_hash, + MobileDevice.apns_environment, + MobileDevice.bundle_id, + MobileDevice.device_name, + MobileDevice.app_version, + MobileDevice.enabled, + MobileDevice.created_at, + MobileDevice.updated_at, + MobileDevice.last_seen_at, + MobileDevice.last_push_at, + MobileDevice.last_push_error, + ) + + +def _device_record_from_mapping(row: Mapping[str, Any]) -> MobileDeviceRecord: + token_hash = str(row["apns_token_hash"]) + return MobileDeviceRecord( + id=str(row["id"]), + platform=row["platform"], + apns_environment=row["apns_environment"], + bundle_id=str(row["bundle_id"]), + device_name=row["device_name"], + app_version=row["app_version"], + enabled=bool(row["enabled"]), + token_fingerprint=token_hash[:12], + created_at=row["created_at"], + updated_at=row["updated_at"], + last_seen_at=row["last_seen_at"], + last_push_at=row["last_push_at"], + last_push_error=row["last_push_error"], + ) + + +def _normalize_apns_token(apns_token: str) -> str: + return apns_token.strip() + + +def _new_device_id() -> str: + return f"dev_{secrets.token_urlsafe(16)}" + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) diff --git a/src/homesec/state/postgres.py b/src/homesec/state/postgres.py index 56c35a55..5f9f7dd7 100644 --- a/src/homesec/state/postgres.py +++ b/src/homesec/state/postgres.py @@ -9,11 +9,13 @@ from sqlalchemy import ( BigInteger, + Boolean, DateTime, ForeignKey, Index, Table, Text, + UniqueConstraint, and_, func, or_, @@ -148,6 +150,47 @@ class ClipEvent(Base): ) +class MobileDevice(Base): + """Registered mobile device for APNs notification delivery.""" + + __tablename__ = "mobile_devices" + + id: Mapped[str] = mapped_column(Text, primary_key=True) + platform: Mapped[str] = mapped_column(Text, nullable=False) + apns_token_hash: Mapped[str] = mapped_column(Text, nullable=False) + apns_token_encrypted: Mapped[str] = mapped_column(Text, nullable=False) + apns_environment: Mapped[str] = mapped_column(Text, nullable=False) + bundle_id: Mapped[str] = mapped_column(Text, nullable=False) + device_name: Mapped[str | None] = mapped_column(Text, nullable=True) + app_version: Mapped[str | None] = mapped_column(Text, nullable=True) + enabled: Mapped[bool] = mapped_column( + Boolean, + server_default=text("true"), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_push_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + last_push_error: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint("apns_token_hash", name="uq_mobile_devices_apns_token_hash"), + Index("idx_mobile_devices_enabled", "enabled"), + Index("idx_mobile_devices_platform_environment", "platform", "apns_environment"), + Index("idx_mobile_devices_updated_at_desc", text("updated_at DESC")), + ) + + class PostgresStateStore(StateStore): """Postgres implementation of StateStore interface. @@ -193,6 +236,13 @@ async def initialize(self) -> bool: self._engine = None return False + @property + def engine(self) -> AsyncEngine: + """Return the initialized SQLAlchemy engine owned by this state store.""" + if self._engine is None: + raise RuntimeError("StateStore not initialized") + return self._engine + async def upsert(self, clip_id: str, data: ClipStateData) -> None: """Insert or update clip state. diff --git a/tests/homesec/test_mobile_device_repository.py b/tests/homesec/test_mobile_device_repository.py new file mode 100644 index 00000000..a8f1b979 --- /dev/null +++ b/tests/homesec/test_mobile_device_repository.py @@ -0,0 +1,215 @@ +"""Tests for mobile device registration repository.""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +import pytest +from pydantic import ValidationError +from sqlalchemy import select + +from homesec.models.mobile import MobileDeviceRegistration, MobileDeviceUpdate +from homesec.repository.mobile_device_repository import MobileDeviceRepository, hash_apns_token +from homesec.state.postgres import MobileDevice, PostgresStateStore + + +def _registration( + *, + apns_token: str = "raw-apns-token-123", + device_name: str = "Lev's iPhone", + app_version: str = "1.0.0", +) -> MobileDeviceRegistration: + return MobileDeviceRegistration( + apns_token=apns_token, + apns_environment="sandbox", + bundle_id="com.levneiman.homesec", + device_name=device_name, + app_version=app_version, + ) + + +def test_mobile_device_registration_rejects_blank_required_values() -> None: + # Given: A registration payload with blank APNs material + payload = { + "apns_token": " ", + "apns_environment": "sandbox", + "bundle_id": "com.levneiman.homesec", + } + + # When: Validating the payload + # Then: Validation rejects it before repository hashing + with pytest.raises(ValidationError): + MobileDeviceRegistration.model_validate(payload) + + +@pytest.mark.asyncio +async def test_register_device_creates_redacted_list_record( + postgres_dsn: str, + clean_test_db: None, +) -> None: + # Given: A mobile device repository backed by Postgres + state_store = PostgresStateStore(postgres_dsn) + await state_store.initialize() + repository = MobileDeviceRepository(state_store.engine) + registration = _registration() + + # When: Registering an iOS device + record = await repository.register_device(registration) + records = await repository.list_devices() + + # Then: The repository returns public device metadata without raw APNs material + assert record.id.startswith("dev_") + assert record.platform == "ios" + assert record.enabled is True + assert record.apns_environment == "sandbox" + assert record.bundle_id == "com.levneiman.homesec" + assert records == [record] + encoded = json.dumps(record.model_dump(mode="json"), sort_keys=True) + assert registration.apns_token not in encoded + assert "apns_token" not in encoded + + # And: The internal table stores a stable hash for dedupe + async with state_store.engine.connect() as conn: + row = ( + await conn.execute( + select(MobileDevice.apns_token_hash).where(MobileDevice.id == record.id) + ) + ).one() + assert row.apns_token_hash == hash_apns_token(registration.apns_token) + + await state_store.shutdown() + + +@pytest.mark.asyncio +async def test_register_device_dedupes_by_token_hash( + postgres_dsn: str, + clean_test_db: None, +) -> None: + # Given: An existing mobile device registration + state_store = PostgresStateStore(postgres_dsn) + await state_store.initialize() + repository = MobileDeviceRepository(state_store.engine) + first = await repository.register_device( + _registration(), + now=datetime(2026, 6, 14, 8, 0, tzinfo=timezone.utc), + ) + + # When: The same APNs token registers again with updated metadata + second = await repository.register_device( + _registration(device_name="Kitchen iPad", app_version="1.1.0"), + now=datetime(2026, 6, 14, 8, 5, tzinfo=timezone.utc), + ) + records = await repository.list_devices() + + # Then: The existing device row is updated instead of duplicated + assert second.id == first.id + assert second.device_name == "Kitchen iPad" + assert second.app_version == "1.1.0" + assert second.last_seen_at == datetime(2026, 6, 14, 8, 5, tzinfo=timezone.utc) + assert records == [second] + + await state_store.shutdown() + + +@pytest.mark.asyncio +async def test_disable_device_hides_record_without_deleting_it( + postgres_dsn: str, + clean_test_db: None, +) -> None: + # Given: A registered mobile device + state_store = PostgresStateStore(postgres_dsn) + await state_store.initialize() + repository = MobileDeviceRepository(state_store.engine) + registered = await repository.register_device(_registration()) + + # When: Disabling the device + disabled = await repository.disable_device(registered.id) + visible_records = await repository.list_devices() + all_records = await repository.list_devices(include_disabled=True) + + # Then: Default listing hides it while retaining disabled history + assert disabled is not None + assert disabled.enabled is False + assert visible_records == [] + assert all_records == [disabled] + + await state_store.shutdown() + + +@pytest.mark.asyncio +async def test_reregistering_disabled_device_preserves_disabled_state( + postgres_dsn: str, + clean_test_db: None, +) -> None: + # Given: A disabled mobile device registration + state_store = PostgresStateStore(postgres_dsn) + await state_store.initialize() + repository = MobileDeviceRepository(state_store.engine) + registered = await repository.register_device(_registration()) + await repository.disable_device(registered.id) + + # When: The app registers the same APNs token again + reregistered = await repository.register_device( + _registration(device_name="Renamed iPhone"), + now=datetime.now(timezone.utc) + timedelta(minutes=5), + ) + + # Then: Startup registration updates metadata without silently re-enabling push + assert reregistered.id == registered.id + assert reregistered.device_name == "Renamed iPhone" + assert reregistered.enabled is False + + await state_store.shutdown() + + +@pytest.mark.asyncio +async def test_update_device_can_reenable_disabled_device( + postgres_dsn: str, + clean_test_db: None, +) -> None: + # Given: A disabled mobile device + state_store = PostgresStateStore(postgres_dsn) + await state_store.initialize() + repository = MobileDeviceRepository(state_store.engine) + registered = await repository.register_device(_registration()) + await repository.disable_device(registered.id) + + # When: Updating the device enabled state explicitly + updated = await repository.update_device( + registered.id, + MobileDeviceUpdate(enabled=True, device_name="Front Door iPhone"), + ) + + # Then: The device returns to default listings with updated metadata + assert updated is not None + assert updated.enabled is True + assert updated.device_name == "Front Door iPhone" + assert await repository.list_devices() == [updated] + + await state_store.shutdown() + + +@pytest.mark.asyncio +async def test_update_device_ignores_null_enabled_patch( + postgres_dsn: str, + clean_test_db: None, +) -> None: + # Given: A registered mobile device + state_store = PostgresStateStore(postgres_dsn) + await state_store.initialize() + repository = MobileDeviceRepository(state_store.engine) + registered = await repository.register_device(_registration()) + + # When: A partial update carries enabled=None + updated = await repository.update_device( + registered.id, + MobileDeviceUpdate(enabled=None, app_version="1.2.0"), + ) + + # Then: The nullable patch value is ignored rather than writing NULL + assert updated is not None + assert updated.enabled is True + assert updated.app_version == "1.2.0" + + await state_store.shutdown() From 63be5d2a21de07a36b9aecc196f2071dbcffe49e Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 14 Jun 2026 01:08:25 -0700 Subject: [PATCH 30/36] feat: add mobile device API routes --- ...ef73f2d4_add_mobile_device_capabilities.py | 35 ++ src/homesec/api/errors.py | 1 + src/homesec/api/routes/__init__.py | 9 + src/homesec/api/routes/mobile.py | 173 +++++++ src/homesec/app.py | 15 + src/homesec/models/mobile.py | 10 + .../repository/mobile_device_repository.py | 15 +- src/homesec/state/postgres.py | 5 + tests/homesec/test_api_bootstrap_matrix.py | 97 +++- tests/homesec/test_api_openapi_export.py | 4 + tests/homesec/test_api_routes.py | 232 +++++++++ .../homesec/test_mobile_device_repository.py | 16 +- ui/src/api/generated/openapi.json | 464 ++++++++++++++++++ ui/src/api/generated/schema.ts | 316 ++++++++++++ 14 files changed, 1387 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/2e87ef73f2d4_add_mobile_device_capabilities.py create mode 100644 src/homesec/api/routes/mobile.py diff --git a/alembic/versions/2e87ef73f2d4_add_mobile_device_capabilities.py b/alembic/versions/2e87ef73f2d4_add_mobile_device_capabilities.py new file mode 100644 index 00000000..1646e263 --- /dev/null +++ b/alembic/versions/2e87ef73f2d4_add_mobile_device_capabilities.py @@ -0,0 +1,35 @@ +"""add mobile device capabilities + +Revision ID: 2e87ef73f2d4 +Revises: 7b0b1fbfc69b +Create Date: 2026-06-14 00:00:01.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "2e87ef73f2d4" +down_revision: str | None = "7b0b1fbfc69b" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "mobile_devices", + sa.Column( + "capabilities", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + ) + + +def downgrade() -> None: + op.drop_column("mobile_devices", "capabilities") diff --git a/src/homesec/api/errors.py b/src/homesec/api/errors.py index 8db01643..60f35511 100644 --- a/src/homesec/api/errors.py +++ b/src/homesec/api/errors.py @@ -59,6 +59,7 @@ class APIErrorCode(StrEnum): CLIPS_CURSOR_INVALID = "CLIPS_CURSOR_INVALID" CLIPS_TIME_RANGE_INVALID = "CLIPS_TIME_RANGE_INVALID" CLIPS_TIMESTAMP_TZ_REQUIRED = "CLIPS_TIMESTAMP_TZ_REQUIRED" + MOBILE_DEVICE_NOT_FOUND = "MOBILE_DEVICE_NOT_FOUND" RELOAD_IN_PROGRESS = "RELOAD_IN_PROGRESS" BACKUP_DISABLED = "BACKUP_DISABLED" BACKUP_UNAVAILABLE = "BACKUP_UNAVAILABLE" diff --git a/src/homesec/api/routes/__init__.py b/src/homesec/api/routes/__init__.py index 9f7f0e4b..772eafac 100644 --- a/src/homesec/api/routes/__init__.py +++ b/src/homesec/api/routes/__init__.py @@ -17,6 +17,7 @@ health, maintenance, media, + mobile, onvif, preview, runtime, @@ -75,6 +76,14 @@ def register_routes(app: FastAPI) -> None: Depends(require_database), ], ) + app.include_router( + mobile.router, + dependencies=[ + Depends(verify_api_key), + Depends(require_normal_mode), + Depends(require_database), + ], + ) app.include_router( runtime.router, dependencies=[Depends(verify_api_key), Depends(require_normal_mode)], diff --git a/src/homesec/api/routes/mobile.py b/src/homesec/api/routes/mobile.py new file mode 100644 index 00000000..5de3ba90 --- /dev/null +++ b/src/homesec/api/routes/mobile.py @@ -0,0 +1,173 @@ +"""Mobile device registration endpoints.""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from fastapi import APIRouter, Depends, status +from pydantic import BaseModel, Field, field_validator + +from homesec.api.dependencies import get_homesec_app +from homesec.api.errors import APIError, APIErrorCode +from homesec.models.mobile import ( + APNSEnvironment, + MobileDeviceCapabilities, + MobileDeviceRecord, + MobileDeviceRegistration, + MobileDeviceUpdate, + MobilePlatform, +) + +if TYPE_CHECKING: + from homesec.app import Application + +router = APIRouter(tags=["mobile"]) + + +class MobileDeviceRegisterRequest(BaseModel): + platform: MobilePlatform = "ios" + apns_token: str = Field(min_length=1) + environment: APNSEnvironment + bundle_id: str = Field(min_length=1) + device_name: str | None = None + app_version: str | None = None + capabilities: MobileDeviceCapabilities = Field(default_factory=MobileDeviceCapabilities) + + @field_validator("apns_token", "bundle_id") + @classmethod + def _strip_required_text(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("value must not be blank") + return normalized + + +class MobileDevicePatchRequest(BaseModel): + device_name: str | None = None + app_version: str | None = None + enabled: bool | None = None + capabilities: MobileDeviceCapabilities | None = None + + +class MobileDeviceResponse(BaseModel): + id: str + platform: MobilePlatform + environment: APNSEnvironment + bundle_id: str + device_name: str | None = None + app_version: str | None = None + capabilities: MobileDeviceCapabilities + enabled: bool + token_fingerprint: str + created_at: datetime + updated_at: datetime + last_seen_at: datetime | None = None + last_push_at: datetime | None = None + last_push_error: str | None = None + + +class MobileNotificationTestResponse(BaseModel): + sent: bool + reason: str + + +def _device_response(record: MobileDeviceRecord) -> MobileDeviceResponse: + return MobileDeviceResponse( + id=record.id, + platform=record.platform, + environment=record.apns_environment, + bundle_id=record.bundle_id, + device_name=record.device_name, + app_version=record.app_version, + capabilities=record.capabilities, + enabled=record.enabled, + token_fingerprint=record.token_fingerprint, + created_at=record.created_at, + updated_at=record.updated_at, + last_seen_at=record.last_seen_at, + last_push_at=record.last_push_at, + last_push_error=record.last_push_error, + ) + + +@router.post( + "/api/v1/mobile/devices", + response_model=MobileDeviceResponse, + status_code=status.HTTP_201_CREATED, +) +async def register_mobile_device( + payload: MobileDeviceRegisterRequest, + app: Application = Depends(get_homesec_app), +) -> MobileDeviceResponse: + """Register or refresh an iOS APNs device.""" + record = await app.mobile_devices.register_device( + MobileDeviceRegistration( + platform=payload.platform, + apns_token=payload.apns_token, + apns_environment=payload.environment, + bundle_id=payload.bundle_id, + device_name=payload.device_name, + app_version=payload.app_version, + capabilities=payload.capabilities, + ) + ) + return _device_response(record) + + +@router.get("/api/v1/mobile/devices", response_model=list[MobileDeviceResponse]) +async def list_mobile_devices( + include_disabled: bool = False, + app: Application = Depends(get_homesec_app), +) -> list[MobileDeviceResponse]: + """List registered iOS devices without raw APNs material.""" + records = await app.mobile_devices.list_devices(include_disabled=include_disabled) + return [_device_response(record) for record in records] + + +@router.patch("/api/v1/mobile/devices/{device_id}", response_model=MobileDeviceResponse) +async def update_mobile_device( + device_id: str, + payload: MobileDevicePatchRequest, + app: Application = Depends(get_homesec_app), +) -> MobileDeviceResponse: + """Update mutable mobile device metadata or enabled state.""" + record = await app.mobile_devices.update_device( + device_id, + MobileDeviceUpdate.model_validate(payload.model_dump(exclude_unset=True)), + ) + if record is None: + raise APIError( + "Mobile device not found", + status_code=status.HTTP_404_NOT_FOUND, + error_code=APIErrorCode.MOBILE_DEVICE_NOT_FOUND, + ) + return _device_response(record) + + +@router.delete("/api/v1/mobile/devices/{device_id}", response_model=MobileDeviceResponse) +async def delete_mobile_device( + device_id: str, + app: Application = Depends(get_homesec_app), +) -> MobileDeviceResponse: + """Disable a mobile device without hard-deleting it.""" + record = await app.mobile_devices.disable_device(device_id) + if record is None: + raise APIError( + "Mobile device not found", + status_code=status.HTTP_404_NOT_FOUND, + error_code=APIErrorCode.MOBILE_DEVICE_NOT_FOUND, + ) + return _device_response(record) + + +@router.post( + "/api/v1/mobile/notifications/test", + response_model=MobileNotificationTestResponse, +) +async def test_mobile_notification() -> MobileNotificationTestResponse: + """Stub test notification endpoint until APNs notifier support lands.""" + return MobileNotificationTestResponse( + sent=False, + reason="APNs mobile notifier is not configured yet", + ) diff --git a/src/homesec/app.py b/src/homesec/app.py index dca3e418..0737a70e 100644 --- a/src/homesec/app.py +++ b/src/homesec/app.py @@ -50,6 +50,7 @@ from homesec.interfaces import EventStore, StateStore, StorageBackend from homesec.models.config import Config from homesec.repository import ClipRepository + from homesec.repository.mobile_device_repository import MobileDeviceRepository logger = logging.getLogger(__name__) RESTART_EXIT_CODE = 42 @@ -95,6 +96,7 @@ def __init__( self._state_store: StateStore = NoopStateStore() self._event_store: EventStore = NoopEventStore() self._repository: ClipRepository | None = None + self._mobile_device_repository: MobileDeviceRepository | None = None self._postgres_backup_manager: PostgresBackupManager | None = None self._api_server: APIServer | None = None self._runtime_manager: RuntimeManager | None = None @@ -530,6 +532,19 @@ def repository(self) -> ClipRepository: raise RuntimeError("Repository not initialized") return self._repository + @property + def mobile_devices(self) -> MobileDeviceRepository: + if self._mobile_device_repository is not None: + return self._mobile_device_repository + + from homesec.repository.mobile_device_repository import MobileDeviceRepository + from homesec.state.postgres import PostgresStateStore + + if not isinstance(self._state_store, PostgresStateStore): + raise RuntimeError("Mobile device repository requires initialized Postgres state store") + self._mobile_device_repository = MobileDeviceRepository(self._state_store.engine) + return self._mobile_device_repository + @property def storage(self) -> StorageBackend: if self._storage is None: diff --git a/src/homesec/models/mobile.py b/src/homesec/models/mobile.py index 24d86edd..f59ff512 100644 --- a/src/homesec/models/mobile.py +++ b/src/homesec/models/mobile.py @@ -11,6 +11,13 @@ APNSEnvironment = Literal["sandbox", "production"] +class MobileDeviceCapabilities(BaseModel): + """Feature flags reported by the current iOS app build.""" + + deep_links: bool = True + rich_notifications: bool = False + + class MobileDeviceRegistration(BaseModel): """Registration payload for an iOS APNs device.""" @@ -20,6 +27,7 @@ class MobileDeviceRegistration(BaseModel): bundle_id: str = Field(min_length=1) device_name: str | None = None app_version: str | None = None + capabilities: MobileDeviceCapabilities = Field(default_factory=MobileDeviceCapabilities) @field_validator("apns_token", "bundle_id") @classmethod @@ -36,6 +44,7 @@ class MobileDeviceUpdate(BaseModel): device_name: str | None = None app_version: str | None = None enabled: bool | None = None + capabilities: MobileDeviceCapabilities | None = None class MobileDeviceRecord(BaseModel): @@ -47,6 +56,7 @@ class MobileDeviceRecord(BaseModel): bundle_id: str device_name: str | None = None app_version: str | None = None + capabilities: MobileDeviceCapabilities = Field(default_factory=MobileDeviceCapabilities) enabled: bool token_fingerprint: str created_at: datetime diff --git a/src/homesec/repository/mobile_device_repository.py b/src/homesec/repository/mobile_device_repository.py index be19a677..8b561999 100644 --- a/src/homesec/repository/mobile_device_repository.py +++ b/src/homesec/repository/mobile_device_repository.py @@ -12,7 +12,12 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncEngine -from homesec.models.mobile import MobileDeviceRecord, MobileDeviceRegistration, MobileDeviceUpdate +from homesec.models.mobile import ( + MobileDeviceCapabilities, + MobileDeviceRecord, + MobileDeviceRegistration, + MobileDeviceUpdate, +) from homesec.state.postgres import MobileDevice @@ -51,6 +56,7 @@ async def register_device( bundle_id=registration.bundle_id, device_name=registration.device_name, app_version=registration.app_version, + capabilities=registration.capabilities.model_dump(mode="json"), enabled=True, created_at=recorded_at, updated_at=recorded_at, @@ -65,6 +71,7 @@ async def register_device( "bundle_id": insert_stmt.excluded.bundle_id, "device_name": insert_stmt.excluded.device_name, "app_version": insert_stmt.excluded.app_version, + "capabilities": insert_stmt.excluded.capabilities, "updated_at": recorded_at, "last_seen_at": recorded_at, }, @@ -107,9 +114,11 @@ async def update_device( now: datetime | None = None, ) -> MobileDeviceRecord | None: """Update mutable device metadata and enabled state.""" - changes = patch.model_dump(exclude_unset=True) + changes = patch.model_dump(exclude_unset=True, mode="json") if changes.get("enabled") is None: changes.pop("enabled", None) + if changes.get("capabilities") is None: + changes.pop("capabilities", None) if not changes: return await self.get_device(device_id) @@ -151,6 +160,7 @@ def _device_record_columns() -> tuple[Any, ...]: MobileDevice.bundle_id, MobileDevice.device_name, MobileDevice.app_version, + MobileDevice.capabilities, MobileDevice.enabled, MobileDevice.created_at, MobileDevice.updated_at, @@ -169,6 +179,7 @@ def _device_record_from_mapping(row: Mapping[str, Any]) -> MobileDeviceRecord: bundle_id=str(row["bundle_id"]), device_name=row["device_name"], app_version=row["app_version"], + capabilities=MobileDeviceCapabilities.model_validate(row["capabilities"] or {}), enabled=bool(row["enabled"]), token_fingerprint=token_hash[:12], created_at=row["created_at"], diff --git a/src/homesec/state/postgres.py b/src/homesec/state/postgres.py index 5f9f7dd7..4f92f588 100644 --- a/src/homesec/state/postgres.py +++ b/src/homesec/state/postgres.py @@ -163,6 +163,11 @@ class MobileDevice(Base): bundle_id: Mapped[str] = mapped_column(Text, nullable=False) device_name: Mapped[str | None] = mapped_column(Text, nullable=True) app_version: Mapped[str | None] = mapped_column(Text, nullable=True) + capabilities: Mapped[dict[str, Any]] = mapped_column( + JSONB, + server_default=text("'{}'::jsonb"), + nullable=False, + ) enabled: Mapped[bool] = mapped_column( Boolean, server_default=text("true"), diff --git a/tests/homesec/test_api_bootstrap_matrix.py b/tests/homesec/test_api_bootstrap_matrix.py index 8a45dea8..8e0b06f0 100644 --- a/tests/homesec/test_api_bootstrap_matrix.py +++ b/tests/homesec/test_api_bootstrap_matrix.py @@ -78,7 +78,7 @@ async def force_stop_camera_preview(self, camera_name: str) -> CameraPreviewStop @dataclass(frozen=True) class _MatrixCase: name: str - method: Literal["GET", "POST", "DELETE"] + method: Literal["GET", "POST", "PATCH", "DELETE"] path: str auth_enabled: bool db_ok: bool @@ -141,6 +141,9 @@ def _build_client(tmp_path: Path, case: _MatrixCase) -> tuple[TestClient, _StubR def _send_request(client: TestClient, case: _MatrixCase, headers: dict[str, str]): if case.method == "GET": return client.get(case.path, headers=headers) + if case.method == "PATCH": + request_json = case.request_json if case.request_json is not None else {} + return client.patch(case.path, headers=headers, json=request_json) if case.method == "DELETE": return client.delete(case.path, headers=headers) request_json = case.request_json if case.request_json is not None else {} @@ -630,6 +633,98 @@ def _send_request(client: TestClient, case: _MatrixCase, headers: dict[str, str] bootstrap_mode=True, expected_error_code="SETUP_REQUIRED", ), + _MatrixCase( + name="mobile_device_register_requires_api_key_when_auth_enabled", + method="POST", + path="/api/v1/mobile/devices", + auth_enabled=True, + db_ok=True, + pipeline_running=True, + auth_header=None, + include_clip=False, + expected_status=401, + expected_error_code="UNAUTHORIZED", + request_json={ + "platform": "ios", + "apns_token": "token", + "environment": "sandbox", + "bundle_id": "com.levneiman.homesec", + }, + ), + _MatrixCase( + name="mobile_device_list_requires_api_key_when_auth_enabled", + method="GET", + path="/api/v1/mobile/devices", + auth_enabled=True, + db_ok=True, + pipeline_running=True, + auth_header=None, + include_clip=False, + expected_status=401, + expected_error_code="UNAUTHORIZED", + ), + _MatrixCase( + name="mobile_device_patch_requires_api_key_when_auth_enabled", + method="PATCH", + path="/api/v1/mobile/devices/dev_1", + auth_enabled=True, + db_ok=True, + pipeline_running=True, + auth_header=None, + include_clip=False, + expected_status=401, + expected_error_code="UNAUTHORIZED", + request_json={"enabled": False}, + ), + _MatrixCase( + name="mobile_device_delete_requires_api_key_when_auth_enabled", + method="DELETE", + path="/api/v1/mobile/devices/dev_1", + auth_enabled=True, + db_ok=True, + pipeline_running=True, + auth_header=None, + include_clip=False, + expected_status=401, + expected_error_code="UNAUTHORIZED", + ), + _MatrixCase( + name="mobile_notification_test_requires_api_key_when_auth_enabled", + method="POST", + path="/api/v1/mobile/notifications/test", + auth_enabled=True, + db_ok=True, + pipeline_running=True, + auth_header=None, + include_clip=False, + expected_status=401, + expected_error_code="UNAUTHORIZED", + ), + _MatrixCase( + name="mobile_device_list_requires_db_when_repository_unavailable", + method="GET", + path="/api/v1/mobile/devices", + auth_enabled=True, + db_ok=False, + pipeline_running=True, + auth_header="Bearer secret", + include_clip=False, + expected_status=503, + expected_error_code="DB_UNAVAILABLE", + ), + _MatrixCase( + name="mobile_device_list_is_blocked_in_bootstrap_mode", + method="GET", + path="/api/v1/mobile/devices", + auth_enabled=False, + db_ok=False, + pipeline_running=False, + auth_header=None, + include_clip=False, + expected_status=503, + bootstrap_mode=True, + expected_error_code="SETUP_REQUIRED", + ), _MatrixCase( name="media_route_rejects_missing_media_auth", method="GET", diff --git a/tests/homesec/test_api_openapi_export.py b/tests/homesec/test_api_openapi_export.py index 4c233158..6a61456f 100644 --- a/tests/homesec/test_api_openapi_export.py +++ b/tests/homesec/test_api_openapi_export.py @@ -19,7 +19,9 @@ def test_build_openapi_schema_includes_health_route() -> None: # Then: Versioned health route and response schema should be present assert "/api/v1/health" in schema["paths"] + assert "/api/v1/mobile/devices" in schema["paths"] assert "HealthResponse" in schema["components"]["schemas"] + assert "MobileDeviceResponse" in schema["components"]["schemas"] def test_write_openapi_schema_writes_deterministic_json(tmp_path: Path) -> None: @@ -34,6 +36,7 @@ def test_write_openapi_schema_writes_deterministic_json(tmp_path: Path) -> None: assert text.endswith("\n") payload = json.loads(text) assert "/api/v1/health" in payload["paths"] + assert "/api/v1/mobile/devices" in payload["paths"] def test_openapi_export_parser_requires_output() -> None: @@ -67,3 +70,4 @@ def test_openapi_export_main_writes_requested_output( # Then: output file is created with expected API paths payload = json.loads(output_path.read_text(encoding="utf-8")) assert "/api/v1/health" in payload["paths"] + assert "/api/v1/mobile/devices" in payload["paths"] diff --git a/tests/homesec/test_api_routes.py b/tests/homesec/test_api_routes.py index ef37ca2b..5e708658 100644 --- a/tests/homesec/test_api_routes.py +++ b/tests/homesec/test_api_routes.py @@ -4,6 +4,7 @@ import asyncio import datetime as dt +import json import time from collections.abc import Callable from pathlib import Path @@ -24,7 +25,13 @@ from homesec.models.config import CameraConfig, CameraSourceConfig, FastAPIServerConfig from homesec.models.enums import ClipStatus, RiskLevel from homesec.models.filter import FilterResult +from homesec.models.mobile import ( + MobileDeviceRecord, + MobileDeviceRegistration, + MobileDeviceUpdate, +) from homesec.models.vlm import AnalysisResult +from homesec.repository.mobile_device_repository import hash_apns_token from homesec.runtime.errors import RuntimeReloadConfigError from homesec.runtime.models import RuntimeReloadRequest from tests.homesec.ui_dist_stub import ensure_stub_ui_dist @@ -166,6 +173,77 @@ def last_heartbeat(self) -> float: return self._heartbeat +class _StubMobileDevices: + def __init__(self) -> None: + self._records_by_hash: dict[str, MobileDeviceRecord] = {} + self._token_hash_by_id: dict[str, str] = {} + self.register_calls: list[MobileDeviceRegistration] = [] + self.update_calls: list[tuple[str, MobileDeviceUpdate]] = [] + self.disable_calls: list[str] = [] + + async def register_device( + self, + registration: MobileDeviceRegistration, + ) -> MobileDeviceRecord: + self.register_calls.append(registration) + token_hash = hash_apns_token(registration.apns_token) + existing = self._records_by_hash.get(token_hash) + now = dt.datetime(2026, 6, 14, tzinfo=dt.timezone.utc) + dt.timedelta( + minutes=len(self.register_calls) + ) + record = MobileDeviceRecord( + id=existing.id if existing is not None else f"dev_{len(self._records_by_hash) + 1}", + platform=registration.platform, + apns_environment=registration.apns_environment, + bundle_id=registration.bundle_id, + device_name=registration.device_name, + app_version=registration.app_version, + capabilities=registration.capabilities, + enabled=existing.enabled if existing is not None else True, + token_fingerprint=token_hash[:12], + created_at=existing.created_at if existing is not None else now, + updated_at=now, + last_seen_at=now, + last_push_at=existing.last_push_at if existing is not None else None, + last_push_error=existing.last_push_error if existing is not None else None, + ) + self._records_by_hash[token_hash] = record + self._token_hash_by_id[record.id] = token_hash + return record + + async def list_devices(self, *, include_disabled: bool = False) -> list[MobileDeviceRecord]: + records = list(self._records_by_hash.values()) + if not include_disabled: + records = [record for record in records if record.enabled] + return records + + async def update_device( + self, + device_id: str, + patch: MobileDeviceUpdate, + ) -> MobileDeviceRecord | None: + self.update_calls.append((device_id, patch)) + token_hash = self._token_hash_by_id.get(device_id) + if token_hash is None: + return None + existing = self._records_by_hash[token_hash] + changes = patch.model_dump(exclude_unset=True, mode="json") + data = existing.model_dump() + data.update(changes) + data["updated_at"] = _mobile_now() + record = MobileDeviceRecord.model_validate(data) + self._records_by_hash[token_hash] = record + return record + + async def disable_device(self, device_id: str) -> MobileDeviceRecord | None: + self.disable_calls.append(device_id) + return await self.update_device(device_id, MobileDeviceUpdate(enabled=False)) + + +def _mobile_now() -> dt.datetime: + return dt.datetime(2026, 6, 14, 1, tzinfo=dt.timezone.utc) + + class _StubApp: def __init__( self, @@ -179,6 +257,7 @@ def __init__( bootstrap_mode: bool = False, runtime_reload_request: RuntimeReloadRequest | None = None, runtime_reload_error: Exception | None = None, + mobile_devices: _StubMobileDevices | None = None, ) -> None: self.config_manager = config_manager self.repository = repository @@ -211,6 +290,7 @@ def __init__( self.restart_requested = False self.uptime_seconds = 0.0 self._setup_test_connection_lock = asyncio.Lock() + self.mobile_devices = mobile_devices or _StubMobileDevices() @property def config(self): # type: ignore[override] @@ -2555,6 +2635,158 @@ def test_diagnostics_reports_unhealthy_when_pipeline_stopped(tmp_path) -> None: assert payload["status"] == "unhealthy" +def _mobile_device_payload(*, token: str = "raw-apns-token") -> dict[str, object]: + return { + "platform": "ios", + "apns_token": token, + "environment": "sandbox", + "bundle_id": "com.levneiman.homesec", + "device_name": "Lev's iPhone", + "app_version": "1.0.0", + "capabilities": {"deep_links": True, "rich_notifications": False}, + } + + +def test_register_mobile_device_creates_or_updates_redacted_record(tmp_path) -> None: + """POST /mobile/devices should upsert an iOS APNs registration.""" + # Given: A configured app with mobile device persistence + manager = _write_config(tmp_path, cameras=[]) + mobile_devices = _StubMobileDevices() + app = _StubApp( + config_manager=manager, + repository=_StubRepository(), + storage=_StubStorage(), + mobile_devices=mobile_devices, + ) + client = _client(app) + + # When: Registering a device with APNs material + response = client.post("/api/v1/mobile/devices", json=_mobile_device_payload()) + + # Then: The API returns redacted metadata and stores the registration + assert response.status_code == 201 + payload = response.json() + assert payload["id"] == "dev_1" + assert payload["platform"] == "ios" + assert payload["environment"] == "sandbox" + assert payload["bundle_id"] == "com.levneiman.homesec" + assert payload["capabilities"] == {"deep_links": True, "rich_notifications": False} + assert "raw-apns-token" not in json.dumps(payload, sort_keys=True) + assert "apns_token" not in payload + assert len(mobile_devices.register_calls) == 1 + + # When: Registering the same APNs token with updated metadata + second_payload = _mobile_device_payload() + second_payload["device_name"] = "Kitchen iPad" + second = client.post("/api/v1/mobile/devices", json=second_payload) + + # Then: The existing device record is updated instead of duplicated + assert second.status_code == 201 + assert second.json()["id"] == "dev_1" + assert second.json()["device_name"] == "Kitchen iPad" + assert client.get("/api/v1/mobile/devices").json()[0]["device_name"] == "Kitchen iPad" + + +def test_patch_mobile_device_updates_only_sent_fields(tmp_path) -> None: + """PATCH /mobile/devices/{id} should preserve omitted fields.""" + # Given: A registered mobile device + manager = _write_config(tmp_path, cameras=[]) + mobile_devices = _StubMobileDevices() + app = _StubApp( + config_manager=manager, + repository=_StubRepository(), + storage=_StubStorage(), + mobile_devices=mobile_devices, + ) + client = _client(app) + created = client.post("/api/v1/mobile/devices", json=_mobile_device_payload()).json() + + # When: Patching only enabled state + response = client.patch(f"/api/v1/mobile/devices/{created['id']}", json={"enabled": False}) + + # Then: Existing metadata is preserved and the record is disabled + assert response.status_code == 200 + payload = response.json() + assert payload["enabled"] is False + assert payload["device_name"] == "Lev's iPhone" + assert payload["app_version"] == "1.0.0" + assert mobile_devices.update_calls[-1][1].model_fields_set == {"enabled"} + + # When: Patching the iOS capability flags reported by the app + capabilities_response = client.patch( + f"/api/v1/mobile/devices/{created['id']}", + json={"capabilities": {"deep_links": True, "rich_notifications": True}}, + ) + + # Then: The API preserves the nested capability patch + assert capabilities_response.status_code == 200 + assert capabilities_response.json()["capabilities"] == { + "deep_links": True, + "rich_notifications": True, + } + assert mobile_devices.update_calls[-1][1].model_fields_set == {"capabilities"} + + +def test_delete_mobile_device_disables_without_hard_delete(tmp_path) -> None: + """DELETE /mobile/devices/{id} should soft-disable the device.""" + # Given: A registered mobile device + manager = _write_config(tmp_path, cameras=[]) + app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage()) + client = _client(app) + created = client.post("/api/v1/mobile/devices", json=_mobile_device_payload()).json() + + # When: Deleting the device + response = client.delete(f"/api/v1/mobile/devices/{created['id']}") + + # Then: The device is disabled and hidden from default listings + assert response.status_code == 200 + assert response.json()["enabled"] is False + assert client.get("/api/v1/mobile/devices").json() == [] + all_devices = client.get("/api/v1/mobile/devices?include_disabled=true").json() + assert all_devices[0]["id"] == created["id"] + assert all_devices[0]["enabled"] is False + + +def test_mobile_device_missing_returns_404(tmp_path) -> None: + """Mobile device mutation routes should report missing device ids.""" + # Given: A configured app with no registered mobile devices + manager = _write_config(tmp_path, cameras=[]) + app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage()) + client = _client(app) + + # When: Updating a missing device + patch_response = client.patch("/api/v1/mobile/devices/dev_missing", json={"enabled": False}) + + # Then: The route returns a canonical not-found error + assert patch_response.status_code == 404 + assert patch_response.json()["error_code"] == "MOBILE_DEVICE_NOT_FOUND" + + # When: Deleting a missing device + delete_response = client.delete("/api/v1/mobile/devices/dev_missing") + + # Then: The route returns the same canonical not-found error + assert delete_response.status_code == 404 + assert delete_response.json()["error_code"] == "MOBILE_DEVICE_NOT_FOUND" + + +def test_mobile_notification_test_route_is_stubbed(tmp_path) -> None: + """POST /mobile/notifications/test should expose the current APNs stub.""" + # Given: A configured app before APNs notifier implementation + manager = _write_config(tmp_path, cameras=[]) + app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage()) + client = _client(app) + + # When: Calling the mobile notification test route + response = client.post("/api/v1/mobile/notifications/test") + + # Then: The route is available but reports that APNs delivery is not wired yet + assert response.status_code == 200 + assert response.json() == { + "sent": False, + "reason": "APNs mobile notifier is not configured yet", + } + + def test_auth_required_when_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: """Auth should be enforced for non-public endpoints.""" # Given auth is enabled diff --git a/tests/homesec/test_mobile_device_repository.py b/tests/homesec/test_mobile_device_repository.py index a8f1b979..d0b82d2b 100644 --- a/tests/homesec/test_mobile_device_repository.py +++ b/tests/homesec/test_mobile_device_repository.py @@ -9,7 +9,11 @@ from pydantic import ValidationError from sqlalchemy import select -from homesec.models.mobile import MobileDeviceRegistration, MobileDeviceUpdate +from homesec.models.mobile import ( + MobileDeviceCapabilities, + MobileDeviceRegistration, + MobileDeviceUpdate, +) from homesec.repository.mobile_device_repository import MobileDeviceRepository, hash_apns_token from homesec.state.postgres import MobileDevice, PostgresStateStore @@ -64,6 +68,8 @@ async def test_register_device_creates_redacted_list_record( assert record.enabled is True assert record.apns_environment == "sandbox" assert record.bundle_id == "com.levneiman.homesec" + assert record.capabilities.deep_links is True + assert record.capabilities.rich_notifications is False assert records == [record] encoded = json.dumps(record.model_dump(mode="json"), sort_keys=True) assert registration.apns_token not in encoded @@ -106,6 +112,7 @@ async def test_register_device_dedupes_by_token_hash( assert second.id == first.id assert second.device_name == "Kitchen iPad" assert second.app_version == "1.1.0" + assert second.capabilities.deep_links is True assert second.last_seen_at == datetime(2026, 6, 14, 8, 5, tzinfo=timezone.utc) assert records == [second] @@ -178,13 +185,18 @@ async def test_update_device_can_reenable_disabled_device( # When: Updating the device enabled state explicitly updated = await repository.update_device( registered.id, - MobileDeviceUpdate(enabled=True, device_name="Front Door iPhone"), + MobileDeviceUpdate( + enabled=True, + device_name="Front Door iPhone", + capabilities=MobileDeviceCapabilities(rich_notifications=True), + ), ) # Then: The device returns to default listings with updated metadata assert updated is not None assert updated.enabled is True assert updated.device_name == "Front Door iPhone" + assert updated.capabilities.rich_notifications is True assert await repository.list_devices() == [updated] await state_store.shutdown() diff --git a/ui/src/api/generated/openapi.json b/ui/src/api/generated/openapi.json index 1820d6cb..a9946208 100644 --- a/ui/src/api/generated/openapi.json +++ b/ui/src/api/generated/openapi.json @@ -1042,6 +1042,266 @@ "title": "MediaProfileResponse", "type": "object" }, + "MobileDeviceCapabilities": { + "description": "Feature flags reported by the current iOS app build.", + "properties": { + "deep_links": { + "default": true, + "title": "Deep Links", + "type": "boolean" + }, + "rich_notifications": { + "default": false, + "title": "Rich Notifications", + "type": "boolean" + } + }, + "title": "MobileDeviceCapabilities", + "type": "object" + }, + "MobileDevicePatchRequest": { + "properties": { + "app_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "App Version" + }, + "capabilities": { + "anyOf": [ + { + "$ref": "#/components/schemas/MobileDeviceCapabilities" + }, + { + "type": "null" + } + ] + }, + "device_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Device Name" + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Enabled" + } + }, + "title": "MobileDevicePatchRequest", + "type": "object" + }, + "MobileDeviceRegisterRequest": { + "properties": { + "apns_token": { + "minLength": 1, + "title": "Apns Token", + "type": "string" + }, + "app_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "App Version" + }, + "bundle_id": { + "minLength": 1, + "title": "Bundle Id", + "type": "string" + }, + "capabilities": { + "$ref": "#/components/schemas/MobileDeviceCapabilities" + }, + "device_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Device Name" + }, + "environment": { + "enum": [ + "sandbox", + "production" + ], + "title": "Environment", + "type": "string" + }, + "platform": { + "const": "ios", + "default": "ios", + "title": "Platform", + "type": "string" + } + }, + "required": [ + "apns_token", + "environment", + "bundle_id" + ], + "title": "MobileDeviceRegisterRequest", + "type": "object" + }, + "MobileDeviceResponse": { + "properties": { + "app_version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "App Version" + }, + "bundle_id": { + "title": "Bundle Id", + "type": "string" + }, + "capabilities": { + "$ref": "#/components/schemas/MobileDeviceCapabilities" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "device_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Device Name" + }, + "enabled": { + "title": "Enabled", + "type": "boolean" + }, + "environment": { + "enum": [ + "sandbox", + "production" + ], + "title": "Environment", + "type": "string" + }, + "id": { + "title": "Id", + "type": "string" + }, + "last_push_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Push At" + }, + "last_push_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Push Error" + }, + "last_seen_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Seen At" + }, + "platform": { + "const": "ios", + "title": "Platform", + "type": "string" + }, + "token_fingerprint": { + "title": "Token Fingerprint", + "type": "string" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "id", + "platform", + "environment", + "bundle_id", + "capabilities", + "enabled", + "token_fingerprint", + "created_at", + "updated_at" + ], + "title": "MobileDeviceResponse", + "type": "object" + }, + "MobileNotificationTestResponse": { + "properties": { + "reason": { + "title": "Reason", + "type": "string" + }, + "sent": { + "title": "Sent", + "type": "boolean" + } + }, + "required": [ + "sent", + "reason" + ], + "title": "MobileNotificationTestResponse", + "type": "object" + }, "NotifierConfig": { "description": "Notifier configuration entry.", "properties": { @@ -2925,6 +3185,210 @@ ] } }, + "/api/v1/mobile/devices": { + "get": { + "description": "List registered iOS devices without raw APNs material.", + "operationId": "list_mobile_devices_api_v1_mobile_devices_get", + "parameters": [ + { + "in": "query", + "name": "include_disabled", + "required": false, + "schema": { + "default": false, + "title": "Include Disabled", + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MobileDeviceResponse" + }, + "title": "Response List Mobile Devices Api V1 Mobile Devices Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Mobile Devices", + "tags": [ + "mobile" + ] + }, + "post": { + "description": "Register or refresh an iOS APNs device.", + "operationId": "register_mobile_device_api_v1_mobile_devices_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileDeviceRegisterRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileDeviceResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Register Mobile Device", + "tags": [ + "mobile" + ] + } + }, + "/api/v1/mobile/devices/{device_id}": { + "delete": { + "description": "Disable a mobile device without hard-deleting it.", + "operationId": "delete_mobile_device_api_v1_mobile_devices__device_id__delete", + "parameters": [ + { + "in": "path", + "name": "device_id", + "required": true, + "schema": { + "title": "Device Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileDeviceResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Mobile Device", + "tags": [ + "mobile" + ] + }, + "patch": { + "description": "Update mutable mobile device metadata or enabled state.", + "operationId": "update_mobile_device_api_v1_mobile_devices__device_id__patch", + "parameters": [ + { + "in": "path", + "name": "device_id", + "required": true, + "schema": { + "title": "Device Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileDevicePatchRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileDeviceResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Mobile Device", + "tags": [ + "mobile" + ] + } + }, + "/api/v1/mobile/notifications/test": { + "post": { + "description": "Stub test notification endpoint until APNs notifier support lands.", + "operationId": "test_mobile_notification_api_v1_mobile_notifications_test_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MobileNotificationTestResponse" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Test Mobile Notification", + "tags": [ + "mobile" + ] + } + }, "/api/v1/onvif/discover": { "post": { "description": "Trigger WS-Discovery scan and return discovered ONVIF cameras.", diff --git a/ui/src/api/generated/schema.ts b/ui/src/api/generated/schema.ts index fa7efd94..d3af1852 100644 --- a/ui/src/api/generated/schema.ts +++ b/ui/src/api/generated/schema.ts @@ -243,6 +243,74 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/mobile/devices": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Mobile Devices + * @description List registered iOS devices without raw APNs material. + */ + get: operations["list_mobile_devices_api_v1_mobile_devices_get"]; + put?: never; + /** + * Register Mobile Device + * @description Register or refresh an iOS APNs device. + */ + post: operations["register_mobile_device_api_v1_mobile_devices_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/mobile/devices/{device_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete Mobile Device + * @description Disable a mobile device without hard-deleting it. + */ + delete: operations["delete_mobile_device_api_v1_mobile_devices__device_id__delete"]; + options?: never; + head?: never; + /** + * Update Mobile Device + * @description Update mutable mobile device metadata or enabled state. + */ + patch: operations["update_mobile_device_api_v1_mobile_devices__device_id__patch"]; + trace?: never; + }; + "/api/v1/mobile/notifications/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Test Mobile Notification + * @description Stub test notification endpoint until APNs notifier support lands. + */ + post: operations["test_mobile_notification_api_v1_mobile_notifications_test_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/onvif/discover": { parameters: { query?: never; @@ -966,6 +1034,104 @@ export interface components { /** Width */ width: number | null; }; + /** + * MobileDeviceCapabilities + * @description Feature flags reported by the current iOS app build. + */ + MobileDeviceCapabilities: { + /** + * Deep Links + * @default true + */ + deep_links: boolean; + /** + * Rich Notifications + * @default false + */ + rich_notifications: boolean; + }; + /** MobileDevicePatchRequest */ + MobileDevicePatchRequest: { + /** App Version */ + app_version?: string | null; + capabilities?: components["schemas"]["MobileDeviceCapabilities"] | null; + /** Device Name */ + device_name?: string | null; + /** Enabled */ + enabled?: boolean | null; + }; + /** MobileDeviceRegisterRequest */ + MobileDeviceRegisterRequest: { + /** Apns Token */ + apns_token: string; + /** App Version */ + app_version?: string | null; + /** Bundle Id */ + bundle_id: string; + capabilities?: components["schemas"]["MobileDeviceCapabilities"]; + /** Device Name */ + device_name?: string | null; + /** + * Environment + * @enum {string} + */ + environment: "sandbox" | "production"; + /** + * Platform + * @default ios + * @constant + */ + platform: "ios"; + }; + /** MobileDeviceResponse */ + MobileDeviceResponse: { + /** App Version */ + app_version?: string | null; + /** Bundle Id */ + bundle_id: string; + capabilities: components["schemas"]["MobileDeviceCapabilities"]; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Device Name */ + device_name?: string | null; + /** Enabled */ + enabled: boolean; + /** + * Environment + * @enum {string} + */ + environment: "sandbox" | "production"; + /** Id */ + id: string; + /** Last Push At */ + last_push_at?: string | null; + /** Last Push Error */ + last_push_error?: string | null; + /** Last Seen At */ + last_seen_at?: string | null; + /** + * Platform + * @constant + */ + platform: "ios"; + /** Token Fingerprint */ + token_fingerprint: string; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** MobileNotificationTestResponse */ + MobileNotificationTestResponse: { + /** Reason */ + reason: string; + /** Sent */ + sent: boolean; + }; /** * NotifierConfig * @description Notifier configuration entry. @@ -1841,6 +2007,156 @@ export interface operations { }; }; }; + list_mobile_devices_api_v1_mobile_devices_get: { + parameters: { + query?: { + include_disabled?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileDeviceResponse"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + register_mobile_device_api_v1_mobile_devices_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MobileDeviceRegisterRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileDeviceResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_mobile_device_api_v1_mobile_devices__device_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + device_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileDeviceResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_mobile_device_api_v1_mobile_devices__device_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + device_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["MobileDevicePatchRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileDeviceResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + test_mobile_notification_api_v1_mobile_notifications_test_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MobileNotificationTestResponse"]; + }; + }; + }; + }; discover_onvif_cameras_api_v1_onvif_discover_post: { parameters: { query?: never; From 2b88e3fbb046cb2f328f6e97d8e773fd3d603f39 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 14 Jun 2026 01:36:28 -0700 Subject: [PATCH 31/36] feat: add iOS APNs registration --- ui/ios/App/App.xcodeproj/project.pbxproj | 18 +- ui/ios/App/App/App.entitlements | 8 + ui/ios/App/App/AppDelegate.swift | 8 + .../App/App/HomeSecBridgeViewController.swift | 1 + ui/ios/App/App/HomeSecDevicePlugin.swift | 61 +++++ ui/ios/App/CapApp-SPM/Package.swift | 10 +- ui/package.json | 1 + ui/pnpm-lock.yaml | 12 + ui/scripts/api_codegen.mjs | 19 +- ui/src/api/client.test.ts | 80 ++++++ ui/src/api/client.ts | 26 ++ ui/src/api/generated/client.ts | 6 + ui/src/api/generated/types.ts | 2 + ui/src/api/parsing.ts | 55 ++++ ui/src/routes/AppRouter.tsx | 13 +- ui/src/runtime/homeSecDevicePlugin.ts | 16 ++ ui/src/runtime/nativePushRegistration.test.ts | 193 ++++++++++++++ ui/src/runtime/nativePushRegistration.ts | 245 ++++++++++++++++++ 18 files changed, 760 insertions(+), 14 deletions(-) create mode 100644 ui/ios/App/App/App.entitlements create mode 100644 ui/ios/App/App/HomeSecDevicePlugin.swift create mode 100644 ui/src/runtime/homeSecDevicePlugin.ts create mode 100644 ui/src/runtime/nativePushRegistration.test.ts create mode 100644 ui/src/runtime/nativePushRegistration.ts diff --git a/ui/ios/App/App.xcodeproj/project.pbxproj b/ui/ios/App/App.xcodeproj/project.pbxproj index 7af6b7bf..32df466a 100644 --- a/ui/ios/App/App.xcodeproj/project.pbxproj +++ b/ui/ios/App/App.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ AB6585AB036645ACB3F18713 /* HomeSecKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BAEE612533413E98770C40 /* HomeSecKeychainStore.swift */; }; 66567A6DF7C547C188064737 /* HomeSecAuthPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A484C75B28C044178789F867 /* HomeSecAuthPlugin.swift */; }; 9B5C5B337D684A7AA00B985B /* HomeSecBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1874C85077A0477C9265DAB5 /* HomeSecBridgeViewController.swift */; }; + EC7D94D22FCB4E8E9A1F3B21 /* HomeSecDevicePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7D94D12FCB4E8E9A1F3B21 /* HomeSecDevicePlugin.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -34,6 +35,8 @@ 27BAEE612533413E98770C40 /* HomeSecKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecKeychainStore.swift; sourceTree = ""; }; A484C75B28C044178789F867 /* HomeSecAuthPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecAuthPlugin.swift; sourceTree = ""; }; 1874C85077A0477C9265DAB5 /* HomeSecBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecBridgeViewController.swift; sourceTree = ""; }; + EC7D94D02FCB4E8E9A1F3B21 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; + EC7D94D12FCB4E8E9A1F3B21 /* HomeSecDevicePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecDevicePlugin.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -72,11 +75,13 @@ 504EC3071FED79650016851F /* AppDelegate.swift */, 1874C85077A0477C9265DAB5 /* HomeSecBridgeViewController.swift */, A484C75B28C044178789F867 /* HomeSecAuthPlugin.swift */, + EC7D94D12FCB4E8E9A1F3B21 /* HomeSecDevicePlugin.swift */, 27BAEE612533413E98770C40 /* HomeSecKeychainStore.swift */, 504EC30B1FED79650016851F /* Main.storyboard */, 504EC30E1FED79650016851F /* Assets.xcassets */, 504EC3101FED79650016851F /* LaunchScreen.storyboard */, 504EC3131FED79650016851F /* Info.plist */, + EC7D94D02FCB4E8E9A1F3B21 /* App.entitlements */, 2FAD9762203C412B000D30F8 /* config.xml */, 50B271D01FEDC1A000F3C39B /* public */, ); @@ -167,6 +172,7 @@ 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, 9B5C5B337D684A7AA00B985B /* HomeSecBridgeViewController.swift in Sources */, 66567A6DF7C547C188064737 /* HomeSecAuthPlugin.swift in Sources */, + EC7D94D22FCB4E8E9A1F3B21 /* HomeSecDevicePlugin.swift in Sources */, AB6585AB036645ACB3F18713 /* HomeSecKeychainStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -242,7 +248,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -293,7 +299,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; @@ -307,10 +313,12 @@ baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + APS_ENVIRONMENT = development; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; INFOPLIST_FILE = App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -329,10 +337,12 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + APS_ENVIRONMENT = production; + CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; INFOPLIST_FILE = App/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ui/ios/App/App/App.entitlements b/ui/ios/App/App/App.entitlements new file mode 100644 index 00000000..6a26dfe4 --- /dev/null +++ b/ui/ios/App/App/App.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + $(APS_ENVIRONMENT) + + diff --git a/ui/ios/App/App/AppDelegate.swift b/ui/ios/App/App/AppDelegate.swift index c3cd83b5..24ece850 100644 --- a/ui/ios/App/App/AppDelegate.swift +++ b/ui/ios/App/App/AppDelegate.swift @@ -46,4 +46,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler) } + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error) + } + } diff --git a/ui/ios/App/App/HomeSecBridgeViewController.swift b/ui/ios/App/App/HomeSecBridgeViewController.swift index a796f0b8..ef50b663 100644 --- a/ui/ios/App/App/HomeSecBridgeViewController.swift +++ b/ui/ios/App/App/HomeSecBridgeViewController.swift @@ -6,5 +6,6 @@ class HomeSecBridgeViewController: CAPBridgeViewController { override func capacitorDidLoad() { super.capacitorDidLoad() bridge?.registerPluginInstance(HomeSecAuthPlugin()) + bridge?.registerPluginInstance(HomeSecDevicePlugin()) } } diff --git a/ui/ios/App/App/HomeSecDevicePlugin.swift b/ui/ios/App/App/HomeSecDevicePlugin.swift new file mode 100644 index 00000000..c8b689d1 --- /dev/null +++ b/ui/ios/App/App/HomeSecDevicePlugin.swift @@ -0,0 +1,61 @@ +import Capacitor +import Foundation +import UIKit + +enum HomeSecDevicePluginError: LocalizedError { + case missingBundleIdentifier + + var errorDescription: String? { + switch self { + case .missingBundleIdentifier: + return "App bundle identifier is unavailable." + } + } +} + +@objc(HomeSecDevicePlugin) +public class HomeSecDevicePlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "HomeSecDevicePlugin" + public let jsName = "HomeSecDevice" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "getRegistrationInfo", returnType: CAPPluginReturnPromise), + ] + + @objc func getRegistrationInfo(_ call: CAPPluginCall) { + do { + guard let bundleIdentifier = Bundle.main.bundleIdentifier else { + throw HomeSecDevicePluginError.missingBundleIdentifier + } + + call.resolve([ + "apnsEnvironment": apnsEnvironment(), + "appVersion": bundleValue("CFBundleShortVersionString") ?? NSNull(), + "bundleId": bundleIdentifier, + "deviceName": nullableDeviceName(), + ]) + } catch { + call.reject(error.localizedDescription, "HOMESEC_DEVICE_INFO_ERROR", error) + } + } + + private func apnsEnvironment() -> String { + #if DEBUG + return "sandbox" + #else + return "production" + #endif + } + + private func bundleValue(_ key: String) -> String? { + guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String else { + return nil + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func nullableDeviceName() -> Any { + let trimmed = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? NSNull() : trimmed + } +} diff --git a/ui/ios/App/CapApp-SPM/Package.swift b/ui/ios/App/CapApp-SPM/Package.swift index d0f1ca5d..d0c35c55 100644 --- a/ui/ios/App/CapApp-SPM/Package.swift +++ b/ui/ios/App/CapApp-SPM/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version: 5.9 +// swift-tools-version: 6.2 import PackageDescription // DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands let package = Package( name: "CapApp-SPM", - platforms: [.iOS(.v15)], + platforms: [.iOS(.v26)], products: [ .library( name: "CapApp-SPM", @@ -12,7 +12,8 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.3"), - .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.3/node_modules/@capacitor/app") + .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.3/node_modules/@capacitor/app"), + .package(name: "CapacitorPushNotifications", path: "../../../node_modules/.pnpm/@capacitor+push-notifications@8.1.1_@capacitor+core@8.3.3/node_modules/@capacitor/push-notifications") ], targets: [ .target( @@ -20,7 +21,8 @@ let package = Package( dependencies: [ .product(name: "Capacitor", package: "capacitor-swift-pm"), .product(name: "Cordova", package: "capacitor-swift-pm"), - .product(name: "CapacitorApp", package: "CapacitorApp") + .product(name: "CapacitorApp", package: "CapacitorApp"), + .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications") ] ) ] diff --git a/ui/package.json b/ui/package.json index ce9e4fcd..0d3fda6d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -28,6 +28,7 @@ "@capacitor/app": "8.1.0", "@capacitor/core": "^8.3.3", "@capacitor/ios": "^8.3.3", + "@capacitor/push-notifications": "8.1.1", "@tanstack/react-query": "^5.90.21", "hls.js": "^1.6.16", "react": "^19.2.0", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 1ea2480f..46b43964 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@capacitor/ios': specifier: ^8.3.3 version: 8.3.3(@capacitor/core@8.3.3) + '@capacitor/push-notifications': + specifier: 8.1.1 + version: 8.1.1(@capacitor/core@8.3.3) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.4) @@ -219,6 +222,11 @@ packages: peerDependencies: '@capacitor/core': ^8.3.0 + '@capacitor/push-notifications@8.1.1': + resolution: {integrity: sha512-WqzjPKIbYbARMN+GC0XMAJcxJpUUzqgzS/Ny8RODLrro38pQhm3GXYwX2Mwd+LZlLY39rGImkCkrKyQSNfuikA==} + peerDependencies: + '@capacitor/core': '>=8.0.0' + '@csstools/color-helpers@6.0.1': resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==} engines: {node: '>=20.19.0'} @@ -2125,6 +2133,10 @@ snapshots: dependencies: '@capacitor/core': 8.3.3 + '@capacitor/push-notifications@8.1.1(@capacitor/core@8.3.3)': + dependencies: + '@capacitor/core': 8.3.3 + '@csstools/color-helpers@6.0.1': {} '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': diff --git a/ui/scripts/api_codegen.mjs b/ui/scripts/api_codegen.mjs index 6d64296b..6ed64438 100644 --- a/ui/scripts/api_codegen.mjs +++ b/ui/scripts/api_codegen.mjs @@ -95,12 +95,14 @@ function buildTypesFile({ probeResponseSchemaName, mediaProfileSchemaName, deviceInfoSchemaName, + mobileDeviceRegisterRequestSchemaName, + mobileDeviceResponseSchemaName, }) { - return `${GENERATED_HEADER}\nimport type { components, paths } from './schema'\n\nexport type OpenAPIComponents = components\nexport type OpenAPIPaths = paths\nexport type CameraResponse = components["schemas"]["${cameraSchemaName}"]\nexport type CameraListResponse = CameraResponse[]\nexport type CameraCreate = components["schemas"]["${cameraCreateSchemaName}"]\nexport type CameraUpdate = components["schemas"]["${cameraUpdateSchemaName}"]\nexport type ConfigChangeResponse = components["schemas"]["${configChangeSchemaName}"]\nexport type PreviewSessionResponse = components["schemas"]["${previewSessionSchemaName}"]\nexport type PreviewState = components["schemas"]["${previewStateSchemaName}"]\nexport type PreviewStatusResponse = components["schemas"]["${previewStatusSchemaName}"]\nexport type PreviewStopResponse = components["schemas"]["${previewStopSchemaName}"]\nexport type TalkInputFormat = components["schemas"]["${talkInputSchemaName}"]\nexport type TalkSessionRequest = components["schemas"]["${talkSessionRequestSchemaName}"]\nexport type TalkSessionResponse = components["schemas"]["${talkSessionSchemaName}"]\nexport type TalkCapabilityState = components["schemas"]["${talkCapabilitySchemaName}"]\nexport type TalkState = components["schemas"]["${talkStateSchemaName}"]\nexport type TalkStatusResponse = components["schemas"]["${talkStatusSchemaName}"]\nexport type TalkStopResponse = components["schemas"]["${talkStopSchemaName}"]\nexport type SetupStatusResponse = components["schemas"]["${setupStatusSchemaName}"]\nexport type FinalizeRequest = components["schemas"]["${finalizeRequestSchemaName}"]\nexport type FinalizeResponse = components["schemas"]["${finalizeResponseSchemaName}"]\nexport type PreflightCheckResponse = components["schemas"]["${preflightCheckSchemaName}"]\nexport type PreflightResponse = components["schemas"]["${preflightResponseSchemaName}"]\nexport type TestConnectionRequest = components["schemas"]["${testConnectionRequestSchemaName}"]\nexport type TestConnectionResponse = components["schemas"]["${testConnectionResponseSchemaName}"]\nexport type HealthResponse = components["schemas"]["${healthSchemaName}"]\nexport type StatsResponse = components["schemas"]["${statsSchemaName}"]\nexport type DiagnosticsResponse = components["schemas"]["${diagnosticsSchemaName}"]\nexport type RuntimeReloadResponse = components["schemas"]["${runtimeReloadSchemaName}"]\nexport type RuntimeState = components["schemas"]["${runtimeStateSchemaName}"]\nexport type RuntimeStatusResponse = components["schemas"]["${runtimeStatusSchemaName}"]\nexport type PostgresBackupStatusResponse = components["schemas"]["${postgresBackupStatusSchemaName}"]\nexport type PostgresBackupRunResponse = components["schemas"]["${postgresBackupRunSchemaName}"]\nexport type ClipListResponse = components["schemas"]["${clipListSchemaName}"]\nexport type ClipResponse = components["schemas"]["${clipSchemaName}"]\nexport type ClipStatus = components["schemas"]["${clipStatusSchemaName}"]\nexport type DiscoverRequest = components["schemas"]["${discoverRequestSchemaName}"]\nexport type DiscoveredCameraResponse = components["schemas"]["${discoveredCameraSchemaName}"]\nexport type ProbeRequest = components["schemas"]["${probeRequestSchemaName}"]\nexport type ProbeResponse = components["schemas"]["${probeResponseSchemaName}"]\nexport type MediaProfileResponse = components["schemas"]["${mediaProfileSchemaName}"]\nexport type DeviceInfoResponse = components["schemas"]["${deviceInfoSchemaName}"]\nexport type ListClipsQuery = NonNullable\n` + return `${GENERATED_HEADER}\nimport type { components, paths } from './schema'\n\nexport type OpenAPIComponents = components\nexport type OpenAPIPaths = paths\nexport type CameraResponse = components["schemas"]["${cameraSchemaName}"]\nexport type CameraListResponse = CameraResponse[]\nexport type CameraCreate = components["schemas"]["${cameraCreateSchemaName}"]\nexport type CameraUpdate = components["schemas"]["${cameraUpdateSchemaName}"]\nexport type ConfigChangeResponse = components["schemas"]["${configChangeSchemaName}"]\nexport type PreviewSessionResponse = components["schemas"]["${previewSessionSchemaName}"]\nexport type PreviewState = components["schemas"]["${previewStateSchemaName}"]\nexport type PreviewStatusResponse = components["schemas"]["${previewStatusSchemaName}"]\nexport type PreviewStopResponse = components["schemas"]["${previewStopSchemaName}"]\nexport type TalkInputFormat = components["schemas"]["${talkInputSchemaName}"]\nexport type TalkSessionRequest = components["schemas"]["${talkSessionRequestSchemaName}"]\nexport type TalkSessionResponse = components["schemas"]["${talkSessionSchemaName}"]\nexport type TalkCapabilityState = components["schemas"]["${talkCapabilitySchemaName}"]\nexport type TalkState = components["schemas"]["${talkStateSchemaName}"]\nexport type TalkStatusResponse = components["schemas"]["${talkStatusSchemaName}"]\nexport type TalkStopResponse = components["schemas"]["${talkStopSchemaName}"]\nexport type SetupStatusResponse = components["schemas"]["${setupStatusSchemaName}"]\nexport type FinalizeRequest = components["schemas"]["${finalizeRequestSchemaName}"]\nexport type FinalizeResponse = components["schemas"]["${finalizeResponseSchemaName}"]\nexport type PreflightCheckResponse = components["schemas"]["${preflightCheckSchemaName}"]\nexport type PreflightResponse = components["schemas"]["${preflightResponseSchemaName}"]\nexport type TestConnectionRequest = components["schemas"]["${testConnectionRequestSchemaName}"]\nexport type TestConnectionResponse = components["schemas"]["${testConnectionResponseSchemaName}"]\nexport type HealthResponse = components["schemas"]["${healthSchemaName}"]\nexport type StatsResponse = components["schemas"]["${statsSchemaName}"]\nexport type DiagnosticsResponse = components["schemas"]["${diagnosticsSchemaName}"]\nexport type RuntimeReloadResponse = components["schemas"]["${runtimeReloadSchemaName}"]\nexport type RuntimeState = components["schemas"]["${runtimeStateSchemaName}"]\nexport type RuntimeStatusResponse = components["schemas"]["${runtimeStatusSchemaName}"]\nexport type PostgresBackupStatusResponse = components["schemas"]["${postgresBackupStatusSchemaName}"]\nexport type PostgresBackupRunResponse = components["schemas"]["${postgresBackupRunSchemaName}"]\nexport type ClipListResponse = components["schemas"]["${clipListSchemaName}"]\nexport type ClipResponse = components["schemas"]["${clipSchemaName}"]\nexport type ClipStatus = components["schemas"]["${clipStatusSchemaName}"]\nexport type DiscoverRequest = components["schemas"]["${discoverRequestSchemaName}"]\nexport type DiscoveredCameraResponse = components["schemas"]["${discoveredCameraSchemaName}"]\nexport type ProbeRequest = components["schemas"]["${probeRequestSchemaName}"]\nexport type ProbeResponse = components["schemas"]["${probeResponseSchemaName}"]\nexport type MediaProfileResponse = components["schemas"]["${mediaProfileSchemaName}"]\nexport type DeviceInfoResponse = components["schemas"]["${deviceInfoSchemaName}"]\nexport type MobileDeviceRegisterRequest = components["schemas"]["${mobileDeviceRegisterRequestSchemaName}"]\nexport type MobileDeviceResponse = components["schemas"]["${mobileDeviceResponseSchemaName}"]\nexport type ListClipsQuery = NonNullable\n` } function buildClientFile() { - return `${GENERATED_HEADER}\nimport type {\n CameraCreate,\n CameraListResponse,\n CameraResponse,\n CameraUpdate,\n ClipListResponse,\n ClipResponse,\n ConfigChangeResponse,\n PreviewSessionResponse,\n PreviewStatusResponse,\n PreviewStopResponse,\n TalkSessionRequest,\n TalkSessionResponse,\n TalkStatusResponse,\n TalkStopResponse,\n DiagnosticsResponse,\n DiscoverRequest,\n DiscoveredCameraResponse,\n FinalizeRequest,\n FinalizeResponse,\n HealthResponse,\n ListClipsQuery,\n PreflightResponse,\n TestConnectionRequest,\n TestConnectionResponse,\n ProbeRequest,\n ProbeResponse,\n RuntimeReloadResponse,\n RuntimeStatusResponse,\n PostgresBackupRunResponse,\n PostgresBackupStatusResponse,\n SetupStatusResponse,\n StatsResponse,\n} from './types'\n\nexport interface ApiRequestOptions {\n signal?: AbortSignal\n apiKey?: string | null\n}\n\nexport interface CameraMutationOptions extends ApiRequestOptions {\n applyChanges?: boolean\n}\n\nexport type ApiResponseWithStatus = TPayload & { httpStatus: number }\n\nexport interface GeneratedHomeSecClient {\n getCameras(options?: ApiRequestOptions): Promise\n getCamera(name: string, options?: ApiRequestOptions): Promise\n createCamera(\n payload: CameraCreate,\n options?: CameraMutationOptions,\n ): Promise>\n updateCamera(\n name: string,\n payload: CameraUpdate,\n options?: CameraMutationOptions,\n ): Promise>\n deleteCamera(\n name: string,\n options?: CameraMutationOptions,\n ): Promise>\n getCameraPreviewStatus(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n ensureCameraPreviewActive(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n stopCameraPreview(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n getCameraTalkStatus(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n prepareCameraTalkSession(\n cameraName: string,\n payload?: TalkSessionRequest,\n options?: ApiRequestOptions,\n ): Promise>\n stopCameraTalkSession(\n cameraName: string,\n sessionId: string,\n options?: ApiRequestOptions,\n ): Promise>\n getSetupStatus(options?: ApiRequestOptions): Promise>\n finalizeSetup(\n payload: FinalizeRequest,\n options?: ApiRequestOptions,\n ): Promise>\n runSetupPreflight(options?: ApiRequestOptions): Promise>\n runSetupTestConnection(\n payload: TestConnectionRequest,\n options?: ApiRequestOptions,\n ): Promise>\n getHealth(options?: ApiRequestOptions): Promise>\n getStats(options?: ApiRequestOptions): Promise>\n getDiagnostics(options?: ApiRequestOptions): Promise>\n reloadRuntime(options?: ApiRequestOptions): Promise>\n getRuntimeStatus(\n options?: ApiRequestOptions,\n ): Promise>\n getPostgresBackupStatus(\n options?: ApiRequestOptions,\n ): Promise>\n runPostgresBackupNow(\n options?: ApiRequestOptions,\n ): Promise>\n discoverOnvifCameras(\n payload?: DiscoverRequest,\n options?: ApiRequestOptions,\n ): Promise\n probeOnvifCamera(payload: ProbeRequest, options?: ApiRequestOptions): Promise\n getClips(\n query?: ListClipsQuery,\n options?: ApiRequestOptions,\n ): Promise>\n getClip(clipId: string, options?: ApiRequestOptions): Promise>\n}\n` + return `${GENERATED_HEADER}\nimport type {\n CameraCreate,\n CameraListResponse,\n CameraResponse,\n CameraUpdate,\n ClipListResponse,\n ClipResponse,\n ConfigChangeResponse,\n PreviewSessionResponse,\n PreviewStatusResponse,\n PreviewStopResponse,\n TalkSessionRequest,\n TalkSessionResponse,\n TalkStatusResponse,\n TalkStopResponse,\n DiagnosticsResponse,\n DiscoverRequest,\n DiscoveredCameraResponse,\n FinalizeRequest,\n FinalizeResponse,\n HealthResponse,\n ListClipsQuery,\n PreflightResponse,\n TestConnectionRequest,\n TestConnectionResponse,\n ProbeRequest,\n ProbeResponse,\n RuntimeReloadResponse,\n RuntimeStatusResponse,\n PostgresBackupRunResponse,\n PostgresBackupStatusResponse,\n SetupStatusResponse,\n StatsResponse,\n MobileDeviceRegisterRequest,\n MobileDeviceResponse,\n} from './types'\n\nexport interface ApiRequestOptions {\n signal?: AbortSignal\n apiKey?: string | null\n}\n\nexport interface CameraMutationOptions extends ApiRequestOptions {\n applyChanges?: boolean\n}\n\nexport type ApiResponseWithStatus = TPayload & { httpStatus: number }\n\nexport interface GeneratedHomeSecClient {\n getCameras(options?: ApiRequestOptions): Promise\n getCamera(name: string, options?: ApiRequestOptions): Promise\n createCamera(\n payload: CameraCreate,\n options?: CameraMutationOptions,\n ): Promise>\n updateCamera(\n name: string,\n payload: CameraUpdate,\n options?: CameraMutationOptions,\n ): Promise>\n deleteCamera(\n name: string,\n options?: CameraMutationOptions,\n ): Promise>\n getCameraPreviewStatus(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n ensureCameraPreviewActive(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n stopCameraPreview(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n getCameraTalkStatus(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n prepareCameraTalkSession(\n cameraName: string,\n payload?: TalkSessionRequest,\n options?: ApiRequestOptions,\n ): Promise>\n stopCameraTalkSession(\n cameraName: string,\n sessionId: string,\n options?: ApiRequestOptions,\n ): Promise>\n getSetupStatus(options?: ApiRequestOptions): Promise>\n finalizeSetup(\n payload: FinalizeRequest,\n options?: ApiRequestOptions,\n ): Promise>\n runSetupPreflight(options?: ApiRequestOptions): Promise>\n runSetupTestConnection(\n payload: TestConnectionRequest,\n options?: ApiRequestOptions,\n ): Promise>\n getHealth(options?: ApiRequestOptions): Promise>\n getStats(options?: ApiRequestOptions): Promise>\n getDiagnostics(options?: ApiRequestOptions): Promise>\n reloadRuntime(options?: ApiRequestOptions): Promise>\n getRuntimeStatus(\n options?: ApiRequestOptions,\n ): Promise>\n getPostgresBackupStatus(\n options?: ApiRequestOptions,\n ): Promise>\n runPostgresBackupNow(\n options?: ApiRequestOptions,\n ): Promise>\n discoverOnvifCameras(\n payload?: DiscoverRequest,\n options?: ApiRequestOptions,\n ): Promise\n probeOnvifCamera(payload: ProbeRequest, options?: ApiRequestOptions): Promise\n getClips(\n query?: ListClipsQuery,\n options?: ApiRequestOptions,\n ): Promise>\n getClip(clipId: string, options?: ApiRequestOptions): Promise>\n registerMobileDevice(\n payload: MobileDeviceRegisterRequest,\n options?: ApiRequestOptions,\n ): Promise>\n}\n` } function resolveResponseSchemaName(openapiSchema, { pathName, method, statuses, fallbackSchemaName }) { @@ -441,6 +443,17 @@ function generateOpenApiArtifacts(tempGeneratedDir) { const discoverRequestSchemaName = resolveComponentSchemaName(schema, 'DiscoverRequest') const mediaProfileSchemaName = resolveComponentSchemaName(schema, 'MediaProfileResponse') const deviceInfoSchemaName = resolveComponentSchemaName(schema, 'DeviceInfoResponse') + const mobileDeviceRegisterRequestSchemaName = resolveRequestBodySchemaName(schema, { + pathName: '/api/v1/mobile/devices', + method: 'post', + fallbackSchemaName: 'MobileDeviceRegisterRequest', + }) + const mobileDeviceResponseSchemaName = resolveResponseSchemaName(schema, { + pathName: '/api/v1/mobile/devices', + method: 'post', + statuses: ['201', '200', 'default'], + fallbackSchemaName: 'MobileDeviceResponse', + }) if (!schema.components?.schemas?.ClipStatus) { throw new Error('Missing ClipStatus schema in exported OpenAPI spec') } @@ -486,6 +499,8 @@ function generateOpenApiArtifacts(tempGeneratedDir) { probeResponseSchemaName, mediaProfileSchemaName, deviceInfoSchemaName, + mobileDeviceRegisterRequestSchemaName, + mobileDeviceResponseSchemaName, }), 'utf-8', ) diff --git a/ui/src/api/client.test.ts b/ui/src/api/client.test.ts index f213f126..16cbbc6a 100644 --- a/ui/src/api/client.test.ts +++ b/ui/src/api/client.test.ts @@ -41,6 +41,86 @@ describe('HomeSecApiClient.getCameras', () => { }) }) +describe('HomeSecApiClient.registerMobileDevice', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('posts APNs registration payloads and parses redacted device records', async () => { + // Given: The mobile device endpoint returns a redacted registration record + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + id: 'dev_1', + platform: 'ios', + environment: 'sandbox', + bundle_id: 'com.levneiman.homesec', + device_name: "Lev's iPhone", + app_version: '1.0.0', + capabilities: { deep_links: true, rich_notifications: false }, + enabled: true, + token_fingerprint: 'abcdef123456', + created_at: '2026-06-14T00:00:00Z', + updated_at: '2026-06-14T00:00:00Z', + last_seen_at: '2026-06-14T00:00:00Z', + last_push_at: null, + last_push_error: null, + }), + { + status: 201, + headers: { 'content-type': 'application/json' }, + }, + ), + ) + const client = new HomeSecApiClient('http://localhost:8081') + + // When: Registering a native iOS APNs device + const result = await client.registerMobileDevice({ + platform: 'ios', + apns_token: 'raw-apns-token', + environment: 'sandbox', + bundle_id: 'com.levneiman.homesec', + device_name: "Lev's iPhone", + app_version: '1.0.0', + capabilities: { deep_links: true, rich_notifications: false }, + }) + + // Then: The API client posts the raw token only to the registration endpoint + expect(result).toEqual({ + id: 'dev_1', + platform: 'ios', + environment: 'sandbox', + bundle_id: 'com.levneiman.homesec', + device_name: "Lev's iPhone", + app_version: '1.0.0', + capabilities: { deep_links: true, rich_notifications: false }, + enabled: true, + token_fingerprint: 'abcdef123456', + created_at: '2026-06-14T00:00:00Z', + updated_at: '2026-06-14T00:00:00Z', + last_seen_at: '2026-06-14T00:00:00Z', + last_push_at: null, + last_push_error: null, + httpStatus: 201, + }) + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8081/api/v1/mobile/devices', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + platform: 'ios', + apns_token: 'raw-apns-token', + environment: 'sandbox', + bundle_id: 'com.levneiman.homesec', + device_name: "Lev's iPhone", + app_version: '1.0.0', + capabilities: { deep_links: true, rich_notifications: false }, + }), + }), + ) + }) +}) + describe('HomeSecApiClient.getHealth', () => { afterEach(() => { vi.restoreAllMocks() diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 354b43b6..73ec699c 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -36,6 +36,8 @@ import type { RuntimeStatusResponse, SetupStatusResponse, StatsResponse, + MobileDeviceRegisterRequest, + MobileDeviceResponse, } from './generated/types' import { isIOSNativeApp } from '../runtime/nativeRuntime' @@ -71,6 +73,7 @@ import { parsePreflightResponse, parsePostgresBackupRunResponse, parsePostgresBackupStatusResponse, + parseMobileDeviceResponse, parseTestConnectionResponse, parseRuntimeReloadResponse, parseRuntimeStatusResponse, @@ -108,6 +111,7 @@ export type FinalizeSnapshot = ApiSnapshot export type PreflightSnapshot = ApiSnapshot export type TestConnectionSnapshot = ApiSnapshot export type ClipMediaTokenSnapshot = ApiSnapshot +export type MobileDeviceSnapshot = ApiSnapshot export class HomeSecApiClient implements GeneratedHomeSecClient { private readonly httpClient: JsonHttpClient @@ -615,6 +619,28 @@ export class HomeSecApiClient implements GeneratedHomeSecClient { } } + async registerMobileDevice( + payload: MobileDeviceRegisterRequest, + options: ApiRequestOptions = {}, + ): Promise { + const response = await this.httpClient.requestJson('/api/v1/mobile/devices', { + ...options, + method: 'POST', + body: payload, + }) + + try { + return withHttpStatus(parseMobileDeviceResponse(response.payload), response.status) + } catch { + throw new APIError( + 'Invalid mobile device response payload', + response.status, + response.payload, + null, + ) + } + } + resolvePath(path: string): string { return this.httpClient.resolvePath(path) } diff --git a/ui/src/api/generated/client.ts b/ui/src/api/generated/client.ts index 33f82d20..426abc80 100644 --- a/ui/src/api/generated/client.ts +++ b/ui/src/api/generated/client.ts @@ -36,6 +36,8 @@ import type { PostgresBackupStatusResponse, SetupStatusResponse, StatsResponse, + MobileDeviceRegisterRequest, + MobileDeviceResponse, } from './types' export interface ApiRequestOptions { @@ -124,4 +126,8 @@ export interface GeneratedHomeSecClient { options?: ApiRequestOptions, ): Promise> getClip(clipId: string, options?: ApiRequestOptions): Promise> + registerMobileDevice( + payload: MobileDeviceRegisterRequest, + options?: ApiRequestOptions, + ): Promise> } diff --git a/ui/src/api/generated/types.ts b/ui/src/api/generated/types.ts index ccb6d206..32c3afd4 100644 --- a/ui/src/api/generated/types.ts +++ b/ui/src/api/generated/types.ts @@ -47,4 +47,6 @@ export type ProbeRequest = components["schemas"]["ProbeRequest"] export type ProbeResponse = components["schemas"]["ProbeResponse"] export type MediaProfileResponse = components["schemas"]["MediaProfileResponse"] export type DeviceInfoResponse = components["schemas"]["DeviceInfoResponse"] +export type MobileDeviceRegisterRequest = components["schemas"]["MobileDeviceRegisterRequest"] +export type MobileDeviceResponse = components["schemas"]["MobileDeviceResponse"] export type ListClipsQuery = NonNullable diff --git a/ui/src/api/parsing.ts b/ui/src/api/parsing.ts index cbff4368..f7dc951b 100644 --- a/ui/src/api/parsing.ts +++ b/ui/src/api/parsing.ts @@ -31,6 +31,7 @@ import type { RuntimeStatusResponse, SetupStatusResponse, StatsResponse, + MobileDeviceResponse, } from './generated/types' type JsonObject = Record @@ -700,6 +701,60 @@ export function parseClipMediaTokenResponse(payload: unknown): ClipMediaTokenRes } } +function parseMobileDeviceCapabilities(payload: unknown): MobileDeviceResponse['capabilities'] { + if (!isJsonObject(payload)) { + throw new Error('capabilities must be an object') + } + + return { + deep_links: expectBoolean(payload.deep_links, 'capabilities.deep_links'), + rich_notifications: expectBoolean( + payload.rich_notifications, + 'capabilities.rich_notifications', + ), + } +} + +function parseMobilePlatform(value: unknown, fieldName: string): MobileDeviceResponse['platform'] { + if (value === 'ios') { + return value + } + throw new Error(`${fieldName} must be ios`) +} + +function parseAPNSEnvironment( + value: unknown, + fieldName: string, +): MobileDeviceResponse['environment'] { + if (value === 'sandbox' || value === 'production') { + return value + } + throw new Error(`${fieldName} must be sandbox or production`) +} + +export function parseMobileDeviceResponse(payload: unknown): MobileDeviceResponse { + if (!isJsonObject(payload)) { + throw new Error('Mobile device response is not a JSON object') + } + + return { + id: expectString(payload.id, 'id'), + platform: parseMobilePlatform(payload.platform, 'platform'), + environment: parseAPNSEnvironment(payload.environment, 'environment'), + bundle_id: expectString(payload.bundle_id, 'bundle_id'), + device_name: expectNullableString(payload.device_name, 'device_name'), + app_version: expectNullableString(payload.app_version, 'app_version'), + capabilities: parseMobileDeviceCapabilities(payload.capabilities), + enabled: expectBoolean(payload.enabled, 'enabled'), + token_fingerprint: expectString(payload.token_fingerprint, 'token_fingerprint'), + created_at: expectString(payload.created_at, 'created_at'), + updated_at: expectString(payload.updated_at, 'updated_at'), + last_seen_at: expectNullableString(payload.last_seen_at, 'last_seen_at'), + last_push_at: expectNullableString(payload.last_push_at, 'last_push_at'), + last_push_error: expectNullableString(payload.last_push_error, 'last_push_error'), + } +} + export function withHttpStatus( payload: TPayload, status: number, diff --git a/ui/src/routes/AppRouter.tsx b/ui/src/routes/AppRouter.tsx index 970336ef..0613dfa3 100644 --- a/ui/src/routes/AppRouter.tsx +++ b/ui/src/routes/AppRouter.tsx @@ -3,6 +3,7 @@ import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-do import { isRuntimeAuthSessionReady, runtimeServerBaseUrlProvider } from '../api/client' import { AppShell } from '../app/layout/AppShell' import { isIOSNativeApp } from '../runtime/nativeRuntime' +import { useNativePushRegistration } from '../runtime/nativePushRegistration' import { CamerasPage } from '../features/cameras/CamerasPage' import { ClipDetailPage } from '../features/clips/ClipDetailPage' import { ClipsPage } from '../features/clips/ClipsPage' @@ -26,11 +27,15 @@ function RedirectClipDetailToEvent() { function NativeSetupGuard() { const location = useLocation() + const serverBaseUrl = runtimeServerBaseUrlProvider.getBaseUrlSync() + const nativeSetupRequired = isIOSNativeApp() && (!serverBaseUrl || !isRuntimeAuthSessionReady()) - if ( - isIOSNativeApp() && - (!runtimeServerBaseUrlProvider.getBaseUrlSync() || !isRuntimeAuthSessionReady()) - ) { + useNativePushRegistration({ + enabled: isIOSNativeApp() && !nativeSetupRequired, + registrationKey: serverBaseUrl ?? 'ios-native', + }) + + if (nativeSetupRequired) { return ( +} + +export const homeSecDevicePlugin = registerPlugin('HomeSecDevice') diff --git a/ui/src/runtime/nativePushRegistration.test.ts b/ui/src/runtime/nativePushRegistration.test.ts new file mode 100644 index 00000000..99e34a4c --- /dev/null +++ b/ui/src/runtime/nativePushRegistration.test.ts @@ -0,0 +1,193 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + PermissionStatus, + RegistrationError, + Token, +} from '@capacitor/push-notifications' +import type { PluginListenerHandle } from '@capacitor/core' + +import type { MobileDeviceRegisterRequest } from '../api/generated/types' +import type { HomeSecDevicePlugin } from './homeSecDevicePlugin' +import { + registerNativePushDevice, + resetNativePushRegistrationForTests, + type NativePushRegistrationOptions, +} from './nativePushRegistration' + +type PushAdapter = NonNullable +type PushRegistrationMode = 'error' | 'success' + +function listenerHandle(): PluginListenerHandle { + return { + remove: vi.fn(async () => {}), + } +} + +function createPushAdapter({ + initialPermission = 'granted', + mode = 'success', + requestedPermission = 'granted', +}: { + initialPermission?: PermissionStatus['receive'] + mode?: PushRegistrationMode + requestedPermission?: PermissionStatus['receive'] +} = {}): PushAdapter { + const registrationListeners: Array<(token: Token) => void> = [] + const registrationErrorListeners: Array<(error: RegistrationError) => void> = [] + + return { + addListener: vi.fn(async (eventName: string, listener: unknown) => { + if (eventName === 'registration') { + registrationListeners.push(listener as (token: Token) => void) + } + if (eventName === 'registrationError') { + registrationErrorListeners.push(listener as (error: RegistrationError) => void) + } + return listenerHandle() + }), + checkPermissions: vi.fn(async () => ({ receive: initialPermission })), + register: vi.fn(async () => { + queueMicrotask(() => { + if (mode === 'success') { + registrationListeners.forEach((listener) => listener({ value: 'apns-token-123' })) + return + } + registrationErrorListeners.forEach((listener) => + listener({ error: 'registration rejected' }), + ) + }) + }), + requestPermissions: vi.fn(async () => ({ receive: requestedPermission })), + } +} + +function createDevicePlugin(): HomeSecDevicePlugin { + return { + getRegistrationInfo: vi.fn(async () => ({ + apnsEnvironment: 'sandbox' as const, + appVersion: '1.0.0', + bundleId: 'com.levneiman.homesec', + deviceName: "Lev's iPhone", + })), + } +} + +function createRegistrationClient() { + return { + registerMobileDevice: vi.fn(async (payload: MobileDeviceRegisterRequest) => ({ + id: 'dev_1', + platform: 'ios' as const, + environment: payload.environment, + bundle_id: payload.bundle_id, + device_name: payload.device_name ?? null, + app_version: payload.app_version ?? null, + capabilities: payload.capabilities ?? { + deep_links: true, + rich_notifications: false, + }, + enabled: true, + token_fingerprint: 'abcdef123456', + created_at: '2026-06-14T00:00:00Z', + updated_at: '2026-06-14T00:00:00Z', + last_seen_at: '2026-06-14T00:00:00Z', + last_push_at: null, + last_push_error: null, + httpStatus: 201, + })), + } +} + +describe('native push registration', () => { + beforeEach(() => { + resetNativePushRegistrationForTests() + }) + + it('skips registration outside iOS native mode', async () => { + // Given: The app is running outside the iOS native shell + const pushNotifications = createPushAdapter() + const client = createRegistrationClient() + + // When: Native push registration runs + const result = await registerNativePushDevice({ + client, + isIOSNative: () => false, + pushNotifications, + }) + + // Then: No permission prompt, APNs registration, or backend request is attempted + expect(result).toEqual({ status: 'skipped', reason: 'not_ios_native' }) + expect(pushNotifications.checkPermissions).not.toHaveBeenCalled() + expect(client.registerMobileDevice).not.toHaveBeenCalled() + }) + + it('posts the APNs registration result to HomeSec when permission is granted', async () => { + // Given: iOS has notification permission and APNs returns a token + const pushNotifications = createPushAdapter() + const devicePlugin = createDevicePlugin() + const client = createRegistrationClient() + + // When: Native push registration runs + const result = await registerNativePushDevice({ + client, + devicePlugin, + isIOSNative: () => true, + pushNotifications, + }) + + // Then: The device is registered with redacted app/device metadata and current capabilities + expect(result).toEqual({ status: 'registered' }) + expect(client.registerMobileDevice).toHaveBeenCalledWith({ + platform: 'ios', + apns_token: 'apns-token-123', + environment: 'sandbox', + bundle_id: 'com.levneiman.homesec', + device_name: "Lev's iPhone", + app_version: '1.0.0', + capabilities: { + deep_links: true, + rich_notifications: false, + }, + }) + }) + + it('requests permission once and skips backend registration when denied', async () => { + // Given: iOS has not prompted yet and the user denies notification permission + const pushNotifications = createPushAdapter({ + initialPermission: 'prompt', + requestedPermission: 'denied', + }) + const client = createRegistrationClient() + + // When: Native push registration runs + const result = await registerNativePushDevice({ + client, + isIOSNative: () => true, + pushNotifications, + }) + + // Then: The denial is handled without APNs registration or a backend request + expect(result).toEqual({ status: 'skipped', reason: 'permission_not_granted' }) + expect(pushNotifications.requestPermissions).toHaveBeenCalledTimes(1) + expect(pushNotifications.register).not.toHaveBeenCalled() + expect(client.registerMobileDevice).not.toHaveBeenCalled() + }) + + it('handles APNs registration errors without posting a device', async () => { + // Given: APNs registration fails after notification permission is granted + const pushNotifications = createPushAdapter({ mode: 'error' }) + const devicePlugin = createDevicePlugin() + const client = createRegistrationClient() + + // When: Native push registration runs + const result = await registerNativePushDevice({ + client, + devicePlugin, + isIOSNative: () => true, + pushNotifications, + }) + + // Then: The failure is reported and the raw token registration endpoint is not called + expect(result).toEqual({ status: 'failed', reason: 'registration rejected' }) + expect(client.registerMobileDevice).not.toHaveBeenCalled() + }) +}) diff --git a/ui/src/runtime/nativePushRegistration.ts b/ui/src/runtime/nativePushRegistration.ts new file mode 100644 index 00000000..1dc5561d --- /dev/null +++ b/ui/src/runtime/nativePushRegistration.ts @@ -0,0 +1,245 @@ +import { useEffect } from 'react' +import { PushNotifications } from '@capacitor/push-notifications' +import type { + PushNotificationsPlugin, + RegistrationError, + Token, +} from '@capacitor/push-notifications' +import type { PluginListenerHandle } from '@capacitor/core' + +import { HomeSecApiClient } from '../api/client' +import type { MobileDeviceRegisterRequest } from '../api/generated/types' +import { homeSecDevicePlugin, type HomeSecDevicePlugin } from './homeSecDevicePlugin' +import { isIOSNativeApp } from './nativeRuntime' + +type MobileDeviceRegistrationClient = Pick +type NativePushNotifications = Pick< + PushNotificationsPlugin, + 'addListener' | 'checkPermissions' | 'register' | 'requestPermissions' +> + +type NativePushRegistrationStatus = 'failed' | 'registered' | 'skipped' + +export interface NativePushRegistrationResult { + reason?: string + status: NativePushRegistrationStatus +} + +export interface NativePushRegistrationOptions { + client?: MobileDeviceRegistrationClient + devicePlugin?: HomeSecDevicePlugin + isIOSNative?: () => boolean + pushNotifications?: NativePushNotifications + timeoutMs?: number +} + +export interface UseNativePushRegistrationOptions extends NativePushRegistrationOptions { + enabled: boolean + registrationKey: string +} + +const completedRegistrationKeys = new Set() +const inFlightRegistrations = new Map>() + +function describeError(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message + } + if (typeof error === 'string' && error.trim().length > 0) { + return error + } + return 'APNs registration failed' +} + +function nullableTrimmed(value: string | null | undefined): string | null { + const normalized = value?.trim() ?? '' + return normalized.length > 0 ? normalized : null +} + +function shouldRequestPermission(receive: string): boolean { + return receive === 'prompt' || receive === 'prompt-with-rationale' +} + +async function hasPushPermission(pushNotifications: NativePushNotifications): Promise { + let permission = await pushNotifications.checkPermissions() + if (shouldRequestPermission(permission.receive)) { + permission = await pushNotifications.requestPermissions() + } + return permission.receive === 'granted' +} + +async function requestAPNSToken( + pushNotifications: NativePushNotifications, + timeoutMs: number, +): Promise { + let registrationHandle: PluginListenerHandle | null = null + let registrationErrorHandle: PluginListenerHandle | null = null + + return await new Promise((resolve, reject) => { + let settled = false + const timer = globalThis.setTimeout(() => { + rejectOnce(new Error('APNs registration timed out')) + }, timeoutMs) + + function cleanup(): void { + globalThis.clearTimeout(timer) + void registrationHandle?.remove() + void registrationErrorHandle?.remove() + } + + function resolveOnce(token: Token): void { + if (settled) { + return + } + const value = token.value.trim() + if (!value) { + rejectOnce(new Error('APNs registration returned an empty token')) + return + } + settled = true + cleanup() + resolve(value) + } + + function rejectOnce(error: Error): void { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + + async function register(): Promise { + registrationHandle = await pushNotifications.addListener('registration', resolveOnce) + registrationErrorHandle = await pushNotifications.addListener( + 'registrationError', + (error: RegistrationError) => { + rejectOnce(new Error(error.error || 'APNs registration failed')) + }, + ) + await pushNotifications.register() + } + + void register().catch((error: unknown) => rejectOnce(new Error(describeError(error)))) + }) +} + +function buildMobileDeviceRegistration( + apnsToken: string, + info: Awaited>, +): MobileDeviceRegisterRequest { + return { + platform: 'ios', + apns_token: apnsToken, + environment: info.apnsEnvironment, + bundle_id: info.bundleId, + device_name: nullableTrimmed(info.deviceName), + app_version: nullableTrimmed(info.appVersion), + capabilities: { + deep_links: true, + rich_notifications: false, + }, + } +} + +export async function registerNativePushDevice( + options: NativePushRegistrationOptions = {}, +): Promise { + const isIOSNative = options.isIOSNative ?? isIOSNativeApp + if (!isIOSNative()) { + return { status: 'skipped', reason: 'not_ios_native' } + } + + try { + const pushNotifications = options.pushNotifications ?? PushNotifications + if (!(await hasPushPermission(pushNotifications))) { + return { status: 'skipped', reason: 'permission_not_granted' } + } + + const devicePlugin = options.devicePlugin ?? homeSecDevicePlugin + const [info, apnsToken] = await Promise.all([ + devicePlugin.getRegistrationInfo(), + requestAPNSToken(pushNotifications, options.timeoutMs ?? 30_000), + ]) + const client = options.client ?? new HomeSecApiClient() + await client.registerMobileDevice(buildMobileDeviceRegistration(apnsToken, info)) + return { status: 'registered' } + } catch (error) { + const reason = describeError(error) + if (reason.includes('plugin is not implemented on web')) { + return { status: 'skipped', reason: 'push_plugin_unavailable' } + } + return { status: 'failed', reason } + } +} + +function registerOnceForKey( + registrationKey: string, + options: NativePushRegistrationOptions, +): Promise { + if (completedRegistrationKeys.has(registrationKey)) { + return Promise.resolve({ status: 'skipped', reason: 'already_registered' }) + } + + const existing = inFlightRegistrations.get(registrationKey) + if (existing) { + return existing + } + + const registration = registerNativePushDevice(options).then((result) => { + inFlightRegistrations.delete(registrationKey) + if (result.status !== 'failed') { + completedRegistrationKeys.add(registrationKey) + } + return result + }) + inFlightRegistrations.set(registrationKey, registration) + return registration +} + +export function resetNativePushRegistrationForTests(): void { + completedRegistrationKeys.clear() + inFlightRegistrations.clear() +} + +export function useNativePushRegistration({ + client, + devicePlugin, + enabled, + isIOSNative, + pushNotifications, + registrationKey, + timeoutMs, +}: UseNativePushRegistrationOptions): void { + useEffect(() => { + if (!enabled) { + return + } + + let cancelled = false + void registerOnceForKey(registrationKey, { + client, + devicePlugin, + isIOSNative, + pushNotifications, + timeoutMs, + }).then((result) => { + if (!cancelled && result.status === 'failed') { + console.warn(`iOS push registration failed: ${result.reason ?? 'unknown error'}`) + } + }) + + return () => { + cancelled = true + } + }, [ + client, + devicePlugin, + enabled, + isIOSNative, + pushNotifications, + registrationKey, + timeoutMs, + ]) +} From 4b3d810cafbcbb8ac59a29d1acedac3e7995572e Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 14 Jun 2026 02:01:06 -0700 Subject: [PATCH 32/36] feat: add APNs mobile notifier --- pyproject.toml | 2 + src/homesec/models/mobile.py | 9 + src/homesec/plugins/notifiers/__init__.py | 9 +- src/homesec/plugins/notifiers/apns_mobile.py | 390 ++++++++++++++++++ src/homesec/plugins/registry.py | 24 +- .../repository/mobile_device_repository.py | 74 ++++ src/homesec/runtime/worker.py | 14 +- tests/homesec/test_apns_mobile_notifier.py | 212 ++++++++++ .../homesec/test_mobile_device_repository.py | 85 +++- tests/homesec/test_plugin_registration.py | 55 ++- uv.lock | 99 ++++- 11 files changed, 961 insertions(+), 12 deletions(-) create mode 100644 src/homesec/plugins/notifiers/apns_mobile.py create mode 100644 tests/homesec/test_apns_mobile_notifier.py diff --git a/pyproject.toml b/pyproject.toml index 7ba43850..9ec09c0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ dependencies = [ "greenlet>=3.3.0", "pyftpdlib>=2.1.0", "websockets>=16.0", + "cryptography>=46.0.3", + "httpx[http2]>=0.28.1", ] [project.scripts] diff --git a/src/homesec/models/mobile.py b/src/homesec/models/mobile.py index f59ff512..b22a6de8 100644 --- a/src/homesec/models/mobile.py +++ b/src/homesec/models/mobile.py @@ -64,3 +64,12 @@ class MobileDeviceRecord(BaseModel): last_seen_at: datetime | None = None last_push_at: datetime | None = None last_push_error: str | None = None + + +class MobileDevicePushTarget(BaseModel): + """Internal APNs send target containing token material.""" + + id: str + apns_token: str = Field(min_length=1, repr=False) + apns_environment: APNSEnvironment + bundle_id: str diff --git a/src/homesec/plugins/notifiers/__init__.py b/src/homesec/plugins/notifiers/__init__.py index 0b9c0d21..da44b10e 100644 --- a/src/homesec/plugins/notifiers/__init__.py +++ b/src/homesec/plugins/notifiers/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import cast +from typing import Any, cast from pydantic import BaseModel @@ -13,7 +13,11 @@ logger = logging.getLogger(__name__) -def load_notifier_plugin(backend: str, config: dict[str, object] | BaseModel) -> Notifier: +def load_notifier_plugin( + backend: str, + config: dict[str, object] | BaseModel, + **runtime_context: Any, +) -> Notifier: """Load and instantiate a notifier plugin. Args: @@ -33,6 +37,7 @@ def load_notifier_plugin(backend: str, config: dict[str, object] | BaseModel) -> PluginType.NOTIFIER, backend, config, + **runtime_context, ), ) diff --git a/src/homesec/plugins/notifiers/apns_mobile.py b/src/homesec/plugins/notifiers/apns_mobile.py new file mode 100644 index 00000000..9cbcdf3b --- /dev/null +++ b/src/homesec/plugins/notifiers/apns_mobile.py @@ -0,0 +1,390 @@ +"""APNs notifier plugin for registered HomeSec iOS devices.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import os +import time +from collections.abc import Mapping +from datetime import datetime, timezone +from typing import Any, Protocol, cast +from urllib.parse import quote + +import httpx +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, utils +from pydantic import BaseModel, Field, field_validator + +from homesec.interfaces import Notifier +from homesec.models.alert import Alert +from homesec.models.mobile import APNSEnvironment, MobileDevicePushTarget +from homesec.plugins.registry import PluginType, plugin + +logger = logging.getLogger(__name__) + +_APNS_CATEGORY = "HOMESEC_EVENT" +_APNS_PUSH_TYPE = "alert" +_PROVIDER_TOKEN_REFRESH_S = 50 * 60 + + +class _MobileDevicePushRepository(Protocol): + async def list_enabled_apns_targets( + self, + *, + environment: APNSEnvironment, + bundle_id: str, + ) -> list[MobileDevicePushTarget]: + """Return enabled APNs targets for one app bundle/environment.""" + ... + + async def record_push_result( + self, + device_id: str, + *, + error: str | None, + now: datetime | None = None, + ) -> object | None: + """Record the latest APNs send outcome for a device.""" + ... + + +class APNsMobileConfig(BaseModel): + """APNs notifier configuration using Apple token-based provider auth.""" + + model_config = {"extra": "forbid"} + + key_id_env: str = "HOMESEC_APNS_KEY_ID" + team_id_env: str = "HOMESEC_APNS_TEAM_ID" + private_key_env: str = "HOMESEC_APNS_PRIVATE_KEY" + bundle_id: str + environment: APNSEnvironment = "sandbox" + request_timeout_s: float = Field(default=10.0, gt=0) + apns_base_url: str | None = None + mobile_device_repository: Any | None = Field(default=None, exclude=True, repr=False) + + @field_validator("key_id_env", "team_id_env", "private_key_env", "bundle_id") + @classmethod + def _strip_required_text(cls, value: str) -> str: + normalized = value.strip() + if not normalized: + raise ValueError("value must not be blank") + return normalized + + @field_validator("apns_base_url") + @classmethod + def _strip_optional_url(cls, value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().rstrip("/") + return normalized or None + + +class _APNsProviderTokenSigner: + """Creates and caches APNs ES256 provider tokens.""" + + def __init__(self, *, key_id: str, team_id: str, private_key_pem: str) -> None: + self._key_id = key_id + self._team_id = team_id + self._private_key = _load_signing_key(private_key_pem) + self._cached_token: str | None = None + self._cached_issued_at = 0 + + def token(self) -> str: + issued_at = int(time.time()) + if ( + self._cached_token is not None + and issued_at - self._cached_issued_at < _PROVIDER_TOKEN_REFRESH_S + ): + return self._cached_token + + header = {"alg": "ES256", "kid": self._key_id} + claims = {"iss": self._team_id, "iat": issued_at} + signing_input = f"{_base64url_json(header)}.{_base64url_json(claims)}".encode("ascii") + der_signature = self._private_key.sign(signing_input, ec.ECDSA(hashes.SHA256())) + r_value, s_value = utils.decode_dss_signature(der_signature) + raw_signature = r_value.to_bytes(32, "big") + s_value.to_bytes(32, "big") + token = f"{signing_input.decode('ascii')}.{_base64url(raw_signature)}" + self._cached_token = token + self._cached_issued_at = issued_at + return token + + +@plugin(plugin_type=PluginType.NOTIFIER, name="apns_mobile") +class APNsMobileNotifier(Notifier): + """Send plain APNs alert notifications to registered HomeSec iOS devices.""" + + config_cls = APNsMobileConfig + + @classmethod + def create(cls, config: APNsMobileConfig) -> Notifier: + return cls(config) + + def __init__(self, config: APNsMobileConfig) -> None: + self._bundle_id = config.bundle_id + self._environment = config.environment + self._timeout_s = float(config.request_timeout_s) + self._base_url = config.apns_base_url or _default_apns_base_url(config.environment) + self._repository = _require_mobile_repository(config.mobile_device_repository) + self._signer = _build_provider_token_signer(config) + self._client: httpx.AsyncClient | None = None + self._shutdown_called = False + + async def send(self, alert: Alert) -> None: + """Send one alert to all currently enabled iOS APNs targets.""" + if self._shutdown_called: + raise RuntimeError("Notifier has been shut down") + if self._signer is None: + raise RuntimeError("APNs provider credentials missing from environment") + + targets = await self._repository.list_enabled_apns_targets( + environment=self._environment, + bundle_id=self._bundle_id, + ) + if not targets: + logger.info( + "APNs mobile notifier found no enabled targets", + extra={ + "event_type": "apns_mobile_no_targets", + "apns_environment": self._environment, + "bundle_id": self._bundle_id, + }, + ) + return + + payload = build_apns_payload(alert) + provider_token = self._signer.token() + sent_at = datetime.now(timezone.utc) + results = await asyncio.gather( + *( + self._send_to_target( + target, + payload=payload, + provider_token=provider_token, + sent_at=sent_at, + ) + for target in targets + ), + return_exceptions=True, + ) + + successes = 0 + failures: list[str] = [] + for target, result in zip(targets, results, strict=True): + match result: + case bool() as delivered: + if delivered: + successes += 1 + else: + failures.append(target.id) + case BaseException() as exc: + failures.append(target.id) + logger.error( + "APNs mobile send failed while recording device result: device_id=%s " + "error=%s", + target.id, + exc, + exc_info=exc, + ) + + if failures: + logger.warning( + "APNs mobile notifier had failed target deliveries: failed=%d succeeded=%d", + len(failures), + successes, + extra={ + "event_type": "apns_mobile_delivery_partial_failure", + "failed_count": len(failures), + "succeeded_count": successes, + }, + ) + if successes == 0 and failures: + raise RuntimeError(f"APNs delivery failed for {len(failures)} device(s)") + + async def ping(self) -> bool: + """Health check for local APNs notifier configuration.""" + return not self._shutdown_called and self._signer is not None + + async def shutdown(self, timeout: float | None = None) -> None: + """Close the HTTP client used for APNs delivery.""" + _ = timeout + if self._shutdown_called: + return + self._shutdown_called = True + if self._client is not None and not self._client.is_closed: + await self._client.aclose() + + async def _send_to_target( + self, + target: MobileDevicePushTarget, + *, + payload: dict[str, object], + provider_token: str, + sent_at: datetime, + ) -> bool: + headers = { + "authorization": f"bearer {provider_token}", + "apns-topic": self._bundle_id, + "apns-push-type": _APNS_PUSH_TYPE, + "apns-priority": "10", + } + try: + response = await (await self._get_client()).post( + _target_url(self._base_url, target.apns_token), + json=payload, + headers=headers, + ) + except httpx.HTTPError as exc: + error = type(exc).__name__ + await self._repository.record_push_result(target.id, error=error, now=sent_at) + logger.warning( + "APNs mobile send transport failed: device_id=%s error=%s", + target.id, + error, + ) + return False + + if 200 <= response.status_code < 300: + await self._repository.record_push_result(target.id, error=None, now=sent_at) + return True + + reason = _apns_response_reason(response) + error = f"HTTP {response.status_code}: {reason}" + await self._repository.record_push_result(target.id, error=error, now=sent_at) + logger.warning( + "APNs mobile send rejected: device_id=%s status=%d reason=%s", + target.id, + response.status_code, + reason, + ) + return False + + async def _get_client(self) -> httpx.AsyncClient: + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + http2=True, + timeout=httpx.Timeout(self._timeout_s), + ) + return self._client + + +def build_apns_payload(alert: Alert) -> dict[str, object]: + """Build the plain APNs payload for a HomeSec alert.""" + risk_level = str(alert.risk_level) if alert.risk_level is not None else "unknown" + activity_type = _notification_value(alert.activity_type, fallback="activity") + title = f"{alert.camera_name}: {activity_type} detected" + body = _notification_body(alert, risk_level=risk_level) + route = f"/events/{quote(alert.clip_id, safe='')}?from=notification" + + return { + "aps": { + "alert": { + "title": title, + "body": body, + }, + "sound": "default", + "category": _APNS_CATEGORY, + }, + "type": "event_alert", + "event_id": alert.clip_id, + "camera": alert.camera_name, + "risk_level": risk_level, + "activity_type": activity_type, + "route": route, + } + + +def _notification_body(alert: Alert, *, risk_level: str) -> str: + if alert.summary: + return alert.summary.strip() + event_time = alert.ts.strftime("%I:%M %p").lstrip("0") + if risk_level != "unknown": + return f"{risk_level.capitalize()}-risk event at {event_time}." + return f"HomeSec event at {event_time}." + + +def _notification_value(value: str | None, *, fallback: str) -> str: + normalized = value.strip() if value is not None else "" + return normalized or fallback + + +def _target_url(base_url: str, apns_token: str) -> str: + return f"{base_url}/3/device/{quote(apns_token, safe='')}" + + +def _default_apns_base_url(environment: APNSEnvironment) -> str: + if environment == "sandbox": + return "https://api.sandbox.push.apple.com" + return "https://api.push.apple.com" + + +def _require_mobile_repository(value: Any | None) -> _MobileDevicePushRepository: + if value is None: + raise RuntimeError("APNs mobile notifier requires mobile device repository context") + return cast(_MobileDevicePushRepository, value) + + +def _build_provider_token_signer(config: APNsMobileConfig) -> _APNsProviderTokenSigner | None: + key_id = _resolve_env(config.key_id_env) + team_id = _resolve_env(config.team_id_env) + private_key = _resolve_private_key_env(config.private_key_env) + if not key_id: + logger.warning("APNs key id not found in env: %s", config.key_id_env) + if not team_id: + logger.warning("APNs team id not found in env: %s", config.team_id_env) + if not private_key: + logger.warning("APNs private key not found in env: %s", config.private_key_env) + if not (key_id and team_id and private_key): + return None + return _APNsProviderTokenSigner( + key_id=key_id, + team_id=team_id, + private_key_pem=private_key, + ) + + +def _resolve_env(env_name: str) -> str | None: + value = os.getenv(env_name) + if value is None: + return None + normalized = value.strip() + return normalized or None + + +def _resolve_private_key_env(env_name: str) -> str | None: + value = _resolve_env(env_name) + if value is None: + return None + return value.replace("\\n", "\n") + + +def _load_signing_key(private_key_pem: str) -> ec.EllipticCurvePrivateKey: + key = serialization.load_pem_private_key(private_key_pem.encode("utf-8"), password=None) + if not isinstance(key, ec.EllipticCurvePrivateKey): + raise RuntimeError("APNs private key must be an EC private key") + if not isinstance(key.curve, ec.SECP256R1): + raise RuntimeError("APNs private key must use the P-256 curve") + return key + + +def _base64url_json(payload: Mapping[str, object]) -> str: + data = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8") + return _base64url(data) + + +def _base64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _apns_response_reason(response: httpx.Response) -> str: + try: + payload = response.json() + except ValueError: + return "unknown" + if isinstance(payload, dict): + reason = payload.get("reason") + if isinstance(reason, str) and reason.strip(): + return reason.strip()[:200] + return "unknown" diff --git a/src/homesec/plugins/registry.py b/src/homesec/plugins/registry.py index 100610e4..68c28453 100644 --- a/src/homesec/plugins/registry.py +++ b/src/homesec/plugins/registry.py @@ -83,11 +83,12 @@ def load( plugin_cls = self._plugins[name] - # 1. Inject runtime context into config (if the config model supports those fields) - # We merge it into the raw dict so Pydantic can validate it. + # 1. Inject runtime context into config when the config model declares those fields. + # We merge supported values into the raw dict so Pydantic can validate them without + # leaking unrelated runtime-only dependencies into plugins that forbid extras. # This allows injecting "camera_name" into SourceConfig, etc. merged_config = config_dict.copy() - merged_config.update(runtime_context) + merged_config.update(self._filter_runtime_context(plugin_cls, runtime_context)) # 2. Validate configuration validated_config = plugin_cls.config_cls.model_validate(merged_config) @@ -104,7 +105,7 @@ def validate(self, name: str, config_dict: dict[str, Any], **runtime_context: An plugin_cls = self._plugins[name] merged_config = config_dict.copy() - merged_config.update(runtime_context) + merged_config.update(self._filter_runtime_context(plugin_cls, runtime_context)) return plugin_cls.config_cls.model_validate(merged_config) @@ -112,6 +113,21 @@ def get_all(self) -> dict[str, type[PluginProtocol[ConfigT, PluginInterfaceT]]]: """Return all registered plugins.""" return self._plugins.copy() + def _filter_runtime_context( + self, + plugin_cls: type[PluginProtocol[ConfigT, PluginInterfaceT]], + runtime_context: dict[str, Any], + ) -> dict[str, Any]: + supported_context_keys = set(plugin_cls.config_cls.model_fields) + supported_context_keys.update( + field.alias + for field in plugin_cls.config_cls.model_fields.values() + if field.alias is not None + ) + return { + key: value for key, value in runtime_context.items() if key in supported_context_keys + } + # Global Registry Storage # We keep separate registries per type for strict typing diff --git a/src/homesec/repository/mobile_device_repository.py b/src/homesec/repository/mobile_device_repository.py index 8b561999..ea2e8eca 100644 --- a/src/homesec/repository/mobile_device_repository.py +++ b/src/homesec/repository/mobile_device_repository.py @@ -13,7 +13,9 @@ from sqlalchemy.ext.asyncio import AsyncEngine from homesec.models.mobile import ( + APNSEnvironment, MobileDeviceCapabilities, + MobileDevicePushTarget, MobileDeviceRecord, MobileDeviceRegistration, MobileDeviceUpdate, @@ -106,6 +108,71 @@ async def list_devices(self, *, include_disabled: bool = False) -> list[MobileDe return [_device_record_from_mapping(cast(Mapping[str, Any], row)) for row in rows] + async def list_enabled_apns_targets( + self, + *, + environment: APNSEnvironment, + bundle_id: str, + ) -> list[MobileDevicePushTarget]: + """List enabled APNs targets for one app bundle/environment. + + The returned records include APNs token material and must stay inside + notifier delivery code. API routes should use list_devices() instead. + """ + stmt = ( + select( + MobileDevice.id, + MobileDevice.apns_token_encrypted, + MobileDevice.apns_environment, + MobileDevice.bundle_id, + ) + .where(MobileDevice.enabled.is_(True)) + .where(MobileDevice.platform == "ios") + .where(MobileDevice.apns_environment == environment) + .where(MobileDevice.bundle_id == bundle_id) + .order_by(MobileDevice.updated_at.desc(), MobileDevice.id.asc()) + ) + + async with self._engine.connect() as conn: + rows = (await conn.execute(stmt)).mappings().all() + + return [ + MobileDevicePushTarget( + id=str(row["id"]), + apns_token=str(row["apns_token_encrypted"]), + apns_environment=row["apns_environment"], + bundle_id=str(row["bundle_id"]), + ) + for row in rows + ] + + async def record_push_result( + self, + device_id: str, + *, + error: str | None, + now: datetime | None = None, + ) -> MobileDeviceRecord | None: + """Record the latest APNs send attempt for a mobile device.""" + recorded_at = _utc_now() if now is None else now + stmt = ( + update(MobileDevice) + .where(MobileDevice.id == device_id) + .values( + last_push_at=recorded_at, + last_push_error=_normalize_last_push_error(error), + updated_at=recorded_at, + ) + .returning(*_device_record_columns()) + ) + async with self._engine.begin() as conn: + row = cast( + Mapping[str, Any] | None, (await conn.execute(stmt)).mappings().one_or_none() + ) + if row is None: + return None + return _device_record_from_mapping(row) + async def update_device( self, device_id: str, @@ -194,6 +261,13 @@ def _normalize_apns_token(apns_token: str) -> str: return apns_token.strip() +def _normalize_last_push_error(error: str | None) -> str | None: + normalized = error.strip() if error is not None else "" + if not normalized: + return None + return normalized[:500] + + def _new_device_id() -> str: return f"dev_{secrets.token_urlsafe(16)}" diff --git a/src/homesec/runtime/worker.py b/src/homesec/runtime/worker.py index 97286be9..45129d86 100644 --- a/src/homesec/runtime/worker.py +++ b/src/homesec/runtime/worker.py @@ -30,6 +30,7 @@ from homesec.plugins.alert_policies import load_alert_policy from homesec.plugins.notifiers import load_notifier_plugin from homesec.plugins.sources import load_source_plugin +from homesec.repository.mobile_device_repository import MobileDeviceRepository from homesec.runtime.assembly import RuntimeAssembler from homesec.runtime.bootstrap import ( RuntimePersistenceStack, @@ -61,6 +62,7 @@ WorkerTalkStatusPayload, WorkerTalkStopPayload, ) +from homesec.state.postgres import PostgresStateStore if TYPE_CHECKING: from homesec.interfaces import ( @@ -308,10 +310,20 @@ async def _build_runtime_persistence_stack(self) -> RuntimePersistenceStack: def _create_notifier(self, config: Config) -> tuple[Notifier, list[NotifierEntry]]: entries: list[NotifierEntry] = [] + runtime_context: dict[str, object] = {} + if isinstance(self._state_store, PostgresStateStore): + runtime_context["mobile_device_repository"] = MobileDeviceRepository( + self._state_store.engine + ) + for index, notifier_cfg in enumerate(config.notifiers): if not notifier_cfg.enabled: continue - notifier = load_notifier_plugin(notifier_cfg.backend, notifier_cfg.config) + notifier = load_notifier_plugin( + notifier_cfg.backend, + notifier_cfg.config, + **runtime_context, + ) entries.append( NotifierEntry(name=f"{notifier_cfg.backend}[{index}]", notifier=notifier) ) diff --git a/tests/homesec/test_apns_mobile_notifier.py b/tests/homesec/test_apns_mobile_notifier.py new file mode 100644 index 00000000..08ce33c6 --- /dev/null +++ b/tests/homesec/test_apns_mobile_notifier.py @@ -0,0 +1,212 @@ +"""Tests for the APNs mobile notifier.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +import httpx +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec + +from homesec.models.alert import Alert +from homesec.models.mobile import MobileDevicePushTarget +from homesec.plugins.notifiers.apns_mobile import ( + APNsMobileConfig, + APNsMobileNotifier, + build_apns_payload, +) + + +class _FakeMobileDeviceRepository: + def __init__(self, targets: list[MobileDevicePushTarget]) -> None: + self.targets = targets + self.list_calls: list[tuple[str, str]] = [] + self.recorded_results: list[tuple[str, str | None, datetime | None]] = [] + + async def list_enabled_apns_targets( + self, + *, + environment: str, + bundle_id: str, + ) -> list[MobileDevicePushTarget]: + self.list_calls.append((environment, bundle_id)) + return self.targets + + async def record_push_result( + self, + device_id: str, + *, + error: str | None, + now: datetime | None = None, + ) -> None: + self.recorded_results.append((device_id, error, now)) + + +class _FakeAPNsClient: + def __init__(self, responses: list[httpx.Response]) -> None: + self.responses = responses + self.requests: list[dict[str, Any]] = [] + self.is_closed = False + + async def post( + self, + url: str, + *, + json: dict[str, object], + headers: dict[str, str], + ) -> httpx.Response: + self.requests.append({"headers": headers, "json": json, "url": url}) + return self.responses.pop(0) + + async def aclose(self) -> None: + self.is_closed = True + + +def _private_key_pem() -> str: + private_key = ec.generate_private_key(ec.SECP256R1()) + return private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + +def _sample_alert(**overrides: Any) -> Alert: + defaults: dict[str, Any] = { + "clip_id": "clip_123", + "camera_name": "front_door", + "storage_uri": "mock://clip_123", + "view_url": "http://example.test/clip_123", + "risk_level": "high", + "activity_type": "person", + "notify_reason": "risk_level=high", + "summary": "Person near the front door.", + "ts": datetime(2026, 6, 14, 8, 30, tzinfo=timezone.utc), + "dedupe_key": "clip_123", + "upload_failed": False, + } + defaults.update(overrides) + return Alert(**defaults) + + +def _config(repository: _FakeMobileDeviceRepository) -> APNsMobileConfig: + return APNsMobileConfig( + key_id_env="TEST_APNS_KEY_ID", + team_id_env="TEST_APNS_TEAM_ID", + private_key_env="TEST_APNS_PRIVATE_KEY", + bundle_id="com.levneiman.homesec", + environment="sandbox", + mobile_device_repository=repository, + ) + + +def test_build_apns_payload_includes_plain_event_route_without_rich_media() -> None: + # Given: A HomeSec alert for an analyzed clip + alert = _sample_alert() + + # When: Building the plain APNs payload + payload = build_apns_payload(alert) + + # Then: The payload includes the notification route and event context + assert payload["type"] == "event_alert" + assert payload["event_id"] == "clip_123" + assert payload["route"] == "/events/clip_123?from=notification" + assert payload["camera"] == "front_door" + assert payload["risk_level"] == "high" + assert payload["activity_type"] == "person" + + # And: Plain push v1 does not request rich notification thumbnail handling + aps = payload["aps"] + assert isinstance(aps, dict) + assert aps["category"] == "HOMESEC_EVENT" + assert "mutable-content" not in aps + assert "thumbnail" not in str(payload).lower() + + +@pytest.mark.asyncio +async def test_apns_notifier_sends_payload_to_registered_targets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given: A configured APNs notifier with one enabled target and mocked HTTP/2 client + monkeypatch.setenv("TEST_APNS_KEY_ID", "KEY1234567") + monkeypatch.setenv("TEST_APNS_TEAM_ID", "TEAM123456") + monkeypatch.setenv("TEST_APNS_PRIVATE_KEY", _private_key_pem()) + repository = _FakeMobileDeviceRepository( + [ + MobileDevicePushTarget( + id="dev_1", + apns_token="apns-token-1", + apns_environment="sandbox", + bundle_id="com.levneiman.homesec", + ) + ] + ) + fake_client = _FakeAPNsClient([httpx.Response(200)]) + monkeypatch.setattr( + "homesec.plugins.notifiers.apns_mobile.httpx.AsyncClient", + lambda **_kwargs: fake_client, + ) + notifier = APNsMobileNotifier(_config(repository)) + + # When: Sending a HomeSec alert + await notifier.send(_sample_alert()) + + # Then: The notifier queries the repository with the configured APNs scope + assert repository.list_calls == [("sandbox", "com.levneiman.homesec")] + + # And: APNs receives the expected route payload and required provider headers + assert len(fake_client.requests) == 1 + request = fake_client.requests[0] + assert request["url"] == "https://api.sandbox.push.apple.com/3/device/apns-token-1" + assert request["json"]["route"] == "/events/clip_123?from=notification" + headers = request["headers"] + assert headers["apns-topic"] == "com.levneiman.homesec" + assert headers["apns-push-type"] == "alert" + assert headers["authorization"].startswith("bearer ") + + # And: Successful delivery clears the device push error + assert len(repository.recorded_results) == 1 + assert repository.recorded_results[0][0] == "dev_1" + assert repository.recorded_results[0][1] is None + + await notifier.shutdown() + assert fake_client.is_closed is True + + +@pytest.mark.asyncio +async def test_apns_notifier_records_rejected_devices_and_raises_when_all_fail( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given: APNs rejects the only enabled target + monkeypatch.setenv("TEST_APNS_KEY_ID", "KEY1234567") + monkeypatch.setenv("TEST_APNS_TEAM_ID", "TEAM123456") + monkeypatch.setenv("TEST_APNS_PRIVATE_KEY", _private_key_pem()) + repository = _FakeMobileDeviceRepository( + [ + MobileDevicePushTarget( + id="dev_bad", + apns_token="bad-token", + apns_environment="sandbox", + bundle_id="com.levneiman.homesec", + ) + ] + ) + fake_client = _FakeAPNsClient([httpx.Response(400, json={"reason": "BadDeviceToken"})]) + monkeypatch.setattr( + "homesec.plugins.notifiers.apns_mobile.httpx.AsyncClient", + lambda **_kwargs: fake_client, + ) + notifier = APNsMobileNotifier(_config(repository)) + + # When: Sending the alert + with pytest.raises(RuntimeError, match="APNs delivery failed"): + await notifier.send(_sample_alert()) + + # Then: The rejection is recorded against the device without logging token material + assert len(repository.recorded_results) == 1 + device_id, error, recorded_at = repository.recorded_results[0] + assert device_id == "dev_bad" + assert error == "HTTP 400: BadDeviceToken" + assert recorded_at is not None diff --git a/tests/homesec/test_mobile_device_repository.py b/tests/homesec/test_mobile_device_repository.py index d0b82d2b..99bcc122 100644 --- a/tests/homesec/test_mobile_device_repository.py +++ b/tests/homesec/test_mobile_device_repository.py @@ -20,14 +20,16 @@ def _registration( *, + apns_environment: str = "sandbox", apns_token: str = "raw-apns-token-123", + bundle_id: str = "com.levneiman.homesec", device_name: str = "Lev's iPhone", app_version: str = "1.0.0", ) -> MobileDeviceRegistration: return MobileDeviceRegistration( apns_token=apns_token, - apns_environment="sandbox", - bundle_id="com.levneiman.homesec", + apns_environment=apns_environment, + bundle_id=bundle_id, device_name=device_name, app_version=app_version, ) @@ -144,6 +146,85 @@ async def test_disable_device_hides_record_without_deleting_it( await state_store.shutdown() +@pytest.mark.asyncio +async def test_list_enabled_apns_targets_filters_disabled_environment_and_bundle( + postgres_dsn: str, + clean_test_db: None, +) -> None: + # Given: Mobile devices across enabled state, APNs environment, and bundle id + state_store = PostgresStateStore(postgres_dsn) + await state_store.initialize() + repository = MobileDeviceRepository(state_store.engine) + enabled_match = await repository.register_device( + _registration(apns_token="enabled-sandbox-token") + ) + disabled_match = await repository.register_device( + _registration(apns_token="disabled-sandbox-token") + ) + await repository.disable_device(disabled_match.id) + await repository.register_device( + _registration(apns_environment="production", apns_token="production-token") + ) + await repository.register_device( + _registration(apns_token="other-bundle-token", bundle_id="com.example.other") + ) + + # When: Listing sandbox APNs push targets for the HomeSec bundle + targets = await repository.list_enabled_apns_targets( + environment="sandbox", + bundle_id="com.levneiman.homesec", + ) + + # Then: Only the enabled matching iOS target is returned with token material + assert len(targets) == 1 + assert targets[0].id == enabled_match.id + assert targets[0].apns_token == "enabled-sandbox-token" + assert targets[0].apns_environment == "sandbox" + assert targets[0].bundle_id == "com.levneiman.homesec" + + await state_store.shutdown() + + +@pytest.mark.asyncio +async def test_record_push_result_updates_last_push_status( + postgres_dsn: str, + clean_test_db: None, +) -> None: + # Given: A registered mobile device + state_store = PostgresStateStore(postgres_dsn) + await state_store.initialize() + repository = MobileDeviceRepository(state_store.engine) + registered = await repository.register_device(_registration()) + failed_at = datetime(2026, 6, 14, 8, 10, tzinfo=timezone.utc) + + # When: Recording a failed APNs delivery + failed = await repository.record_push_result( + registered.id, + error=" BadDeviceToken ", + now=failed_at, + ) + + # Then: The latest push attempt and normalized error are persisted + assert failed is not None + assert failed.last_push_at == failed_at + assert failed.last_push_error == "BadDeviceToken" + + # When: Recording a later successful APNs delivery + succeeded_at = datetime(2026, 6, 14, 8, 15, tzinfo=timezone.utc) + succeeded = await repository.record_push_result( + registered.id, + error=None, + now=succeeded_at, + ) + + # Then: The latest push time is refreshed and the previous error is cleared + assert succeeded is not None + assert succeeded.last_push_at == succeeded_at + assert succeeded.last_push_error is None + + await state_store.shutdown() + + @pytest.mark.asyncio async def test_reregistering_disabled_device_preserves_disabled_state( postgres_dsn: str, diff --git a/tests/homesec/test_plugin_registration.py b/tests/homesec/test_plugin_registration.py index a7bba924..954466b4 100644 --- a/tests/homesec/test_plugin_registration.py +++ b/tests/homesec/test_plugin_registration.py @@ -3,9 +3,15 @@ from __future__ import annotations import pytest -from pydantic import BaseModel +from pydantic import BaseModel, Field -from homesec.plugins.registry import PluginType, get_plugin_names, load_plugin, plugin +from homesec.plugins.registry import ( + PluginType, + get_plugin_names, + load_plugin, + plugin, + validate_plugin, +) class DummyConfig(BaseModel): @@ -87,3 +93,48 @@ def test_unknown_plugin_error() -> None: """Test loading unknown plugin raises ValueError.""" with pytest.raises(ValueError): load_plugin(PluginType.SOURCE, "missing_plugin", {}) + + +def test_runtime_context_filters_unknown_keys_and_supports_aliases( + clean_registry: None, +) -> None: + class RuntimeConfig(BaseModel): + model_config = {"extra": "forbid"} + + foo: str + runtime_value: str = Field(default="", alias="__runtime_value__") + + class RuntimePlugin: + config_cls = RuntimeConfig + + def __init__(self, config: RuntimeConfig) -> None: + self.config = config + + @classmethod + def create(cls, config: RuntimeConfig) -> RuntimePlugin: + return cls(config) + + # Given: A strict plugin config with an alias-only runtime context field + plugin(plugin_type=PluginType.SOURCE, name="runtime_source")(RuntimePlugin) + + # When: Loading and validating with both supported and unrelated runtime context + loaded = load_plugin( + PluginType.SOURCE, + "runtime_source", + {"foo": "configured"}, + __runtime_value__="injected", + unrelated_dependency=object(), + ) + validated = validate_plugin( + PluginType.SOURCE, + "runtime_source", + {"foo": "configured"}, + __runtime_value__="injected", + unrelated_dependency=object(), + ) + + # Then: Supported alias context is injected and unknown context is ignored + assert isinstance(loaded, RuntimePlugin) + assert loaded.config.runtime_value == "injected" + assert isinstance(validated, RuntimeConfig) + assert validated.runtime_value == "injected" diff --git a/uv.lock b/uv.lock index aedfa46a..db8e4b47 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.12' and sys_platform == 'win32'", @@ -842,6 +842,63 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "49.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, + { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, + { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, + { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, + { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, + { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, + { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, + { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, + { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, + { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, + { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, + { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, + { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, + { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, + { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, + { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, + { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, +] + [[package]] name = "cycler" version = "0.12.1" @@ -1221,6 +1278,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + [[package]] name = "homesec" version = "1.11.0" @@ -1230,10 +1300,12 @@ dependencies = [ { name = "alembic" }, { name = "anyio" }, { name = "asyncpg" }, + { name = "cryptography" }, { name = "dropbox" }, { name = "fastapi" }, { name = "fire" }, { name = "greenlet" }, + { name = "httpx", extra = ["http2"] }, { name = "onvif-zeep-async" }, { name = "opencv-python" }, { name = "paho-mqtt" }, @@ -1268,10 +1340,12 @@ requires-dist = [ { name = "alembic", specifier = ">=1.13.0" }, { name = "anyio", specifier = ">=4.0.0" }, { name = "asyncpg", specifier = ">=0.29.0" }, + { name = "cryptography", specifier = ">=46.0.3" }, { name = "dropbox", specifier = ">=12.0.2" }, { name = "fastapi", specifier = ">=0.111.0" }, { name = "fire", specifier = ">=0.7.1" }, { name = "greenlet", specifier = ">=3.3.0" }, + { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" }, { name = "onvif-zeep-async", specifier = ">=4.0.0" }, { name = "opencv-python", specifier = ">=4.12.0.88" }, { name = "paho-mqtt", specifier = ">=2.1.0" }, @@ -1300,6 +1374,15 @@ dev = [ { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, ] +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -1328,6 +1411,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + [[package]] name = "idna" version = "3.11" From 90f002d6595a7d89e6bd7194dceb7f3489f844f2 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 14 Jun 2026 02:15:31 -0700 Subject: [PATCH 33/36] feat: route iOS notification taps --- ui/src/runtime/nativeDeepLinkRoutes.ts | 28 +++++ ui/src/runtime/nativeDeepLinks.test.tsx | 159 +++++++++++++++++++++++- ui/src/runtime/nativeDeepLinks.tsx | 53 +++++++- 3 files changed, 229 insertions(+), 11 deletions(-) diff --git a/ui/src/runtime/nativeDeepLinkRoutes.ts b/ui/src/runtime/nativeDeepLinkRoutes.ts index 4fb949ea..498e63a5 100644 --- a/ui/src/runtime/nativeDeepLinkRoutes.ts +++ b/ui/src/runtime/nativeDeepLinkRoutes.ts @@ -12,6 +12,10 @@ const ALLOWED_DEEP_LINK_ROUTE_PREFIXES = [ '/home', ] +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + function routeIsAllowed(route: string): boolean { return ALLOWED_DEEP_LINK_ROUTE_PREFIXES.some((prefix) => { return route === prefix || route.startsWith(`${prefix}/`) @@ -42,3 +46,27 @@ export function parseNativeDeepLinkRoute(rawUrl: string): string | null { return `${pathname}${url.search}${url.hash}` } + +export function parseNativeNotificationRoute(data: unknown): string | null { + if (!isRecord(data) || typeof data.route !== 'string') { + return null + } + + const route = data.route.trim() + if (!route.startsWith('/') || route.startsWith('//')) { + return null + } + + let url: URL + try { + url = new URL(route, 'https://homesec.local') + } catch { + return null + } + + if (!routeIsAllowed(url.pathname)) { + return DEFAULT_DEEP_LINK_ROUTE + } + + return `${url.pathname}${url.search}${url.hash}` +} diff --git a/ui/src/runtime/nativeDeepLinks.test.tsx b/ui/src/runtime/nativeDeepLinks.test.tsx index be4f47e4..8b2fb0c9 100644 --- a/ui/src/runtime/nativeDeepLinks.test.tsx +++ b/ui/src/runtime/nativeDeepLinks.test.tsx @@ -3,6 +3,7 @@ import { act, cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' +import type { ActionPerformed } from '@capacitor/push-notifications' const nativeRuntimeMock = vi.hoisted(() => ({ isIOSNativeApp: vi.fn<() => boolean>(() => false), @@ -12,7 +13,7 @@ vi.mock('./nativeRuntime', () => ({ isIOSNativeApp: () => nativeRuntimeMock.isIOSNativeApp(), })) -import { parseNativeDeepLinkRoute } from './nativeDeepLinkRoutes' +import { parseNativeDeepLinkRoute, parseNativeNotificationRoute } from './nativeDeepLinkRoutes' import { NativeDeepLinkRouter } from './nativeDeepLinks' type DeepLinkEvent = { @@ -27,11 +28,47 @@ type TestNativeDeepLinkApp = { ) => Promise<{ remove: () => Promise }> } +type TestNativePushNotifications = { + addListener: ( + eventName: 'pushNotificationActionPerformed', + listenerFunc: (event: ActionPerformed) => void, + ) => Promise<{ remove: () => Promise }> +} + function LocationProbe() { const location = useLocation() return

{`${location.pathname}${location.search}${location.hash}`}

} +function createNativePushNotifications() { + let pushActionListener: ((event: ActionPerformed) => void) | null = null + const remove = vi.fn(async () => {}) + const pushNotifications: TestNativePushNotifications = { + addListener: vi.fn(async ( + eventName: 'pushNotificationActionPerformed', + listenerFunc: (event: ActionPerformed) => void, + ) => { + expect(eventName).toBe('pushNotificationActionPerformed') + pushActionListener = listenerFunc + return { remove } + }), + } + + return { + pushNotifications, + emitAction(data: unknown) { + pushActionListener?.({ + actionId: 'tap', + notification: { + id: 'notif_1', + data, + }, + }) + }, + remove, + } +} + function createNativeDeepLinkApp(launchUrl?: string | null) { let appUrlOpenListener: ((event: DeepLinkEvent) => void) | null = null const remove = vi.fn(async () => {}) @@ -90,10 +127,11 @@ function createDeferredNativeDeepLinkApp() { function renderNativeDeepLinkRouter( app: TestNativeDeepLinkApp, initialPath = '/live', + pushNotifications = createNativePushNotifications().pushNotifications, ) { render( - + } /> @@ -104,11 +142,14 @@ function renderNativeDeepLinkRouter( function renderToggleableNativeDeepLinkRouter( app: TestNativeDeepLinkApp, initialPath = '/live', + pushNotifications = createNativePushNotifications().pushNotifications, ) { function Harness({ enabled }: { enabled: boolean }) { return ( - {enabled ? : null} + {enabled ? ( + + ) : null} } /> @@ -175,6 +216,42 @@ describe('parseNativeDeepLinkRoute', () => { }) }) +describe('parseNativeNotificationRoute', () => { + it('accepts APNs payload event routes', () => { + // Given: A plain APNs payload with the HomeSec event route + const route = parseNativeNotificationRoute({ + route: '/events/clip-123?from=notification', + }) + + // Then: The app-relative route is safe to pass to React Router + expect(route).toBe('/events/clip-123?from=notification') + }) + + it('falls back safely for unsupported APNs payload routes', () => { + // Given: A notification payload attempts to open an unsupported app route + const route = parseNativeNotificationRoute({ route: '/admin/secrets' }) + + // Then: The native router falls back to the default safe route + expect(route).toBe('/live') + }) + + it('ignores missing or non-relative APNs payload routes', () => { + // Given: Notification payloads without a valid app-relative route + const missingRoute = parseNativeNotificationRoute({}) + const externalRoute = parseNativeNotificationRoute({ + route: 'https://homesec.example.com/events/clip-123', + }) + const protocolRelativeRoute = parseNativeNotificationRoute({ + route: '//homesec.example.com/events/clip-123', + }) + + // Then: The app ignores them rather than navigating to untrusted content + expect(missingRoute).toBeNull() + expect(externalRoute).toBeNull() + expect(protocolRelativeRoute).toBeNull() + }) +}) + describe('NativeDeepLinkRouter', () => { beforeEach(() => { nativeRuntimeMock.isIOSNativeApp.mockReturnValue(true) @@ -221,6 +298,44 @@ describe('NativeDeepLinkRouter', () => { ) }) + it('routes notification tap actions into the React app', async () => { + // Given: The app has started and registered a push notification action listener + const nativeApp = createNativeDeepLinkApp() + const nativePush = createNativePushNotifications() + renderNativeDeepLinkRouter(nativeApp.app, '/live', nativePush.pushNotifications) + await waitFor(() => { + expect(nativePush.pushNotifications.addListener).toHaveBeenCalledTimes(1) + }) + + // When: iOS reports that the user tapped a HomeSec APNs notification + await act(async () => { + nativePush.emitAction({ route: '/events/push-clip?from=notification' }) + }) + + // Then: React Router opens the event detail route from the payload + expect(screen.getByTestId('location').textContent).toBe( + '/events/push-clip?from=notification', + ) + }) + + it('falls back to Live for unsupported notification tap routes', async () => { + // Given: The app receives a notification action with an unsupported route + const nativeApp = createNativeDeepLinkApp() + const nativePush = createNativePushNotifications() + renderNativeDeepLinkRouter(nativeApp.app, '/events', nativePush.pushNotifications) + await waitFor(() => { + expect(nativePush.pushNotifications.addListener).toHaveBeenCalledTimes(1) + }) + + // When: The notification action is delivered + await act(async () => { + nativePush.emitAction({ route: '/admin/secrets' }) + }) + + // Then: The app navigates to a safe default route + expect(screen.getByTestId('location').textContent).toBe('/live') + }) + it('falls back to Live for unsupported HomeSec appUrlOpen routes', async () => { // Given: The app receives an invalid route under the HomeSec scheme const nativeApp = createNativeDeepLinkApp() @@ -255,9 +370,11 @@ describe('NativeDeepLinkRouter', () => { it('removes the appUrlOpen listener on unmount', async () => { // Given: The deep-link router registered a native listener const nativeApp = createNativeDeepLinkApp() - renderNativeDeepLinkRouter(nativeApp.app, '/live') + const nativePush = createNativePushNotifications() + renderNativeDeepLinkRouter(nativeApp.app, '/live', nativePush.pushNotifications) await waitFor(() => { expect(nativeApp.app.addListener).toHaveBeenCalledTimes(1) + expect(nativePush.pushNotifications.addListener).toHaveBeenCalledTimes(1) }) // When: React unmounts the router @@ -265,14 +382,21 @@ describe('NativeDeepLinkRouter', () => { // Then: The native listener is removed expect(nativeApp.remove).toHaveBeenCalledTimes(1) + expect(nativePush.remove).toHaveBeenCalledTimes(1) }) it('ignores stale appUrlOpen events after cleanup', async () => { // Given: Native listener registration captured a callback but has not resolved yet const nativeApp = createDeferredNativeDeepLinkApp() - const view = renderToggleableNativeDeepLinkRouter(nativeApp.app, '/live') + const nativePush = createNativePushNotifications() + const view = renderToggleableNativeDeepLinkRouter( + nativeApp.app, + '/live', + nativePush.pushNotifications, + ) await waitFor(() => { expect(nativeApp.app.addListener).toHaveBeenCalledTimes(1) + expect(nativePush.pushNotifications.addListener).toHaveBeenCalledTimes(1) }) // When: React removes the deep-link router before the native listener resolves @@ -285,5 +409,30 @@ describe('NativeDeepLinkRouter', () => { // Then: The stale native callback does not navigate after cleanup expect(screen.getByTestId('location').textContent).toBe('/live') expect(nativeApp.remove).toHaveBeenCalledTimes(1) + expect(nativePush.remove).toHaveBeenCalledTimes(1) + }) + + it('ignores stale notification tap actions after cleanup', async () => { + // Given: Native notification action registration captured a callback + const nativeApp = createNativeDeepLinkApp() + const nativePush = createNativePushNotifications() + const view = renderToggleableNativeDeepLinkRouter( + nativeApp.app, + '/live', + nativePush.pushNotifications, + ) + await waitFor(() => { + expect(nativePush.pushNotifications.addListener).toHaveBeenCalledTimes(1) + }) + + // When: React removes the router before a stale notification action arrives + view.disableRouter() + await act(async () => { + nativePush.emitAction({ route: '/events/stale-push?from=notification' }) + }) + + // Then: The stale native callback does not navigate after cleanup + expect(screen.getByTestId('location').textContent).toBe('/live') + expect(nativePush.remove).toHaveBeenCalledTimes(1) }) }) diff --git a/ui/src/runtime/nativeDeepLinks.tsx b/ui/src/runtime/nativeDeepLinks.tsx index 53ed4330..53e6e3a4 100644 --- a/ui/src/runtime/nativeDeepLinks.tsx +++ b/ui/src/runtime/nativeDeepLinks.tsx @@ -1,9 +1,11 @@ import { useCallback, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { App } from '@capacitor/app' +import { PushNotifications } from '@capacitor/push-notifications' import type { PluginListenerHandle } from '@capacitor/core' +import type { ActionPerformed } from '@capacitor/push-notifications' -import { parseNativeDeepLinkRoute } from './nativeDeepLinkRoutes' +import { parseNativeDeepLinkRoute, parseNativeNotificationRoute } from './nativeDeepLinkRoutes' import { isIOSNativeApp } from './nativeRuntime' interface NativeDeepLinkEvent { @@ -18,7 +20,20 @@ interface NativeDeepLinkApp { ) => Promise } -export function NativeDeepLinkRouter({ app = App }: { app?: NativeDeepLinkApp }) { +interface NativePushNotificationActions { + addListener: ( + eventName: 'pushNotificationActionPerformed', + listenerFunc: (notification: ActionPerformed) => void, + ) => Promise +} + +export function NativeDeepLinkRouter({ + app = App, + pushNotifications = PushNotifications, +}: { + app?: NativeDeepLinkApp + pushNotifications?: NativePushNotificationActions +}) { const navigate = useNavigate() const navigateRef = useRef(navigate) const isIOS = isIOSNativeApp() @@ -41,13 +56,24 @@ export function NativeDeepLinkRouter({ app = App }: { app?: NativeDeepLinkApp }) navigateRef.current(route, { replace: options.replace }) }, []) + const navigateToNotificationRoute = useCallback(( + action: ActionPerformed, + options: { replace: boolean }, + ) => { + const route = parseNativeNotificationRoute(action.notification.data) + if (route === null) { + return + } + navigateRef.current(route, { replace: options.replace }) + }, []) + useEffect(() => { if (!isIOS) { return } let cancelled = false - let handle: PluginListenerHandle | null = null + const handles: PluginListenerHandle[] = [] void app.getLaunchUrl() .then((event) => { @@ -68,17 +94,32 @@ export function NativeDeepLinkRouter({ app = App }: { app?: NativeDeepLinkApp }) void nextHandle.remove() return } - handle = nextHandle + handles.push(nextHandle) + }) + .catch(() => {}) + + void pushNotifications.addListener('pushNotificationActionPerformed', (action) => { + if (cancelled) { + return + } + navigateToNotificationRoute(action, { replace: false }) + }) + .then((nextHandle) => { + if (cancelled) { + void nextHandle.remove() + return + } + handles.push(nextHandle) }) .catch(() => {}) return () => { cancelled = true - if (handle !== null) { + for (const handle of handles) { void handle.remove() } } - }, [app, isIOS, navigateToDeepLink]) + }, [app, isIOS, navigateToDeepLink, navigateToNotificationRoute, pushNotifications]) return null } From 8e9ed88c27a5d7dc73b2c576594a9cf589f7e4d1 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 14 Jun 2026 02:32:31 -0700 Subject: [PATCH 34/36] fix: keep iOS SPM manifest on Swift tools 6.2 --- ui/capacitor.config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/ui/capacitor.config.ts b/ui/capacitor.config.ts index 7662a0be..bdaf9dd0 100644 --- a/ui/capacitor.config.ts +++ b/ui/capacitor.config.ts @@ -4,6 +4,13 @@ const config: CapacitorConfig = { appId: 'com.levneiman.homesec', appName: 'HomeSec', webDir: 'dist', + experimental: { + ios: { + spm: { + swiftToolsVersion: '6.2', + }, + }, + }, } export default config From a38998e02f7f95fff1ead65fb29cf2a487b74eb8 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 14 Jun 2026 02:37:02 -0700 Subject: [PATCH 35/36] docs: add iOS personal build runbook --- docs/ios-build-runbook.md | 214 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/ios-build-runbook.md diff --git a/docs/ios-build-runbook.md b/docs/ios-build-runbook.md new file mode 100644 index 00000000..acbfa252 --- /dev/null +++ b/docs/ios-build-runbook.md @@ -0,0 +1,214 @@ +# HomeSec iOS Build And Runbook + +Last reviewed: 2026-06-14 + +This runbook covers personal HomeSec iPhone and iPad builds from this repo. +The current app is a Capacitor iOS shell around the React UI in `ui/`. + +HomeSec intentionally supports the latest iOS major only. The native project +currently builds with the installed iOS 26.5 SDK and has +`IPHONEOS_DEPLOYMENT_TARGET = 26.0`. Older iOS 17/18 simulator runtimes may be +installed locally, but they are not supported targets for this app stream. + +## Prerequisites + +- macOS with Xcode installed and selected by `xcode-select`. +- Xcode command line tools available: `xcodebuild -version` should succeed. +- Node compatible with `ui/package.json` (`>=22.12.0`). +- pnpm compatible with the repo lockfile. +- Python/uv dependencies installed for backend validation. +- An Apple Developer account/team for real-device signing and APNs. +- A reachable HomeSec server over HTTPS, VPN, or local LAN. + +Recommended preflight: + +```bash +xcodebuild -showsdks +xcrun devicectl list devices +uv sync +pnpm --dir ui install +``` + +`xcodebuild -showsdks` should show an iOS SDK matching the latest supported +major version. On 2026-06-14 this repo was validated with iOS SDK 26.5 and iOS +Simulator SDK 26.5. + +## Local Development Build + +Use this path for simulator work and web/native asset sync checks. + +```bash +pnpm --dir ui ios:sync +xcodebuild \ + -project ui/ios/App/App.xcodeproj \ + -scheme App \ + -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5' \ + -configuration Debug \ + -derivedDataPath /tmp/homesec-ios-qa \ + build +``` + +The `ios:sync` script builds the React app, copies web assets into +`ui/ios/App/App/public`, and regenerates the local Capacitor SPM package. +`ui/capacitor.config.ts` pins `experimental.ios.spm.swiftToolsVersion` to +`6.2`; keep that setting while the package platform is `.iOS(.v26)`. +Without it, Capacitor can regenerate `Package.swift` with Swift tools 5.9, +which Xcode cannot resolve for the iOS 26 package platform enum. + +To install and launch a simulator build manually: + +```bash +xcrun simctl bootstatus booted -b +xcrun simctl install booted /tmp/homesec-ios-qa/Build/Products/Debug-iphonesimulator/App.app +xcrun simctl launch booted com.levneiman.homesec +``` + +The expected first-launch screen is `Connect to HomeSec` with server URL and +API token controls. + +## Personal Device Build + +Use this path for installing the app on your own iPhone or iPad. + +1. Connect the iPhone/iPad over USB or enable wireless debugging in Xcode. +2. Unlock the device and trust the Mac if prompted. +3. Confirm Xcode can see it: + + ```bash + xcrun devicectl list devices + ``` + +4. Sync the native assets: + + ```bash + pnpm --dir ui ios:sync + ``` + +5. Open the native project: + + ```bash + open ui/ios/App/App.xcodeproj + ``` + +6. In Xcode, select the `App` target and set Signing & Capabilities: + - Team: your Apple Developer team. + - Bundle Identifier: keep `com.levneiman.homesec` for the default personal + build, or change it consistently in Xcode and backend APNs config if your + Apple account requires a unique identifier. + - Signing: automatic signing is expected. + - Push Notifications capability must be present when testing APNs. + +7. Select the connected device as the run destination and run the `App` scheme. + +The Debug target uses `APS_ENVIRONMENT = development`; the Release target uses +`APS_ENVIRONMENT = production`. For personal device QA and sandbox pushes, run +Debug unless you are intentionally validating a production APNs profile. + +## HomeSec Server Setup + +The iOS shell stores the server URL and API token in the native Keychain bridge, +not WebView storage. + +On first launch: + +1. Enter the HomeSec server base URL. +2. Tap `Check server`. +3. Paste the HomeSec API token. +4. Tap `Save and continue`. + +Use HTTPS or VPN whenever possible. Plain HTTP is only acceptable for a trusted +LAN/VPN development setup; the app allows local networking for LAN bootstrap but +should not be treated as secure over untrusted networks. + +If server auth is disabled, the app can proceed for first LAN/VPN iteration, but +that is a personal-use convenience only. Do not expose auth-disabled HomeSec to +the public internet. + +## APNs Sandbox Setup + +APNs is optional for basic app browsing but required for notification QA. + +Apple-side setup: + +1. In the Apple Developer portal, make sure the bundle id has Push + Notifications enabled. +2. Create or reuse an APNs Auth Key. +3. Record the key id and team id. +4. Download the `.p8` private key once and store it outside the repo. + +HomeSec server environment variables: + +```bash +export HOMESEC_APNS_KEY_ID='ABC123DEFG' +export HOMESEC_APNS_TEAM_ID='TEAM123456' +export HOMESEC_APNS_PRIVATE_KEY="$(cat /secure/path/AuthKey_ABC123DEFG.p8)" +``` + +Example notifier config: + +```yaml +notifiers: + - backend: apns_mobile + config: + bundle_id: com.levneiman.homesec + environment: sandbox + key_id_env: HOMESEC_APNS_KEY_ID + team_id_env: HOMESEC_APNS_TEAM_ID + private_key_env: HOMESEC_APNS_PRIVATE_KEY +``` + +Use `environment: sandbox` for Debug builds and `environment: production` only +for Release/TestFlight/App Store builds signed with the production APNs +environment. The bundle id and APNs environment must match the registered mobile +device record, otherwise HomeSec will not find an enabled APNs target. + +Never commit APNs keys, HomeSec API tokens, RTSP credentials, or `.env` files. + +## QA Checklist + +Run the real-device QA matrix before treating a personal build as ready: + +- First launch setup renders. +- VPN/LAN server URL check succeeds. +- API token paste auth succeeds. +- API token persists after app restart. +- Live page loads. +- Events page loads. +- Event detail playback works. +- Live HLS preview works. +- Push-to-talk path works if enabled for the configured camera. +- Backgrounding stops active preview and talk sessions. +- Plain APNs push is received. +- Tapping a push opens the event detail route. +- iPad layout is usable. + +File bugs for failures and keep iOS-19 updated with pass/fail notes. + +## Future TestFlight Build + +TestFlight is not required for the first personal release. When it is needed: + +1. Switch to a unique production bundle id if `com.levneiman.homesec` is not + owned by the target Apple Developer team. +2. Keep `APS_ENVIRONMENT = production` for Release. +3. Use `environment: production` in the `apns_mobile` notifier config. +4. Archive from Xcode with the `App` scheme. +5. Upload through Xcode Organizer or `xcrun altool`/Transporter. +6. Re-test APNs because sandbox device tokens do not work against production + APNs, and production tokens do not work against sandbox APNs. + +## Troubleshooting + +- `No devices found.` from `xcrun devicectl list devices`: unlock the device, + trust the Mac, reconnect USB, or enable wireless debugging from Xcode. +- `PackageDescription.SupportedPlatform.IOSVersion.v26 is unavailable`: rerun + `pnpm --dir ui ios:sync` and confirm `ui/ios/App/CapApp-SPM/Package.swift` + starts with `// swift-tools-version: 6.2`. +- App installs but cannot connect to HomeSec: confirm the iPhone can reach the + server URL in Safari over the same VPN/LAN, and confirm auth is enabled with + the expected bearer token. +- No APNs devices receive alerts: confirm the iOS app registered after setup, + the device is enabled in the mobile device list, the APNs environment matches + the build configuration, and `bundle_id` matches the app bundle identifier. +- Push tap opens the app but not the event: confirm payload `data.route` is an + app-relative route such as `/events/?from=notification`. From dccfc32f05afefca99b2c4be0a92166220d8cdd9 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sun, 14 Jun 2026 13:26:29 -0700 Subject: [PATCH 36/36] fix: harden iOS mobile integration --- .github/workflows/ci.yml | 39 ++++++ ...ef73f2d4_add_mobile_device_capabilities.py | 35 ----- ...7b0b1fbfc69b_add_mobile_device_registry.py | 9 +- docs/ios-app-design.md | 55 ++++---- src/homesec/api/routes/mobile.py | 17 --- src/homesec/pipeline/core.py | 14 +- src/homesec/plugins/notifiers/apns_mobile.py | 73 ++++++++-- .../repository/mobile_device_repository.py | 10 +- src/homesec/runtime/worker.py | 28 +++- src/homesec/state/postgres.py | 2 +- tests/homesec/test_api_bootstrap_matrix.py | 12 -- tests/homesec/test_api_routes.py | 18 --- tests/homesec/test_apns_mobile_notifier.py | 124 ++++++++++++++++- tests/homesec/test_pipeline.py | 45 +++++++ tests/homesec/test_runtime_worker.py | 34 +++++ ui/ios/App/App.xcodeproj/project.pbxproj | 4 +- ui/ios/App/App/AppDelegate.swift | 23 ---- ui/ios/App/App/HomeSecAuthPlugin.swift | 24 ++-- ui/ios/App/App/HomeSecDevicePlugin.swift | 11 ++ ui/ios/App/App/Info.plist | 6 +- ui/src/api/generated/openapi.json | 40 ------ ui/src/api/generated/schema.ts | 47 ------- ui/src/api/serverBaseUrlProvider.test.ts | 2 + .../features/native-setup/nativeSetup.test.ts | 9 ++ ui/src/runtime/nativePushRegistration.test.ts | 127 +++++++++++++++++- ui/src/runtime/nativePushRegistration.ts | 6 +- 26 files changed, 542 insertions(+), 272 deletions(-) delete mode 100644 alembic/versions/2e87ef73f2d4_add_mobile_device_capabilities.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fed1a724..129ef683 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,3 +102,42 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml fail_ci_if_error: false + + ios-native: + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15.1 + run_install: false + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22.12.0" + cache: pnpm + cache-dependency-path: ui/pnpm-lock.yaml + + - name: Install UI dependencies + run: pnpm --dir ui install --frozen-lockfile + + - name: Sync Capacitor iOS project + run: pnpm --dir ui ios:sync + + - name: Verify committed iOS sync output + run: git diff --exit-code -- ui/ios ui/capacitor.config.ts ui/package.json ui/pnpm-lock.yaml + + - name: Build iOS simulator app + run: | + xcodebuild \ + -project ui/ios/App/App.xcodeproj \ + -scheme App \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + build diff --git a/alembic/versions/2e87ef73f2d4_add_mobile_device_capabilities.py b/alembic/versions/2e87ef73f2d4_add_mobile_device_capabilities.py deleted file mode 100644 index 1646e263..00000000 --- a/alembic/versions/2e87ef73f2d4_add_mobile_device_capabilities.py +++ /dev/null @@ -1,35 +0,0 @@ -"""add mobile device capabilities - -Revision ID: 2e87ef73f2d4 -Revises: 7b0b1fbfc69b -Create Date: 2026-06-14 00:00:01.000000 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = "2e87ef73f2d4" -down_revision: str | None = "7b0b1fbfc69b" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - op.add_column( - "mobile_devices", - sa.Column( - "capabilities", - postgresql.JSONB(astext_type=sa.Text()), - server_default=sa.text("'{}'::jsonb"), - nullable=False, - ), - ) - - -def downgrade() -> None: - op.drop_column("mobile_devices", "capabilities") diff --git a/alembic/versions/7b0b1fbfc69b_add_mobile_device_registry.py b/alembic/versions/7b0b1fbfc69b_add_mobile_device_registry.py index 32fc0948..e0ec6b7e 100644 --- a/alembic/versions/7b0b1fbfc69b_add_mobile_device_registry.py +++ b/alembic/versions/7b0b1fbfc69b_add_mobile_device_registry.py @@ -10,6 +10,7 @@ import sqlalchemy as sa from alembic import op +from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision: str = "7b0b1fbfc69b" @@ -24,11 +25,17 @@ def upgrade() -> None: sa.Column("id", sa.Text(), nullable=False), sa.Column("platform", sa.Text(), nullable=False), sa.Column("apns_token_hash", sa.Text(), nullable=False), - sa.Column("apns_token_encrypted", sa.Text(), nullable=False), + sa.Column("apns_token", sa.Text(), nullable=False), sa.Column("apns_environment", sa.Text(), nullable=False), sa.Column("bundle_id", sa.Text(), nullable=False), sa.Column("device_name", sa.Text(), nullable=True), sa.Column("app_version", sa.Text(), nullable=True), + sa.Column( + "capabilities", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), sa.Column("enabled", sa.Boolean(), server_default=sa.text("true"), nullable=False), sa.Column( "created_at", diff --git a/docs/ios-app-design.md b/docs/ios-app-design.md index 256c7174..c2a3d3e9 100644 --- a/docs/ios-app-design.md +++ b/docs/ios-app-design.md @@ -1,10 +1,12 @@ # HomeSec iOS and iPad App Design -Last reviewed: 2026-05-10 +Last reviewed: 2026-06-14 -This document is the repo-level source of truth for the first HomeSec iOS and -iPad app. The v1 direction is to package the existing React app in a Capacitor -iOS shell and add narrow native bridges only where iOS capabilities are required. +This document is the repo-level architecture baseline for the first HomeSec iOS +and iPad app. The v1 direction is to package the existing React app in a +Capacitor iOS shell and add narrow native bridges only where iOS capabilities +are required. The build runbook remains the operational source for signing, +device QA, APNs credentials, and App Store/TestFlight mechanics. ## Executive Decision @@ -43,7 +45,7 @@ The existing React app remains the canonical UI for v1: | Notification route | Open `/events/:clipId?from=notification`. | | Alert review scope | `alerted == true` is enough. No alert-review or review-state work in this stream. | | Mobile device registry | Named iOS devices with enable/disable semantics. | -| APNs config | Implement later as a notifier backend under `notifiers`, using `backend: apns_mobile`. | +| APNs config | Implemented as a notifier backend under `notifiers`, using `backend: apns_mobile`. | | Push-to-talk | Keep React parity. Test the WebView path first; add native audio only if needed. | | Background behavior | Stop live preview and push-to-talk when the app backgrounds. | | iPad v1 | Same responsive app. No dedicated iPad split view in v1. | @@ -193,18 +195,16 @@ Provider selection: | Environment | Token provider | Base URL provider | | --- | --- | --- | | Browser web app | Existing `sessionStorage` key `homesec.apiKey` | Build-time `VITE_API_BASE_URL`, then optional runtime storage | -| iOS Capacitor app after native bridge lands | Native bridge to Keychain | Native bridge to stored server URL | +| iOS Capacitor app | Native bridge to Keychain | Native bridge to stored server URL | | Tests | In-memory provider | In-memory provider | Future pairing/QR auth should be designed separately and should not be added to -the M1 app shell. +the v1 app shell. -M1 introduces the provider contracts and runtime base URL setup path, but it must -not create a new insecure native persistence path for the API token. Until the -Keychain bridge lands in `iOS-06`/`iOS-07`, native-mode setup may validate the -entered server URL and API token and keep them in app runtime state for the -current WebView session, but durable native-mode token persistence belongs to the -Keychain bridge milestone. +The implemented native-mode setup path validates the entered server URL and API +token, then persists the server URL, API token, and auth-disabled acknowledgement +through the native Keychain bridge. Browser mode keeps the existing +session-storage behavior. ## iOS Setup UX @@ -215,10 +215,8 @@ Native-mode first launch should support: 3. User enters HomeSec API token. 4. App validates the token against an auth-protected endpoint such as `/api/v1/cameras`. -5. App stores server URL and API token through the selected providers when the - selected provider has durable storage. Before the native Keychain provider - exists, native mode must avoid durable API-token storage and may retain the - token only for the current app session. +5. App stores server URL and API token through the native Keychain-backed + providers. 6. App routes to `/live`. The setup screen must show actionable errors for invalid URLs and invalid tokens. @@ -227,8 +225,7 @@ appears disabled. Existing browser `/setup` behavior must remain intact. ## Push And Deep-Link Design -Plain APNs comes after the M1 app shell. The eventual APNs payload should include -an app route: +Plain APNs notifications include an app route: ```json { @@ -298,7 +295,7 @@ URL and API token support. 1. `iOS-19` - iOS device QA pass. 2. `iOS-20` - Personal release build notes. -## M1 Validation Expectations +## Validation Expectations Use focused validation while developing, then run the relevant repo gates before publishing or handing off: @@ -315,16 +312,22 @@ M1 tickets that touch UI runtime code, run the UI gate at minimum: make ui-check ``` -For the Capacitor scaffold, also verify: +For the Capacitor scaffold and native bridge changes, also verify: ```bash -cd ui && pnpm ios:build -cd ui && pnpm ios:sync -cd ui && pnpm ios:run +pnpm --dir ui ios:sync +xcodebuild \ + -project ui/ios/App/App.xcodeproj \ + -scheme App \ + -destination 'generic/platform=iOS Simulator' \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO \ + build ``` -Launching in the simulator is expected for `iOS-04` when local Xcode setup -allows it. Real-device signing is not required for M1. +Launching in the simulator is expected when local Xcode setup allows it. +Real-device signing uses the personal Apple Development team for Debug and a +distribution identity for Release/TestFlight. ## Deferred Follow-Ups diff --git a/src/homesec/api/routes/mobile.py b/src/homesec/api/routes/mobile.py index 5de3ba90..5ce3850b 100644 --- a/src/homesec/api/routes/mobile.py +++ b/src/homesec/api/routes/mobile.py @@ -67,11 +67,6 @@ class MobileDeviceResponse(BaseModel): last_push_error: str | None = None -class MobileNotificationTestResponse(BaseModel): - sent: bool - reason: str - - def _device_response(record: MobileDeviceRecord) -> MobileDeviceResponse: return MobileDeviceResponse( id=record.id, @@ -159,15 +154,3 @@ async def delete_mobile_device( error_code=APIErrorCode.MOBILE_DEVICE_NOT_FOUND, ) return _device_response(record) - - -@router.post( - "/api/v1/mobile/notifications/test", - response_model=MobileNotificationTestResponse, -) -async def test_mobile_notification() -> MobileNotificationTestResponse: - """Stub test notification endpoint until APNs notifier support lands.""" - return MobileNotificationTestResponse( - sent=False, - reason="APNs mobile notifier is not configured yet", - ) diff --git a/src/homesec/pipeline/core.py b/src/homesec/pipeline/core.py index 7d03bde9..f4d36321 100644 --- a/src/homesec/pipeline/core.py +++ b/src/homesec/pipeline/core.py @@ -373,10 +373,10 @@ async def _run_stage_with_retries( result = await op() except Exception as exc: duration_ms = int((time.monotonic() - started) * 1000) - will_retry = attempts < max_attempts + will_retry = attempts < max_attempts and self._is_retryable_stage_error(exc) if on_attempt_failure is not None: await on_attempt_failure(exc, attempts, will_retry, duration_ms) - if attempts >= max_attempts: + if not will_retry: raise logger.warning( "Stage %s failed for %s (attempt %d/%d): %s", @@ -397,6 +397,16 @@ async def _run_stage_with_retries( await on_attempt_success(result, attempts, duration_ms) return result + @classmethod + def _is_retryable_stage_error(cls, exc: Exception) -> bool: + retryable = getattr(exc, "retryable", None) + if isinstance(retryable, bool): + return retryable + cause = getattr(exc, "cause", None) + if isinstance(cause, Exception): + return cls._is_retryable_stage_error(cause) + return True + async def _upload_stage(self, clip: Clip) -> UploadOutcome | UploadError: """Upload clip to storage. Returns UploadOutcome or UploadError.""" dest_path = build_clip_path(clip, self._config.storage.paths) diff --git a/src/homesec/plugins/notifiers/apns_mobile.py b/src/homesec/plugins/notifiers/apns_mobile.py index 9cbcdf3b..8e6e6fbd 100644 --- a/src/homesec/plugins/notifiers/apns_mobile.py +++ b/src/homesec/plugins/notifiers/apns_mobile.py @@ -28,6 +28,13 @@ _APNS_CATEGORY = "HOMESEC_EVENT" _APNS_PUSH_TYPE = "alert" _PROVIDER_TOKEN_REFRESH_S = 50 * 60 +_PERMANENT_TOKEN_REJECTION_REASONS = frozenset( + { + "BadDeviceToken", + "DeviceTokenNotForTopic", + "Unregistered", + } +) class _MobileDevicePushRepository(Protocol): @@ -50,6 +57,15 @@ async def record_push_result( """Record the latest APNs send outcome for a device.""" ... + async def disable_device( + self, + device_id: str, + *, + now: datetime | None = None, + ) -> object | None: + """Disable a permanently invalid APNs target.""" + ... + class APNsMobileConfig(BaseModel): """APNs notifier configuration using Apple token-based provider auth.""" @@ -171,16 +187,19 @@ async def send(self, alert: Alert) -> None: ) successes = 0 - failures: list[str] = [] + permanent_failures: list[str] = [] + retryable_failures: list[str] = [] for target, result in zip(targets, results, strict=True): match result: - case bool() as delivered: - if delivered: + case _DeliveryResult() as delivery: + if delivery.delivered: successes += 1 + elif delivery.retryable: + retryable_failures.append(target.id) else: - failures.append(target.id) + permanent_failures.append(target.id) case BaseException() as exc: - failures.append(target.id) + retryable_failures.append(target.id) logger.error( "APNs mobile send failed while recording device result: device_id=%s " "error=%s", @@ -189,19 +208,25 @@ async def send(self, alert: Alert) -> None: exc_info=exc, ) - if failures: + failure_count = len(permanent_failures) + len(retryable_failures) + if failure_count: logger.warning( "APNs mobile notifier had failed target deliveries: failed=%d succeeded=%d", - len(failures), + failure_count, successes, extra={ "event_type": "apns_mobile_delivery_partial_failure", - "failed_count": len(failures), + "failed_count": failure_count, + "permanent_failed_count": len(permanent_failures), + "retryable_failed_count": len(retryable_failures), "succeeded_count": successes, }, ) - if successes == 0 and failures: - raise RuntimeError(f"APNs delivery failed for {len(failures)} device(s)") + if retryable_failures or permanent_failures: + raise APNsDeliveryError( + f"APNs delivery failed for {failure_count} of {len(targets)} device(s)", + retryable=successes == 0 and bool(retryable_failures), + ) async def ping(self) -> bool: """Health check for local APNs notifier configuration.""" @@ -223,7 +248,7 @@ async def _send_to_target( payload: dict[str, object], provider_token: str, sent_at: datetime, - ) -> bool: + ) -> _DeliveryResult: headers = { "authorization": f"bearer {provider_token}", "apns-topic": self._bundle_id, @@ -244,22 +269,25 @@ async def _send_to_target( target.id, error, ) - return False + return _DeliveryResult(delivered=False, retryable=True) if 200 <= response.status_code < 300: await self._repository.record_push_result(target.id, error=None, now=sent_at) - return True + return _DeliveryResult(delivered=True, retryable=False) reason = _apns_response_reason(response) error = f"HTTP {response.status_code}: {reason}" await self._repository.record_push_result(target.id, error=error, now=sent_at) + retryable = not _is_permanent_token_rejection(response.status_code, reason) + if not retryable: + await self._repository.disable_device(target.id, now=sent_at) logger.warning( "APNs mobile send rejected: device_id=%s status=%d reason=%s", target.id, response.status_code, reason, ) - return False + return _DeliveryResult(delivered=False, retryable=retryable) async def _get_client(self) -> httpx.AsyncClient: if self._client is None or self._client.is_closed: @@ -320,6 +348,23 @@ def _default_apns_base_url(environment: APNSEnvironment) -> str: return "https://api.push.apple.com" +class _DeliveryResult(BaseModel): + delivered: bool + retryable: bool + + +class APNsDeliveryError(RuntimeError): + """APNs fanout failed with retry guidance for the pipeline.""" + + def __init__(self, message: str, *, retryable: bool) -> None: + super().__init__(message) + self.retryable = retryable + + +def _is_permanent_token_rejection(status_code: int, reason: str) -> bool: + return status_code == 410 or reason in _PERMANENT_TOKEN_REJECTION_REASONS + + def _require_mobile_repository(value: Any | None) -> _MobileDevicePushRepository: if value is None: raise RuntimeError("APNs mobile notifier requires mobile device repository context") diff --git a/src/homesec/repository/mobile_device_repository.py b/src/homesec/repository/mobile_device_repository.py index ea2e8eca..e68aa1fd 100644 --- a/src/homesec/repository/mobile_device_repository.py +++ b/src/homesec/repository/mobile_device_repository.py @@ -51,9 +51,7 @@ async def register_device( id=_new_device_id(), platform=registration.platform, apns_token_hash=token_hash, - # No encryption utility exists yet. Keep the raw token confined to - # this internal column until the APNs sender ticket adds key management. - apns_token_encrypted=token, + apns_token=token, apns_environment=registration.apns_environment, bundle_id=registration.bundle_id, device_name=registration.device_name, @@ -68,7 +66,7 @@ async def register_device( index_elements=[table.c.apns_token_hash], set_={ "platform": insert_stmt.excluded.platform, - "apns_token_encrypted": insert_stmt.excluded.apns_token_encrypted, + "apns_token": insert_stmt.excluded.apns_token, "apns_environment": insert_stmt.excluded.apns_environment, "bundle_id": insert_stmt.excluded.bundle_id, "device_name": insert_stmt.excluded.device_name, @@ -122,7 +120,7 @@ async def list_enabled_apns_targets( stmt = ( select( MobileDevice.id, - MobileDevice.apns_token_encrypted, + MobileDevice.apns_token, MobileDevice.apns_environment, MobileDevice.bundle_id, ) @@ -139,7 +137,7 @@ async def list_enabled_apns_targets( return [ MobileDevicePushTarget( id=str(row["id"]), - apns_token=str(row["apns_token_encrypted"]), + apns_token=str(row["apns_token"]), apns_environment=row["apns_environment"], bundle_id=str(row["bundle_id"]), ) diff --git a/src/homesec/runtime/worker.py b/src/homesec/runtime/worker.py index 45129d86..bf6d534a 100644 --- a/src/homesec/runtime/worker.py +++ b/src/homesec/runtime/worker.py @@ -310,15 +310,20 @@ async def _build_runtime_persistence_stack(self) -> RuntimePersistenceStack: def _create_notifier(self, config: Config) -> tuple[Notifier, list[NotifierEntry]]: entries: list[NotifierEntry] = [] - runtime_context: dict[str, object] = {} - if isinstance(self._state_store, PostgresStateStore): - runtime_context["mobile_device_repository"] = MobileDeviceRepository( - self._state_store.engine - ) for index, notifier_cfg in enumerate(config.notifiers): if not notifier_cfg.enabled: continue + runtime_context: dict[str, object] = {} + if notifier_cfg.backend == "apns_mobile": + mobile_device_repository = self._create_mobile_device_repository() + if mobile_device_repository is None: + logger.warning( + "Skipping apns_mobile notifier because mobile device repository " + "is unavailable" + ) + continue + runtime_context["mobile_device_repository"] = mobile_device_repository notifier = load_notifier_plugin( notifier_cfg.backend, notifier_cfg.config, @@ -335,6 +340,19 @@ def _create_notifier(self, config: Config) -> tuple[Notifier, list[NotifierEntry return entries[0].notifier, entries return MultiplexNotifier(entries), entries + def _create_mobile_device_repository(self) -> MobileDeviceRepository | None: + """Create the mobile repository only when Postgres initialized successfully.""" + if not isinstance(self._state_store, PostgresStateStore): + return None + try: + return MobileDeviceRepository(self._state_store.engine) + except RuntimeError as exc: + logger.warning( + "Mobile device repository unavailable for runtime worker: %s", + exc, + ) + return None + async def _log_notifier_health(self, entries: list[NotifierEntry]) -> None: if not entries: return diff --git a/src/homesec/state/postgres.py b/src/homesec/state/postgres.py index 4f92f588..efabc690 100644 --- a/src/homesec/state/postgres.py +++ b/src/homesec/state/postgres.py @@ -158,7 +158,7 @@ class MobileDevice(Base): id: Mapped[str] = mapped_column(Text, primary_key=True) platform: Mapped[str] = mapped_column(Text, nullable=False) apns_token_hash: Mapped[str] = mapped_column(Text, nullable=False) - apns_token_encrypted: Mapped[str] = mapped_column(Text, nullable=False) + apns_token: Mapped[str] = mapped_column(Text, nullable=False) apns_environment: Mapped[str] = mapped_column(Text, nullable=False) bundle_id: Mapped[str] = mapped_column(Text, nullable=False) device_name: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/tests/homesec/test_api_bootstrap_matrix.py b/tests/homesec/test_api_bootstrap_matrix.py index 8e0b06f0..929d2956 100644 --- a/tests/homesec/test_api_bootstrap_matrix.py +++ b/tests/homesec/test_api_bootstrap_matrix.py @@ -688,18 +688,6 @@ def _send_request(client: TestClient, case: _MatrixCase, headers: dict[str, str] expected_status=401, expected_error_code="UNAUTHORIZED", ), - _MatrixCase( - name="mobile_notification_test_requires_api_key_when_auth_enabled", - method="POST", - path="/api/v1/mobile/notifications/test", - auth_enabled=True, - db_ok=True, - pipeline_running=True, - auth_header=None, - include_clip=False, - expected_status=401, - expected_error_code="UNAUTHORIZED", - ), _MatrixCase( name="mobile_device_list_requires_db_when_repository_unavailable", method="GET", diff --git a/tests/homesec/test_api_routes.py b/tests/homesec/test_api_routes.py index 5e708658..cc411ab9 100644 --- a/tests/homesec/test_api_routes.py +++ b/tests/homesec/test_api_routes.py @@ -2769,24 +2769,6 @@ def test_mobile_device_missing_returns_404(tmp_path) -> None: assert delete_response.json()["error_code"] == "MOBILE_DEVICE_NOT_FOUND" -def test_mobile_notification_test_route_is_stubbed(tmp_path) -> None: - """POST /mobile/notifications/test should expose the current APNs stub.""" - # Given: A configured app before APNs notifier implementation - manager = _write_config(tmp_path, cameras=[]) - app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage()) - client = _client(app) - - # When: Calling the mobile notification test route - response = client.post("/api/v1/mobile/notifications/test") - - # Then: The route is available but reports that APNs delivery is not wired yet - assert response.status_code == 200 - assert response.json() == { - "sent": False, - "reason": "APNs mobile notifier is not configured yet", - } - - def test_auth_required_when_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: """Auth should be enforced for non-public endpoints.""" # Given auth is enabled diff --git a/tests/homesec/test_apns_mobile_notifier.py b/tests/homesec/test_apns_mobile_notifier.py index 08ce33c6..3938eb63 100644 --- a/tests/homesec/test_apns_mobile_notifier.py +++ b/tests/homesec/test_apns_mobile_notifier.py @@ -13,6 +13,7 @@ from homesec.models.alert import Alert from homesec.models.mobile import MobileDevicePushTarget from homesec.plugins.notifiers.apns_mobile import ( + APNsDeliveryError, APNsMobileConfig, APNsMobileNotifier, build_apns_payload, @@ -22,6 +23,7 @@ class _FakeMobileDeviceRepository: def __init__(self, targets: list[MobileDevicePushTarget]) -> None: self.targets = targets + self.disabled_devices: list[tuple[str, datetime | None]] = [] self.list_calls: list[tuple[str, str]] = [] self.recorded_results: list[tuple[str, str | None, datetime | None]] = [] @@ -43,6 +45,14 @@ async def record_push_result( ) -> None: self.recorded_results.append((device_id, error, now)) + async def disable_device( + self, + device_id: str, + *, + now: datetime | None = None, + ) -> None: + self.disabled_devices.append((device_id, now)) + class _FakeAPNsClient: def __init__(self, responses: list[httpx.Response]) -> None: @@ -179,7 +189,7 @@ async def test_apns_notifier_sends_payload_to_registered_targets( async def test_apns_notifier_records_rejected_devices_and_raises_when_all_fail( monkeypatch: pytest.MonkeyPatch, ) -> None: - # Given: APNs rejects the only enabled target + # Given: APNs rejects the only enabled target with a retryable provider error monkeypatch.setenv("TEST_APNS_KEY_ID", "KEY1234567") monkeypatch.setenv("TEST_APNS_TEAM_ID", "TEAM123456") monkeypatch.setenv("TEST_APNS_PRIVATE_KEY", _private_key_pem()) @@ -193,7 +203,7 @@ async def test_apns_notifier_records_rejected_devices_and_raises_when_all_fail( ) ] ) - fake_client = _FakeAPNsClient([httpx.Response(400, json={"reason": "BadDeviceToken"})]) + fake_client = _FakeAPNsClient([httpx.Response(500, json={"reason": "InternalServerError"})]) monkeypatch.setattr( "homesec.plugins.notifiers.apns_mobile.httpx.AsyncClient", lambda **_kwargs: fake_client, @@ -201,12 +211,116 @@ async def test_apns_notifier_records_rejected_devices_and_raises_when_all_fail( notifier = APNsMobileNotifier(_config(repository)) # When: Sending the alert - with pytest.raises(RuntimeError, match="APNs delivery failed"): + with pytest.raises(APNsDeliveryError, match="APNs delivery failed") as exc_info: await notifier.send(_sample_alert()) - # Then: The rejection is recorded against the device without logging token material + # Then: The retryable rejection is recorded without disabling the device + assert exc_info.value.retryable is True assert len(repository.recorded_results) == 1 device_id, error, recorded_at = repository.recorded_results[0] assert device_id == "dev_bad" - assert error == "HTTP 400: BadDeviceToken" + assert error == "HTTP 500: InternalServerError" assert recorded_at is not None + assert repository.disabled_devices == [] + + +@pytest.mark.asyncio +async def test_apns_notifier_disables_permanent_failures_without_retrying_successes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given: APNs accepts one registered target and rejects another target permanently + monkeypatch.setenv("TEST_APNS_KEY_ID", "KEY1234567") + monkeypatch.setenv("TEST_APNS_TEAM_ID", "TEAM123456") + monkeypatch.setenv("TEST_APNS_PRIVATE_KEY", _private_key_pem()) + repository = _FakeMobileDeviceRepository( + [ + MobileDevicePushTarget( + id="dev_ok", + apns_token="good-token", + apns_environment="sandbox", + bundle_id="com.levneiman.homesec", + ), + MobileDevicePushTarget( + id="dev_bad", + apns_token="bad-token", + apns_environment="sandbox", + bundle_id="com.levneiman.homesec", + ), + ] + ) + fake_client = _FakeAPNsClient( + [ + httpx.Response(200), + httpx.Response(410, json={"reason": "Unregistered"}), + ] + ) + monkeypatch.setattr( + "homesec.plugins.notifiers.apns_mobile.httpx.AsyncClient", + lambda **_kwargs: fake_client, + ) + notifier = APNsMobileNotifier(_config(repository)) + + # When: Sending the alert + with pytest.raises(APNsDeliveryError, match="APNs delivery failed") as exc_info: + await notifier.send(_sample_alert()) + + # Then: Successful and failed device outcomes are both recorded without a whole-fanout retry + assert exc_info.value.retryable is False + assert [(device_id, error) for device_id, error, _ in repository.recorded_results] == [ + ("dev_ok", None), + ("dev_bad", "HTTP 410: Unregistered"), + ] + disabled_device_id, disabled_at = repository.disabled_devices[0] + assert disabled_device_id == "dev_bad" + assert disabled_at == repository.recorded_results[1][2] + + +@pytest.mark.asyncio +async def test_apns_notifier_raises_on_partial_retryable_delivery_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given: APNs accepts one target but has a retryable provider failure for another + monkeypatch.setenv("TEST_APNS_KEY_ID", "KEY1234567") + monkeypatch.setenv("TEST_APNS_TEAM_ID", "TEAM123456") + monkeypatch.setenv("TEST_APNS_PRIVATE_KEY", _private_key_pem()) + repository = _FakeMobileDeviceRepository( + [ + MobileDevicePushTarget( + id="dev_ok", + apns_token="good-token", + apns_environment="sandbox", + bundle_id="com.levneiman.homesec", + ), + MobileDevicePushTarget( + id="dev_retry", + apns_token="retry-token", + apns_environment="sandbox", + bundle_id="com.levneiman.homesec", + ), + ] + ) + fake_client = _FakeAPNsClient( + [ + httpx.Response(200), + httpx.Response(503, json={"reason": "ServiceUnavailable"}), + ] + ) + monkeypatch.setattr( + "homesec.plugins.notifiers.apns_mobile.httpx.AsyncClient", + lambda **_kwargs: fake_client, + ) + notifier = APNsMobileNotifier(_config(repository)) + + # When: Sending the alert + with pytest.raises( + APNsDeliveryError, match="APNs delivery failed for 1 of 2 device" + ) as exc_info: + await notifier.send(_sample_alert()) + + # Then: The partial retryable failure is recorded without retrying the already delivered target + assert exc_info.value.retryable is False + assert [(device_id, error) for device_id, error, _ in repository.recorded_results] == [ + ("dev_ok", None), + ("dev_retry", "HTTP 503: ServiceUnavailable"), + ] + assert repository.disabled_devices == [] diff --git a/tests/homesec/test_pipeline.py b/tests/homesec/test_pipeline.py index ebab9060..547190d9 100644 --- a/tests/homesec/test_pipeline.py +++ b/tests/homesec/test_pipeline.py @@ -822,6 +822,51 @@ async def send(self, alert) -> None: assert notifier.send_calls == 2 assert len(notifier.sent_alerts) == 1 + @pytest.mark.asyncio + async def test_non_retryable_notify_failure_does_not_retry( + self, base_config: Config, sample_clip: Clip, mocks: PipelineMocks + ) -> None: + """Non-retryable notifier failures should be recorded without duplicate sends.""" + # Given retry config with multiple attempts and a non-retryable notifier failure + base_config.retry = RetryConfig(max_attempts=3, backoff_s=0.0) + + class NonRetryableNotifierError(RuntimeError): + retryable = False + + class NonRetryableNotifier(MockNotifier): + def __init__(self) -> None: + super().__init__(simulate_failure=False) + self.send_calls = 0 + + async def send(self, alert) -> None: + self.send_calls += 1 + raise NonRetryableNotifierError("Partial APNs fanout already delivered") + + notifier = NonRetryableNotifier() + pipeline = ClipPipeline( + config=base_config, + storage=mocks.storage, + repository=make_repository(base_config, mocks), + filter_plugin=mocks.filter, + vlm_plugin=mocks.vlm, + notifier=notifier, + alert_policy=make_alert_policy(base_config), + retention_pruner=MockRetentionPruner(), + ) + + # When a clip is processed + pipeline.on_new_clip(sample_clip) + await pipeline.shutdown() + + # Then the notifier is not retried and the failure event is final + assert notifier.send_calls == 1 + notify_failed_events = [ + event for event in mocks.event_store.events if event.event_type == "notification_failed" + ] + assert len(notify_failed_events) == 1 + assert notify_failed_events[0].attempt == 1 + assert notify_failed_events[0].will_retry is False + @pytest.mark.asyncio async def test_state_store_upsert_retries( self, base_config: Config, sample_clip: Clip, mocks: PipelineMocks diff --git a/tests/homesec/test_runtime_worker.py b/tests/homesec/test_runtime_worker.py index 92a1b82b..28b98913 100644 --- a/tests/homesec/test_runtime_worker.py +++ b/tests/homesec/test_runtime_worker.py @@ -1226,6 +1226,40 @@ def _unexpected_plugin_load(*_: object) -> object: assert entries == [] +def test_runtime_worker_create_notifier_skips_apns_when_postgres_unavailable( + caplog: pytest.LogCaptureFixture, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Given: APNs mobile notifications are configured but the worker Postgres store did not initialize + config = _make_config( + notifiers=[ + NotifierConfig( + backend="apns_mobile", + enabled=True, + config={"bundle_id": "com.levneiman.homesec"}, + ) + ] + ) + service = _make_service(config) + service._state_store = worker_module.PostgresStateStore( + "postgresql://homesec:homesec@localhost/homesec" + ) + + def _unexpected_plugin_load(*_: object, **__: object) -> object: + raise AssertionError("APNs notifier should not load without repository context") + + monkeypatch.setattr(worker_module, "load_notifier_plugin", _unexpected_plugin_load) + + # When: Building notifier stack for runtime bundle + with caplog.at_level(logging.WARNING): + notifier, entries = service._create_notifier(config) + + # Then: The worker keeps recording runtime startup independent of APNs persistence + assert isinstance(notifier, worker_module._NoopNotifier) + assert entries == [] + assert "Skipping apns_mobile notifier" in caplog.text + + @pytest.mark.asyncio async def test_runtime_worker_run_runtime_skips_analyzer_load_when_run_mode_never( monkeypatch: pytest.MonkeyPatch, diff --git a/ui/ios/App/App.xcodeproj/project.pbxproj b/ui/ios/App/App.xcodeproj/project.pbxproj index 32df466a..6ec5fd9b 100644 --- a/ui/ios/App/App.xcodeproj/project.pbxproj +++ b/ui/ios/App/App.xcodeproj/project.pbxproj @@ -229,7 +229,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Development"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -286,7 +286,7 @@ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_IDENTITY = "Apple Distribution"; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; diff --git a/ui/ios/App/App/AppDelegate.swift b/ui/ios/App/App/AppDelegate.swift index 24ece850..94a798b8 100644 --- a/ui/ios/App/App/AppDelegate.swift +++ b/ui/ios/App/App/AppDelegate.swift @@ -7,32 +7,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. return true } - func applicationWillResignActive(_ application: UIApplication) { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. - } - - func applicationDidEnterBackground(_ application: UIApplication) { - // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. - // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - } - - func applicationWillEnterForeground(_ application: UIApplication) { - // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. - } - - func applicationDidBecomeActive(_ application: UIApplication) { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - } - - func applicationWillTerminate(_ application: UIApplication) { - // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. - } - func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { // Called when the app was launched with a url. Feel free to add additional processing here, // but if you want the App API to support tracking app url opens, make sure to keep this call diff --git a/ui/ios/App/App/HomeSecAuthPlugin.swift b/ui/ios/App/App/HomeSecAuthPlugin.swift index 4b901453..f323f485 100644 --- a/ui/ios/App/App/HomeSecAuthPlugin.swift +++ b/ui/ios/App/App/HomeSecAuthPlugin.swift @@ -47,10 +47,10 @@ public class HomeSecAuthPlugin: CAPPlugin, CAPBridgedPlugin { do { let value = try requiredString(call, key: "value") let normalized = try normalizedServerBaseUrl(value) - let previousOrigin = try currentServerOrigin() - if let previousOrigin, previousOrigin != normalized { - try keychain.delete(account: "\(tokenAccountPrefix)\(previousOrigin)") - try keychain.delete(account: "\(authDisabledReadyPrefix)\(previousOrigin)") + let previousBaseUrl = try currentServerBaseUrl() + if let previousBaseUrl, previousBaseUrl != normalized { + try keychain.delete(account: "\(tokenAccountPrefix)\(previousBaseUrl)") + try keychain.delete(account: "\(authDisabledReadyPrefix)\(previousBaseUrl)") } try keychain.set(normalized, account: serverBaseUrlAccount) call.resolve() @@ -176,7 +176,11 @@ public class HomeSecAuthPlugin: CAPPlugin, CAPBridgedPlugin { throw HomeSecAuthPluginError.invalidServerBaseUrl(value) } components.scheme = scheme - components.path = "" + var path = components.percentEncodedPath + while path.count > 1 && path.hasSuffix("/") { + path.removeLast() + } + components.percentEncodedPath = path == "/" ? "" : path components.query = nil components.fragment = nil guard let normalized = components.string else { @@ -185,7 +189,7 @@ public class HomeSecAuthPlugin: CAPPlugin, CAPBridgedPlugin { return normalized } - private func currentServerOrigin() throws -> String? { + private func currentServerBaseUrl() throws -> String? { guard let serverBaseUrl = try keychain.read(account: serverBaseUrlAccount) else { return nil } @@ -193,10 +197,10 @@ public class HomeSecAuthPlugin: CAPPlugin, CAPBridgedPlugin { } private func currentApiTokenAccount() throws -> String? { - guard let origin = try currentServerOrigin() else { + guard let serverBaseUrl = try currentServerBaseUrl() else { return nil } - return "\(tokenAccountPrefix)\(origin)" + return "\(tokenAccountPrefix)\(serverBaseUrl)" } private func requiredApiTokenAccount() throws -> String { @@ -207,10 +211,10 @@ public class HomeSecAuthPlugin: CAPPlugin, CAPBridgedPlugin { } private func currentAuthDisabledReadyAccount() throws -> String? { - guard let origin = try currentServerOrigin() else { + guard let serverBaseUrl = try currentServerBaseUrl() else { return nil } - return "\(authDisabledReadyPrefix)\(origin)" + return "\(authDisabledReadyPrefix)\(serverBaseUrl)" } private func requiredAuthDisabledReadyAccount() throws -> String { diff --git a/ui/ios/App/App/HomeSecDevicePlugin.swift b/ui/ios/App/App/HomeSecDevicePlugin.swift index c8b689d1..df346c2b 100644 --- a/ui/ios/App/App/HomeSecDevicePlugin.swift +++ b/ui/ios/App/App/HomeSecDevicePlugin.swift @@ -39,6 +39,17 @@ public class HomeSecDevicePlugin: CAPPlugin, CAPBridgedPlugin { } private func apnsEnvironment() -> String { + if let configured = bundleValue("HomeSecAPNSEnvironment")?.lowercased() { + switch configured { + case "development", "sandbox": + return "sandbox" + case "production": + return "production" + default: + break + } + } + #if DEBUG return "sandbox" #else diff --git a/ui/ios/App/App/Info.plist b/ui/ios/App/App/Info.plist index d68c7c18..216599eb 100644 --- a/ui/ios/App/App/Info.plist +++ b/ui/ios/App/App/Info.plist @@ -33,6 +33,8 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) + HomeSecAPNSEnvironment + $(APS_ENVIRONMENT) LSRequiresIPhoneOS NSAppTransportSecurity @@ -48,10 +50,6 @@ LaunchScreen UIMainStoryboardFile Main - UIRequiredDeviceCapabilities - - armv7 - UISupportedInterfaceOrientations UIInterfaceOrientationPortrait diff --git a/ui/src/api/generated/openapi.json b/ui/src/api/generated/openapi.json index a9946208..e2cd1457 100644 --- a/ui/src/api/generated/openapi.json +++ b/ui/src/api/generated/openapi.json @@ -1284,24 +1284,6 @@ "title": "MobileDeviceResponse", "type": "object" }, - "MobileNotificationTestResponse": { - "properties": { - "reason": { - "title": "Reason", - "type": "string" - }, - "sent": { - "title": "Sent", - "type": "boolean" - } - }, - "required": [ - "sent", - "reason" - ], - "title": "MobileNotificationTestResponse", - "type": "object" - }, "NotifierConfig": { "description": "Notifier configuration entry.", "properties": { @@ -3367,28 +3349,6 @@ ] } }, - "/api/v1/mobile/notifications/test": { - "post": { - "description": "Stub test notification endpoint until APNs notifier support lands.", - "operationId": "test_mobile_notification_api_v1_mobile_notifications_test_post", - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/MobileNotificationTestResponse" - } - } - }, - "description": "Successful Response" - } - }, - "summary": "Test Mobile Notification", - "tags": [ - "mobile" - ] - } - }, "/api/v1/onvif/discover": { "post": { "description": "Trigger WS-Discovery scan and return discovered ONVIF cameras.", diff --git a/ui/src/api/generated/schema.ts b/ui/src/api/generated/schema.ts index d3af1852..4eb4c280 100644 --- a/ui/src/api/generated/schema.ts +++ b/ui/src/api/generated/schema.ts @@ -291,26 +291,6 @@ export interface paths { patch: operations["update_mobile_device_api_v1_mobile_devices__device_id__patch"]; trace?: never; }; - "/api/v1/mobile/notifications/test": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * Test Mobile Notification - * @description Stub test notification endpoint until APNs notifier support lands. - */ - post: operations["test_mobile_notification_api_v1_mobile_notifications_test_post"]; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/v1/onvif/discover": { parameters: { query?: never; @@ -1125,13 +1105,6 @@ export interface components { */ updated_at: string; }; - /** MobileNotificationTestResponse */ - MobileNotificationTestResponse: { - /** Reason */ - reason: string; - /** Sent */ - sent: boolean; - }; /** * NotifierConfig * @description Notifier configuration entry. @@ -2137,26 +2110,6 @@ export interface operations { }; }; }; - test_mobile_notification_api_v1_mobile_notifications_test_post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["MobileNotificationTestResponse"]; - }; - }; - }; - }; discover_onvif_cameras_api_v1_onvif_discover_post: { parameters: { query?: never; diff --git a/ui/src/api/serverBaseUrlProvider.test.ts b/ui/src/api/serverBaseUrlProvider.test.ts index 07d9a4ba..59f9825a 100644 --- a/ui/src/api/serverBaseUrlProvider.test.ts +++ b/ui/src/api/serverBaseUrlProvider.test.ts @@ -50,10 +50,12 @@ describe('normalizeServerBaseUrl', () => { // Given: Candidate server base URL values const lanUrl = ' http://192.168.1.10:8081/// ' const httpsUrl = 'https://homesec.example.com/' + const pathUrl = 'https://homesec.example.com/homesec///' // When / Then: URLs are trimmed and empty values stay unset expect(normalizeServerBaseUrl(lanUrl)).toBe('http://192.168.1.10:8081') expect(normalizeServerBaseUrl(httpsUrl)).toBe('https://homesec.example.com') + expect(normalizeServerBaseUrl(pathUrl)).toBe('https://homesec.example.com/homesec') expect(normalizeServerBaseUrl(' ')).toBeNull() expect(normalizeServerBaseUrl(null)).toBeNull() }) diff --git a/ui/src/features/native-setup/nativeSetup.test.ts b/ui/src/features/native-setup/nativeSetup.test.ts index 9f9bb5a1..1eb1dd7a 100644 --- a/ui/src/features/native-setup/nativeSetup.test.ts +++ b/ui/src/features/native-setup/nativeSetup.test.ts @@ -6,6 +6,7 @@ describe('validateNativeSetupServerUrl', () => { it('normalizes HTTPS and LAN URLs while rejecting unsupported input', () => { // Given: Server URL candidates from the native setup form const httpsUrl = ' https://homesec.example.com/// ' + const pathUrl = 'https://homesec.example.com/homesec///' const lanUrl = 'http://192.168.1.10:8081/' const localHostUrl = 'http://homesec.local:8081/' const singleLabelUrl = 'http://homesec:8081/' @@ -13,6 +14,7 @@ describe('validateNativeSetupServerUrl', () => { // When: Validating each value const httpsResult = validateNativeSetupServerUrl(httpsUrl) + const pathResult = validateNativeSetupServerUrl(pathUrl) const lanResult = validateNativeSetupServerUrl(lanUrl) const localHostResult = validateNativeSetupServerUrl(localHostUrl) const singleLabelResult = validateNativeSetupServerUrl(singleLabelUrl) @@ -26,6 +28,13 @@ describe('validateNativeSetupServerUrl', () => { isPlainHttp: false, }, }) + expect(pathResult).toEqual({ + ok: true, + value: { + serverBaseUrl: 'https://homesec.example.com/homesec', + isPlainHttp: false, + }, + }) expect(lanResult).toEqual({ ok: true, value: { diff --git a/ui/src/runtime/nativePushRegistration.test.ts b/ui/src/runtime/nativePushRegistration.test.ts index 99e34a4c..4eaa1fb5 100644 --- a/ui/src/runtime/nativePushRegistration.test.ts +++ b/ui/src/runtime/nativePushRegistration.test.ts @@ -1,4 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +// @vitest-environment jsdom + +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PermissionStatus, RegistrationError, @@ -6,16 +9,24 @@ import type { } from '@capacitor/push-notifications' import type { PluginListenerHandle } from '@capacitor/core' +import { + BROWSER_AUTH_TOKEN_STORAGE_KEY, + BROWSER_SERVER_BASE_URL_STORAGE_KEY, +} from '../api/client' import type { MobileDeviceRegisterRequest } from '../api/generated/types' import type { HomeSecDevicePlugin } from './homeSecDevicePlugin' import { registerNativePushDevice, resetNativePushRegistrationForTests, + useNativePushRegistration, type NativePushRegistrationOptions, } from './nativePushRegistration' type PushAdapter = NonNullable type PushRegistrationMode = 'error' | 'success' +type TestStorage = Pick & { + values: Map +} function listenerHandle(): PluginListenerHandle { return { @@ -97,11 +108,54 @@ function createRegistrationClient() { } } +function installWindowSessionStorageMock(): TestStorage { + const storage: TestStorage = { + values: new Map(), + getItem: (key: string): string | null => storage.values.get(key) ?? null, + setItem: (key: string, value: string): void => { + storage.values.set(key, value) + }, + removeItem: (key: string): void => { + storage.values.delete(key) + }, + } + vi.stubGlobal('window', { sessionStorage: storage }) + return storage +} + +function mobileDeviceResponse() { + return { + id: 'dev_1', + platform: 'ios' as const, + environment: 'sandbox' as const, + bundle_id: 'com.levneiman.homesec', + device_name: "Lev's iPhone", + app_version: '1.0.0', + capabilities: { + deep_links: true, + rich_notifications: false, + }, + enabled: true, + token_fingerprint: 'abcdef123456', + created_at: '2026-06-14T00:00:00Z', + updated_at: '2026-06-14T00:00:00Z', + last_seen_at: '2026-06-14T00:00:00Z', + last_push_at: null, + last_push_error: null, + } +} + describe('native push registration', () => { beforeEach(() => { resetNativePushRegistrationForTests() }) + afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + it('skips registration outside iOS native mode', async () => { // Given: The app is running outside the iOS native shell const pushNotifications = createPushAdapter() @@ -150,6 +204,42 @@ describe('native push registration', () => { }) }) + it('uses the configured runtime API client when no client is injected', async () => { + // Given: Native setup has stored a server URL and API token in the runtime providers + const storage = installWindowSessionStorageMock() + storage.setItem(BROWSER_SERVER_BASE_URL_STORAGE_KEY, 'http://192.168.1.10:8081') + storage.setItem(BROWSER_AUTH_TOKEN_STORAGE_KEY, 'secret-token') + const pushNotifications = createPushAdapter() + const devicePlugin = createDevicePlugin() + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(mobileDeviceResponse()), { + status: 201, + headers: { 'content-type': 'application/json' }, + }), + ) + + // When: Native push registration runs without an injected test client + const result = await registerNativePushDevice({ + devicePlugin, + isIOSNative: () => true, + pushNotifications, + }) + + // Then: Registration posts through the runtime-configured HomeSec origin with auth + expect(result).toEqual({ status: 'registered' }) + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy.mock.calls[0]?.[0]).toBe( + 'http://192.168.1.10:8081/api/v1/mobile/devices', + ) + expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ + headers: { + Accept: 'application/json', + Authorization: 'Bearer secret-token', + 'content-type': 'application/json', + }, + }) + }) + it('requests permission once and skips backend registration when denied', async () => { // Given: iOS has not prompted yet and the user denies notification permission const pushNotifications = createPushAdapter({ @@ -172,6 +262,41 @@ describe('native push registration', () => { expect(client.registerMobileDevice).not.toHaveBeenCalled() }) + it('does not cache denied permission as a completed registration', async () => { + // Given: The first startup sees denied permission and a later startup has permission + const deniedPushNotifications = createPushAdapter({ initialPermission: 'denied' }) + const grantedPushNotifications = createPushAdapter() + const devicePlugin = createDevicePlugin() + const client = createRegistrationClient() + + const { rerender } = renderHook( + ({ pushNotifications }) => + useNativePushRegistration({ + client, + devicePlugin, + enabled: true, + isIOSNative: () => true, + pushNotifications, + registrationKey: 'same-device', + }), + { initialProps: { pushNotifications: deniedPushNotifications } }, + ) + + await waitFor(() => { + expect(deniedPushNotifications.checkPermissions).toHaveBeenCalledTimes(1) + }) + + // When: The hook runs again for the same device key after permission becomes available + await act(async () => { + rerender({ pushNotifications: grantedPushNotifications }) + }) + + // Then: The second attempt is allowed to register the device + await waitFor(() => { + expect(client.registerMobileDevice).toHaveBeenCalledTimes(1) + }) + }) + it('handles APNs registration errors without posting a device', async () => { // Given: APNs registration fails after notification permission is granted const pushNotifications = createPushAdapter({ mode: 'error' }) diff --git a/ui/src/runtime/nativePushRegistration.ts b/ui/src/runtime/nativePushRegistration.ts index 1dc5561d..68545990 100644 --- a/ui/src/runtime/nativePushRegistration.ts +++ b/ui/src/runtime/nativePushRegistration.ts @@ -7,7 +7,7 @@ import type { } from '@capacitor/push-notifications' import type { PluginListenerHandle } from '@capacitor/core' -import { HomeSecApiClient } from '../api/client' +import { apiClient, type HomeSecApiClient } from '../api/client' import type { MobileDeviceRegisterRequest } from '../api/generated/types' import { homeSecDevicePlugin, type HomeSecDevicePlugin } from './homeSecDevicePlugin' import { isIOSNativeApp } from './nativeRuntime' @@ -162,7 +162,7 @@ export async function registerNativePushDevice( devicePlugin.getRegistrationInfo(), requestAPNSToken(pushNotifications, options.timeoutMs ?? 30_000), ]) - const client = options.client ?? new HomeSecApiClient() + const client = options.client ?? apiClient await client.registerMobileDevice(buildMobileDeviceRegistration(apnsToken, info)) return { status: 'registered' } } catch (error) { @@ -189,7 +189,7 @@ function registerOnceForKey( const registration = registerNativePushDevice(options).then((result) => { inFlightRegistrations.delete(registrationKey) - if (result.status !== 'failed') { + if (result.status === 'registered') { completedRegistrationKeys.add(registrationKey) } return result