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
+
+
+
+
+
+ {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}
+
+
+
+
+ )
+}
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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
p{#L&8ukc`X_Q{M?b^0&Y-g^#JkvF%;!7
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 KlA*B|At1~%9#--eiXFS7x#hjyR6u*i47m}
z->q4+f8ZVeC8W6p33Sd-#(=0E&k}167zI6TtZyZv^pjDAV^zRPMPzSg}
zUfQ9NROwoaIAhaDn`Yl^@E&xdQ_;zDVsmRznvC82&BWQ0s7DxOq3T2VKz$9HA(
z0@fK@$FijmD$9{La6#Y#57GR_;4W-RmHpOY@8?+CyKm&sZWZ~n!s(V0vVbvRLZyv{
zByDYm{cBGse)vzMFDO))zips$Zp|N-rAb2NO4DKC!OgJ4>CaEvF_6GDhjJE2mtXiy
zRDLRQDBeN7Lu8baP%EoIr)%Ki2l9=lJg?>Y1z;hntwb>XpO4=P8I;$HqpNteCy+Y|t@uy+SS-5ntUMrKV3zR5Z=Z^;7
zp`|j>g!_}4s2YibI<@Oz0Ov<(tPMr(BvkVyaJd-@y`wadgobx9@LB2W02
z*Cv~aWY*+`*Juh6cgFz1%Gsu7ST$6LxiPa$USFwPYIuHGg^{aV03rrF32W=XHWdnJ
zI|3))9f(y5kMe21>hrvfsk~3z(QU+eqp%a-$Twf|x5SB@FAwKCVOdL@C$;c(B|(@S
zn?L2C@nCnd#BiQzf`r2L&KgEEs0kwzMe}c%G>lB2c|S0X6tNF2QT&1P&t3z`UI>FS
z(Dj*^m$$g#1BmFlMZMi&`Ujj6VyED%v##Ez$P+hC@F-^Q?JA{ymp#xV-bk7{q$lRTT^IT!ve2&`#j
zi@zx6{Xnt7Nm$<}g>V*OEesDwZULV5q2d&t5O`1FYgWy;4lRYQ6|s6=wJ`XV9?kqN
zi>Cd-u1p!>rR3fd;UeGK+UMUVHx6}EwZ+XBWYEK!_mV+Zo+>0WvY~&-tSL;!ex)I8
zSVkZ|zdH6+>QYCV`%Iz8?~r3d%793ju@abc#)rCYSqqP7UXZe$9phGf8U3Z_39#==
zJ09_OdojIAJE8k)u$Ox|K{ODa$9Y^wp-2BK!!(-+ee&xFTx#)(kv}jg@fA{|17=k?
z#F+Iu&Ow+Lr1p#Tmm7siFyze-IUz
z`v>8(vK5~rHqRF?UP;U1YZCC{wO)E{zIvD&S)5jmze6{|aBn4zR0gt=18bRS1~Jab
zJ=T|bs=}W7x;%Ku-)&pLXXnPSdPmZ7UdIJr`q_*56`@u=pF?QL$l6^QdopCbWl~|l
zODjgOb0FvQrB6TbAIcHJA=}u?YFKk3+lb`;GjWy&r8E+^vDe^!vUTI^?kA10Z616@=6sBPF#27?RMpH@QSO^2|9wKR0j5AE(7JW;!3+ru_Ck!Q(AW*r?GtSBV}a~R+luNi-NGLBGb
zlXeH&+0=H)A_;5eEn6}BmzKtM{%@VtWE&Ox9iMNeu^h7Oa+3Jg3KYeoUr|S(=LKBs
z`L3YR726qM+v46r1{?u5pl&$2S)~wclP_JKh4OdeCrI(sbeDtu4>^eP3u`j%2EH)C
z84Z>W(lg`}10&2E84?`!EfmQw8U*luj*h3Ca)*qB=Qj4=wsVPp9XurRXyFG=$&{A6
zhCO(rd!t4@a>n>+FU{2!cm%T&o<48CkPlkBM<*T*?c#f|v>8T(W$9p^=bhw186h)2
zU5b3gZoUfJUC |