diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 61147895..129ef683 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -90,9 +90,54 @@ jobs:
- name: UI check
run: pnpm --dir ui check
+ - name: Install Playwright browsers
+ run: pnpm --dir ui exec playwright install --with-deps chromium webkit
+
+ - name: UI e2e
+ run: pnpm --dir ui test:e2e
+
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.xml
fail_ci_if_error: false
+
+ ios-native:
+ runs-on: macos-26
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 10.15.1
+ run_install: false
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22.12.0"
+ cache: pnpm
+ cache-dependency-path: ui/pnpm-lock.yaml
+
+ - name: Install UI dependencies
+ run: pnpm --dir ui install --frozen-lockfile
+
+ - name: Sync Capacitor iOS project
+ run: pnpm --dir ui ios:sync
+
+ - name: Verify committed iOS sync output
+ run: git diff --exit-code -- ui/ios ui/capacitor.config.ts ui/package.json ui/pnpm-lock.yaml
+
+ - name: Build iOS simulator app
+ run: |
+ xcodebuild \
+ -project ui/ios/App/App.xcodeproj \
+ -scheme App \
+ -destination 'generic/platform=iOS Simulator' \
+ -configuration Debug \
+ CODE_SIGNING_ALLOWED=NO \
+ build
diff --git a/alembic/versions/7b0b1fbfc69b_add_mobile_device_registry.py b/alembic/versions/7b0b1fbfc69b_add_mobile_device_registry.py
new file mode 100644
index 00000000..e0ec6b7e
--- /dev/null
+++ b/alembic/versions/7b0b1fbfc69b_add_mobile_device_registry.py
@@ -0,0 +1,77 @@
+"""add mobile device registry
+
+Revision ID: 7b0b1fbfc69b
+Revises: d936851f725a
+Create Date: 2026-06-14 00:00:00.000000
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = "7b0b1fbfc69b"
+down_revision: str | None = "d936851f725a"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "mobile_devices",
+ sa.Column("id", sa.Text(), nullable=False),
+ sa.Column("platform", sa.Text(), nullable=False),
+ sa.Column("apns_token_hash", sa.Text(), nullable=False),
+ sa.Column("apns_token", sa.Text(), nullable=False),
+ sa.Column("apns_environment", sa.Text(), nullable=False),
+ sa.Column("bundle_id", sa.Text(), nullable=False),
+ sa.Column("device_name", sa.Text(), nullable=True),
+ sa.Column("app_version", sa.Text(), nullable=True),
+ sa.Column(
+ "capabilities",
+ postgresql.JSONB(astext_type=sa.Text()),
+ server_default=sa.text("'{}'::jsonb"),
+ nullable=False,
+ ),
+ sa.Column("enabled", sa.Boolean(), server_default=sa.text("true"), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("last_push_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("last_push_error", sa.Text(), nullable=True),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("apns_token_hash", name="uq_mobile_devices_apns_token_hash"),
+ )
+ op.create_index("idx_mobile_devices_enabled", "mobile_devices", ["enabled"], unique=False)
+ op.create_index(
+ "idx_mobile_devices_platform_environment",
+ "mobile_devices",
+ ["platform", "apns_environment"],
+ unique=False,
+ )
+ op.create_index(
+ "idx_mobile_devices_updated_at_desc",
+ "mobile_devices",
+ [sa.literal_column("updated_at DESC")],
+ unique=False,
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("idx_mobile_devices_updated_at_desc", table_name="mobile_devices")
+ op.drop_index("idx_mobile_devices_platform_environment", table_name="mobile_devices")
+ op.drop_index("idx_mobile_devices_enabled", table_name="mobile_devices")
+ op.drop_table("mobile_devices")
diff --git a/docs/ios-app-design.md b/docs/ios-app-design.md
new file mode 100644
index 00000000..c2a3d3e9
--- /dev/null
+++ b/docs/ios-app-design.md
@@ -0,0 +1,338 @@
+# HomeSec iOS and iPad App Design
+
+Last reviewed: 2026-06-14
+
+This document is the repo-level architecture baseline for the first HomeSec iOS
+and iPad app. The v1 direction is to package the existing React app in a
+Capacitor iOS shell and add narrow native bridges only where iOS capabilities
+are required. The build runbook remains the operational source for signing,
+device QA, APNs credentials, and App Store/TestFlight mechanics.
+
+## Executive Decision
+
+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 | Implemented as a notifier backend under `notifiers`, using `backend: apns_mobile`. |
+| Push-to-talk | Keep React parity. Test the WebView path first; add native audio only if needed. |
+| Background behavior | Stop live preview and push-to-talk when the app backgrounds. |
+| iPad v1 | Same responsive app. No dedicated iPad split view in v1. |
+| 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 | 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 v1 app shell.
+
+The implemented native-mode setup path validates the entered server URL and API
+token, then persists the server URL, API token, and auth-disabled acknowledgement
+through the native Keychain bridge. Browser mode keeps the existing
+session-storage behavior.
+
+## iOS Setup UX
+
+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 native Keychain-backed
+ providers.
+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 notifications 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.
+
+## 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 and native bridge changes, also verify:
+
+```bash
+pnpm --dir ui ios:sync
+xcodebuild \
+ -project ui/ios/App/App.xcodeproj \
+ -scheme App \
+ -destination 'generic/platform=iOS Simulator' \
+ -configuration Debug \
+ CODE_SIGNING_ALLOWED=NO \
+ build
+```
+
+Launching in the simulator is expected when local Xcode setup allows it.
+Real-device signing uses the personal Apple Development team for Debug and a
+distribution identity for Release/TestFlight.
+
+## Deferred Follow-Ups
+
+- 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
diff --git a/docs/ios-build-runbook.md b/docs/ios-build-runbook.md
new file mode 100644
index 00000000..acbfa252
--- /dev/null
+++ b/docs/ios-build-runbook.md
@@ -0,0 +1,214 @@
+# HomeSec iOS Build And Runbook
+
+Last reviewed: 2026-06-14
+
+This runbook covers personal HomeSec iPhone and iPad builds from this repo.
+The current app is a Capacitor iOS shell around the React UI in `ui/`.
+
+HomeSec intentionally supports the latest iOS major only. The native project
+currently builds with the installed iOS 26.5 SDK and has
+`IPHONEOS_DEPLOYMENT_TARGET = 26.0`. Older iOS 17/18 simulator runtimes may be
+installed locally, but they are not supported targets for this app stream.
+
+## Prerequisites
+
+- macOS with Xcode installed and selected by `xcode-select`.
+- Xcode command line tools available: `xcodebuild -version` should succeed.
+- Node compatible with `ui/package.json` (`>=22.12.0`).
+- pnpm compatible with the repo lockfile.
+- Python/uv dependencies installed for backend validation.
+- An Apple Developer account/team for real-device signing and APNs.
+- A reachable HomeSec server over HTTPS, VPN, or local LAN.
+
+Recommended preflight:
+
+```bash
+xcodebuild -showsdks
+xcrun devicectl list devices
+uv sync
+pnpm --dir ui install
+```
+
+`xcodebuild -showsdks` should show an iOS SDK matching the latest supported
+major version. On 2026-06-14 this repo was validated with iOS SDK 26.5 and iOS
+Simulator SDK 26.5.
+
+## Local Development Build
+
+Use this path for simulator work and web/native asset sync checks.
+
+```bash
+pnpm --dir ui ios:sync
+xcodebuild \
+ -project ui/ios/App/App.xcodeproj \
+ -scheme App \
+ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.5' \
+ -configuration Debug \
+ -derivedDataPath /tmp/homesec-ios-qa \
+ build
+```
+
+The `ios:sync` script builds the React app, copies web assets into
+`ui/ios/App/App/public`, and regenerates the local Capacitor SPM package.
+`ui/capacitor.config.ts` pins `experimental.ios.spm.swiftToolsVersion` to
+`6.2`; keep that setting while the package platform is `.iOS(.v26)`.
+Without it, Capacitor can regenerate `Package.swift` with Swift tools 5.9,
+which Xcode cannot resolve for the iOS 26 package platform enum.
+
+To install and launch a simulator build manually:
+
+```bash
+xcrun simctl bootstatus booted -b
+xcrun simctl install booted /tmp/homesec-ios-qa/Build/Products/Debug-iphonesimulator/App.app
+xcrun simctl launch booted com.levneiman.homesec
+```
+
+The expected first-launch screen is `Connect to HomeSec` with server URL and
+API token controls.
+
+## Personal Device Build
+
+Use this path for installing the app on your own iPhone or iPad.
+
+1. Connect the iPhone/iPad over USB or enable wireless debugging in Xcode.
+2. Unlock the device and trust the Mac if prompted.
+3. Confirm Xcode can see it:
+
+ ```bash
+ xcrun devicectl list devices
+ ```
+
+4. Sync the native assets:
+
+ ```bash
+ pnpm --dir ui ios:sync
+ ```
+
+5. Open the native project:
+
+ ```bash
+ open ui/ios/App/App.xcodeproj
+ ```
+
+6. In Xcode, select the `App` target and set Signing & Capabilities:
+ - Team: your Apple Developer team.
+ - Bundle Identifier: keep `com.levneiman.homesec` for the default personal
+ build, or change it consistently in Xcode and backend APNs config if your
+ Apple account requires a unique identifier.
+ - Signing: automatic signing is expected.
+ - Push Notifications capability must be present when testing APNs.
+
+7. Select the connected device as the run destination and run the `App` scheme.
+
+The Debug target uses `APS_ENVIRONMENT = development`; the Release target uses
+`APS_ENVIRONMENT = production`. For personal device QA and sandbox pushes, run
+Debug unless you are intentionally validating a production APNs profile.
+
+## HomeSec Server Setup
+
+The iOS shell stores the server URL and API token in the native Keychain bridge,
+not WebView storage.
+
+On first launch:
+
+1. Enter the HomeSec server base URL.
+2. Tap `Check server`.
+3. Paste the HomeSec API token.
+4. Tap `Save and continue`.
+
+Use HTTPS or VPN whenever possible. Plain HTTP is only acceptable for a trusted
+LAN/VPN development setup; the app allows local networking for LAN bootstrap but
+should not be treated as secure over untrusted networks.
+
+If server auth is disabled, the app can proceed for first LAN/VPN iteration, but
+that is a personal-use convenience only. Do not expose auth-disabled HomeSec to
+the public internet.
+
+## APNs Sandbox Setup
+
+APNs is optional for basic app browsing but required for notification QA.
+
+Apple-side setup:
+
+1. In the Apple Developer portal, make sure the bundle id has Push
+ Notifications enabled.
+2. Create or reuse an APNs Auth Key.
+3. Record the key id and team id.
+4. Download the `.p8` private key once and store it outside the repo.
+
+HomeSec server environment variables:
+
+```bash
+export HOMESEC_APNS_KEY_ID='ABC123DEFG'
+export HOMESEC_APNS_TEAM_ID='TEAM123456'
+export HOMESEC_APNS_PRIVATE_KEY="$(cat /secure/path/AuthKey_ABC123DEFG.p8)"
+```
+
+Example notifier config:
+
+```yaml
+notifiers:
+ - backend: apns_mobile
+ config:
+ bundle_id: com.levneiman.homesec
+ environment: sandbox
+ key_id_env: HOMESEC_APNS_KEY_ID
+ team_id_env: HOMESEC_APNS_TEAM_ID
+ private_key_env: HOMESEC_APNS_PRIVATE_KEY
+```
+
+Use `environment: sandbox` for Debug builds and `environment: production` only
+for Release/TestFlight/App Store builds signed with the production APNs
+environment. The bundle id and APNs environment must match the registered mobile
+device record, otherwise HomeSec will not find an enabled APNs target.
+
+Never commit APNs keys, HomeSec API tokens, RTSP credentials, or `.env` files.
+
+## QA Checklist
+
+Run the real-device QA matrix before treating a personal build as ready:
+
+- First launch setup renders.
+- VPN/LAN server URL check succeeds.
+- API token paste auth succeeds.
+- API token persists after app restart.
+- Live page loads.
+- Events page loads.
+- Event detail playback works.
+- Live HLS preview works.
+- Push-to-talk path works if enabled for the configured camera.
+- Backgrounding stops active preview and talk sessions.
+- Plain APNs push is received.
+- Tapping a push opens the event detail route.
+- iPad layout is usable.
+
+File bugs for failures and keep iOS-19 updated with pass/fail notes.
+
+## Future TestFlight Build
+
+TestFlight is not required for the first personal release. When it is needed:
+
+1. Switch to a unique production bundle id if `com.levneiman.homesec` is not
+ owned by the target Apple Developer team.
+2. Keep `APS_ENVIRONMENT = production` for Release.
+3. Use `environment: production` in the `apns_mobile` notifier config.
+4. Archive from Xcode with the `App` scheme.
+5. Upload through Xcode Organizer or `xcrun altool`/Transporter.
+6. Re-test APNs because sandbox device tokens do not work against production
+ APNs, and production tokens do not work against sandbox APNs.
+
+## Troubleshooting
+
+- `No devices found.` from `xcrun devicectl list devices`: unlock the device,
+ trust the Mac, reconnect USB, or enable wireless debugging from Xcode.
+- `PackageDescription.SupportedPlatform.IOSVersion.v26 is unavailable`: rerun
+ `pnpm --dir ui ios:sync` and confirm `ui/ios/App/CapApp-SPM/Package.swift`
+ starts with `// swift-tools-version: 6.2`.
+- App installs but cannot connect to HomeSec: confirm the iPhone can reach the
+ server URL in Safari over the same VPN/LAN, and confirm auth is enabled with
+ the expected bearer token.
+- No APNs devices receive alerts: confirm the iOS app registered after setup,
+ the device is enabled in the mobile device list, the APNs environment matches
+ the build configuration, and `bundle_id` matches the app bundle identifier.
+- Push tap opens the app but not the event: confirm payload `data.route` is an
+ app-relative route such as `/events/?from=notification`.
diff --git a/pyproject.toml b/pyproject.toml
index 7ba43850..9ec09c0f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,6 +46,8 @@ dependencies = [
"greenlet>=3.3.0",
"pyftpdlib>=2.1.0",
"websockets>=16.0",
+ "cryptography>=46.0.3",
+ "httpx[http2]>=0.28.1",
]
[project.scripts]
diff --git a/src/homesec/api/errors.py b/src/homesec/api/errors.py
index 8db01643..60f35511 100644
--- a/src/homesec/api/errors.py
+++ b/src/homesec/api/errors.py
@@ -59,6 +59,7 @@ class APIErrorCode(StrEnum):
CLIPS_CURSOR_INVALID = "CLIPS_CURSOR_INVALID"
CLIPS_TIME_RANGE_INVALID = "CLIPS_TIME_RANGE_INVALID"
CLIPS_TIMESTAMP_TZ_REQUIRED = "CLIPS_TIMESTAMP_TZ_REQUIRED"
+ MOBILE_DEVICE_NOT_FOUND = "MOBILE_DEVICE_NOT_FOUND"
RELOAD_IN_PROGRESS = "RELOAD_IN_PROGRESS"
BACKUP_DISABLED = "BACKUP_DISABLED"
BACKUP_UNAVAILABLE = "BACKUP_UNAVAILABLE"
diff --git a/src/homesec/api/routes/__init__.py b/src/homesec/api/routes/__init__.py
index 9f7f0e4b..772eafac 100644
--- a/src/homesec/api/routes/__init__.py
+++ b/src/homesec/api/routes/__init__.py
@@ -17,6 +17,7 @@
health,
maintenance,
media,
+ mobile,
onvif,
preview,
runtime,
@@ -75,6 +76,14 @@ def register_routes(app: FastAPI) -> None:
Depends(require_database),
],
)
+ app.include_router(
+ mobile.router,
+ dependencies=[
+ Depends(verify_api_key),
+ Depends(require_normal_mode),
+ Depends(require_database),
+ ],
+ )
app.include_router(
runtime.router,
dependencies=[Depends(verify_api_key), Depends(require_normal_mode)],
diff --git a/src/homesec/api/routes/mobile.py b/src/homesec/api/routes/mobile.py
new file mode 100644
index 00000000..5ce3850b
--- /dev/null
+++ b/src/homesec/api/routes/mobile.py
@@ -0,0 +1,156 @@
+"""Mobile device registration endpoints."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import TYPE_CHECKING
+
+from fastapi import APIRouter, Depends, status
+from pydantic import BaseModel, Field, field_validator
+
+from homesec.api.dependencies import get_homesec_app
+from homesec.api.errors import APIError, APIErrorCode
+from homesec.models.mobile import (
+ APNSEnvironment,
+ MobileDeviceCapabilities,
+ MobileDeviceRecord,
+ MobileDeviceRegistration,
+ MobileDeviceUpdate,
+ MobilePlatform,
+)
+
+if TYPE_CHECKING:
+ from homesec.app import Application
+
+router = APIRouter(tags=["mobile"])
+
+
+class MobileDeviceRegisterRequest(BaseModel):
+ platform: MobilePlatform = "ios"
+ apns_token: str = Field(min_length=1)
+ environment: APNSEnvironment
+ bundle_id: str = Field(min_length=1)
+ device_name: str | None = None
+ app_version: str | None = None
+ capabilities: MobileDeviceCapabilities = Field(default_factory=MobileDeviceCapabilities)
+
+ @field_validator("apns_token", "bundle_id")
+ @classmethod
+ def _strip_required_text(cls, value: str) -> str:
+ normalized = value.strip()
+ if not normalized:
+ raise ValueError("value must not be blank")
+ return normalized
+
+
+class MobileDevicePatchRequest(BaseModel):
+ device_name: str | None = None
+ app_version: str | None = None
+ enabled: bool | None = None
+ capabilities: MobileDeviceCapabilities | None = None
+
+
+class MobileDeviceResponse(BaseModel):
+ id: str
+ platform: MobilePlatform
+ environment: APNSEnvironment
+ bundle_id: str
+ device_name: str | None = None
+ app_version: str | None = None
+ capabilities: MobileDeviceCapabilities
+ enabled: bool
+ token_fingerprint: str
+ created_at: datetime
+ updated_at: datetime
+ last_seen_at: datetime | None = None
+ last_push_at: datetime | None = None
+ last_push_error: str | None = None
+
+
+def _device_response(record: MobileDeviceRecord) -> MobileDeviceResponse:
+ return MobileDeviceResponse(
+ id=record.id,
+ platform=record.platform,
+ environment=record.apns_environment,
+ bundle_id=record.bundle_id,
+ device_name=record.device_name,
+ app_version=record.app_version,
+ capabilities=record.capabilities,
+ enabled=record.enabled,
+ token_fingerprint=record.token_fingerprint,
+ created_at=record.created_at,
+ updated_at=record.updated_at,
+ last_seen_at=record.last_seen_at,
+ last_push_at=record.last_push_at,
+ last_push_error=record.last_push_error,
+ )
+
+
+@router.post(
+ "/api/v1/mobile/devices",
+ response_model=MobileDeviceResponse,
+ status_code=status.HTTP_201_CREATED,
+)
+async def register_mobile_device(
+ payload: MobileDeviceRegisterRequest,
+ app: Application = Depends(get_homesec_app),
+) -> MobileDeviceResponse:
+ """Register or refresh an iOS APNs device."""
+ record = await app.mobile_devices.register_device(
+ MobileDeviceRegistration(
+ platform=payload.platform,
+ apns_token=payload.apns_token,
+ apns_environment=payload.environment,
+ bundle_id=payload.bundle_id,
+ device_name=payload.device_name,
+ app_version=payload.app_version,
+ capabilities=payload.capabilities,
+ )
+ )
+ return _device_response(record)
+
+
+@router.get("/api/v1/mobile/devices", response_model=list[MobileDeviceResponse])
+async def list_mobile_devices(
+ include_disabled: bool = False,
+ app: Application = Depends(get_homesec_app),
+) -> list[MobileDeviceResponse]:
+ """List registered iOS devices without raw APNs material."""
+ records = await app.mobile_devices.list_devices(include_disabled=include_disabled)
+ return [_device_response(record) for record in records]
+
+
+@router.patch("/api/v1/mobile/devices/{device_id}", response_model=MobileDeviceResponse)
+async def update_mobile_device(
+ device_id: str,
+ payload: MobileDevicePatchRequest,
+ app: Application = Depends(get_homesec_app),
+) -> MobileDeviceResponse:
+ """Update mutable mobile device metadata or enabled state."""
+ record = await app.mobile_devices.update_device(
+ device_id,
+ MobileDeviceUpdate.model_validate(payload.model_dump(exclude_unset=True)),
+ )
+ if record is None:
+ raise APIError(
+ "Mobile device not found",
+ status_code=status.HTTP_404_NOT_FOUND,
+ error_code=APIErrorCode.MOBILE_DEVICE_NOT_FOUND,
+ )
+ return _device_response(record)
+
+
+@router.delete("/api/v1/mobile/devices/{device_id}", response_model=MobileDeviceResponse)
+async def delete_mobile_device(
+ device_id: str,
+ app: Application = Depends(get_homesec_app),
+) -> MobileDeviceResponse:
+ """Disable a mobile device without hard-deleting it."""
+ record = await app.mobile_devices.disable_device(device_id)
+ if record is None:
+ raise APIError(
+ "Mobile device not found",
+ status_code=status.HTTP_404_NOT_FOUND,
+ error_code=APIErrorCode.MOBILE_DEVICE_NOT_FOUND,
+ )
+ return _device_response(record)
diff --git a/src/homesec/app.py b/src/homesec/app.py
index dca3e418..0737a70e 100644
--- a/src/homesec/app.py
+++ b/src/homesec/app.py
@@ -50,6 +50,7 @@
from homesec.interfaces import EventStore, StateStore, StorageBackend
from homesec.models.config import Config
from homesec.repository import ClipRepository
+ from homesec.repository.mobile_device_repository import MobileDeviceRepository
logger = logging.getLogger(__name__)
RESTART_EXIT_CODE = 42
@@ -95,6 +96,7 @@ def __init__(
self._state_store: StateStore = NoopStateStore()
self._event_store: EventStore = NoopEventStore()
self._repository: ClipRepository | None = None
+ self._mobile_device_repository: MobileDeviceRepository | None = None
self._postgres_backup_manager: PostgresBackupManager | None = None
self._api_server: APIServer | None = None
self._runtime_manager: RuntimeManager | None = None
@@ -530,6 +532,19 @@ def repository(self) -> ClipRepository:
raise RuntimeError("Repository not initialized")
return self._repository
+ @property
+ def mobile_devices(self) -> MobileDeviceRepository:
+ if self._mobile_device_repository is not None:
+ return self._mobile_device_repository
+
+ from homesec.repository.mobile_device_repository import MobileDeviceRepository
+ from homesec.state.postgres import PostgresStateStore
+
+ if not isinstance(self._state_store, PostgresStateStore):
+ raise RuntimeError("Mobile device repository requires initialized Postgres state store")
+ self._mobile_device_repository = MobileDeviceRepository(self._state_store.engine)
+ return self._mobile_device_repository
+
@property
def storage(self) -> StorageBackend:
if self._storage is None:
diff --git a/src/homesec/models/mobile.py b/src/homesec/models/mobile.py
new file mode 100644
index 00000000..b22a6de8
--- /dev/null
+++ b/src/homesec/models/mobile.py
@@ -0,0 +1,75 @@
+"""Mobile device registration models."""
+
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, Field, field_validator
+
+MobilePlatform = Literal["ios"]
+APNSEnvironment = Literal["sandbox", "production"]
+
+
+class MobileDeviceCapabilities(BaseModel):
+ """Feature flags reported by the current iOS app build."""
+
+ deep_links: bool = True
+ rich_notifications: bool = False
+
+
+class MobileDeviceRegistration(BaseModel):
+ """Registration payload for an iOS APNs device."""
+
+ platform: MobilePlatform = "ios"
+ apns_token: str = Field(min_length=1)
+ apns_environment: APNSEnvironment
+ bundle_id: str = Field(min_length=1)
+ device_name: str | None = None
+ app_version: str | None = None
+ capabilities: MobileDeviceCapabilities = Field(default_factory=MobileDeviceCapabilities)
+
+ @field_validator("apns_token", "bundle_id")
+ @classmethod
+ def _strip_required_text(cls, value: str) -> str:
+ normalized = value.strip()
+ if not normalized:
+ raise ValueError("value must not be blank")
+ return normalized
+
+
+class MobileDeviceUpdate(BaseModel):
+ """Mutable fields for a registered mobile device."""
+
+ device_name: str | None = None
+ app_version: str | None = None
+ enabled: bool | None = None
+ capabilities: MobileDeviceCapabilities | None = None
+
+
+class MobileDeviceRecord(BaseModel):
+ """Public mobile device record without raw APNs registration material."""
+
+ id: str
+ platform: MobilePlatform
+ apns_environment: APNSEnvironment
+ bundle_id: str
+ device_name: str | None = None
+ app_version: str | None = None
+ capabilities: MobileDeviceCapabilities = Field(default_factory=MobileDeviceCapabilities)
+ enabled: bool
+ token_fingerprint: str
+ created_at: datetime
+ updated_at: datetime
+ last_seen_at: datetime | None = None
+ last_push_at: datetime | None = None
+ last_push_error: str | None = None
+
+
+class MobileDevicePushTarget(BaseModel):
+ """Internal APNs send target containing token material."""
+
+ id: str
+ apns_token: str = Field(min_length=1, repr=False)
+ apns_environment: APNSEnvironment
+ bundle_id: str
diff --git a/src/homesec/pipeline/core.py b/src/homesec/pipeline/core.py
index 7d03bde9..f4d36321 100644
--- a/src/homesec/pipeline/core.py
+++ b/src/homesec/pipeline/core.py
@@ -373,10 +373,10 @@ async def _run_stage_with_retries(
result = await op()
except Exception as exc:
duration_ms = int((time.monotonic() - started) * 1000)
- will_retry = attempts < max_attempts
+ will_retry = attempts < max_attempts and self._is_retryable_stage_error(exc)
if on_attempt_failure is not None:
await on_attempt_failure(exc, attempts, will_retry, duration_ms)
- if attempts >= max_attempts:
+ if not will_retry:
raise
logger.warning(
"Stage %s failed for %s (attempt %d/%d): %s",
@@ -397,6 +397,16 @@ async def _run_stage_with_retries(
await on_attempt_success(result, attempts, duration_ms)
return result
+ @classmethod
+ def _is_retryable_stage_error(cls, exc: Exception) -> bool:
+ retryable = getattr(exc, "retryable", None)
+ if isinstance(retryable, bool):
+ return retryable
+ cause = getattr(exc, "cause", None)
+ if isinstance(cause, Exception):
+ return cls._is_retryable_stage_error(cause)
+ return True
+
async def _upload_stage(self, clip: Clip) -> UploadOutcome | UploadError:
"""Upload clip to storage. Returns UploadOutcome or UploadError."""
dest_path = build_clip_path(clip, self._config.storage.paths)
diff --git a/src/homesec/plugins/notifiers/__init__.py b/src/homesec/plugins/notifiers/__init__.py
index 0b9c0d21..da44b10e 100644
--- a/src/homesec/plugins/notifiers/__init__.py
+++ b/src/homesec/plugins/notifiers/__init__.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
-from typing import cast
+from typing import Any, cast
from pydantic import BaseModel
@@ -13,7 +13,11 @@
logger = logging.getLogger(__name__)
-def load_notifier_plugin(backend: str, config: dict[str, object] | BaseModel) -> Notifier:
+def load_notifier_plugin(
+ backend: str,
+ config: dict[str, object] | BaseModel,
+ **runtime_context: Any,
+) -> Notifier:
"""Load and instantiate a notifier plugin.
Args:
@@ -33,6 +37,7 @@ def load_notifier_plugin(backend: str, config: dict[str, object] | BaseModel) ->
PluginType.NOTIFIER,
backend,
config,
+ **runtime_context,
),
)
diff --git a/src/homesec/plugins/notifiers/apns_mobile.py b/src/homesec/plugins/notifiers/apns_mobile.py
new file mode 100644
index 00000000..8e6e6fbd
--- /dev/null
+++ b/src/homesec/plugins/notifiers/apns_mobile.py
@@ -0,0 +1,435 @@
+"""APNs notifier plugin for registered HomeSec iOS devices."""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import json
+import logging
+import os
+import time
+from collections.abc import Mapping
+from datetime import datetime, timezone
+from typing import Any, Protocol, cast
+from urllib.parse import quote
+
+import httpx
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import ec, utils
+from pydantic import BaseModel, Field, field_validator
+
+from homesec.interfaces import Notifier
+from homesec.models.alert import Alert
+from homesec.models.mobile import APNSEnvironment, MobileDevicePushTarget
+from homesec.plugins.registry import PluginType, plugin
+
+logger = logging.getLogger(__name__)
+
+_APNS_CATEGORY = "HOMESEC_EVENT"
+_APNS_PUSH_TYPE = "alert"
+_PROVIDER_TOKEN_REFRESH_S = 50 * 60
+_PERMANENT_TOKEN_REJECTION_REASONS = frozenset(
+ {
+ "BadDeviceToken",
+ "DeviceTokenNotForTopic",
+ "Unregistered",
+ }
+)
+
+
+class _MobileDevicePushRepository(Protocol):
+ async def list_enabled_apns_targets(
+ self,
+ *,
+ environment: APNSEnvironment,
+ bundle_id: str,
+ ) -> list[MobileDevicePushTarget]:
+ """Return enabled APNs targets for one app bundle/environment."""
+ ...
+
+ async def record_push_result(
+ self,
+ device_id: str,
+ *,
+ error: str | None,
+ now: datetime | None = None,
+ ) -> object | None:
+ """Record the latest APNs send outcome for a device."""
+ ...
+
+ async def disable_device(
+ self,
+ device_id: str,
+ *,
+ now: datetime | None = None,
+ ) -> object | None:
+ """Disable a permanently invalid APNs target."""
+ ...
+
+
+class APNsMobileConfig(BaseModel):
+ """APNs notifier configuration using Apple token-based provider auth."""
+
+ model_config = {"extra": "forbid"}
+
+ key_id_env: str = "HOMESEC_APNS_KEY_ID"
+ team_id_env: str = "HOMESEC_APNS_TEAM_ID"
+ private_key_env: str = "HOMESEC_APNS_PRIVATE_KEY"
+ bundle_id: str
+ environment: APNSEnvironment = "sandbox"
+ request_timeout_s: float = Field(default=10.0, gt=0)
+ apns_base_url: str | None = None
+ mobile_device_repository: Any | None = Field(default=None, exclude=True, repr=False)
+
+ @field_validator("key_id_env", "team_id_env", "private_key_env", "bundle_id")
+ @classmethod
+ def _strip_required_text(cls, value: str) -> str:
+ normalized = value.strip()
+ if not normalized:
+ raise ValueError("value must not be blank")
+ return normalized
+
+ @field_validator("apns_base_url")
+ @classmethod
+ def _strip_optional_url(cls, value: str | None) -> str | None:
+ if value is None:
+ return None
+ normalized = value.strip().rstrip("/")
+ return normalized or None
+
+
+class _APNsProviderTokenSigner:
+ """Creates and caches APNs ES256 provider tokens."""
+
+ def __init__(self, *, key_id: str, team_id: str, private_key_pem: str) -> None:
+ self._key_id = key_id
+ self._team_id = team_id
+ self._private_key = _load_signing_key(private_key_pem)
+ self._cached_token: str | None = None
+ self._cached_issued_at = 0
+
+ def token(self) -> str:
+ issued_at = int(time.time())
+ if (
+ self._cached_token is not None
+ and issued_at - self._cached_issued_at < _PROVIDER_TOKEN_REFRESH_S
+ ):
+ return self._cached_token
+
+ header = {"alg": "ES256", "kid": self._key_id}
+ claims = {"iss": self._team_id, "iat": issued_at}
+ signing_input = f"{_base64url_json(header)}.{_base64url_json(claims)}".encode("ascii")
+ der_signature = self._private_key.sign(signing_input, ec.ECDSA(hashes.SHA256()))
+ r_value, s_value = utils.decode_dss_signature(der_signature)
+ raw_signature = r_value.to_bytes(32, "big") + s_value.to_bytes(32, "big")
+ token = f"{signing_input.decode('ascii')}.{_base64url(raw_signature)}"
+ self._cached_token = token
+ self._cached_issued_at = issued_at
+ return token
+
+
+@plugin(plugin_type=PluginType.NOTIFIER, name="apns_mobile")
+class APNsMobileNotifier(Notifier):
+ """Send plain APNs alert notifications to registered HomeSec iOS devices."""
+
+ config_cls = APNsMobileConfig
+
+ @classmethod
+ def create(cls, config: APNsMobileConfig) -> Notifier:
+ return cls(config)
+
+ def __init__(self, config: APNsMobileConfig) -> None:
+ self._bundle_id = config.bundle_id
+ self._environment = config.environment
+ self._timeout_s = float(config.request_timeout_s)
+ self._base_url = config.apns_base_url or _default_apns_base_url(config.environment)
+ self._repository = _require_mobile_repository(config.mobile_device_repository)
+ self._signer = _build_provider_token_signer(config)
+ self._client: httpx.AsyncClient | None = None
+ self._shutdown_called = False
+
+ async def send(self, alert: Alert) -> None:
+ """Send one alert to all currently enabled iOS APNs targets."""
+ if self._shutdown_called:
+ raise RuntimeError("Notifier has been shut down")
+ if self._signer is None:
+ raise RuntimeError("APNs provider credentials missing from environment")
+
+ targets = await self._repository.list_enabled_apns_targets(
+ environment=self._environment,
+ bundle_id=self._bundle_id,
+ )
+ if not targets:
+ logger.info(
+ "APNs mobile notifier found no enabled targets",
+ extra={
+ "event_type": "apns_mobile_no_targets",
+ "apns_environment": self._environment,
+ "bundle_id": self._bundle_id,
+ },
+ )
+ return
+
+ payload = build_apns_payload(alert)
+ provider_token = self._signer.token()
+ sent_at = datetime.now(timezone.utc)
+ results = await asyncio.gather(
+ *(
+ self._send_to_target(
+ target,
+ payload=payload,
+ provider_token=provider_token,
+ sent_at=sent_at,
+ )
+ for target in targets
+ ),
+ return_exceptions=True,
+ )
+
+ successes = 0
+ permanent_failures: list[str] = []
+ retryable_failures: list[str] = []
+ for target, result in zip(targets, results, strict=True):
+ match result:
+ case _DeliveryResult() as delivery:
+ if delivery.delivered:
+ successes += 1
+ elif delivery.retryable:
+ retryable_failures.append(target.id)
+ else:
+ permanent_failures.append(target.id)
+ case BaseException() as exc:
+ retryable_failures.append(target.id)
+ logger.error(
+ "APNs mobile send failed while recording device result: device_id=%s "
+ "error=%s",
+ target.id,
+ exc,
+ exc_info=exc,
+ )
+
+ failure_count = len(permanent_failures) + len(retryable_failures)
+ if failure_count:
+ logger.warning(
+ "APNs mobile notifier had failed target deliveries: failed=%d succeeded=%d",
+ failure_count,
+ successes,
+ extra={
+ "event_type": "apns_mobile_delivery_partial_failure",
+ "failed_count": failure_count,
+ "permanent_failed_count": len(permanent_failures),
+ "retryable_failed_count": len(retryable_failures),
+ "succeeded_count": successes,
+ },
+ )
+ if retryable_failures or permanent_failures:
+ raise APNsDeliveryError(
+ f"APNs delivery failed for {failure_count} of {len(targets)} device(s)",
+ retryable=successes == 0 and bool(retryable_failures),
+ )
+
+ async def ping(self) -> bool:
+ """Health check for local APNs notifier configuration."""
+ return not self._shutdown_called and self._signer is not None
+
+ async def shutdown(self, timeout: float | None = None) -> None:
+ """Close the HTTP client used for APNs delivery."""
+ _ = timeout
+ if self._shutdown_called:
+ return
+ self._shutdown_called = True
+ if self._client is not None and not self._client.is_closed:
+ await self._client.aclose()
+
+ async def _send_to_target(
+ self,
+ target: MobileDevicePushTarget,
+ *,
+ payload: dict[str, object],
+ provider_token: str,
+ sent_at: datetime,
+ ) -> _DeliveryResult:
+ headers = {
+ "authorization": f"bearer {provider_token}",
+ "apns-topic": self._bundle_id,
+ "apns-push-type": _APNS_PUSH_TYPE,
+ "apns-priority": "10",
+ }
+ try:
+ response = await (await self._get_client()).post(
+ _target_url(self._base_url, target.apns_token),
+ json=payload,
+ headers=headers,
+ )
+ except httpx.HTTPError as exc:
+ error = type(exc).__name__
+ await self._repository.record_push_result(target.id, error=error, now=sent_at)
+ logger.warning(
+ "APNs mobile send transport failed: device_id=%s error=%s",
+ target.id,
+ error,
+ )
+ return _DeliveryResult(delivered=False, retryable=True)
+
+ if 200 <= response.status_code < 300:
+ await self._repository.record_push_result(target.id, error=None, now=sent_at)
+ return _DeliveryResult(delivered=True, retryable=False)
+
+ reason = _apns_response_reason(response)
+ error = f"HTTP {response.status_code}: {reason}"
+ await self._repository.record_push_result(target.id, error=error, now=sent_at)
+ retryable = not _is_permanent_token_rejection(response.status_code, reason)
+ if not retryable:
+ await self._repository.disable_device(target.id, now=sent_at)
+ logger.warning(
+ "APNs mobile send rejected: device_id=%s status=%d reason=%s",
+ target.id,
+ response.status_code,
+ reason,
+ )
+ return _DeliveryResult(delivered=False, retryable=retryable)
+
+ async def _get_client(self) -> httpx.AsyncClient:
+ if self._client is None or self._client.is_closed:
+ self._client = httpx.AsyncClient(
+ http2=True,
+ timeout=httpx.Timeout(self._timeout_s),
+ )
+ return self._client
+
+
+def build_apns_payload(alert: Alert) -> dict[str, object]:
+ """Build the plain APNs payload for a HomeSec alert."""
+ risk_level = str(alert.risk_level) if alert.risk_level is not None else "unknown"
+ activity_type = _notification_value(alert.activity_type, fallback="activity")
+ title = f"{alert.camera_name}: {activity_type} detected"
+ body = _notification_body(alert, risk_level=risk_level)
+ route = f"/events/{quote(alert.clip_id, safe='')}?from=notification"
+
+ return {
+ "aps": {
+ "alert": {
+ "title": title,
+ "body": body,
+ },
+ "sound": "default",
+ "category": _APNS_CATEGORY,
+ },
+ "type": "event_alert",
+ "event_id": alert.clip_id,
+ "camera": alert.camera_name,
+ "risk_level": risk_level,
+ "activity_type": activity_type,
+ "route": route,
+ }
+
+
+def _notification_body(alert: Alert, *, risk_level: str) -> str:
+ if alert.summary:
+ return alert.summary.strip()
+ event_time = alert.ts.strftime("%I:%M %p").lstrip("0")
+ if risk_level != "unknown":
+ return f"{risk_level.capitalize()}-risk event at {event_time}."
+ return f"HomeSec event at {event_time}."
+
+
+def _notification_value(value: str | None, *, fallback: str) -> str:
+ normalized = value.strip() if value is not None else ""
+ return normalized or fallback
+
+
+def _target_url(base_url: str, apns_token: str) -> str:
+ return f"{base_url}/3/device/{quote(apns_token, safe='')}"
+
+
+def _default_apns_base_url(environment: APNSEnvironment) -> str:
+ if environment == "sandbox":
+ return "https://api.sandbox.push.apple.com"
+ return "https://api.push.apple.com"
+
+
+class _DeliveryResult(BaseModel):
+ delivered: bool
+ retryable: bool
+
+
+class APNsDeliveryError(RuntimeError):
+ """APNs fanout failed with retry guidance for the pipeline."""
+
+ def __init__(self, message: str, *, retryable: bool) -> None:
+ super().__init__(message)
+ self.retryable = retryable
+
+
+def _is_permanent_token_rejection(status_code: int, reason: str) -> bool:
+ return status_code == 410 or reason in _PERMANENT_TOKEN_REJECTION_REASONS
+
+
+def _require_mobile_repository(value: Any | None) -> _MobileDevicePushRepository:
+ if value is None:
+ raise RuntimeError("APNs mobile notifier requires mobile device repository context")
+ return cast(_MobileDevicePushRepository, value)
+
+
+def _build_provider_token_signer(config: APNsMobileConfig) -> _APNsProviderTokenSigner | None:
+ key_id = _resolve_env(config.key_id_env)
+ team_id = _resolve_env(config.team_id_env)
+ private_key = _resolve_private_key_env(config.private_key_env)
+ if not key_id:
+ logger.warning("APNs key id not found in env: %s", config.key_id_env)
+ if not team_id:
+ logger.warning("APNs team id not found in env: %s", config.team_id_env)
+ if not private_key:
+ logger.warning("APNs private key not found in env: %s", config.private_key_env)
+ if not (key_id and team_id and private_key):
+ return None
+ return _APNsProviderTokenSigner(
+ key_id=key_id,
+ team_id=team_id,
+ private_key_pem=private_key,
+ )
+
+
+def _resolve_env(env_name: str) -> str | None:
+ value = os.getenv(env_name)
+ if value is None:
+ return None
+ normalized = value.strip()
+ return normalized or None
+
+
+def _resolve_private_key_env(env_name: str) -> str | None:
+ value = _resolve_env(env_name)
+ if value is None:
+ return None
+ return value.replace("\\n", "\n")
+
+
+def _load_signing_key(private_key_pem: str) -> ec.EllipticCurvePrivateKey:
+ key = serialization.load_pem_private_key(private_key_pem.encode("utf-8"), password=None)
+ if not isinstance(key, ec.EllipticCurvePrivateKey):
+ raise RuntimeError("APNs private key must be an EC private key")
+ if not isinstance(key.curve, ec.SECP256R1):
+ raise RuntimeError("APNs private key must use the P-256 curve")
+ return key
+
+
+def _base64url_json(payload: Mapping[str, object]) -> str:
+ data = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
+ return _base64url(data)
+
+
+def _base64url(data: bytes) -> str:
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
+
+
+def _apns_response_reason(response: httpx.Response) -> str:
+ try:
+ payload = response.json()
+ except ValueError:
+ return "unknown"
+ if isinstance(payload, dict):
+ reason = payload.get("reason")
+ if isinstance(reason, str) and reason.strip():
+ return reason.strip()[:200]
+ return "unknown"
diff --git a/src/homesec/plugins/registry.py b/src/homesec/plugins/registry.py
index 100610e4..68c28453 100644
--- a/src/homesec/plugins/registry.py
+++ b/src/homesec/plugins/registry.py
@@ -83,11 +83,12 @@ def load(
plugin_cls = self._plugins[name]
- # 1. Inject runtime context into config (if the config model supports those fields)
- # We merge it into the raw dict so Pydantic can validate it.
+ # 1. Inject runtime context into config when the config model declares those fields.
+ # We merge supported values into the raw dict so Pydantic can validate them without
+ # leaking unrelated runtime-only dependencies into plugins that forbid extras.
# This allows injecting "camera_name" into SourceConfig, etc.
merged_config = config_dict.copy()
- merged_config.update(runtime_context)
+ merged_config.update(self._filter_runtime_context(plugin_cls, runtime_context))
# 2. Validate configuration
validated_config = plugin_cls.config_cls.model_validate(merged_config)
@@ -104,7 +105,7 @@ def validate(self, name: str, config_dict: dict[str, Any], **runtime_context: An
plugin_cls = self._plugins[name]
merged_config = config_dict.copy()
- merged_config.update(runtime_context)
+ merged_config.update(self._filter_runtime_context(plugin_cls, runtime_context))
return plugin_cls.config_cls.model_validate(merged_config)
@@ -112,6 +113,21 @@ def get_all(self) -> dict[str, type[PluginProtocol[ConfigT, PluginInterfaceT]]]:
"""Return all registered plugins."""
return self._plugins.copy()
+ def _filter_runtime_context(
+ self,
+ plugin_cls: type[PluginProtocol[ConfigT, PluginInterfaceT]],
+ runtime_context: dict[str, Any],
+ ) -> dict[str, Any]:
+ supported_context_keys = set(plugin_cls.config_cls.model_fields)
+ supported_context_keys.update(
+ field.alias
+ for field in plugin_cls.config_cls.model_fields.values()
+ if field.alias is not None
+ )
+ return {
+ key: value for key, value in runtime_context.items() if key in supported_context_keys
+ }
+
# Global Registry Storage
# We keep separate registries per type for strict typing
diff --git a/src/homesec/repository/mobile_device_repository.py b/src/homesec/repository/mobile_device_repository.py
new file mode 100644
index 00000000..e68aa1fd
--- /dev/null
+++ b/src/homesec/repository/mobile_device_repository.py
@@ -0,0 +1,274 @@
+"""Repository for iOS mobile device registrations."""
+
+from __future__ import annotations
+
+import hashlib
+import secrets
+from collections.abc import Mapping
+from datetime import datetime, timezone
+from typing import Any, cast
+
+from sqlalchemy import Table, select, update
+from sqlalchemy.dialects.postgresql import insert as pg_insert
+from sqlalchemy.ext.asyncio import AsyncEngine
+
+from homesec.models.mobile import (
+ APNSEnvironment,
+ MobileDeviceCapabilities,
+ MobileDevicePushTarget,
+ MobileDeviceRecord,
+ MobileDeviceRegistration,
+ MobileDeviceUpdate,
+)
+from homesec.state.postgres import MobileDevice
+
+
+def hash_apns_token(apns_token: str) -> str:
+ """Return the stable lookup hash for an APNs token."""
+ normalized = _normalize_apns_token(apns_token)
+ return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
+
+
+class MobileDeviceRepository:
+ """Persistence boundary for mobile APNs device registrations."""
+
+ def __init__(self, engine: AsyncEngine) -> None:
+ self._engine = engine
+
+ async def register_device(
+ self,
+ registration: MobileDeviceRegistration,
+ *,
+ now: datetime | None = None,
+ ) -> MobileDeviceRecord:
+ """Create or update a device by APNs token hash."""
+ recorded_at = _utc_now() if now is None else now
+ token = _normalize_apns_token(registration.apns_token)
+ token_hash = hash_apns_token(token)
+
+ table = cast(Table, MobileDevice.__table__)
+ insert_stmt = pg_insert(table).values(
+ id=_new_device_id(),
+ platform=registration.platform,
+ apns_token_hash=token_hash,
+ apns_token=token,
+ apns_environment=registration.apns_environment,
+ bundle_id=registration.bundle_id,
+ device_name=registration.device_name,
+ app_version=registration.app_version,
+ capabilities=registration.capabilities.model_dump(mode="json"),
+ enabled=True,
+ created_at=recorded_at,
+ updated_at=recorded_at,
+ last_seen_at=recorded_at,
+ )
+ upsert_stmt = insert_stmt.on_conflict_do_update(
+ index_elements=[table.c.apns_token_hash],
+ set_={
+ "platform": insert_stmt.excluded.platform,
+ "apns_token": insert_stmt.excluded.apns_token,
+ "apns_environment": insert_stmt.excluded.apns_environment,
+ "bundle_id": insert_stmt.excluded.bundle_id,
+ "device_name": insert_stmt.excluded.device_name,
+ "app_version": insert_stmt.excluded.app_version,
+ "capabilities": insert_stmt.excluded.capabilities,
+ "updated_at": recorded_at,
+ "last_seen_at": recorded_at,
+ },
+ )
+ returning_stmt = upsert_stmt.returning(*_device_record_columns())
+
+ async with self._engine.begin() as conn:
+ row = cast(Mapping[str, Any], (await conn.execute(returning_stmt)).mappings().one())
+
+ return _device_record_from_mapping(row)
+
+ async def get_device(self, device_id: str) -> MobileDeviceRecord | None:
+ """Return one mobile device record without raw APNs token material."""
+ stmt = select(*_device_record_columns()).where(MobileDevice.id == device_id)
+ async with self._engine.connect() as conn:
+ row = cast(
+ Mapping[str, Any] | None, (await conn.execute(stmt)).mappings().one_or_none()
+ )
+ if row is None:
+ return None
+ return _device_record_from_mapping(row)
+
+ async def list_devices(self, *, include_disabled: bool = False) -> list[MobileDeviceRecord]:
+ """List mobile device records without raw APNs token material."""
+ stmt = select(*_device_record_columns())
+ if not include_disabled:
+ stmt = stmt.where(MobileDevice.enabled.is_(True))
+ stmt = stmt.order_by(MobileDevice.updated_at.desc(), MobileDevice.id.asc())
+
+ async with self._engine.connect() as conn:
+ rows = (await conn.execute(stmt)).mappings().all()
+
+ return [_device_record_from_mapping(cast(Mapping[str, Any], row)) for row in rows]
+
+ async def list_enabled_apns_targets(
+ self,
+ *,
+ environment: APNSEnvironment,
+ bundle_id: str,
+ ) -> list[MobileDevicePushTarget]:
+ """List enabled APNs targets for one app bundle/environment.
+
+ The returned records include APNs token material and must stay inside
+ notifier delivery code. API routes should use list_devices() instead.
+ """
+ stmt = (
+ select(
+ MobileDevice.id,
+ MobileDevice.apns_token,
+ MobileDevice.apns_environment,
+ MobileDevice.bundle_id,
+ )
+ .where(MobileDevice.enabled.is_(True))
+ .where(MobileDevice.platform == "ios")
+ .where(MobileDevice.apns_environment == environment)
+ .where(MobileDevice.bundle_id == bundle_id)
+ .order_by(MobileDevice.updated_at.desc(), MobileDevice.id.asc())
+ )
+
+ async with self._engine.connect() as conn:
+ rows = (await conn.execute(stmt)).mappings().all()
+
+ return [
+ MobileDevicePushTarget(
+ id=str(row["id"]),
+ apns_token=str(row["apns_token"]),
+ apns_environment=row["apns_environment"],
+ bundle_id=str(row["bundle_id"]),
+ )
+ for row in rows
+ ]
+
+ async def record_push_result(
+ self,
+ device_id: str,
+ *,
+ error: str | None,
+ now: datetime | None = None,
+ ) -> MobileDeviceRecord | None:
+ """Record the latest APNs send attempt for a mobile device."""
+ recorded_at = _utc_now() if now is None else now
+ stmt = (
+ update(MobileDevice)
+ .where(MobileDevice.id == device_id)
+ .values(
+ last_push_at=recorded_at,
+ last_push_error=_normalize_last_push_error(error),
+ updated_at=recorded_at,
+ )
+ .returning(*_device_record_columns())
+ )
+ async with self._engine.begin() as conn:
+ row = cast(
+ Mapping[str, Any] | None, (await conn.execute(stmt)).mappings().one_or_none()
+ )
+ if row is None:
+ return None
+ return _device_record_from_mapping(row)
+
+ async def update_device(
+ self,
+ device_id: str,
+ patch: MobileDeviceUpdate,
+ *,
+ now: datetime | None = None,
+ ) -> MobileDeviceRecord | None:
+ """Update mutable device metadata and enabled state."""
+ changes = patch.model_dump(exclude_unset=True, mode="json")
+ if changes.get("enabled") is None:
+ changes.pop("enabled", None)
+ if changes.get("capabilities") is None:
+ changes.pop("capabilities", None)
+ if not changes:
+ return await self.get_device(device_id)
+
+ changes["updated_at"] = _utc_now() if now is None else now
+ stmt = (
+ update(MobileDevice)
+ .where(MobileDevice.id == device_id)
+ .values(**changes)
+ .returning(*_device_record_columns())
+ )
+ async with self._engine.begin() as conn:
+ row = cast(
+ Mapping[str, Any] | None, (await conn.execute(stmt)).mappings().one_or_none()
+ )
+ if row is None:
+ return None
+ return _device_record_from_mapping(row)
+
+ async def disable_device(
+ self,
+ device_id: str,
+ *,
+ now: datetime | None = None,
+ ) -> MobileDeviceRecord | None:
+ """Disable a mobile device without deleting its registration history."""
+ return await self.update_device(
+ device_id,
+ MobileDeviceUpdate(enabled=False),
+ now=now,
+ )
+
+
+def _device_record_columns() -> tuple[Any, ...]:
+ return (
+ MobileDevice.id,
+ MobileDevice.platform,
+ MobileDevice.apns_token_hash,
+ MobileDevice.apns_environment,
+ MobileDevice.bundle_id,
+ MobileDevice.device_name,
+ MobileDevice.app_version,
+ MobileDevice.capabilities,
+ MobileDevice.enabled,
+ MobileDevice.created_at,
+ MobileDevice.updated_at,
+ MobileDevice.last_seen_at,
+ MobileDevice.last_push_at,
+ MobileDevice.last_push_error,
+ )
+
+
+def _device_record_from_mapping(row: Mapping[str, Any]) -> MobileDeviceRecord:
+ token_hash = str(row["apns_token_hash"])
+ return MobileDeviceRecord(
+ id=str(row["id"]),
+ platform=row["platform"],
+ apns_environment=row["apns_environment"],
+ bundle_id=str(row["bundle_id"]),
+ device_name=row["device_name"],
+ app_version=row["app_version"],
+ capabilities=MobileDeviceCapabilities.model_validate(row["capabilities"] or {}),
+ enabled=bool(row["enabled"]),
+ token_fingerprint=token_hash[:12],
+ created_at=row["created_at"],
+ updated_at=row["updated_at"],
+ last_seen_at=row["last_seen_at"],
+ last_push_at=row["last_push_at"],
+ last_push_error=row["last_push_error"],
+ )
+
+
+def _normalize_apns_token(apns_token: str) -> str:
+ return apns_token.strip()
+
+
+def _normalize_last_push_error(error: str | None) -> str | None:
+ normalized = error.strip() if error is not None else ""
+ if not normalized:
+ return None
+ return normalized[:500]
+
+
+def _new_device_id() -> str:
+ return f"dev_{secrets.token_urlsafe(16)}"
+
+
+def _utc_now() -> datetime:
+ return datetime.now(timezone.utc)
diff --git a/src/homesec/runtime/worker.py b/src/homesec/runtime/worker.py
index 97286be9..bf6d534a 100644
--- a/src/homesec/runtime/worker.py
+++ b/src/homesec/runtime/worker.py
@@ -30,6 +30,7 @@
from homesec.plugins.alert_policies import load_alert_policy
from homesec.plugins.notifiers import load_notifier_plugin
from homesec.plugins.sources import load_source_plugin
+from homesec.repository.mobile_device_repository import MobileDeviceRepository
from homesec.runtime.assembly import RuntimeAssembler
from homesec.runtime.bootstrap import (
RuntimePersistenceStack,
@@ -61,6 +62,7 @@
WorkerTalkStatusPayload,
WorkerTalkStopPayload,
)
+from homesec.state.postgres import PostgresStateStore
if TYPE_CHECKING:
from homesec.interfaces import (
@@ -308,10 +310,25 @@ async def _build_runtime_persistence_stack(self) -> RuntimePersistenceStack:
def _create_notifier(self, config: Config) -> tuple[Notifier, list[NotifierEntry]]:
entries: list[NotifierEntry] = []
+
for index, notifier_cfg in enumerate(config.notifiers):
if not notifier_cfg.enabled:
continue
- notifier = load_notifier_plugin(notifier_cfg.backend, notifier_cfg.config)
+ runtime_context: dict[str, object] = {}
+ if notifier_cfg.backend == "apns_mobile":
+ mobile_device_repository = self._create_mobile_device_repository()
+ if mobile_device_repository is None:
+ logger.warning(
+ "Skipping apns_mobile notifier because mobile device repository "
+ "is unavailable"
+ )
+ continue
+ runtime_context["mobile_device_repository"] = mobile_device_repository
+ notifier = load_notifier_plugin(
+ notifier_cfg.backend,
+ notifier_cfg.config,
+ **runtime_context,
+ )
entries.append(
NotifierEntry(name=f"{notifier_cfg.backend}[{index}]", notifier=notifier)
)
@@ -323,6 +340,19 @@ def _create_notifier(self, config: Config) -> tuple[Notifier, list[NotifierEntry
return entries[0].notifier, entries
return MultiplexNotifier(entries), entries
+ def _create_mobile_device_repository(self) -> MobileDeviceRepository | None:
+ """Create the mobile repository only when Postgres initialized successfully."""
+ if not isinstance(self._state_store, PostgresStateStore):
+ return None
+ try:
+ return MobileDeviceRepository(self._state_store.engine)
+ except RuntimeError as exc:
+ logger.warning(
+ "Mobile device repository unavailable for runtime worker: %s",
+ exc,
+ )
+ return None
+
async def _log_notifier_health(self, entries: list[NotifierEntry]) -> None:
if not entries:
return
diff --git a/src/homesec/state/postgres.py b/src/homesec/state/postgres.py
index 56c35a55..efabc690 100644
--- a/src/homesec/state/postgres.py
+++ b/src/homesec/state/postgres.py
@@ -9,11 +9,13 @@
from sqlalchemy import (
BigInteger,
+ Boolean,
DateTime,
ForeignKey,
Index,
Table,
Text,
+ UniqueConstraint,
and_,
func,
or_,
@@ -148,6 +150,52 @@ class ClipEvent(Base):
)
+class MobileDevice(Base):
+ """Registered mobile device for APNs notification delivery."""
+
+ __tablename__ = "mobile_devices"
+
+ id: Mapped[str] = mapped_column(Text, primary_key=True)
+ platform: Mapped[str] = mapped_column(Text, nullable=False)
+ apns_token_hash: Mapped[str] = mapped_column(Text, nullable=False)
+ apns_token: Mapped[str] = mapped_column(Text, nullable=False)
+ apns_environment: Mapped[str] = mapped_column(Text, nullable=False)
+ bundle_id: Mapped[str] = mapped_column(Text, nullable=False)
+ device_name: Mapped[str | None] = mapped_column(Text, nullable=True)
+ app_version: Mapped[str | None] = mapped_column(Text, nullable=True)
+ capabilities: Mapped[dict[str, Any]] = mapped_column(
+ JSONB,
+ server_default=text("'{}'::jsonb"),
+ nullable=False,
+ )
+ enabled: Mapped[bool] = mapped_column(
+ Boolean,
+ server_default=text("true"),
+ nullable=False,
+ )
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ server_default=func.now(),
+ nullable=False,
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ server_default=func.now(),
+ onupdate=func.now(),
+ nullable=False,
+ )
+ last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ last_push_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ last_push_error: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ __table_args__ = (
+ UniqueConstraint("apns_token_hash", name="uq_mobile_devices_apns_token_hash"),
+ Index("idx_mobile_devices_enabled", "enabled"),
+ Index("idx_mobile_devices_platform_environment", "platform", "apns_environment"),
+ Index("idx_mobile_devices_updated_at_desc", text("updated_at DESC")),
+ )
+
+
class PostgresStateStore(StateStore):
"""Postgres implementation of StateStore interface.
@@ -193,6 +241,13 @@ async def initialize(self) -> bool:
self._engine = None
return False
+ @property
+ def engine(self) -> AsyncEngine:
+ """Return the initialized SQLAlchemy engine owned by this state store."""
+ if self._engine is None:
+ raise RuntimeError("StateStore not initialized")
+ return self._engine
+
async def upsert(self, clip_id: str, data: ClipStateData) -> None:
"""Insert or update clip state.
diff --git a/tests/homesec/test_api_bootstrap_matrix.py b/tests/homesec/test_api_bootstrap_matrix.py
index 8a45dea8..929d2956 100644
--- a/tests/homesec/test_api_bootstrap_matrix.py
+++ b/tests/homesec/test_api_bootstrap_matrix.py
@@ -78,7 +78,7 @@ async def force_stop_camera_preview(self, camera_name: str) -> CameraPreviewStop
@dataclass(frozen=True)
class _MatrixCase:
name: str
- method: Literal["GET", "POST", "DELETE"]
+ method: Literal["GET", "POST", "PATCH", "DELETE"]
path: str
auth_enabled: bool
db_ok: bool
@@ -141,6 +141,9 @@ def _build_client(tmp_path: Path, case: _MatrixCase) -> tuple[TestClient, _StubR
def _send_request(client: TestClient, case: _MatrixCase, headers: dict[str, str]):
if case.method == "GET":
return client.get(case.path, headers=headers)
+ if case.method == "PATCH":
+ request_json = case.request_json if case.request_json is not None else {}
+ return client.patch(case.path, headers=headers, json=request_json)
if case.method == "DELETE":
return client.delete(case.path, headers=headers)
request_json = case.request_json if case.request_json is not None else {}
@@ -630,6 +633,86 @@ def _send_request(client: TestClient, case: _MatrixCase, headers: dict[str, str]
bootstrap_mode=True,
expected_error_code="SETUP_REQUIRED",
),
+ _MatrixCase(
+ name="mobile_device_register_requires_api_key_when_auth_enabled",
+ method="POST",
+ path="/api/v1/mobile/devices",
+ auth_enabled=True,
+ db_ok=True,
+ pipeline_running=True,
+ auth_header=None,
+ include_clip=False,
+ expected_status=401,
+ expected_error_code="UNAUTHORIZED",
+ request_json={
+ "platform": "ios",
+ "apns_token": "token",
+ "environment": "sandbox",
+ "bundle_id": "com.levneiman.homesec",
+ },
+ ),
+ _MatrixCase(
+ name="mobile_device_list_requires_api_key_when_auth_enabled",
+ method="GET",
+ path="/api/v1/mobile/devices",
+ auth_enabled=True,
+ db_ok=True,
+ pipeline_running=True,
+ auth_header=None,
+ include_clip=False,
+ expected_status=401,
+ expected_error_code="UNAUTHORIZED",
+ ),
+ _MatrixCase(
+ name="mobile_device_patch_requires_api_key_when_auth_enabled",
+ method="PATCH",
+ path="/api/v1/mobile/devices/dev_1",
+ auth_enabled=True,
+ db_ok=True,
+ pipeline_running=True,
+ auth_header=None,
+ include_clip=False,
+ expected_status=401,
+ expected_error_code="UNAUTHORIZED",
+ request_json={"enabled": False},
+ ),
+ _MatrixCase(
+ name="mobile_device_delete_requires_api_key_when_auth_enabled",
+ method="DELETE",
+ path="/api/v1/mobile/devices/dev_1",
+ auth_enabled=True,
+ db_ok=True,
+ pipeline_running=True,
+ auth_header=None,
+ include_clip=False,
+ expected_status=401,
+ expected_error_code="UNAUTHORIZED",
+ ),
+ _MatrixCase(
+ name="mobile_device_list_requires_db_when_repository_unavailable",
+ method="GET",
+ path="/api/v1/mobile/devices",
+ auth_enabled=True,
+ db_ok=False,
+ pipeline_running=True,
+ auth_header="Bearer secret",
+ include_clip=False,
+ expected_status=503,
+ expected_error_code="DB_UNAVAILABLE",
+ ),
+ _MatrixCase(
+ name="mobile_device_list_is_blocked_in_bootstrap_mode",
+ method="GET",
+ path="/api/v1/mobile/devices",
+ auth_enabled=False,
+ db_ok=False,
+ pipeline_running=False,
+ auth_header=None,
+ include_clip=False,
+ expected_status=503,
+ bootstrap_mode=True,
+ expected_error_code="SETUP_REQUIRED",
+ ),
_MatrixCase(
name="media_route_rejects_missing_media_auth",
method="GET",
diff --git a/tests/homesec/test_api_openapi_export.py b/tests/homesec/test_api_openapi_export.py
index 4c233158..6a61456f 100644
--- a/tests/homesec/test_api_openapi_export.py
+++ b/tests/homesec/test_api_openapi_export.py
@@ -19,7 +19,9 @@ def test_build_openapi_schema_includes_health_route() -> None:
# Then: Versioned health route and response schema should be present
assert "/api/v1/health" in schema["paths"]
+ assert "/api/v1/mobile/devices" in schema["paths"]
assert "HealthResponse" in schema["components"]["schemas"]
+ assert "MobileDeviceResponse" in schema["components"]["schemas"]
def test_write_openapi_schema_writes_deterministic_json(tmp_path: Path) -> None:
@@ -34,6 +36,7 @@ def test_write_openapi_schema_writes_deterministic_json(tmp_path: Path) -> None:
assert text.endswith("\n")
payload = json.loads(text)
assert "/api/v1/health" in payload["paths"]
+ assert "/api/v1/mobile/devices" in payload["paths"]
def test_openapi_export_parser_requires_output() -> None:
@@ -67,3 +70,4 @@ def test_openapi_export_main_writes_requested_output(
# Then: output file is created with expected API paths
payload = json.loads(output_path.read_text(encoding="utf-8"))
assert "/api/v1/health" in payload["paths"]
+ assert "/api/v1/mobile/devices" in payload["paths"]
diff --git a/tests/homesec/test_api_routes.py b/tests/homesec/test_api_routes.py
index ef37ca2b..cc411ab9 100644
--- a/tests/homesec/test_api_routes.py
+++ b/tests/homesec/test_api_routes.py
@@ -4,6 +4,7 @@
import asyncio
import datetime as dt
+import json
import time
from collections.abc import Callable
from pathlib import Path
@@ -24,7 +25,13 @@
from homesec.models.config import CameraConfig, CameraSourceConfig, FastAPIServerConfig
from homesec.models.enums import ClipStatus, RiskLevel
from homesec.models.filter import FilterResult
+from homesec.models.mobile import (
+ MobileDeviceRecord,
+ MobileDeviceRegistration,
+ MobileDeviceUpdate,
+)
from homesec.models.vlm import AnalysisResult
+from homesec.repository.mobile_device_repository import hash_apns_token
from homesec.runtime.errors import RuntimeReloadConfigError
from homesec.runtime.models import RuntimeReloadRequest
from tests.homesec.ui_dist_stub import ensure_stub_ui_dist
@@ -166,6 +173,77 @@ def last_heartbeat(self) -> float:
return self._heartbeat
+class _StubMobileDevices:
+ def __init__(self) -> None:
+ self._records_by_hash: dict[str, MobileDeviceRecord] = {}
+ self._token_hash_by_id: dict[str, str] = {}
+ self.register_calls: list[MobileDeviceRegistration] = []
+ self.update_calls: list[tuple[str, MobileDeviceUpdate]] = []
+ self.disable_calls: list[str] = []
+
+ async def register_device(
+ self,
+ registration: MobileDeviceRegistration,
+ ) -> MobileDeviceRecord:
+ self.register_calls.append(registration)
+ token_hash = hash_apns_token(registration.apns_token)
+ existing = self._records_by_hash.get(token_hash)
+ now = dt.datetime(2026, 6, 14, tzinfo=dt.timezone.utc) + dt.timedelta(
+ minutes=len(self.register_calls)
+ )
+ record = MobileDeviceRecord(
+ id=existing.id if existing is not None else f"dev_{len(self._records_by_hash) + 1}",
+ platform=registration.platform,
+ apns_environment=registration.apns_environment,
+ bundle_id=registration.bundle_id,
+ device_name=registration.device_name,
+ app_version=registration.app_version,
+ capabilities=registration.capabilities,
+ enabled=existing.enabled if existing is not None else True,
+ token_fingerprint=token_hash[:12],
+ created_at=existing.created_at if existing is not None else now,
+ updated_at=now,
+ last_seen_at=now,
+ last_push_at=existing.last_push_at if existing is not None else None,
+ last_push_error=existing.last_push_error if existing is not None else None,
+ )
+ self._records_by_hash[token_hash] = record
+ self._token_hash_by_id[record.id] = token_hash
+ return record
+
+ async def list_devices(self, *, include_disabled: bool = False) -> list[MobileDeviceRecord]:
+ records = list(self._records_by_hash.values())
+ if not include_disabled:
+ records = [record for record in records if record.enabled]
+ return records
+
+ async def update_device(
+ self,
+ device_id: str,
+ patch: MobileDeviceUpdate,
+ ) -> MobileDeviceRecord | None:
+ self.update_calls.append((device_id, patch))
+ token_hash = self._token_hash_by_id.get(device_id)
+ if token_hash is None:
+ return None
+ existing = self._records_by_hash[token_hash]
+ changes = patch.model_dump(exclude_unset=True, mode="json")
+ data = existing.model_dump()
+ data.update(changes)
+ data["updated_at"] = _mobile_now()
+ record = MobileDeviceRecord.model_validate(data)
+ self._records_by_hash[token_hash] = record
+ return record
+
+ async def disable_device(self, device_id: str) -> MobileDeviceRecord | None:
+ self.disable_calls.append(device_id)
+ return await self.update_device(device_id, MobileDeviceUpdate(enabled=False))
+
+
+def _mobile_now() -> dt.datetime:
+ return dt.datetime(2026, 6, 14, 1, tzinfo=dt.timezone.utc)
+
+
class _StubApp:
def __init__(
self,
@@ -179,6 +257,7 @@ def __init__(
bootstrap_mode: bool = False,
runtime_reload_request: RuntimeReloadRequest | None = None,
runtime_reload_error: Exception | None = None,
+ mobile_devices: _StubMobileDevices | None = None,
) -> None:
self.config_manager = config_manager
self.repository = repository
@@ -211,6 +290,7 @@ def __init__(
self.restart_requested = False
self.uptime_seconds = 0.0
self._setup_test_connection_lock = asyncio.Lock()
+ self.mobile_devices = mobile_devices or _StubMobileDevices()
@property
def config(self): # type: ignore[override]
@@ -2555,6 +2635,140 @@ def test_diagnostics_reports_unhealthy_when_pipeline_stopped(tmp_path) -> None:
assert payload["status"] == "unhealthy"
+def _mobile_device_payload(*, token: str = "raw-apns-token") -> dict[str, object]:
+ return {
+ "platform": "ios",
+ "apns_token": token,
+ "environment": "sandbox",
+ "bundle_id": "com.levneiman.homesec",
+ "device_name": "Lev's iPhone",
+ "app_version": "1.0.0",
+ "capabilities": {"deep_links": True, "rich_notifications": False},
+ }
+
+
+def test_register_mobile_device_creates_or_updates_redacted_record(tmp_path) -> None:
+ """POST /mobile/devices should upsert an iOS APNs registration."""
+ # Given: A configured app with mobile device persistence
+ manager = _write_config(tmp_path, cameras=[])
+ mobile_devices = _StubMobileDevices()
+ app = _StubApp(
+ config_manager=manager,
+ repository=_StubRepository(),
+ storage=_StubStorage(),
+ mobile_devices=mobile_devices,
+ )
+ client = _client(app)
+
+ # When: Registering a device with APNs material
+ response = client.post("/api/v1/mobile/devices", json=_mobile_device_payload())
+
+ # Then: The API returns redacted metadata and stores the registration
+ assert response.status_code == 201
+ payload = response.json()
+ assert payload["id"] == "dev_1"
+ assert payload["platform"] == "ios"
+ assert payload["environment"] == "sandbox"
+ assert payload["bundle_id"] == "com.levneiman.homesec"
+ assert payload["capabilities"] == {"deep_links": True, "rich_notifications": False}
+ assert "raw-apns-token" not in json.dumps(payload, sort_keys=True)
+ assert "apns_token" not in payload
+ assert len(mobile_devices.register_calls) == 1
+
+ # When: Registering the same APNs token with updated metadata
+ second_payload = _mobile_device_payload()
+ second_payload["device_name"] = "Kitchen iPad"
+ second = client.post("/api/v1/mobile/devices", json=second_payload)
+
+ # Then: The existing device record is updated instead of duplicated
+ assert second.status_code == 201
+ assert second.json()["id"] == "dev_1"
+ assert second.json()["device_name"] == "Kitchen iPad"
+ assert client.get("/api/v1/mobile/devices").json()[0]["device_name"] == "Kitchen iPad"
+
+
+def test_patch_mobile_device_updates_only_sent_fields(tmp_path) -> None:
+ """PATCH /mobile/devices/{id} should preserve omitted fields."""
+ # Given: A registered mobile device
+ manager = _write_config(tmp_path, cameras=[])
+ mobile_devices = _StubMobileDevices()
+ app = _StubApp(
+ config_manager=manager,
+ repository=_StubRepository(),
+ storage=_StubStorage(),
+ mobile_devices=mobile_devices,
+ )
+ client = _client(app)
+ created = client.post("/api/v1/mobile/devices", json=_mobile_device_payload()).json()
+
+ # When: Patching only enabled state
+ response = client.patch(f"/api/v1/mobile/devices/{created['id']}", json={"enabled": False})
+
+ # Then: Existing metadata is preserved and the record is disabled
+ assert response.status_code == 200
+ payload = response.json()
+ assert payload["enabled"] is False
+ assert payload["device_name"] == "Lev's iPhone"
+ assert payload["app_version"] == "1.0.0"
+ assert mobile_devices.update_calls[-1][1].model_fields_set == {"enabled"}
+
+ # When: Patching the iOS capability flags reported by the app
+ capabilities_response = client.patch(
+ f"/api/v1/mobile/devices/{created['id']}",
+ json={"capabilities": {"deep_links": True, "rich_notifications": True}},
+ )
+
+ # Then: The API preserves the nested capability patch
+ assert capabilities_response.status_code == 200
+ assert capabilities_response.json()["capabilities"] == {
+ "deep_links": True,
+ "rich_notifications": True,
+ }
+ assert mobile_devices.update_calls[-1][1].model_fields_set == {"capabilities"}
+
+
+def test_delete_mobile_device_disables_without_hard_delete(tmp_path) -> None:
+ """DELETE /mobile/devices/{id} should soft-disable the device."""
+ # Given: A registered mobile device
+ manager = _write_config(tmp_path, cameras=[])
+ app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage())
+ client = _client(app)
+ created = client.post("/api/v1/mobile/devices", json=_mobile_device_payload()).json()
+
+ # When: Deleting the device
+ response = client.delete(f"/api/v1/mobile/devices/{created['id']}")
+
+ # Then: The device is disabled and hidden from default listings
+ assert response.status_code == 200
+ assert response.json()["enabled"] is False
+ assert client.get("/api/v1/mobile/devices").json() == []
+ all_devices = client.get("/api/v1/mobile/devices?include_disabled=true").json()
+ assert all_devices[0]["id"] == created["id"]
+ assert all_devices[0]["enabled"] is False
+
+
+def test_mobile_device_missing_returns_404(tmp_path) -> None:
+ """Mobile device mutation routes should report missing device ids."""
+ # Given: A configured app with no registered mobile devices
+ manager = _write_config(tmp_path, cameras=[])
+ app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage())
+ client = _client(app)
+
+ # When: Updating a missing device
+ patch_response = client.patch("/api/v1/mobile/devices/dev_missing", json={"enabled": False})
+
+ # Then: The route returns a canonical not-found error
+ assert patch_response.status_code == 404
+ assert patch_response.json()["error_code"] == "MOBILE_DEVICE_NOT_FOUND"
+
+ # When: Deleting a missing device
+ delete_response = client.delete("/api/v1/mobile/devices/dev_missing")
+
+ # Then: The route returns the same canonical not-found error
+ assert delete_response.status_code == 404
+ assert delete_response.json()["error_code"] == "MOBILE_DEVICE_NOT_FOUND"
+
+
def test_auth_required_when_enabled(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Auth should be enforced for non-public endpoints."""
# Given auth is enabled
diff --git a/tests/homesec/test_apns_mobile_notifier.py b/tests/homesec/test_apns_mobile_notifier.py
new file mode 100644
index 00000000..3938eb63
--- /dev/null
+++ b/tests/homesec/test_apns_mobile_notifier.py
@@ -0,0 +1,326 @@
+"""Tests for the APNs mobile notifier."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any
+
+import httpx
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+
+from homesec.models.alert import Alert
+from homesec.models.mobile import MobileDevicePushTarget
+from homesec.plugins.notifiers.apns_mobile import (
+ APNsDeliveryError,
+ APNsMobileConfig,
+ APNsMobileNotifier,
+ build_apns_payload,
+)
+
+
+class _FakeMobileDeviceRepository:
+ def __init__(self, targets: list[MobileDevicePushTarget]) -> None:
+ self.targets = targets
+ self.disabled_devices: list[tuple[str, datetime | None]] = []
+ self.list_calls: list[tuple[str, str]] = []
+ self.recorded_results: list[tuple[str, str | None, datetime | None]] = []
+
+ async def list_enabled_apns_targets(
+ self,
+ *,
+ environment: str,
+ bundle_id: str,
+ ) -> list[MobileDevicePushTarget]:
+ self.list_calls.append((environment, bundle_id))
+ return self.targets
+
+ async def record_push_result(
+ self,
+ device_id: str,
+ *,
+ error: str | None,
+ now: datetime | None = None,
+ ) -> None:
+ self.recorded_results.append((device_id, error, now))
+
+ async def disable_device(
+ self,
+ device_id: str,
+ *,
+ now: datetime | None = None,
+ ) -> None:
+ self.disabled_devices.append((device_id, now))
+
+
+class _FakeAPNsClient:
+ def __init__(self, responses: list[httpx.Response]) -> None:
+ self.responses = responses
+ self.requests: list[dict[str, Any]] = []
+ self.is_closed = False
+
+ async def post(
+ self,
+ url: str,
+ *,
+ json: dict[str, object],
+ headers: dict[str, str],
+ ) -> httpx.Response:
+ self.requests.append({"headers": headers, "json": json, "url": url})
+ return self.responses.pop(0)
+
+ async def aclose(self) -> None:
+ self.is_closed = True
+
+
+def _private_key_pem() -> str:
+ private_key = ec.generate_private_key(ec.SECP256R1())
+ return private_key.private_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PrivateFormat.PKCS8,
+ encryption_algorithm=serialization.NoEncryption(),
+ ).decode("utf-8")
+
+
+def _sample_alert(**overrides: Any) -> Alert:
+ defaults: dict[str, Any] = {
+ "clip_id": "clip_123",
+ "camera_name": "front_door",
+ "storage_uri": "mock://clip_123",
+ "view_url": "http://example.test/clip_123",
+ "risk_level": "high",
+ "activity_type": "person",
+ "notify_reason": "risk_level=high",
+ "summary": "Person near the front door.",
+ "ts": datetime(2026, 6, 14, 8, 30, tzinfo=timezone.utc),
+ "dedupe_key": "clip_123",
+ "upload_failed": False,
+ }
+ defaults.update(overrides)
+ return Alert(**defaults)
+
+
+def _config(repository: _FakeMobileDeviceRepository) -> APNsMobileConfig:
+ return APNsMobileConfig(
+ key_id_env="TEST_APNS_KEY_ID",
+ team_id_env="TEST_APNS_TEAM_ID",
+ private_key_env="TEST_APNS_PRIVATE_KEY",
+ bundle_id="com.levneiman.homesec",
+ environment="sandbox",
+ mobile_device_repository=repository,
+ )
+
+
+def test_build_apns_payload_includes_plain_event_route_without_rich_media() -> None:
+ # Given: A HomeSec alert for an analyzed clip
+ alert = _sample_alert()
+
+ # When: Building the plain APNs payload
+ payload = build_apns_payload(alert)
+
+ # Then: The payload includes the notification route and event context
+ assert payload["type"] == "event_alert"
+ assert payload["event_id"] == "clip_123"
+ assert payload["route"] == "/events/clip_123?from=notification"
+ assert payload["camera"] == "front_door"
+ assert payload["risk_level"] == "high"
+ assert payload["activity_type"] == "person"
+
+ # And: Plain push v1 does not request rich notification thumbnail handling
+ aps = payload["aps"]
+ assert isinstance(aps, dict)
+ assert aps["category"] == "HOMESEC_EVENT"
+ assert "mutable-content" not in aps
+ assert "thumbnail" not in str(payload).lower()
+
+
+@pytest.mark.asyncio
+async def test_apns_notifier_sends_payload_to_registered_targets(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Given: A configured APNs notifier with one enabled target and mocked HTTP/2 client
+ monkeypatch.setenv("TEST_APNS_KEY_ID", "KEY1234567")
+ monkeypatch.setenv("TEST_APNS_TEAM_ID", "TEAM123456")
+ monkeypatch.setenv("TEST_APNS_PRIVATE_KEY", _private_key_pem())
+ repository = _FakeMobileDeviceRepository(
+ [
+ MobileDevicePushTarget(
+ id="dev_1",
+ apns_token="apns-token-1",
+ apns_environment="sandbox",
+ bundle_id="com.levneiman.homesec",
+ )
+ ]
+ )
+ fake_client = _FakeAPNsClient([httpx.Response(200)])
+ monkeypatch.setattr(
+ "homesec.plugins.notifiers.apns_mobile.httpx.AsyncClient",
+ lambda **_kwargs: fake_client,
+ )
+ notifier = APNsMobileNotifier(_config(repository))
+
+ # When: Sending a HomeSec alert
+ await notifier.send(_sample_alert())
+
+ # Then: The notifier queries the repository with the configured APNs scope
+ assert repository.list_calls == [("sandbox", "com.levneiman.homesec")]
+
+ # And: APNs receives the expected route payload and required provider headers
+ assert len(fake_client.requests) == 1
+ request = fake_client.requests[0]
+ assert request["url"] == "https://api.sandbox.push.apple.com/3/device/apns-token-1"
+ assert request["json"]["route"] == "/events/clip_123?from=notification"
+ headers = request["headers"]
+ assert headers["apns-topic"] == "com.levneiman.homesec"
+ assert headers["apns-push-type"] == "alert"
+ assert headers["authorization"].startswith("bearer ")
+
+ # And: Successful delivery clears the device push error
+ assert len(repository.recorded_results) == 1
+ assert repository.recorded_results[0][0] == "dev_1"
+ assert repository.recorded_results[0][1] is None
+
+ await notifier.shutdown()
+ assert fake_client.is_closed is True
+
+
+@pytest.mark.asyncio
+async def test_apns_notifier_records_rejected_devices_and_raises_when_all_fail(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Given: APNs rejects the only enabled target with a retryable provider error
+ monkeypatch.setenv("TEST_APNS_KEY_ID", "KEY1234567")
+ monkeypatch.setenv("TEST_APNS_TEAM_ID", "TEAM123456")
+ monkeypatch.setenv("TEST_APNS_PRIVATE_KEY", _private_key_pem())
+ repository = _FakeMobileDeviceRepository(
+ [
+ MobileDevicePushTarget(
+ id="dev_bad",
+ apns_token="bad-token",
+ apns_environment="sandbox",
+ bundle_id="com.levneiman.homesec",
+ )
+ ]
+ )
+ fake_client = _FakeAPNsClient([httpx.Response(500, json={"reason": "InternalServerError"})])
+ monkeypatch.setattr(
+ "homesec.plugins.notifiers.apns_mobile.httpx.AsyncClient",
+ lambda **_kwargs: fake_client,
+ )
+ notifier = APNsMobileNotifier(_config(repository))
+
+ # When: Sending the alert
+ with pytest.raises(APNsDeliveryError, match="APNs delivery failed") as exc_info:
+ await notifier.send(_sample_alert())
+
+ # Then: The retryable rejection is recorded without disabling the device
+ assert exc_info.value.retryable is True
+ assert len(repository.recorded_results) == 1
+ device_id, error, recorded_at = repository.recorded_results[0]
+ assert device_id == "dev_bad"
+ assert error == "HTTP 500: InternalServerError"
+ assert recorded_at is not None
+ assert repository.disabled_devices == []
+
+
+@pytest.mark.asyncio
+async def test_apns_notifier_disables_permanent_failures_without_retrying_successes(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Given: APNs accepts one registered target and rejects another target permanently
+ monkeypatch.setenv("TEST_APNS_KEY_ID", "KEY1234567")
+ monkeypatch.setenv("TEST_APNS_TEAM_ID", "TEAM123456")
+ monkeypatch.setenv("TEST_APNS_PRIVATE_KEY", _private_key_pem())
+ repository = _FakeMobileDeviceRepository(
+ [
+ MobileDevicePushTarget(
+ id="dev_ok",
+ apns_token="good-token",
+ apns_environment="sandbox",
+ bundle_id="com.levneiman.homesec",
+ ),
+ MobileDevicePushTarget(
+ id="dev_bad",
+ apns_token="bad-token",
+ apns_environment="sandbox",
+ bundle_id="com.levneiman.homesec",
+ ),
+ ]
+ )
+ fake_client = _FakeAPNsClient(
+ [
+ httpx.Response(200),
+ httpx.Response(410, json={"reason": "Unregistered"}),
+ ]
+ )
+ monkeypatch.setattr(
+ "homesec.plugins.notifiers.apns_mobile.httpx.AsyncClient",
+ lambda **_kwargs: fake_client,
+ )
+ notifier = APNsMobileNotifier(_config(repository))
+
+ # When: Sending the alert
+ with pytest.raises(APNsDeliveryError, match="APNs delivery failed") as exc_info:
+ await notifier.send(_sample_alert())
+
+ # Then: Successful and failed device outcomes are both recorded without a whole-fanout retry
+ assert exc_info.value.retryable is False
+ assert [(device_id, error) for device_id, error, _ in repository.recorded_results] == [
+ ("dev_ok", None),
+ ("dev_bad", "HTTP 410: Unregistered"),
+ ]
+ disabled_device_id, disabled_at = repository.disabled_devices[0]
+ assert disabled_device_id == "dev_bad"
+ assert disabled_at == repository.recorded_results[1][2]
+
+
+@pytest.mark.asyncio
+async def test_apns_notifier_raises_on_partial_retryable_delivery_failure(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Given: APNs accepts one target but has a retryable provider failure for another
+ monkeypatch.setenv("TEST_APNS_KEY_ID", "KEY1234567")
+ monkeypatch.setenv("TEST_APNS_TEAM_ID", "TEAM123456")
+ monkeypatch.setenv("TEST_APNS_PRIVATE_KEY", _private_key_pem())
+ repository = _FakeMobileDeviceRepository(
+ [
+ MobileDevicePushTarget(
+ id="dev_ok",
+ apns_token="good-token",
+ apns_environment="sandbox",
+ bundle_id="com.levneiman.homesec",
+ ),
+ MobileDevicePushTarget(
+ id="dev_retry",
+ apns_token="retry-token",
+ apns_environment="sandbox",
+ bundle_id="com.levneiman.homesec",
+ ),
+ ]
+ )
+ fake_client = _FakeAPNsClient(
+ [
+ httpx.Response(200),
+ httpx.Response(503, json={"reason": "ServiceUnavailable"}),
+ ]
+ )
+ monkeypatch.setattr(
+ "homesec.plugins.notifiers.apns_mobile.httpx.AsyncClient",
+ lambda **_kwargs: fake_client,
+ )
+ notifier = APNsMobileNotifier(_config(repository))
+
+ # When: Sending the alert
+ with pytest.raises(
+ APNsDeliveryError, match="APNs delivery failed for 1 of 2 device"
+ ) as exc_info:
+ await notifier.send(_sample_alert())
+
+ # Then: The partial retryable failure is recorded without retrying the already delivered target
+ assert exc_info.value.retryable is False
+ assert [(device_id, error) for device_id, error, _ in repository.recorded_results] == [
+ ("dev_ok", None),
+ ("dev_retry", "HTTP 503: ServiceUnavailable"),
+ ]
+ assert repository.disabled_devices == []
diff --git a/tests/homesec/test_mobile_device_repository.py b/tests/homesec/test_mobile_device_repository.py
new file mode 100644
index 00000000..99bcc122
--- /dev/null
+++ b/tests/homesec/test_mobile_device_repository.py
@@ -0,0 +1,308 @@
+"""Tests for mobile device registration repository."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from pydantic import ValidationError
+from sqlalchemy import select
+
+from homesec.models.mobile import (
+ MobileDeviceCapabilities,
+ MobileDeviceRegistration,
+ MobileDeviceUpdate,
+)
+from homesec.repository.mobile_device_repository import MobileDeviceRepository, hash_apns_token
+from homesec.state.postgres import MobileDevice, PostgresStateStore
+
+
+def _registration(
+ *,
+ apns_environment: str = "sandbox",
+ apns_token: str = "raw-apns-token-123",
+ bundle_id: str = "com.levneiman.homesec",
+ device_name: str = "Lev's iPhone",
+ app_version: str = "1.0.0",
+) -> MobileDeviceRegistration:
+ return MobileDeviceRegistration(
+ apns_token=apns_token,
+ apns_environment=apns_environment,
+ bundle_id=bundle_id,
+ device_name=device_name,
+ app_version=app_version,
+ )
+
+
+def test_mobile_device_registration_rejects_blank_required_values() -> None:
+ # Given: A registration payload with blank APNs material
+ payload = {
+ "apns_token": " ",
+ "apns_environment": "sandbox",
+ "bundle_id": "com.levneiman.homesec",
+ }
+
+ # When: Validating the payload
+ # Then: Validation rejects it before repository hashing
+ with pytest.raises(ValidationError):
+ MobileDeviceRegistration.model_validate(payload)
+
+
+@pytest.mark.asyncio
+async def test_register_device_creates_redacted_list_record(
+ postgres_dsn: str,
+ clean_test_db: None,
+) -> None:
+ # Given: A mobile device repository backed by Postgres
+ state_store = PostgresStateStore(postgres_dsn)
+ await state_store.initialize()
+ repository = MobileDeviceRepository(state_store.engine)
+ registration = _registration()
+
+ # When: Registering an iOS device
+ record = await repository.register_device(registration)
+ records = await repository.list_devices()
+
+ # Then: The repository returns public device metadata without raw APNs material
+ assert record.id.startswith("dev_")
+ assert record.platform == "ios"
+ assert record.enabled is True
+ assert record.apns_environment == "sandbox"
+ assert record.bundle_id == "com.levneiman.homesec"
+ assert record.capabilities.deep_links is True
+ assert record.capabilities.rich_notifications is False
+ assert records == [record]
+ encoded = json.dumps(record.model_dump(mode="json"), sort_keys=True)
+ assert registration.apns_token not in encoded
+ assert "apns_token" not in encoded
+
+ # And: The internal table stores a stable hash for dedupe
+ async with state_store.engine.connect() as conn:
+ row = (
+ await conn.execute(
+ select(MobileDevice.apns_token_hash).where(MobileDevice.id == record.id)
+ )
+ ).one()
+ assert row.apns_token_hash == hash_apns_token(registration.apns_token)
+
+ await state_store.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_register_device_dedupes_by_token_hash(
+ postgres_dsn: str,
+ clean_test_db: None,
+) -> None:
+ # Given: An existing mobile device registration
+ state_store = PostgresStateStore(postgres_dsn)
+ await state_store.initialize()
+ repository = MobileDeviceRepository(state_store.engine)
+ first = await repository.register_device(
+ _registration(),
+ now=datetime(2026, 6, 14, 8, 0, tzinfo=timezone.utc),
+ )
+
+ # When: The same APNs token registers again with updated metadata
+ second = await repository.register_device(
+ _registration(device_name="Kitchen iPad", app_version="1.1.0"),
+ now=datetime(2026, 6, 14, 8, 5, tzinfo=timezone.utc),
+ )
+ records = await repository.list_devices()
+
+ # Then: The existing device row is updated instead of duplicated
+ assert second.id == first.id
+ assert second.device_name == "Kitchen iPad"
+ assert second.app_version == "1.1.0"
+ assert second.capabilities.deep_links is True
+ assert second.last_seen_at == datetime(2026, 6, 14, 8, 5, tzinfo=timezone.utc)
+ assert records == [second]
+
+ await state_store.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_disable_device_hides_record_without_deleting_it(
+ postgres_dsn: str,
+ clean_test_db: None,
+) -> None:
+ # Given: A registered mobile device
+ state_store = PostgresStateStore(postgres_dsn)
+ await state_store.initialize()
+ repository = MobileDeviceRepository(state_store.engine)
+ registered = await repository.register_device(_registration())
+
+ # When: Disabling the device
+ disabled = await repository.disable_device(registered.id)
+ visible_records = await repository.list_devices()
+ all_records = await repository.list_devices(include_disabled=True)
+
+ # Then: Default listing hides it while retaining disabled history
+ assert disabled is not None
+ assert disabled.enabled is False
+ assert visible_records == []
+ assert all_records == [disabled]
+
+ await state_store.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_list_enabled_apns_targets_filters_disabled_environment_and_bundle(
+ postgres_dsn: str,
+ clean_test_db: None,
+) -> None:
+ # Given: Mobile devices across enabled state, APNs environment, and bundle id
+ state_store = PostgresStateStore(postgres_dsn)
+ await state_store.initialize()
+ repository = MobileDeviceRepository(state_store.engine)
+ enabled_match = await repository.register_device(
+ _registration(apns_token="enabled-sandbox-token")
+ )
+ disabled_match = await repository.register_device(
+ _registration(apns_token="disabled-sandbox-token")
+ )
+ await repository.disable_device(disabled_match.id)
+ await repository.register_device(
+ _registration(apns_environment="production", apns_token="production-token")
+ )
+ await repository.register_device(
+ _registration(apns_token="other-bundle-token", bundle_id="com.example.other")
+ )
+
+ # When: Listing sandbox APNs push targets for the HomeSec bundle
+ targets = await repository.list_enabled_apns_targets(
+ environment="sandbox",
+ bundle_id="com.levneiman.homesec",
+ )
+
+ # Then: Only the enabled matching iOS target is returned with token material
+ assert len(targets) == 1
+ assert targets[0].id == enabled_match.id
+ assert targets[0].apns_token == "enabled-sandbox-token"
+ assert targets[0].apns_environment == "sandbox"
+ assert targets[0].bundle_id == "com.levneiman.homesec"
+
+ await state_store.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_record_push_result_updates_last_push_status(
+ postgres_dsn: str,
+ clean_test_db: None,
+) -> None:
+ # Given: A registered mobile device
+ state_store = PostgresStateStore(postgres_dsn)
+ await state_store.initialize()
+ repository = MobileDeviceRepository(state_store.engine)
+ registered = await repository.register_device(_registration())
+ failed_at = datetime(2026, 6, 14, 8, 10, tzinfo=timezone.utc)
+
+ # When: Recording a failed APNs delivery
+ failed = await repository.record_push_result(
+ registered.id,
+ error=" BadDeviceToken ",
+ now=failed_at,
+ )
+
+ # Then: The latest push attempt and normalized error are persisted
+ assert failed is not None
+ assert failed.last_push_at == failed_at
+ assert failed.last_push_error == "BadDeviceToken"
+
+ # When: Recording a later successful APNs delivery
+ succeeded_at = datetime(2026, 6, 14, 8, 15, tzinfo=timezone.utc)
+ succeeded = await repository.record_push_result(
+ registered.id,
+ error=None,
+ now=succeeded_at,
+ )
+
+ # Then: The latest push time is refreshed and the previous error is cleared
+ assert succeeded is not None
+ assert succeeded.last_push_at == succeeded_at
+ assert succeeded.last_push_error is None
+
+ await state_store.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_reregistering_disabled_device_preserves_disabled_state(
+ postgres_dsn: str,
+ clean_test_db: None,
+) -> None:
+ # Given: A disabled mobile device registration
+ state_store = PostgresStateStore(postgres_dsn)
+ await state_store.initialize()
+ repository = MobileDeviceRepository(state_store.engine)
+ registered = await repository.register_device(_registration())
+ await repository.disable_device(registered.id)
+
+ # When: The app registers the same APNs token again
+ reregistered = await repository.register_device(
+ _registration(device_name="Renamed iPhone"),
+ now=datetime.now(timezone.utc) + timedelta(minutes=5),
+ )
+
+ # Then: Startup registration updates metadata without silently re-enabling push
+ assert reregistered.id == registered.id
+ assert reregistered.device_name == "Renamed iPhone"
+ assert reregistered.enabled is False
+
+ await state_store.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_update_device_can_reenable_disabled_device(
+ postgres_dsn: str,
+ clean_test_db: None,
+) -> None:
+ # Given: A disabled mobile device
+ state_store = PostgresStateStore(postgres_dsn)
+ await state_store.initialize()
+ repository = MobileDeviceRepository(state_store.engine)
+ registered = await repository.register_device(_registration())
+ await repository.disable_device(registered.id)
+
+ # When: Updating the device enabled state explicitly
+ updated = await repository.update_device(
+ registered.id,
+ MobileDeviceUpdate(
+ enabled=True,
+ device_name="Front Door iPhone",
+ capabilities=MobileDeviceCapabilities(rich_notifications=True),
+ ),
+ )
+
+ # Then: The device returns to default listings with updated metadata
+ assert updated is not None
+ assert updated.enabled is True
+ assert updated.device_name == "Front Door iPhone"
+ assert updated.capabilities.rich_notifications is True
+ assert await repository.list_devices() == [updated]
+
+ await state_store.shutdown()
+
+
+@pytest.mark.asyncio
+async def test_update_device_ignores_null_enabled_patch(
+ postgres_dsn: str,
+ clean_test_db: None,
+) -> None:
+ # Given: A registered mobile device
+ state_store = PostgresStateStore(postgres_dsn)
+ await state_store.initialize()
+ repository = MobileDeviceRepository(state_store.engine)
+ registered = await repository.register_device(_registration())
+
+ # When: A partial update carries enabled=None
+ updated = await repository.update_device(
+ registered.id,
+ MobileDeviceUpdate(enabled=None, app_version="1.2.0"),
+ )
+
+ # Then: The nullable patch value is ignored rather than writing NULL
+ assert updated is not None
+ assert updated.enabled is True
+ assert updated.app_version == "1.2.0"
+
+ await state_store.shutdown()
diff --git a/tests/homesec/test_pipeline.py b/tests/homesec/test_pipeline.py
index ebab9060..547190d9 100644
--- a/tests/homesec/test_pipeline.py
+++ b/tests/homesec/test_pipeline.py
@@ -822,6 +822,51 @@ async def send(self, alert) -> None:
assert notifier.send_calls == 2
assert len(notifier.sent_alerts) == 1
+ @pytest.mark.asyncio
+ async def test_non_retryable_notify_failure_does_not_retry(
+ self, base_config: Config, sample_clip: Clip, mocks: PipelineMocks
+ ) -> None:
+ """Non-retryable notifier failures should be recorded without duplicate sends."""
+ # Given retry config with multiple attempts and a non-retryable notifier failure
+ base_config.retry = RetryConfig(max_attempts=3, backoff_s=0.0)
+
+ class NonRetryableNotifierError(RuntimeError):
+ retryable = False
+
+ class NonRetryableNotifier(MockNotifier):
+ def __init__(self) -> None:
+ super().__init__(simulate_failure=False)
+ self.send_calls = 0
+
+ async def send(self, alert) -> None:
+ self.send_calls += 1
+ raise NonRetryableNotifierError("Partial APNs fanout already delivered")
+
+ notifier = NonRetryableNotifier()
+ pipeline = ClipPipeline(
+ config=base_config,
+ storage=mocks.storage,
+ repository=make_repository(base_config, mocks),
+ filter_plugin=mocks.filter,
+ vlm_plugin=mocks.vlm,
+ notifier=notifier,
+ alert_policy=make_alert_policy(base_config),
+ retention_pruner=MockRetentionPruner(),
+ )
+
+ # When a clip is processed
+ pipeline.on_new_clip(sample_clip)
+ await pipeline.shutdown()
+
+ # Then the notifier is not retried and the failure event is final
+ assert notifier.send_calls == 1
+ notify_failed_events = [
+ event for event in mocks.event_store.events if event.event_type == "notification_failed"
+ ]
+ assert len(notify_failed_events) == 1
+ assert notify_failed_events[0].attempt == 1
+ assert notify_failed_events[0].will_retry is False
+
@pytest.mark.asyncio
async def test_state_store_upsert_retries(
self, base_config: Config, sample_clip: Clip, mocks: PipelineMocks
diff --git a/tests/homesec/test_plugin_registration.py b/tests/homesec/test_plugin_registration.py
index a7bba924..954466b4 100644
--- a/tests/homesec/test_plugin_registration.py
+++ b/tests/homesec/test_plugin_registration.py
@@ -3,9 +3,15 @@
from __future__ import annotations
import pytest
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
-from homesec.plugins.registry import PluginType, get_plugin_names, load_plugin, plugin
+from homesec.plugins.registry import (
+ PluginType,
+ get_plugin_names,
+ load_plugin,
+ plugin,
+ validate_plugin,
+)
class DummyConfig(BaseModel):
@@ -87,3 +93,48 @@ def test_unknown_plugin_error() -> None:
"""Test loading unknown plugin raises ValueError."""
with pytest.raises(ValueError):
load_plugin(PluginType.SOURCE, "missing_plugin", {})
+
+
+def test_runtime_context_filters_unknown_keys_and_supports_aliases(
+ clean_registry: None,
+) -> None:
+ class RuntimeConfig(BaseModel):
+ model_config = {"extra": "forbid"}
+
+ foo: str
+ runtime_value: str = Field(default="", alias="__runtime_value__")
+
+ class RuntimePlugin:
+ config_cls = RuntimeConfig
+
+ def __init__(self, config: RuntimeConfig) -> None:
+ self.config = config
+
+ @classmethod
+ def create(cls, config: RuntimeConfig) -> RuntimePlugin:
+ return cls(config)
+
+ # Given: A strict plugin config with an alias-only runtime context field
+ plugin(plugin_type=PluginType.SOURCE, name="runtime_source")(RuntimePlugin)
+
+ # When: Loading and validating with both supported and unrelated runtime context
+ loaded = load_plugin(
+ PluginType.SOURCE,
+ "runtime_source",
+ {"foo": "configured"},
+ __runtime_value__="injected",
+ unrelated_dependency=object(),
+ )
+ validated = validate_plugin(
+ PluginType.SOURCE,
+ "runtime_source",
+ {"foo": "configured"},
+ __runtime_value__="injected",
+ unrelated_dependency=object(),
+ )
+
+ # Then: Supported alias context is injected and unknown context is ignored
+ assert isinstance(loaded, RuntimePlugin)
+ assert loaded.config.runtime_value == "injected"
+ assert isinstance(validated, RuntimeConfig)
+ assert validated.runtime_value == "injected"
diff --git a/tests/homesec/test_runtime_worker.py b/tests/homesec/test_runtime_worker.py
index 92a1b82b..28b98913 100644
--- a/tests/homesec/test_runtime_worker.py
+++ b/tests/homesec/test_runtime_worker.py
@@ -1226,6 +1226,40 @@ def _unexpected_plugin_load(*_: object) -> object:
assert entries == []
+def test_runtime_worker_create_notifier_skips_apns_when_postgres_unavailable(
+ caplog: pytest.LogCaptureFixture,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ # Given: APNs mobile notifications are configured but the worker Postgres store did not initialize
+ config = _make_config(
+ notifiers=[
+ NotifierConfig(
+ backend="apns_mobile",
+ enabled=True,
+ config={"bundle_id": "com.levneiman.homesec"},
+ )
+ ]
+ )
+ service = _make_service(config)
+ service._state_store = worker_module.PostgresStateStore(
+ "postgresql://homesec:homesec@localhost/homesec"
+ )
+
+ def _unexpected_plugin_load(*_: object, **__: object) -> object:
+ raise AssertionError("APNs notifier should not load without repository context")
+
+ monkeypatch.setattr(worker_module, "load_notifier_plugin", _unexpected_plugin_load)
+
+ # When: Building notifier stack for runtime bundle
+ with caplog.at_level(logging.WARNING):
+ notifier, entries = service._create_notifier(config)
+
+ # Then: The worker keeps recording runtime startup independent of APNs persistence
+ assert isinstance(notifier, worker_module._NoopNotifier)
+ assert entries == []
+ assert "Skipping apns_mobile notifier" in caplog.text
+
+
@pytest.mark.asyncio
async def test_runtime_worker_run_runtime_skips_analyzer_load_when_run_mode_never(
monkeypatch: pytest.MonkeyPatch,
diff --git a/ui/.gitignore b/ui/.gitignore
index 253cc7d5..f5d336cc 100644
--- a/ui/.gitignore
+++ b/ui/.gitignore
@@ -10,6 +10,8 @@ lerna-debug.log*
node_modules
dist
dist-ssr
+test-results
+playwright-report
*.local
# Editor directories and files
diff --git a/ui/README.md b/ui/README.md
index b6bb6654..243e18ad 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` syncs and 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..bdaf9dd0
--- /dev/null
+++ b/ui/capacitor.config.ts
@@ -0,0 +1,16 @@
+import type { CapacitorConfig } from '@capacitor/cli'
+
+const config: CapacitorConfig = {
+ appId: 'com.levneiman.homesec',
+ appName: 'HomeSec',
+ webDir: 'dist',
+ experimental: {
+ ios: {
+ spm: {
+ swiftToolsVersion: '6.2',
+ },
+ },
+ },
+}
+
+export default config
diff --git a/ui/e2e/mobile-layout.spec.ts b/ui/e2e/mobile-layout.spec.ts
new file mode 100644
index 00000000..a73a809b
--- /dev/null
+++ b/ui/e2e/mobile-layout.spec.ts
@@ -0,0 +1,398 @@
+import { expect, test, type Page } from '@playwright/test'
+
+const MOBILE_VIEWPORT = { width: 320, height: 700 }
+const DESKTOP_VIEWPORT = { width: 1280, height: 800 }
+const SHELL_ROUTES = [
+ '/live',
+ '/events',
+ '/events/test-id',
+ '/settings',
+ '/settings/cameras',
+ '/system',
+] as const
+
+const SAFE_AREA_OVERRIDES = {
+ top: '8px',
+ right: '12px',
+ bottom: '34px',
+ left: '12px',
+} as const
+
+const camera = {
+ name: 'front_door',
+ enabled: true,
+ healthy: true,
+ last_heartbeat: 1_797_187_200,
+ source_backend: 'rtsp',
+ source_config: {},
+}
+
+const clip = {
+ id: 'test-id',
+ camera: 'front_door',
+ status: 'done',
+ created_at: '2026-06-14T02:30:00Z',
+ activity_type: 'package',
+ risk_level: 'medium',
+ summary: 'Package delivery at the front door.',
+ detected_objects: ['person', 'package'],
+ storage_uri: 'dropbox:/clips/test-id.mp4',
+ view_url: null,
+ alerted: true,
+}
+
+async function mockHomeSecApi(page: Page): Promise {
+ await page.route('**/api/v1/**', async (route) => {
+ const request = route.request()
+ const url = new URL(request.url())
+ const path = url.pathname
+ const method = request.method()
+
+ const fulfillJson = (payload: unknown, status = 200) => route.fulfill({
+ status,
+ contentType: 'application/json',
+ headers: {
+ 'access-control-allow-origin': '*',
+ 'access-control-allow-headers': '*',
+ 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
+ },
+ body: JSON.stringify(payload),
+ })
+
+ if (method === 'OPTIONS') {
+ await route.fulfill({
+ status: 204,
+ headers: {
+ 'access-control-allow-origin': '*',
+ 'access-control-allow-headers': '*',
+ 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
+ },
+ })
+ return
+ }
+
+ if (path === '/api/v1/setup/status') {
+ await fulfillJson({
+ state: 'complete',
+ has_cameras: true,
+ pipeline_running: true,
+ auth_configured: false,
+ })
+ return
+ }
+
+ if (path === '/api/v1/health') {
+ await fulfillJson({
+ status: 'healthy',
+ bootstrap_mode: false,
+ pipeline: 'running',
+ postgres: 'ok',
+ cameras_online: 1,
+ })
+ return
+ }
+
+ if (path === '/api/v1/stats') {
+ await fulfillJson({
+ clips_today: 3,
+ alerts_today: 1,
+ cameras_total: 1,
+ cameras_online: 1,
+ uptime_seconds: 18_000,
+ })
+ return
+ }
+
+ if (path === '/api/v1/maintenance/postgres-backups/status') {
+ await fulfillJson({
+ enabled: true,
+ available: true,
+ running: false,
+ last_attempted_at: '2026-06-14T02:00:00Z',
+ last_success_at: '2026-06-14T02:00:00Z',
+ last_error: null,
+ last_local_path: '/backups/homesec.sql',
+ last_uploaded_uri: null,
+ next_run_at: '2026-06-15T02:00:00Z',
+ pending_remote_delete_count: 0,
+ unavailable_reason: null,
+ })
+ return
+ }
+
+ if (path === '/api/v1/cameras') {
+ await fulfillJson([camera])
+ return
+ }
+
+ if (path === '/api/v1/runtime/status') {
+ await fulfillJson({
+ state: 'idle',
+ generation: 3,
+ reload_in_progress: false,
+ active_config_version: 'cfg-v3',
+ last_reload_at: null,
+ last_reload_error: null,
+ })
+ return
+ }
+
+ if (path === '/api/v1/preview/cameras/front_door') {
+ await fulfillJson({
+ camera_name: 'front_door',
+ enabled: true,
+ state: 'idle',
+ viewer_count: 0,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ })
+ return
+ }
+
+ if (path === '/api/v1/talk/cameras/front_door') {
+ await fulfillJson({
+ camera_name: 'front_door',
+ enabled: true,
+ policy_enabled: true,
+ capability: 'supported',
+ state: 'idle',
+ active_session_id: null,
+ supported_codecs: ['pcm_s16le'],
+ offered_codecs: ['pcm_s16le'],
+ selected_codec: 'pcm_s16le',
+ backend: 'rtsp',
+ backend_reason: null,
+ last_error: null,
+ })
+ return
+ }
+
+ if (path === '/api/v1/clips' && method === 'GET') {
+ await fulfillJson({
+ clips: [clip],
+ limit: 25,
+ next_cursor: null,
+ has_more: false,
+ })
+ return
+ }
+
+ if (path === '/api/v1/clips/test-id/media-token' && method === 'POST') {
+ await fulfillJson({
+ media_url: '/event-video.mp4',
+ tokenized: false,
+ expires_at: null,
+ })
+ return
+ }
+
+ if (path === '/api/v1/clips/test-id' && method === 'GET') {
+ await fulfillJson(clip)
+ return
+ }
+
+ await fulfillJson({ detail: `Unhandled ${method} ${path}` }, 404)
+ })
+}
+
+async function openApp(page: Page, path: string): Promise {
+ await page.goto(path)
+ await page.getByRole('main').waitFor()
+}
+
+async function applySafeAreaOverrides(page: Page): Promise {
+ await page.evaluate((tokens) => {
+ const root = document.documentElement
+ root.style.setProperty('--safe-area-inset-top', tokens.top)
+ root.style.setProperty('--safe-area-inset-right', tokens.right)
+ root.style.setProperty('--safe-area-inset-bottom', tokens.bottom)
+ root.style.setProperty('--safe-area-inset-left', tokens.left)
+ }, SAFE_AREA_OVERRIDES)
+}
+
+async function expectNoHorizontalOverflow(page: Page): Promise {
+ const metrics = await page.evaluate(() => ({
+ viewportWidth: window.innerWidth,
+ htmlScrollWidth: document.documentElement.scrollWidth,
+ bodyScrollWidth: document.body.scrollWidth,
+ }))
+
+ expect(metrics.htmlScrollWidth).toBeLessThanOrEqual(metrics.viewportWidth)
+ expect(metrics.bodyScrollWidth).toBeLessThanOrEqual(metrics.viewportWidth)
+}
+
+async function expectMobileBottomNavClearance(page: Page): Promise {
+ const nav = page.locator('.mobile-bottom-nav')
+ const navBox = await nav.boundingBox()
+ const metrics = await page.evaluate(() => {
+ const content = document.querySelector('.app-shell__content')
+ const styles = content ? getComputedStyle(content) : null
+ return {
+ viewportWidth: window.innerWidth,
+ viewportHeight: window.innerHeight,
+ contentPaddingBottom: styles ? Number.parseFloat(styles.paddingBottom) : 0,
+ scrollPaddingBottom: Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom),
+ }
+ })
+
+ expect(navBox).not.toBeNull()
+ expect(navBox?.x ?? -1).toBeGreaterThanOrEqual(0)
+ expect((navBox?.x ?? 0) + (navBox?.width ?? 0)).toBeLessThanOrEqual(metrics.viewportWidth)
+ expect((navBox?.y ?? 0) + (navBox?.height ?? 0)).toBeLessThanOrEqual(metrics.viewportHeight)
+ expect(metrics.contentPaddingBottom).toBeGreaterThan(80)
+ expect(metrics.scrollPaddingBottom).toBeGreaterThan(80)
+}
+
+async function expectElementAboveMobileNav(page: Page, selector: string): Promise {
+ const target = page.locator(selector).first()
+ await target.scrollIntoViewIfNeeded()
+ const targetBox = await target.boundingBox()
+ const navBox = await page.locator('.mobile-bottom-nav').boundingBox()
+
+ expect(targetBox).not.toBeNull()
+ expect(navBox).not.toBeNull()
+ expect(targetBox?.y ?? 0).toBeGreaterThanOrEqual(0)
+ expect((targetBox?.y ?? 0) + (targetBox?.height ?? 0)).toBeLessThanOrEqual(navBox?.y ?? 0)
+}
+
+test.beforeEach(async ({ page }) => {
+ await mockHomeSecApi(page)
+})
+
+test.describe('iOS M1 mobile layout hardening', () => {
+ test.use({ viewport: MOBILE_VIEWPORT })
+
+ for (const route of SHELL_ROUTES) {
+ test(`${route} has no horizontal overflow and keeps bottom nav clear`, async ({ page }) => {
+ // Given: The HomeSec app is opened at iPhone width with API responses mocked
+ await openApp(page, route)
+
+ // When: The rendered route is measured in a real browser layout engine
+ await expect(page.getByRole('heading').first()).toBeVisible()
+
+ // Then: Page content stays within the viewport and reserves room for fixed nav
+ await expectNoHorizontalOverflow(page)
+ await expectMobileBottomNavClearance(page)
+ })
+ }
+
+ test('honors nonzero safe-area insets for mobile shell chrome', async ({ page }) => {
+ // Given: The shell renders with simulated iPhone notch and home-indicator insets
+ await openApp(page, '/live')
+ await applySafeAreaOverrides(page)
+
+ // When: The topbar, content, and fixed bottom nav are measured after re-layout
+ const metrics = await page.evaluate(() => {
+ const topbar = document.querySelector('.app-shell__topbar')
+ const content = document.querySelector('.app-shell__content')
+ const nav = document.querySelector('.mobile-bottom-nav')
+ const topbarStyles = topbar ? getComputedStyle(topbar) : null
+ const contentStyles = content ? getComputedStyle(content) : null
+ const navBox = nav?.getBoundingClientRect()
+
+ return {
+ viewportWidth: window.innerWidth,
+ viewportHeight: window.innerHeight,
+ topbarPaddingTop: topbarStyles ? Number.parseFloat(topbarStyles.paddingTop) : 0,
+ contentPaddingBottom: contentStyles ? Number.parseFloat(contentStyles.paddingBottom) : 0,
+ scrollPaddingBottom: Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingBottom),
+ navLeft: navBox?.left ?? -1,
+ navRight: navBox?.right ?? -1,
+ navBottom: navBox?.bottom ?? -1,
+ }
+ })
+
+ // Then: Safe-area tokens move chrome away from each unsafe viewport edge
+ expect(metrics.topbarPaddingTop).toBeGreaterThanOrEqual(20)
+ expect(metrics.contentPaddingBottom).toBeGreaterThanOrEqual(120)
+ expect(metrics.scrollPaddingBottom).toBeGreaterThanOrEqual(120)
+ expect(metrics.navLeft).toBeGreaterThanOrEqual(24)
+ expect(metrics.viewportWidth - metrics.navRight).toBeGreaterThanOrEqual(24)
+ expect(metrics.viewportHeight - metrics.navBottom).toBeGreaterThanOrEqual(46)
+ })
+
+ test('keeps live preview controls above the bottom nav', async ({ page }) => {
+ // Given: Live view renders a camera preview at iPhone width
+ await openApp(page, '/live')
+
+ // When: The preview viewport is scrolled into view
+ await expect(page.getByText('Start live view to watch this camera.')).toBeVisible()
+
+ // Then: The preview surface remains above the fixed bottom nav
+ await expectElementAboveMobileNav(page, '.camera-preview__viewport')
+ })
+
+ test('keeps event video controls above the bottom nav', async ({ page }) => {
+ // Given: Event detail renders a video panel at iPhone width
+ await openApp(page, '/events/test-id')
+
+ // When: The event video panel is scrolled into view
+ await expect(page.locator('.clip-detail-video')).toBeVisible()
+
+ // Then: The media viewport remains above the fixed bottom nav
+ await expectElementAboveMobileNav(page, '.clip-detail-media .media-panel__viewport')
+ })
+
+ test('hides bottom nav while a form control is focused', async ({ page }) => {
+ // Given: Events exposes a mobile filter form control
+ await openApp(page, '/events')
+
+ // When: The Camera filter receives focus as it would with the iOS keyboard
+ await page.getByRole('combobox', { name: 'Camera' }).focus()
+
+ // Then: The fixed bottom nav is removed from the focus layout
+ await expect(page.locator('.mobile-bottom-nav')).toHaveCSS('display', 'none')
+ })
+
+ test('keeps native setup inside safe-area-aware viewport padding', async ({ page }) => {
+ // Given: Native setup bypasses AppShell and renders with simulated iPhone safe-area insets
+ await page.goto('/native-setup')
+ await applySafeAreaOverrides(page)
+
+ // When: The setup page is measured at iPhone width
+ await expect(page.getByRole('heading', { name: 'Connect to HomeSec' })).toBeVisible()
+ const metrics = await page.locator('.native-setup-page').evaluate((element) => {
+ const styles = getComputedStyle(element)
+ return {
+ minHeight: styles.minHeight,
+ paddingTop: Number.parseFloat(styles.paddingTop),
+ paddingBottom: Number.parseFloat(styles.paddingBottom),
+ scrollPaddingBottom: Number.parseFloat(styles.scrollPaddingBottom),
+ htmlScrollWidth: document.documentElement.scrollWidth,
+ viewportWidth: window.innerWidth,
+ }
+ })
+
+ // Then: Setup has dynamic viewport sizing and no mobile horizontal overflow
+ expect(metrics.minHeight).toBe('700px')
+ expect(metrics.paddingTop).toBeGreaterThanOrEqual(24)
+ expect(metrics.paddingBottom).toBeGreaterThanOrEqual(50)
+ expect(metrics.scrollPaddingBottom).toBeGreaterThanOrEqual(50)
+ expect(metrics.htmlScrollWidth).toBeLessThanOrEqual(metrics.viewportWidth)
+ })
+})
+
+test.describe('desktop layout regression guard', () => {
+ test.use({ viewport: DESKTOP_VIEWPORT })
+
+ for (const route of SHELL_ROUTES) {
+ test(`${route} keeps desktop nav in the topbar`, async ({ page }) => {
+ // Given: The app is opened at desktop width
+ await openApp(page, route)
+
+ // When: Navigation CSS is inspected in a real browser
+ const desktopNavDisplay = await page.locator('.app-shell__nav').evaluate((element) =>
+ getComputedStyle(element).display
+ )
+ const mobileNavDisplay = await page.locator('.mobile-bottom-nav').evaluate((element) =>
+ getComputedStyle(element).display
+ )
+
+ // Then: Desktop keeps the topbar nav visible and the mobile nav hidden
+ expect(desktopNavDisplay).toBe('flex')
+ expect(mobileNavDisplay).toBe('none')
+ await expectNoHorizontalOverflow(page)
+ })
+ }
+})
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/index.html b/ui/index.html
index 592716ba..c10642eb 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -3,7 +3,7 @@
-
+
ui
diff --git a/ui/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..6ec5fd9b
--- /dev/null
+++ b/ui/ios/App/App.xcodeproj/project.pbxproj
@@ -0,0 +1,398 @@
+// !$*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 */; };
+ AB6585AB036645ACB3F18713 /* HomeSecKeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27BAEE612533413E98770C40 /* HomeSecKeychainStore.swift */; };
+ 66567A6DF7C547C188064737 /* HomeSecAuthPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = A484C75B28C044178789F867 /* HomeSecAuthPlugin.swift */; };
+ 9B5C5B337D684A7AA00B985B /* HomeSecBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1874C85077A0477C9265DAB5 /* HomeSecBridgeViewController.swift */; };
+ EC7D94D22FCB4E8E9A1F3B21 /* HomeSecDevicePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC7D94D12FCB4E8E9A1F3B21 /* HomeSecDevicePlugin.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 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; };
+ 27BAEE612533413E98770C40 /* HomeSecKeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecKeychainStore.swift; sourceTree = ""; };
+ A484C75B28C044178789F867 /* HomeSecAuthPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecAuthPlugin.swift; sourceTree = ""; };
+ 1874C85077A0477C9265DAB5 /* HomeSecBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecBridgeViewController.swift; sourceTree = ""; };
+ EC7D94D02FCB4E8E9A1F3B21 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; };
+ EC7D94D12FCB4E8E9A1F3B21 /* HomeSecDevicePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSecDevicePlugin.swift; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 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 */,
+ 1874C85077A0477C9265DAB5 /* HomeSecBridgeViewController.swift */,
+ A484C75B28C044178789F867 /* HomeSecAuthPlugin.swift */,
+ EC7D94D12FCB4E8E9A1F3B21 /* HomeSecDevicePlugin.swift */,
+ 27BAEE612533413E98770C40 /* HomeSecKeychainStore.swift */,
+ 504EC30B1FED79650016851F /* Main.storyboard */,
+ 504EC30E1FED79650016851F /* Assets.xcassets */,
+ 504EC3101FED79650016851F /* LaunchScreen.storyboard */,
+ 504EC3131FED79650016851F /* Info.plist */,
+ EC7D94D02FCB4E8E9A1F3B21 /* App.entitlements */,
+ 2FAD9762203C412B000D30F8 /* config.xml */,
+ 50B271D01FEDC1A000F3C39B /* public */,
+ );
+ 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 */,
+ 9B5C5B337D684A7AA00B985B /* HomeSecBridgeViewController.swift in Sources */,
+ 66567A6DF7C547C188064737 /* HomeSecAuthPlugin.swift in Sources */,
+ EC7D94D22FCB4E8E9A1F3B21 /* HomeSecDevicePlugin.swift in Sources */,
+ AB6585AB036645ACB3F18713 /* HomeSecKeychainStore.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 = "Apple Development";
+ 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 = 26.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 = "Apple Distribution";
+ 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 = 26.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;
+ APS_ENVIRONMENT = development;
+ CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ INFOPLIST_FILE = App/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.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;
+ APS_ENVIRONMENT = production;
+ CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ INFOPLIST_FILE = App/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 26.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/App.entitlements b/ui/ios/App/App/App.entitlements
new file mode 100644
index 00000000..6a26dfe4
--- /dev/null
+++ b/ui/ios/App/App/App.entitlements
@@ -0,0 +1,8 @@
+
+
+
+
+ aps-environment
+ $(APS_ENVIRONMENT)
+
+
diff --git a/ui/ios/App/App/AppDelegate.swift b/ui/ios/App/App/AppDelegate.swift
new file mode 100644
index 00000000..94a798b8
--- /dev/null
+++ b/ui/ios/App/App/AppDelegate.swift
@@ -0,0 +1,34 @@
+import UIKit
+import Capacitor
+
+@UIApplicationMain
+class AppDelegate: UIResponder, UIApplicationDelegate {
+
+ var window: UIWindow?
+
+ func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ return true
+ }
+
+ 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)
+ }
+
+ func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
+ }
+
+ func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
+ NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
+ }
+
+}
diff --git a/ui/ios/App/App/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 00000000..adf6ba01
Binary files /dev/null and b/ui/ios/App/App/Assets.xcassets/AppIcon.appiconset/AppIcon-512@2x.png differ
diff --git a/ui/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/ui/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..9b7d382d
--- /dev/null
+++ b/ui/ios/App/App/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "AppIcon-512@2x.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ui/ios/App/App/Assets.xcassets/Contents.json b/ui/ios/App/App/Assets.xcassets/Contents.json
new file mode 100644
index 00000000..da4a164c
--- /dev/null
+++ b/ui/ios/App/App/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/ui/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json b/ui/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
new file mode 100644
index 00000000..d7d96a67
--- /dev/null
+++ b/ui/ios/App/App/Assets.xcassets/Splash.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "splash-2732x2732-2.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "splash-2732x2732-1.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "splash-2732x2732.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
\ No newline at end of file
diff --git a/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png b/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png
new file mode 100644
index 00000000..33ea6c97
Binary files /dev/null and b/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-1.png differ
diff --git a/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png b/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png
new file mode 100644
index 00000000..33ea6c97
Binary files /dev/null and b/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732-2.png differ
diff --git a/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png b/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png
new file mode 100644
index 00000000..33ea6c97
Binary files /dev/null and b/ui/ios/App/App/Assets.xcassets/Splash.imageset/splash-2732x2732.png differ
diff --git a/ui/ios/App/App/Base.lproj/LaunchScreen.storyboard b/ui/ios/App/App/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 00000000..e7ae5d78
--- /dev/null
+++ b/ui/ios/App/App/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/ios/App/App/Base.lproj/Main.storyboard b/ui/ios/App/App/Base.lproj/Main.storyboard
new file mode 100644
index 00000000..4bfea4c3
--- /dev/null
+++ b/ui/ios/App/App/Base.lproj/Main.storyboard
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/ios/App/App/HomeSecAuthPlugin.swift b/ui/ios/App/App/HomeSecAuthPlugin.swift
new file mode 100644
index 00000000..f323f485
--- /dev/null
+++ b/ui/ios/App/App/HomeSecAuthPlugin.swift
@@ -0,0 +1,230 @@
+import Capacitor
+import Foundation
+
+enum HomeSecAuthPluginError: LocalizedError {
+ case invalidServerBaseUrl(String)
+ case missingValue(String)
+ case serverBaseUrlRequired
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidServerBaseUrl(let value):
+ return "Invalid HomeSec server URL: \(value)"
+ case .missingValue(let field):
+ return "\(field) is required."
+ case .serverBaseUrlRequired:
+ return "Set the HomeSec server URL before storing an API token."
+ }
+ }
+}
+
+@objc(HomeSecAuthPlugin)
+public class HomeSecAuthPlugin: CAPPlugin, CAPBridgedPlugin {
+ public let identifier = "HomeSecAuthPlugin"
+ public let jsName = "HomeSecAuth"
+ public let pluginMethods: [CAPPluginMethod] = [
+ CAPPluginMethod(name: "getServerBaseUrl", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "setServerBaseUrl", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "clearServerBaseUrl", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "getApiToken", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "setApiToken", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "clearApiToken", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "getAuthDisabledReady", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "setAuthDisabledReady", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "clearAuthDisabledReady", returnType: CAPPluginReturnPromise),
+ ]
+
+ private let authDisabledReadyPrefix = "auth-disabled-ready:"
+ private let keychain = HomeSecKeychainStore()
+ private let serverBaseUrlAccount = "server-base-url"
+ private let tokenAccountPrefix = "api-token:"
+
+ @objc func getServerBaseUrl(_ call: CAPPluginCall) {
+ resolveStoredValue(call, account: serverBaseUrlAccount)
+ }
+
+ @objc func setServerBaseUrl(_ call: CAPPluginCall) {
+ do {
+ let value = try requiredString(call, key: "value")
+ let normalized = try normalizedServerBaseUrl(value)
+ let previousBaseUrl = try currentServerBaseUrl()
+ if let previousBaseUrl, previousBaseUrl != normalized {
+ try keychain.delete(account: "\(tokenAccountPrefix)\(previousBaseUrl)")
+ try keychain.delete(account: "\(authDisabledReadyPrefix)\(previousBaseUrl)")
+ }
+ try keychain.set(normalized, account: serverBaseUrlAccount)
+ call.resolve()
+ } catch {
+ reject(call, error: error)
+ }
+ }
+
+ @objc func clearServerBaseUrl(_ call: CAPPluginCall) {
+ do {
+ if let tokenAccount = try currentApiTokenAccount() {
+ try keychain.delete(account: tokenAccount)
+ }
+ if let authDisabledAccount = try currentAuthDisabledReadyAccount() {
+ try keychain.delete(account: authDisabledAccount)
+ }
+ try keychain.delete(account: serverBaseUrlAccount)
+ call.resolve()
+ } catch {
+ reject(call, error: error)
+ }
+ }
+
+ @objc func getApiToken(_ call: CAPPluginCall) {
+ do {
+ guard let account = try currentApiTokenAccount() else {
+ call.resolve(["value": NSNull()])
+ return
+ }
+ resolveStoredValue(call, account: account)
+ } catch {
+ reject(call, error: error)
+ }
+ }
+
+ @objc func setApiToken(_ call: CAPPluginCall) {
+ do {
+ let value = try requiredString(call, key: "value")
+ let account = try requiredApiTokenAccount()
+ try keychain.set(value, account: account)
+ call.resolve()
+ } catch {
+ reject(call, error: error)
+ }
+ }
+
+ @objc func clearApiToken(_ call: CAPPluginCall) {
+ do {
+ if let account = try currentApiTokenAccount() {
+ try keychain.delete(account: account)
+ }
+ call.resolve()
+ } catch {
+ reject(call, error: error)
+ }
+ }
+
+ @objc func getAuthDisabledReady(_ call: CAPPluginCall) {
+ do {
+ guard let account = try currentAuthDisabledReadyAccount() else {
+ call.resolve(["value": false])
+ return
+ }
+ call.resolve(["value": try keychain.read(account: account) == "true"])
+ } catch {
+ reject(call, error: error)
+ }
+ }
+
+ @objc func setAuthDisabledReady(_ call: CAPPluginCall) {
+ do {
+ let ready = call.getBool("value", false)
+ let account = try requiredAuthDisabledReadyAccount()
+ if ready {
+ try keychain.set("true", account: account)
+ } else {
+ try keychain.delete(account: account)
+ }
+ call.resolve()
+ } catch {
+ reject(call, error: error)
+ }
+ }
+
+ @objc func clearAuthDisabledReady(_ call: CAPPluginCall) {
+ do {
+ if let account = try currentAuthDisabledReadyAccount() {
+ try keychain.delete(account: account)
+ }
+ call.resolve()
+ } catch {
+ reject(call, error: error)
+ }
+ }
+
+ private func resolveStoredValue(_ call: CAPPluginCall, account: String) {
+ do {
+ if let value = try keychain.read(account: account) {
+ call.resolve(["value": value])
+ } else {
+ call.resolve(["value": NSNull()])
+ }
+ } catch {
+ reject(call, error: error)
+ }
+ }
+
+ private func requiredString(_ call: CAPPluginCall, key: String) throws -> String {
+ guard let value = call.getString(key), !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw HomeSecAuthPluginError.missingValue(key)
+ }
+ return value
+ }
+
+ private func normalizedServerBaseUrl(_ value: String) throws -> String {
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard
+ var components = URLComponents(string: trimmed),
+ let scheme = components.scheme?.lowercased(),
+ (scheme == "http" || scheme == "https"),
+ components.host != nil
+ else {
+ throw HomeSecAuthPluginError.invalidServerBaseUrl(value)
+ }
+ components.scheme = scheme
+ var path = components.percentEncodedPath
+ while path.count > 1 && path.hasSuffix("/") {
+ path.removeLast()
+ }
+ components.percentEncodedPath = path == "/" ? "" : path
+ components.query = nil
+ components.fragment = nil
+ guard let normalized = components.string else {
+ throw HomeSecAuthPluginError.invalidServerBaseUrl(value)
+ }
+ return normalized
+ }
+
+ private func currentServerBaseUrl() throws -> String? {
+ guard let serverBaseUrl = try keychain.read(account: serverBaseUrlAccount) else {
+ return nil
+ }
+ return try normalizedServerBaseUrl(serverBaseUrl)
+ }
+
+ private func currentApiTokenAccount() throws -> String? {
+ guard let serverBaseUrl = try currentServerBaseUrl() else {
+ return nil
+ }
+ return "\(tokenAccountPrefix)\(serverBaseUrl)"
+ }
+
+ private func requiredApiTokenAccount() throws -> String {
+ guard let account = try currentApiTokenAccount() else {
+ throw HomeSecAuthPluginError.serverBaseUrlRequired
+ }
+ return account
+ }
+
+ private func currentAuthDisabledReadyAccount() throws -> String? {
+ guard let serverBaseUrl = try currentServerBaseUrl() else {
+ return nil
+ }
+ return "\(authDisabledReadyPrefix)\(serverBaseUrl)"
+ }
+
+ private func requiredAuthDisabledReadyAccount() throws -> String {
+ guard let account = try currentAuthDisabledReadyAccount() else {
+ throw HomeSecAuthPluginError.serverBaseUrlRequired
+ }
+ return account
+ }
+
+ private func reject(_ call: CAPPluginCall, error: Error) {
+ call.reject(error.localizedDescription, "HOMESEC_AUTH_STORAGE_ERROR", error)
+ }
+}
diff --git a/ui/ios/App/App/HomeSecBridgeViewController.swift b/ui/ios/App/App/HomeSecBridgeViewController.swift
new file mode 100644
index 00000000..ef50b663
--- /dev/null
+++ b/ui/ios/App/App/HomeSecBridgeViewController.swift
@@ -0,0 +1,11 @@
+import Capacitor
+import UIKit
+
+@objc(HomeSecBridgeViewController)
+class HomeSecBridgeViewController: CAPBridgeViewController {
+ override func capacitorDidLoad() {
+ super.capacitorDidLoad()
+ bridge?.registerPluginInstance(HomeSecAuthPlugin())
+ bridge?.registerPluginInstance(HomeSecDevicePlugin())
+ }
+}
diff --git a/ui/ios/App/App/HomeSecDevicePlugin.swift b/ui/ios/App/App/HomeSecDevicePlugin.swift
new file mode 100644
index 00000000..df346c2b
--- /dev/null
+++ b/ui/ios/App/App/HomeSecDevicePlugin.swift
@@ -0,0 +1,72 @@
+import Capacitor
+import Foundation
+import UIKit
+
+enum HomeSecDevicePluginError: LocalizedError {
+ case missingBundleIdentifier
+
+ var errorDescription: String? {
+ switch self {
+ case .missingBundleIdentifier:
+ return "App bundle identifier is unavailable."
+ }
+ }
+}
+
+@objc(HomeSecDevicePlugin)
+public class HomeSecDevicePlugin: CAPPlugin, CAPBridgedPlugin {
+ public let identifier = "HomeSecDevicePlugin"
+ public let jsName = "HomeSecDevice"
+ public let pluginMethods: [CAPPluginMethod] = [
+ CAPPluginMethod(name: "getRegistrationInfo", returnType: CAPPluginReturnPromise),
+ ]
+
+ @objc func getRegistrationInfo(_ call: CAPPluginCall) {
+ do {
+ guard let bundleIdentifier = Bundle.main.bundleIdentifier else {
+ throw HomeSecDevicePluginError.missingBundleIdentifier
+ }
+
+ call.resolve([
+ "apnsEnvironment": apnsEnvironment(),
+ "appVersion": bundleValue("CFBundleShortVersionString") ?? NSNull(),
+ "bundleId": bundleIdentifier,
+ "deviceName": nullableDeviceName(),
+ ])
+ } catch {
+ call.reject(error.localizedDescription, "HOMESEC_DEVICE_INFO_ERROR", error)
+ }
+ }
+
+ private func apnsEnvironment() -> String {
+ if let configured = bundleValue("HomeSecAPNSEnvironment")?.lowercased() {
+ switch configured {
+ case "development", "sandbox":
+ return "sandbox"
+ case "production":
+ return "production"
+ default:
+ break
+ }
+ }
+
+ #if DEBUG
+ return "sandbox"
+ #else
+ return "production"
+ #endif
+ }
+
+ private func bundleValue(_ key: String) -> String? {
+ guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String else {
+ return nil
+ }
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+
+ private func nullableDeviceName() -> Any {
+ let trimmed = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? NSNull() : trimmed
+ }
+}
diff --git a/ui/ios/App/App/HomeSecKeychainStore.swift b/ui/ios/App/App/HomeSecKeychainStore.swift
new file mode 100644
index 00000000..94e6e034
--- /dev/null
+++ b/ui/ios/App/App/HomeSecKeychainStore.swift
@@ -0,0 +1,86 @@
+import Foundation
+import Security
+
+enum HomeSecKeychainError: LocalizedError {
+ case invalidStoredValue(account: String)
+ case unexpectedStatus(operation: String, status: OSStatus)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidStoredValue(let account):
+ return "Stored Keychain value for \(account) is not valid UTF-8."
+ case .unexpectedStatus(let operation, let status):
+ return "Keychain \(operation) failed with status \(status)."
+ }
+ }
+}
+
+final class HomeSecKeychainStore {
+ private let service: String
+
+ init(service: String = "homesec") {
+ self.service = service
+ }
+
+ func read(account: String) throws -> String? {
+ var query = baseQuery(account: account)
+ query[kSecReturnData as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ if status == errSecItemNotFound {
+ return nil
+ }
+ guard status == errSecSuccess else {
+ throw HomeSecKeychainError.unexpectedStatus(operation: "read", status: status)
+ }
+ guard
+ let data = result as? Data,
+ let value = String(data: data, encoding: .utf8)
+ else {
+ throw HomeSecKeychainError.invalidStoredValue(account: account)
+ }
+
+ return value
+ }
+
+ func set(_ value: String, account: String) throws {
+ let data = Data(value.utf8)
+ let query = baseQuery(account: account)
+ let updateStatus = SecItemUpdate(
+ query as CFDictionary,
+ [kSecValueData as String: data] as CFDictionary
+ )
+
+ if updateStatus == errSecSuccess {
+ return
+ }
+ if updateStatus != errSecItemNotFound {
+ throw HomeSecKeychainError.unexpectedStatus(operation: "update", status: updateStatus)
+ }
+
+ var addQuery = query
+ addQuery[kSecValueData as String] = data
+ addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
+ guard addStatus == errSecSuccess else {
+ throw HomeSecKeychainError.unexpectedStatus(operation: "add", status: addStatus)
+ }
+ }
+
+ func delete(account: String) throws {
+ let status = SecItemDelete(baseQuery(account: account) as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw HomeSecKeychainError.unexpectedStatus(operation: "delete", status: status)
+ }
+ }
+
+ private func baseQuery(account: String) -> [String: Any] {
+ [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ ]
+ }
+}
diff --git a/ui/ios/App/App/Info.plist b/ui/ios/App/App/Info.plist
new file mode 100644
index 00000000..216599eb
--- /dev/null
+++ b/ui/ios/App/App/Info.plist
@@ -0,0 +1,69 @@
+
+
+
+
+ CAPACITOR_DEBUG
+ $(CAPACITOR_DEBUG)
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ HomeSec
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ com.levneiman.homesec
+ CFBundleURLSchemes
+
+ homesec
+
+
+
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ HomeSecAPNSEnvironment
+ $(APS_ENVIRONMENT)
+ LSRequiresIPhoneOS
+
+ NSAppTransportSecurity
+
+ NSAllowsLocalNetworking
+
+
+ NSLocalNetworkUsageDescription
+ HomeSec connects to your HomeSec server on your local network.
+ NSMicrophoneUsageDescription
+ HomeSec uses the microphone for push-to-talk camera audio.
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UIViewControllerBasedStatusBarAppearance
+
+
+
diff --git a/ui/ios/App/CapApp-SPM/.gitignore b/ui/ios/App/CapApp-SPM/.gitignore
new file mode 100644
index 00000000..3b298120
--- /dev/null
+++ b/ui/ios/App/CapApp-SPM/.gitignore
@@ -0,0 +1,9 @@
+.DS_Store
+/.build
+/Packages
+/*.xcodeproj
+xcuserdata/
+DerivedData/
+.swiftpm/config/registries.json
+.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
+.netrc
diff --git a/ui/ios/App/CapApp-SPM/Package.resolved b/ui/ios/App/CapApp-SPM/Package.resolved
new file mode 100644
index 00000000..7e636d22
--- /dev/null
+++ b/ui/ios/App/CapApp-SPM/Package.resolved
@@ -0,0 +1,14 @@
+{
+ "pins" : [
+ {
+ "identity" : "capacitor-swift-pm",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ionic-team/capacitor-swift-pm.git",
+ "state" : {
+ "revision" : "1af38be000bb5fcd1d8fec09694115cdcb179695",
+ "version" : "8.3.3"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/ui/ios/App/CapApp-SPM/Package.swift b/ui/ios/App/CapApp-SPM/Package.swift
new file mode 100644
index 00000000..d0c35c55
--- /dev/null
+++ b/ui/ios/App/CapApp-SPM/Package.swift
@@ -0,0 +1,29 @@
+// swift-tools-version: 6.2
+import PackageDescription
+
+// DO NOT MODIFY THIS FILE - managed by Capacitor CLI commands
+let package = Package(
+ name: "CapApp-SPM",
+ platforms: [.iOS(.v26)],
+ products: [
+ .library(
+ name: "CapApp-SPM",
+ targets: ["CapApp-SPM"])
+ ],
+ dependencies: [
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", exact: "8.3.3"),
+ .package(name: "CapacitorApp", path: "../../../node_modules/.pnpm/@capacitor+app@8.1.0_@capacitor+core@8.3.3/node_modules/@capacitor/app"),
+ .package(name: "CapacitorPushNotifications", path: "../../../node_modules/.pnpm/@capacitor+push-notifications@8.1.1_@capacitor+core@8.3.3/node_modules/@capacitor/push-notifications")
+ ],
+ targets: [
+ .target(
+ name: "CapApp-SPM",
+ dependencies: [
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
+ .product(name: "Cordova", package: "capacitor-swift-pm"),
+ .product(name: "CapacitorApp", package: "CapacitorApp"),
+ .product(name: "CapacitorPushNotifications", package: "CapacitorPushNotifications")
+ ]
+ )
+ ]
+)
diff --git a/ui/ios/App/CapApp-SPM/README.md b/ui/ios/App/CapApp-SPM/README.md
new file mode 100644
index 00000000..03964db9
--- /dev/null
+++ b/ui/ios/App/CapApp-SPM/README.md
@@ -0,0 +1,5 @@
+# CapApp-SPM
+
+This package is used to host SPM dependencies for your Capacitor project
+
+Do not modify the contents of it or there may be unintended consequences.
diff --git a/ui/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift b/ui/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift
new file mode 100644
index 00000000..945afec8
--- /dev/null
+++ b/ui/ios/App/CapApp-SPM/Sources/CapApp-SPM/CapApp-SPM.swift
@@ -0,0 +1 @@
+public let isCapacitorApp = true
diff --git a/ui/ios/debug.xcconfig b/ui/ios/debug.xcconfig
new file mode 100644
index 00000000..53ce18de
--- /dev/null
+++ b/ui/ios/debug.xcconfig
@@ -0,0 +1 @@
+CAPACITOR_DEBUG = true
diff --git a/ui/package.json b/ui/package.json
index 140f99e1..0d3fda6d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -4,6 +4,9 @@
"version": "0.0.0",
"type": "module",
"packageManager": "pnpm@10.15.1",
+ "engines": {
+ "node": ">=22.12.0"
+ },
"scripts": {
"dev": "vite",
"api:generate": "node ./scripts/api_codegen.mjs generate",
@@ -13,11 +16,19 @@
"test:watch": "vitest",
"test:e2e": "playwright test",
"build": "pnpm typecheck && vite build",
+ "ios:build": "pnpm build && cap copy ios",
+ "ios:sync": "pnpm build && cap sync ios",
+ "ios:open": "pnpm ios:sync && cap open ios",
+ "ios:run": "pnpm ios:sync && cap run ios",
"lint": "eslint .",
"check": "pnpm api:check && pnpm lint && pnpm test && pnpm build",
"preview": "vite preview"
},
"dependencies": {
+ "@capacitor/app": "8.1.0",
+ "@capacitor/core": "^8.3.3",
+ "@capacitor/ios": "^8.3.3",
+ "@capacitor/push-notifications": "8.1.1",
"@tanstack/react-query": "^5.90.21",
"hls.js": "^1.6.16",
"react": "^19.2.0",
@@ -25,6 +36,7 @@
"react-router-dom": "^7.13.0"
},
"devDependencies": {
+ "@capacitor/cli": "^8.3.3",
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.9.1",
diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts
new file mode 100644
index 00000000..53ff1b2a
--- /dev/null
+++ b/ui/playwright.config.ts
@@ -0,0 +1,27 @@
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: true,
+ reporter: 'list',
+ use: {
+ baseURL: 'http://127.0.0.1:4173',
+ trace: 'on-first-retry',
+ },
+ webServer: {
+ command: 'pnpm dev --host 127.0.0.1 --port 4173',
+ url: 'http://127.0.0.1:4173',
+ reuseExistingServer: false,
+ timeout: 120_000,
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ {
+ name: 'webkit-iphone',
+ use: { ...devices['iPhone 15'] },
+ },
+ ],
+})
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 0cbef57d..46b43964 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -8,6 +8,18 @@ importers:
.:
dependencies:
+ '@capacitor/app':
+ specifier: 8.1.0
+ version: 8.1.0(@capacitor/core@8.3.3)
+ '@capacitor/core':
+ specifier: ^8.3.3
+ version: 8.3.3
+ '@capacitor/ios':
+ specifier: ^8.3.3
+ version: 8.3.3(@capacitor/core@8.3.3)
+ '@capacitor/push-notifications':
+ specifier: 8.1.1
+ version: 8.1.1(@capacitor/core@8.3.3)
'@tanstack/react-query':
specifier: ^5.90.21
version: 5.90.21(react@19.2.4)
@@ -24,6 +36,9 @@ importers:
specifier: ^7.13.0
version: 7.13.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
devDependencies:
+ '@capacitor/cli':
+ specifier: ^8.3.3
+ version: 8.3.3
'@eslint/js':
specifier: ^9.39.1
version: 9.39.2
@@ -189,6 +204,29 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
+ '@capacitor/app@8.1.0':
+ resolution: {integrity: sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==}
+ peerDependencies:
+ '@capacitor/core': '>=8.0.0'
+
+ '@capacitor/cli@8.3.3':
+ resolution: {integrity: sha512-FHebL02KEyU5vs+Os5s1yZuE8QT3FzxoO4nZLywGk7Ny957E6gOujKouGKsnKYq01eAWWJGGV/Fv04rY27tSsw==}
+ engines: {node: '>=22.0.0'}
+ hasBin: true
+
+ '@capacitor/core@8.3.3':
+ resolution: {integrity: sha512-xx1FIriZQ5jwqEkZwmWQHfXNEn5a9ZLOtdFoJXslh0ian6T/EU+QJtRmZw3KRsmcUV6p5ufczBrzF1rVP8Nu3A==}
+
+ '@capacitor/ios@8.3.3':
+ resolution: {integrity: sha512-BHlTOxarrvkaqDdlTxKwip+dQmfQSnfctizpheR7SWp/VIlR0HcPpYzWMTiVbHQLq3nLRUdeZO1wSPVGvxzAPA==}
+ peerDependencies:
+ '@capacitor/core': ^8.3.0
+
+ '@capacitor/push-notifications@8.1.1':
+ resolution: {integrity: sha512-WqzjPKIbYbARMN+GC0XMAJcxJpUUzqgzS/Ny8RODLrro38pQhm3GXYwX2Mwd+LZlLY39rGImkCkrKyQSNfuikA==}
+ peerDependencies:
+ '@capacitor/core': '>=8.0.0'
+
'@csstools/color-helpers@6.0.1':
resolution: {integrity: sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==}
engines: {node: '>=20.19.0'}
@@ -439,6 +477,42 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
+ '@ionic/cli-framework-output@2.2.8':
+ resolution: {integrity: sha512-TshtaFQsovB4NWRBydbNFawql6yul7d5bMiW1WYYf17hd99V6xdDdk3vtF51bw6sLkxON3bDQpWsnUc9/hVo3g==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-array@2.1.6':
+ resolution: {integrity: sha512-0JZ1Zkp3wURnv8oq6Qt7fMPo5MpjbLoUoa9Bu2Q4PJuSDWM8H8gwF3dQO7VTeUj3/0o1IB1wGkFWZZYgUXZMUg==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-fs@3.1.7':
+ resolution: {integrity: sha512-2EknRvMVfhnyhL1VhFkSLa5gOcycK91VnjfrTB0kbqkTFCOXyXgVLI5whzq7SLrgD9t1aqos3lMMQyVzaQ5gVA==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-object@2.1.6':
+ resolution: {integrity: sha512-vCl7sl6JjBHFw99CuAqHljYJpcE88YaH2ZW4ELiC/Zwxl5tiwn4kbdP/gxi2OT3MQb1vOtgAmSNRtusvgxI8ww==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-process@2.1.12':
+ resolution: {integrity: sha512-Jqkgyq7zBs/v/J3YvKtQQiIcxfJyplPgECMWgdO0E1fKrrH8EF0QGHNJ9mJCn6PYe2UtHNS8JJf5G21e09DfYg==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-stream@3.1.7':
+ resolution: {integrity: sha512-eSELBE7NWNFIHTbTC2jiMvh1ABKGIpGdUIvARsNPMNQhxJB3wpwdiVnoBoTYp+5a6UUIww4Kpg7v6S7iTctH1w==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-subprocess@3.0.1':
+ resolution: {integrity: sha512-cT4te3AQQPeIM9WCwIg8ohroJ8TjsYaMb2G4ZEgv9YzeDqHZ4JpeIKqG2SoaA3GmVQ3sOfhPM6Ox9sxphV/d1A==}
+ engines: {node: '>=16.0.0'}
+
+ '@ionic/utils-terminal@2.3.5':
+ resolution: {integrity: sha512-3cKScz9Jx2/Pr9ijj1OzGlBDfcmx7OMVBt4+P1uRR0SSW4cm1/y3Mo4OY3lfkuaYifMNBW8Wz6lQHbs1bihr7A==}
+ engines: {node: '>=16.0.0'}
+
+ '@isaacs/fs-minipass@4.0.1':
+ resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
+ engines: {node: '>=18.0.0'}
+
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -662,6 +736,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/fs-extra@8.1.5':
+ resolution: {integrity: sha512-0dzKcwO+S8s2kuF5Z9oUWatQJj5Uq/iqphEtE3GQJVRRYm/tD1LglU2UnXi2A8jLq5umkGouOXOR9y0n613ZwQ==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -676,6 +753,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+ '@types/slice-ansi@4.0.0':
+ resolution: {integrity: sha512-+OpjSaq85gvlZAYINyzKpLeiFkSC4EsC6IIiT6v6TLSU5k5U83fHGj9Lel8oKEXM0HqgrMVCjXPDPVICtxF7EQ==}
+
'@typescript-eslint/eslint-plugin@8.55.0':
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -770,6 +850,10 @@ packages:
'@vitest/utils@4.0.18':
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
+ '@xmldom/xmldom@0.9.10':
+ resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
+ engines: {node: '>=14.6'}
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -817,9 +901,24 @@ packages:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
+ astral-regex@2.0.0:
+ resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
+ engines: {node: '>=8'}
+
+ at-least-node@1.0.0:
+ resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
+ engines: {node: '>= 4.0.0'}
+
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
+ base64-js@1.5.1:
+ resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
+
baseline-browser-mapping@2.9.19:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true
@@ -827,17 +926,32 @@ packages:
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+ big-integer@1.6.52:
+ resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==}
+ engines: {node: '>=0.6'}
+
+ bplist-parser@0.3.2:
+ resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==}
+ engines: {node: '>= 5.10.0'}
+
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
+ brace-expansion@5.0.6:
+ resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
+ engines: {node: 18 || 20 || >=22}
+
browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
+ buffer-crc32@0.2.13:
+ resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
@@ -856,6 +970,10 @@ packages:
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
+ chownr@3.0.0:
+ resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
+ engines: {node: '>=18'}
+
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -866,6 +984,10 @@ packages:
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
+ commander@12.1.0:
+ resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
+ engines: {node: '>=18'}
+
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -913,6 +1035,10 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+ define-lazy-prop@2.0.0:
+ resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
+ engines: {node: '>=8'}
+
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -926,6 +1052,13 @@ packages:
electron-to-chromium@1.5.286:
resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
+ elementtree@0.1.7:
+ resolution: {integrity: sha512-wkgGT6kugeQk/P6VZ/f4T+4HB41BVgNBq5CDIZVbQ02nvTVqAiVTbskxxu3eA/X96lMlfYOwnLQpN2v5E1zDEg==}
+ engines: {node: '>= 0.4.0'}
+
+ emoji-regex@8.0.0:
+ resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
+
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
@@ -934,6 +1067,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
+ env-paths@2.2.1:
+ resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
+ engines: {node: '>=6'}
+
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
@@ -1022,6 +1159,9 @@ packages:
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
+ fd-slicer@1.1.0:
+ resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -1046,6 +1186,14 @@ packages:
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
+ fs-extra@11.3.5:
+ resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
+ engines: {node: '>=14.14'}
+
+ fs-extra@9.1.0:
+ resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
+ engines: {node: '>=10'}
+
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1064,6 +1212,10 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
+ glob@13.0.6:
+ resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
+ engines: {node: 18 || 20 || >=22}
+
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
@@ -1072,6 +1224,9 @@ packages:
resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==}
engines: {node: '>=18'}
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
happy-dom@15.11.7:
resolution: {integrity: sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==}
engines: {node: '>=18.0.0'}
@@ -1125,10 +1280,26 @@ packages:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
+ inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+ ini@4.1.3:
+ resolution: {integrity: sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==}
+ engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
+
+ is-docker@2.2.1:
+ resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
+ engines: {node: '>=8'}
+ hasBin: true
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
+ is-fullwidth-code-point@3.0.0:
+ resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
+ engines: {node: '>=8'}
+
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
@@ -1136,6 +1307,10 @@ packages:
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+ is-wsl@2.2.0:
+ resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
+ engines: {node: '>=8'}
+
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
@@ -1181,9 +1356,20 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ jsonfile@6.2.1:
+ resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
+ kleur@3.0.3:
+ resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
+ engines: {node: '>=6'}
+
+ kleur@4.1.5:
+ resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
+ engines: {node: '>=6'}
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -1216,6 +1402,10 @@ packages:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
+ minimatch@10.2.5:
+ resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
+ engines: {node: 18 || 20 || >=22}
+
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
@@ -1227,6 +1417,14 @@ packages:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
+ minipass@7.1.3:
+ resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
+ engines: {node: '>=16 || 14 >=14.17'}
+
+ minizlib@3.1.0:
+ resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
+ engines: {node: '>= 18'}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1235,6 +1433,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
+ native-run@2.0.3:
+ resolution: {integrity: sha512-U1PllBuzW5d1gfan+88L+Hky2eZx+9gv3Pf6rNBxKbORxi7boHzqiA6QFGSnqMem4j0A9tZ08NMIs5+0m/VS1Q==}
+ engines: {node: '>=16.0.0'}
+ hasBin: true
+
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
@@ -1244,6 +1447,10 @@ packages:
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+ open@8.4.2:
+ resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
+ engines: {node: '>=12'}
+
openapi-typescript@7.13.0:
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
hasBin: true
@@ -1262,6 +1469,9 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ package-json-from-dist@1.0.1:
+ resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -1281,9 +1491,16 @@ packages:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
+ path-scurry@2.0.2:
+ resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
+ engines: {node: 18 || 20 || >=22}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ pend@1.2.0:
+ resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1301,6 +1518,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ plist@3.1.1:
+ resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==}
+ engines: {node: '>=10.4.0'}
+
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
@@ -1317,6 +1538,10 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+ prompts@2.4.2:
+ resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
+ engines: {node: '>= 6'}
+
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1354,6 +1579,10 @@ packages:
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
engines: {node: '>=0.10.0'}
+ readable-stream@3.6.2:
+ resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
+ engines: {node: '>= 6'}
+
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
@@ -1366,11 +1595,26 @@ packages:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
+ rimraf@6.1.3:
+ resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==}
+ engines: {node: 20 || >=22}
+ hasBin: true
+
rollup@4.57.1:
resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+ sax@1.1.4:
+ resolution: {integrity: sha512-5f3k2PbGGp+YtKJjOItpg3P99IMD84E4HOvcfleTb5joCHNXYLsR9yWFPOYGgaeMPDubQILTCMdsFb2OMeOjtg==}
+
+ sax@1.6.0:
+ resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
+ engines: {node: '>=11.0.0'}
+
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
@@ -1401,16 +1645,41 @@ packages:
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+ signal-exit@3.0.7:
+ resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
+ sisteransi@1.0.5:
+ resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
+
+ slice-ansi@4.0.0:
+ resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
+ engines: {node: '>=10'}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
+ split2@4.2.0:
+ resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
+ engines: {node: '>= 10.x'}
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
+ string-width@4.2.3:
+ resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
+ engines: {node: '>=8'}
+
+ string_decoder@1.3.0:
+ resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
+
+ strip-ansi@6.0.1:
+ resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
+ engines: {node: '>=8'}
+
strip-indent@3.0.0:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
@@ -1430,6 +1699,13 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+ tar@7.5.15:
+ resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==}
+ engines: {node: '>=18'}
+
+ through2@4.0.2:
+ resolution: {integrity: sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==}
+
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -1460,12 +1736,19 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
+ tree-kill@1.2.2:
+ resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
+ hasBin: true
+
ts-api-utils@2.4.0:
resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
+ tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -1493,6 +1776,14 @@ packages:
resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==}
engines: {node: '>=20.18.1'}
+ universalify@2.0.1:
+ resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
+ engines: {node: '>= 10.0.0'}
+
+ untildify@4.0.0:
+ resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
+ engines: {node: '>=8'}
+
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -1502,6 +1793,9 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
+ util-deprecate@1.0.2:
+ resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
+
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1614,16 +1908,36 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ wrap-ansi@7.0.0:
+ resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
+ engines: {node: '>=10'}
+
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
+ xml2js@0.6.2:
+ resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
+ engines: {node: '>=4.0.0'}
+
+ xmlbuilder@11.0.1:
+ resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
+ engines: {node: '>=4.0'}
+
+ xmlbuilder@15.1.1:
+ resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==}
+ engines: {node: '>=8.0'}
+
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+ yallist@5.0.0:
+ resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
+ engines: {node: '>=18'}
+
yaml-ast-parser@0.0.43:
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
@@ -1631,6 +1945,9 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
+ yauzl@2.10.0:
+ resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -1782,6 +2099,44 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ '@capacitor/app@8.1.0(@capacitor/core@8.3.3)':
+ dependencies:
+ '@capacitor/core': 8.3.3
+
+ '@capacitor/cli@8.3.3':
+ dependencies:
+ '@ionic/cli-framework-output': 2.2.8
+ '@ionic/utils-subprocess': 3.0.1
+ '@ionic/utils-terminal': 2.3.5
+ commander: 12.1.0
+ debug: 4.4.3(supports-color@10.2.2)
+ env-paths: 2.2.1
+ fs-extra: 11.3.5
+ kleur: 4.1.5
+ native-run: 2.0.3
+ open: 8.4.2
+ plist: 3.1.1
+ prompts: 2.4.2
+ rimraf: 6.1.3
+ semver: 7.7.4
+ tar: 7.5.15
+ tslib: 2.8.1
+ xml2js: 0.6.2
+ transitivePeerDependencies:
+ - supports-color
+
+ '@capacitor/core@8.3.3':
+ dependencies:
+ tslib: 2.8.1
+
+ '@capacitor/ios@8.3.3(@capacitor/core@8.3.3)':
+ dependencies:
+ '@capacitor/core': 8.3.3
+
+ '@capacitor/push-notifications@8.1.1(@capacitor/core@8.3.3)':
+ dependencies:
+ '@capacitor/core': 8.3.3
+
'@csstools/color-helpers@6.0.1': {}
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
@@ -1941,6 +2296,86 @@ snapshots:
'@humanwhocodes/retry@0.4.3': {}
+ '@ionic/cli-framework-output@2.2.8':
+ dependencies:
+ '@ionic/utils-terminal': 2.3.5
+ debug: 4.4.3(supports-color@10.2.2)
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-array@2.1.6':
+ dependencies:
+ debug: 4.4.3(supports-color@10.2.2)
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-fs@3.1.7':
+ dependencies:
+ '@types/fs-extra': 8.1.5
+ debug: 4.4.3(supports-color@10.2.2)
+ fs-extra: 9.1.0
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-object@2.1.6':
+ dependencies:
+ debug: 4.4.3(supports-color@10.2.2)
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-process@2.1.12':
+ dependencies:
+ '@ionic/utils-object': 2.1.6
+ '@ionic/utils-terminal': 2.3.5
+ debug: 4.4.3(supports-color@10.2.2)
+ signal-exit: 3.0.7
+ tree-kill: 1.2.2
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-stream@3.1.7':
+ dependencies:
+ debug: 4.4.3(supports-color@10.2.2)
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-subprocess@3.0.1':
+ dependencies:
+ '@ionic/utils-array': 2.1.6
+ '@ionic/utils-fs': 3.1.7
+ '@ionic/utils-process': 2.1.12
+ '@ionic/utils-stream': 3.1.7
+ '@ionic/utils-terminal': 2.3.5
+ cross-spawn: 7.0.6
+ debug: 4.4.3(supports-color@10.2.2)
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@ionic/utils-terminal@2.3.5':
+ dependencies:
+ '@types/slice-ansi': 4.0.0
+ debug: 4.4.3(supports-color@10.2.2)
+ signal-exit: 3.0.7
+ slice-ansi: 4.0.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+ tslib: 2.8.1
+ untildify: 4.0.0
+ wrap-ansi: 7.0.0
+ transitivePeerDependencies:
+ - supports-color
+
+ '@isaacs/fs-minipass@4.0.1':
+ dependencies:
+ minipass: 7.1.3
+
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -2139,6 +2574,10 @@ snapshots:
'@types/estree@1.0.8': {}
+ '@types/fs-extra@8.1.5':
+ dependencies:
+ '@types/node': 24.10.13
+
'@types/json-schema@7.0.15': {}
'@types/node@24.10.13':
@@ -2153,6 +2592,8 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/slice-ansi@4.0.0': {}
+
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -2295,6 +2736,8 @@ snapshots:
'@vitest/pretty-format': 4.0.18
tinyrainbow: 3.0.3
+ '@xmldom/xmldom@0.9.10': {}
+
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -2330,14 +2773,28 @@ snapshots:
assertion-error@2.0.1: {}
+ astral-regex@2.0.0: {}
+
+ at-least-node@1.0.0: {}
+
balanced-match@1.0.2: {}
+ balanced-match@4.0.4: {}
+
+ base64-js@1.5.1: {}
+
baseline-browser-mapping@2.9.19: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
+ big-integer@1.6.52: {}
+
+ bplist-parser@0.3.2:
+ dependencies:
+ big-integer: 1.6.52
+
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
@@ -2347,6 +2804,10 @@ snapshots:
dependencies:
balanced-match: 1.0.2
+ brace-expansion@5.0.6:
+ dependencies:
+ balanced-match: 4.0.4
+
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.9.19
@@ -2355,6 +2816,8 @@ snapshots:
node-releases: 2.0.27
update-browserslist-db: 1.2.3(browserslist@4.28.1)
+ buffer-crc32@0.2.13: {}
+
callsites@3.1.0: {}
caniuse-lite@1.0.30001769: {}
@@ -2368,6 +2831,8 @@ snapshots:
change-case@5.4.4: {}
+ chownr@3.0.0: {}
+
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -2376,6 +2841,8 @@ snapshots:
colorette@1.4.0: {}
+ commander@12.1.0: {}
+
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
@@ -2421,6 +2888,8 @@ snapshots:
deep-is@0.1.4: {}
+ define-lazy-prop@2.0.0: {}
+
dequal@2.0.3: {}
dom-accessibility-api@0.5.16: {}
@@ -2429,10 +2898,18 @@ snapshots:
electron-to-chromium@1.5.286: {}
+ elementtree@0.1.7:
+ dependencies:
+ sax: 1.1.4
+
+ emoji-regex@8.0.0: {}
+
entities@4.5.0: {}
entities@6.0.1: {}
+ env-paths@2.2.1: {}
+
es-module-lexer@1.7.0: {}
esbuild@0.27.3:
@@ -2563,6 +3040,10 @@ snapshots:
fast-uri@3.1.0: {}
+ fd-slicer@1.1.0:
+ dependencies:
+ pend: 1.2.0
+
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
@@ -2583,6 +3064,19 @@ snapshots:
flatted@3.3.3: {}
+ fs-extra@11.3.5:
+ dependencies:
+ graceful-fs: 4.2.11
+ jsonfile: 6.2.1
+ universalify: 2.0.1
+
+ fs-extra@9.1.0:
+ dependencies:
+ at-least-node: 1.0.0
+ graceful-fs: 4.2.11
+ jsonfile: 6.2.1
+ universalify: 2.0.1
+
fsevents@2.3.2:
optional: true
@@ -2595,10 +3089,18 @@ snapshots:
dependencies:
is-glob: 4.0.3
+ glob@13.0.6:
+ dependencies:
+ minimatch: 10.2.5
+ minipass: 7.1.3
+ path-scurry: 2.0.2
+
globals@14.0.0: {}
globals@16.5.0: {}
+ graceful-fs@4.2.11: {}
+
happy-dom@15.11.7:
dependencies:
entities: 4.5.0
@@ -2650,14 +3152,26 @@ snapshots:
index-to-position@1.2.0: {}
+ inherits@2.0.4: {}
+
+ ini@4.1.3: {}
+
+ is-docker@2.2.1: {}
+
is-extglob@2.1.1: {}
+ is-fullwidth-code-point@3.0.0: {}
+
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-potential-custom-element-name@1.0.1: {}
+ is-wsl@2.2.0:
+ dependencies:
+ is-docker: 2.2.1
+
isexe@2.0.0: {}
js-levenshtein@1.1.6: {}
@@ -2706,10 +3220,20 @@ snapshots:
json5@2.2.3: {}
+ jsonfile@6.2.1:
+ dependencies:
+ universalify: 2.0.1
+ optionalDependencies:
+ graceful-fs: 4.2.11
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
+ kleur@3.0.3: {}
+
+ kleur@4.1.5: {}
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -2737,6 +3261,10 @@ snapshots:
min-indent@1.0.1: {}
+ minimatch@10.2.5:
+ dependencies:
+ brace-expansion: 5.0.6
+
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
@@ -2749,16 +3277,44 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
+ minipass@7.1.3: {}
+
+ minizlib@3.1.0:
+ dependencies:
+ minipass: 7.1.3
+
ms@2.1.3: {}
nanoid@3.3.11: {}
+ native-run@2.0.3:
+ dependencies:
+ '@ionic/utils-fs': 3.1.7
+ '@ionic/utils-terminal': 2.3.5
+ bplist-parser: 0.3.2
+ debug: 4.4.3(supports-color@10.2.2)
+ elementtree: 0.1.7
+ ini: 4.1.3
+ plist: 3.1.1
+ split2: 4.2.0
+ through2: 4.0.2
+ tslib: 2.8.1
+ yauzl: 2.10.0
+ transitivePeerDependencies:
+ - supports-color
+
natural-compare@1.4.0: {}
node-releases@2.0.27: {}
obug@2.1.1: {}
+ open@8.4.2:
+ dependencies:
+ define-lazy-prop: 2.0.0
+ is-docker: 2.2.1
+ is-wsl: 2.2.0
+
openapi-typescript@7.13.0(typescript@5.9.3):
dependencies:
'@redocly/openapi-core': 1.34.6(supports-color@10.2.2)
@@ -2786,6 +3342,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
+ package-json-from-dist@1.0.1: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -2804,8 +3362,15 @@ snapshots:
path-key@3.1.1: {}
+ path-scurry@2.0.2:
+ dependencies:
+ lru-cache: 11.2.6
+ minipass: 7.1.3
+
pathe@2.0.3: {}
+ pend@1.2.0: {}
+
picocolors@1.1.1: {}
picomatch@4.0.3: {}
@@ -2818,6 +3383,12 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
+ plist@3.1.1:
+ dependencies:
+ '@xmldom/xmldom': 0.9.10
+ base64-js: 1.5.1
+ xmlbuilder: 15.1.1
+
pluralize@8.0.0: {}
postcss@8.5.6:
@@ -2834,6 +3405,11 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
+ prompts@2.4.2:
+ dependencies:
+ kleur: 3.0.3
+ sisteransi: 1.0.5
+
punycode@2.3.1: {}
react-dom@19.2.4(react@19.2.4):
@@ -2861,6 +3437,12 @@ snapshots:
react@19.2.4: {}
+ readable-stream@3.6.2:
+ dependencies:
+ inherits: 2.0.4
+ string_decoder: 1.3.0
+ util-deprecate: 1.0.2
+
redent@3.0.0:
dependencies:
indent-string: 4.0.0
@@ -2870,6 +3452,11 @@ snapshots:
resolve-from@4.0.0: {}
+ rimraf@6.1.3:
+ dependencies:
+ glob: 13.0.6
+ package-json-from-dist: 1.0.1
+
rollup@4.57.1:
dependencies:
'@types/estree': 1.0.8
@@ -2901,6 +3488,12 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.57.1
fsevents: 2.3.3
+ safe-buffer@5.2.1: {}
+
+ sax@1.1.4: {}
+
+ sax@1.6.0: {}
+
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
@@ -2921,12 +3514,38 @@ snapshots:
siginfo@2.0.0: {}
+ signal-exit@3.0.7: {}
+
+ sisteransi@1.0.5: {}
+
+ slice-ansi@4.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ astral-regex: 2.0.0
+ is-fullwidth-code-point: 3.0.0
+
source-map-js@1.2.1: {}
+ split2@4.2.0: {}
+
stackback@0.0.2: {}
std-env@3.10.0: {}
+ string-width@4.2.3:
+ dependencies:
+ emoji-regex: 8.0.0
+ is-fullwidth-code-point: 3.0.0
+ strip-ansi: 6.0.1
+
+ string_decoder@1.3.0:
+ dependencies:
+ safe-buffer: 5.2.1
+
+ strip-ansi@6.0.1:
+ dependencies:
+ ansi-regex: 5.0.1
+
strip-indent@3.0.0:
dependencies:
min-indent: 1.0.1
@@ -2941,6 +3560,18 @@ snapshots:
symbol-tree@3.2.4: {}
+ tar@7.5.15:
+ dependencies:
+ '@isaacs/fs-minipass': 4.0.1
+ chownr: 3.0.0
+ minipass: 7.1.3
+ minizlib: 3.1.0
+ yallist: 5.0.0
+
+ through2@4.0.2:
+ dependencies:
+ readable-stream: 3.6.2
+
tinybench@2.9.0: {}
tinyexec@1.0.2: {}
@@ -2966,10 +3597,14 @@ snapshots:
dependencies:
punycode: 2.3.1
+ tree-kill@1.2.2: {}
+
ts-api-utils@2.4.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
+ tslib@2.8.1: {}
+
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -2993,6 +3628,10 @@ snapshots:
undici@7.22.0: {}
+ universalify@2.0.1: {}
+
+ untildify@4.0.0: {}
+
update-browserslist-db@1.2.3(browserslist@4.28.1):
dependencies:
browserslist: 4.28.1
@@ -3003,6 +3642,8 @@ snapshots:
dependencies:
punycode: 2.3.1
+ util-deprecate@1.0.2: {}
+
vite@7.3.1(@types/node@24.10.13):
dependencies:
esbuild: 0.27.3
@@ -3085,16 +3726,38 @@ snapshots:
word-wrap@1.2.5: {}
+ wrap-ansi@7.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ string-width: 4.2.3
+ strip-ansi: 6.0.1
+
xml-name-validator@5.0.0: {}
+ xml2js@0.6.2:
+ dependencies:
+ sax: 1.6.0
+ xmlbuilder: 11.0.1
+
+ xmlbuilder@11.0.1: {}
+
+ xmlbuilder@15.1.1: {}
+
xmlchars@2.2.0: {}
yallist@3.1.1: {}
+ yallist@5.0.0: {}
+
yaml-ast-parser@0.0.43: {}
yargs-parser@21.1.1: {}
+ yauzl@2.10.0:
+ dependencies:
+ buffer-crc32: 0.2.13
+ fd-slicer: 1.1.0
+
yocto-queue@0.1.0: {}
zod-validation-error@4.0.2(zod@4.3.6):
diff --git a/ui/scripts/api_codegen.mjs b/ui/scripts/api_codegen.mjs
index 6d64296b..6ed64438 100644
--- a/ui/scripts/api_codegen.mjs
+++ b/ui/scripts/api_codegen.mjs
@@ -95,12 +95,14 @@ function buildTypesFile({
probeResponseSchemaName,
mediaProfileSchemaName,
deviceInfoSchemaName,
+ mobileDeviceRegisterRequestSchemaName,
+ mobileDeviceResponseSchemaName,
}) {
- return `${GENERATED_HEADER}\nimport type { components, paths } from './schema'\n\nexport type OpenAPIComponents = components\nexport type OpenAPIPaths = paths\nexport type CameraResponse = components["schemas"]["${cameraSchemaName}"]\nexport type CameraListResponse = CameraResponse[]\nexport type CameraCreate = components["schemas"]["${cameraCreateSchemaName}"]\nexport type CameraUpdate = components["schemas"]["${cameraUpdateSchemaName}"]\nexport type ConfigChangeResponse = components["schemas"]["${configChangeSchemaName}"]\nexport type PreviewSessionResponse = components["schemas"]["${previewSessionSchemaName}"]\nexport type PreviewState = components["schemas"]["${previewStateSchemaName}"]\nexport type PreviewStatusResponse = components["schemas"]["${previewStatusSchemaName}"]\nexport type PreviewStopResponse = components["schemas"]["${previewStopSchemaName}"]\nexport type TalkInputFormat = components["schemas"]["${talkInputSchemaName}"]\nexport type TalkSessionRequest = components["schemas"]["${talkSessionRequestSchemaName}"]\nexport type TalkSessionResponse = components["schemas"]["${talkSessionSchemaName}"]\nexport type TalkCapabilityState = components["schemas"]["${talkCapabilitySchemaName}"]\nexport type TalkState = components["schemas"]["${talkStateSchemaName}"]\nexport type TalkStatusResponse = components["schemas"]["${talkStatusSchemaName}"]\nexport type TalkStopResponse = components["schemas"]["${talkStopSchemaName}"]\nexport type SetupStatusResponse = components["schemas"]["${setupStatusSchemaName}"]\nexport type FinalizeRequest = components["schemas"]["${finalizeRequestSchemaName}"]\nexport type FinalizeResponse = components["schemas"]["${finalizeResponseSchemaName}"]\nexport type PreflightCheckResponse = components["schemas"]["${preflightCheckSchemaName}"]\nexport type PreflightResponse = components["schemas"]["${preflightResponseSchemaName}"]\nexport type TestConnectionRequest = components["schemas"]["${testConnectionRequestSchemaName}"]\nexport type TestConnectionResponse = components["schemas"]["${testConnectionResponseSchemaName}"]\nexport type HealthResponse = components["schemas"]["${healthSchemaName}"]\nexport type StatsResponse = components["schemas"]["${statsSchemaName}"]\nexport type DiagnosticsResponse = components["schemas"]["${diagnosticsSchemaName}"]\nexport type RuntimeReloadResponse = components["schemas"]["${runtimeReloadSchemaName}"]\nexport type RuntimeState = components["schemas"]["${runtimeStateSchemaName}"]\nexport type RuntimeStatusResponse = components["schemas"]["${runtimeStatusSchemaName}"]\nexport type PostgresBackupStatusResponse = components["schemas"]["${postgresBackupStatusSchemaName}"]\nexport type PostgresBackupRunResponse = components["schemas"]["${postgresBackupRunSchemaName}"]\nexport type ClipListResponse = components["schemas"]["${clipListSchemaName}"]\nexport type ClipResponse = components["schemas"]["${clipSchemaName}"]\nexport type ClipStatus = components["schemas"]["${clipStatusSchemaName}"]\nexport type DiscoverRequest = components["schemas"]["${discoverRequestSchemaName}"]\nexport type DiscoveredCameraResponse = components["schemas"]["${discoveredCameraSchemaName}"]\nexport type ProbeRequest = components["schemas"]["${probeRequestSchemaName}"]\nexport type ProbeResponse = components["schemas"]["${probeResponseSchemaName}"]\nexport type MediaProfileResponse = components["schemas"]["${mediaProfileSchemaName}"]\nexport type DeviceInfoResponse = components["schemas"]["${deviceInfoSchemaName}"]\nexport type ListClipsQuery = NonNullable\n`
+ return `${GENERATED_HEADER}\nimport type { components, paths } from './schema'\n\nexport type OpenAPIComponents = components\nexport type OpenAPIPaths = paths\nexport type CameraResponse = components["schemas"]["${cameraSchemaName}"]\nexport type CameraListResponse = CameraResponse[]\nexport type CameraCreate = components["schemas"]["${cameraCreateSchemaName}"]\nexport type CameraUpdate = components["schemas"]["${cameraUpdateSchemaName}"]\nexport type ConfigChangeResponse = components["schemas"]["${configChangeSchemaName}"]\nexport type PreviewSessionResponse = components["schemas"]["${previewSessionSchemaName}"]\nexport type PreviewState = components["schemas"]["${previewStateSchemaName}"]\nexport type PreviewStatusResponse = components["schemas"]["${previewStatusSchemaName}"]\nexport type PreviewStopResponse = components["schemas"]["${previewStopSchemaName}"]\nexport type TalkInputFormat = components["schemas"]["${talkInputSchemaName}"]\nexport type TalkSessionRequest = components["schemas"]["${talkSessionRequestSchemaName}"]\nexport type TalkSessionResponse = components["schemas"]["${talkSessionSchemaName}"]\nexport type TalkCapabilityState = components["schemas"]["${talkCapabilitySchemaName}"]\nexport type TalkState = components["schemas"]["${talkStateSchemaName}"]\nexport type TalkStatusResponse = components["schemas"]["${talkStatusSchemaName}"]\nexport type TalkStopResponse = components["schemas"]["${talkStopSchemaName}"]\nexport type SetupStatusResponse = components["schemas"]["${setupStatusSchemaName}"]\nexport type FinalizeRequest = components["schemas"]["${finalizeRequestSchemaName}"]\nexport type FinalizeResponse = components["schemas"]["${finalizeResponseSchemaName}"]\nexport type PreflightCheckResponse = components["schemas"]["${preflightCheckSchemaName}"]\nexport type PreflightResponse = components["schemas"]["${preflightResponseSchemaName}"]\nexport type TestConnectionRequest = components["schemas"]["${testConnectionRequestSchemaName}"]\nexport type TestConnectionResponse = components["schemas"]["${testConnectionResponseSchemaName}"]\nexport type HealthResponse = components["schemas"]["${healthSchemaName}"]\nexport type StatsResponse = components["schemas"]["${statsSchemaName}"]\nexport type DiagnosticsResponse = components["schemas"]["${diagnosticsSchemaName}"]\nexport type RuntimeReloadResponse = components["schemas"]["${runtimeReloadSchemaName}"]\nexport type RuntimeState = components["schemas"]["${runtimeStateSchemaName}"]\nexport type RuntimeStatusResponse = components["schemas"]["${runtimeStatusSchemaName}"]\nexport type PostgresBackupStatusResponse = components["schemas"]["${postgresBackupStatusSchemaName}"]\nexport type PostgresBackupRunResponse = components["schemas"]["${postgresBackupRunSchemaName}"]\nexport type ClipListResponse = components["schemas"]["${clipListSchemaName}"]\nexport type ClipResponse = components["schemas"]["${clipSchemaName}"]\nexport type ClipStatus = components["schemas"]["${clipStatusSchemaName}"]\nexport type DiscoverRequest = components["schemas"]["${discoverRequestSchemaName}"]\nexport type DiscoveredCameraResponse = components["schemas"]["${discoveredCameraSchemaName}"]\nexport type ProbeRequest = components["schemas"]["${probeRequestSchemaName}"]\nexport type ProbeResponse = components["schemas"]["${probeResponseSchemaName}"]\nexport type MediaProfileResponse = components["schemas"]["${mediaProfileSchemaName}"]\nexport type DeviceInfoResponse = components["schemas"]["${deviceInfoSchemaName}"]\nexport type MobileDeviceRegisterRequest = components["schemas"]["${mobileDeviceRegisterRequestSchemaName}"]\nexport type MobileDeviceResponse = components["schemas"]["${mobileDeviceResponseSchemaName}"]\nexport type ListClipsQuery = NonNullable\n`
}
function buildClientFile() {
- return `${GENERATED_HEADER}\nimport type {\n CameraCreate,\n CameraListResponse,\n CameraResponse,\n CameraUpdate,\n ClipListResponse,\n ClipResponse,\n ConfigChangeResponse,\n PreviewSessionResponse,\n PreviewStatusResponse,\n PreviewStopResponse,\n TalkSessionRequest,\n TalkSessionResponse,\n TalkStatusResponse,\n TalkStopResponse,\n DiagnosticsResponse,\n DiscoverRequest,\n DiscoveredCameraResponse,\n FinalizeRequest,\n FinalizeResponse,\n HealthResponse,\n ListClipsQuery,\n PreflightResponse,\n TestConnectionRequest,\n TestConnectionResponse,\n ProbeRequest,\n ProbeResponse,\n RuntimeReloadResponse,\n RuntimeStatusResponse,\n PostgresBackupRunResponse,\n PostgresBackupStatusResponse,\n SetupStatusResponse,\n StatsResponse,\n} from './types'\n\nexport interface ApiRequestOptions {\n signal?: AbortSignal\n apiKey?: string | null\n}\n\nexport interface CameraMutationOptions extends ApiRequestOptions {\n applyChanges?: boolean\n}\n\nexport type ApiResponseWithStatus = TPayload & { httpStatus: number }\n\nexport interface GeneratedHomeSecClient {\n getCameras(options?: ApiRequestOptions): Promise\n getCamera(name: string, options?: ApiRequestOptions): Promise\n createCamera(\n payload: CameraCreate,\n options?: CameraMutationOptions,\n ): Promise>\n updateCamera(\n name: string,\n payload: CameraUpdate,\n options?: CameraMutationOptions,\n ): Promise>\n deleteCamera(\n name: string,\n options?: CameraMutationOptions,\n ): Promise>\n getCameraPreviewStatus(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n ensureCameraPreviewActive(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n stopCameraPreview(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n getCameraTalkStatus(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n prepareCameraTalkSession(\n cameraName: string,\n payload?: TalkSessionRequest,\n options?: ApiRequestOptions,\n ): Promise>\n stopCameraTalkSession(\n cameraName: string,\n sessionId: string,\n options?: ApiRequestOptions,\n ): Promise>\n getSetupStatus(options?: ApiRequestOptions): Promise>\n finalizeSetup(\n payload: FinalizeRequest,\n options?: ApiRequestOptions,\n ): Promise>\n runSetupPreflight(options?: ApiRequestOptions): Promise>\n runSetupTestConnection(\n payload: TestConnectionRequest,\n options?: ApiRequestOptions,\n ): Promise>\n getHealth(options?: ApiRequestOptions): Promise>\n getStats(options?: ApiRequestOptions): Promise>\n getDiagnostics(options?: ApiRequestOptions): Promise>\n reloadRuntime(options?: ApiRequestOptions): Promise>\n getRuntimeStatus(\n options?: ApiRequestOptions,\n ): Promise>\n getPostgresBackupStatus(\n options?: ApiRequestOptions,\n ): Promise>\n runPostgresBackupNow(\n options?: ApiRequestOptions,\n ): Promise>\n discoverOnvifCameras(\n payload?: DiscoverRequest,\n options?: ApiRequestOptions,\n ): Promise\n probeOnvifCamera(payload: ProbeRequest, options?: ApiRequestOptions): Promise\n getClips(\n query?: ListClipsQuery,\n options?: ApiRequestOptions,\n ): Promise>\n getClip(clipId: string, options?: ApiRequestOptions): Promise>\n}\n`
+ return `${GENERATED_HEADER}\nimport type {\n CameraCreate,\n CameraListResponse,\n CameraResponse,\n CameraUpdate,\n ClipListResponse,\n ClipResponse,\n ConfigChangeResponse,\n PreviewSessionResponse,\n PreviewStatusResponse,\n PreviewStopResponse,\n TalkSessionRequest,\n TalkSessionResponse,\n TalkStatusResponse,\n TalkStopResponse,\n DiagnosticsResponse,\n DiscoverRequest,\n DiscoveredCameraResponse,\n FinalizeRequest,\n FinalizeResponse,\n HealthResponse,\n ListClipsQuery,\n PreflightResponse,\n TestConnectionRequest,\n TestConnectionResponse,\n ProbeRequest,\n ProbeResponse,\n RuntimeReloadResponse,\n RuntimeStatusResponse,\n PostgresBackupRunResponse,\n PostgresBackupStatusResponse,\n SetupStatusResponse,\n StatsResponse,\n MobileDeviceRegisterRequest,\n MobileDeviceResponse,\n} from './types'\n\nexport interface ApiRequestOptions {\n signal?: AbortSignal\n apiKey?: string | null\n}\n\nexport interface CameraMutationOptions extends ApiRequestOptions {\n applyChanges?: boolean\n}\n\nexport type ApiResponseWithStatus = TPayload & { httpStatus: number }\n\nexport interface GeneratedHomeSecClient {\n getCameras(options?: ApiRequestOptions): Promise\n getCamera(name: string, options?: ApiRequestOptions): Promise\n createCamera(\n payload: CameraCreate,\n options?: CameraMutationOptions,\n ): Promise>\n updateCamera(\n name: string,\n payload: CameraUpdate,\n options?: CameraMutationOptions,\n ): Promise>\n deleteCamera(\n name: string,\n options?: CameraMutationOptions,\n ): Promise>\n getCameraPreviewStatus(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n ensureCameraPreviewActive(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n stopCameraPreview(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n getCameraTalkStatus(\n cameraName: string,\n options?: ApiRequestOptions,\n ): Promise>\n prepareCameraTalkSession(\n cameraName: string,\n payload?: TalkSessionRequest,\n options?: ApiRequestOptions,\n ): Promise>\n stopCameraTalkSession(\n cameraName: string,\n sessionId: string,\n options?: ApiRequestOptions,\n ): Promise>\n getSetupStatus(options?: ApiRequestOptions): Promise>\n finalizeSetup(\n payload: FinalizeRequest,\n options?: ApiRequestOptions,\n ): Promise>\n runSetupPreflight(options?: ApiRequestOptions): Promise>\n runSetupTestConnection(\n payload: TestConnectionRequest,\n options?: ApiRequestOptions,\n ): Promise>\n getHealth(options?: ApiRequestOptions): Promise>\n getStats(options?: ApiRequestOptions): Promise>\n getDiagnostics(options?: ApiRequestOptions): Promise>\n reloadRuntime(options?: ApiRequestOptions): Promise>\n getRuntimeStatus(\n options?: ApiRequestOptions,\n ): Promise>\n getPostgresBackupStatus(\n options?: ApiRequestOptions,\n ): Promise>\n runPostgresBackupNow(\n options?: ApiRequestOptions,\n ): Promise>\n discoverOnvifCameras(\n payload?: DiscoverRequest,\n options?: ApiRequestOptions,\n ): Promise\n probeOnvifCamera(payload: ProbeRequest, options?: ApiRequestOptions): Promise\n getClips(\n query?: ListClipsQuery,\n options?: ApiRequestOptions,\n ): Promise>\n getClip(clipId: string, options?: ApiRequestOptions): Promise>\n registerMobileDevice(\n payload: MobileDeviceRegisterRequest,\n options?: ApiRequestOptions,\n ): Promise>\n}\n`
}
function resolveResponseSchemaName(openapiSchema, { pathName, method, statuses, fallbackSchemaName }) {
@@ -441,6 +443,17 @@ function generateOpenApiArtifacts(tempGeneratedDir) {
const discoverRequestSchemaName = resolveComponentSchemaName(schema, 'DiscoverRequest')
const mediaProfileSchemaName = resolveComponentSchemaName(schema, 'MediaProfileResponse')
const deviceInfoSchemaName = resolveComponentSchemaName(schema, 'DeviceInfoResponse')
+ const mobileDeviceRegisterRequestSchemaName = resolveRequestBodySchemaName(schema, {
+ pathName: '/api/v1/mobile/devices',
+ method: 'post',
+ fallbackSchemaName: 'MobileDeviceRegisterRequest',
+ })
+ const mobileDeviceResponseSchemaName = resolveResponseSchemaName(schema, {
+ pathName: '/api/v1/mobile/devices',
+ method: 'post',
+ statuses: ['201', '200', 'default'],
+ fallbackSchemaName: 'MobileDeviceResponse',
+ })
if (!schema.components?.schemas?.ClipStatus) {
throw new Error('Missing ClipStatus schema in exported OpenAPI spec')
}
@@ -486,6 +499,8 @@ function generateOpenApiArtifacts(tempGeneratedDir) {
probeResponseSchemaName,
mediaProfileSchemaName,
deviceInfoSchemaName,
+ mobileDeviceRegisterRequestSchemaName,
+ mobileDeviceResponseSchemaName,
}),
'utf-8',
)
diff --git a/ui/src/api/apiKeyStorage.test.ts b/ui/src/api/apiKeyStorage.test.ts
index 4d4a3a8f..172774f2 100644
--- a/ui/src/api/apiKeyStorage.test.ts
+++ b/ui/src/api/apiKeyStorage.test.ts
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
+ API_KEY_STORAGE_KEY,
clearApiKey,
getStoredApiKey,
hasStoredApiKey,
@@ -29,18 +30,20 @@ function installWindowSessionStorageMock(): void {
describe('apiKeyStorage', () => {
afterEach(() => {
vi.unstubAllGlobals()
+ vi.doUnmock('./homeSecAuthPlugin')
+ vi.doUnmock('../runtime/nativeRuntime')
})
- it('saves, loads, and clears API key values', () => {
+ it('saves, loads, and clears API key values', async () => {
// Given: A client API key
installWindowSessionStorageMock()
const apiKey = 'secret-key'
// When: Saving and reading key from session storage
- saveApiKey(apiKey)
+ await saveApiKey(apiKey)
const stored = getStoredApiKey()
const hasBeforeClear = hasStoredApiKey()
- clearApiKey()
+ await clearApiKey()
const cleared = getStoredApiKey()
// Then: Key persistence and clear behavior are consistent
@@ -49,10 +52,10 @@ describe('apiKeyStorage', () => {
expect(cleared).toBeNull()
})
- it('prefers explicit apiKey and falls back to storage when omitted', () => {
+ it('prefers explicit apiKey and falls back to storage when omitted', async () => {
// Given: A stored API key and an explicit override key
installWindowSessionStorageMock()
- saveApiKey('stored-secret')
+ await saveApiKey('stored-secret')
// When: Resolving keys with explicit and implicit values
const explicit = resolveApiKey('explicit-secret')
@@ -62,4 +65,58 @@ describe('apiKeyStorage', () => {
expect(explicit).toBe('explicit-secret')
expect(implicit).toBe('stored-secret')
})
+
+ it('normalizes blank API key values as absent', async () => {
+ // Given: A browser storage area and a whitespace API key
+ installWindowSessionStorageMock()
+
+ // When: Saving a blank key value
+ await 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()
+ })
+
+ it('uses the native runtime token provider when iOS mode is active', async () => {
+ // Given: The runtime is loaded in native iOS mode with browser session storage available
+ vi.resetModules()
+ installWindowSessionStorageMock()
+ const nativeAuthPlugin = {
+ getServerBaseUrl: vi.fn(async () => ({ value: 'https://homesec.example.com' })),
+ setServerBaseUrl: vi.fn(async () => {}),
+ clearServerBaseUrl: vi.fn(async () => {}),
+ getApiToken: vi.fn(async () => ({ value: null })),
+ setApiToken: vi.fn(async () => {}),
+ clearApiToken: vi.fn(async () => {}),
+ getAuthDisabledReady: vi.fn(async () => ({ value: false })),
+ setAuthDisabledReady: vi.fn(async () => {}),
+ clearAuthDisabledReady: vi.fn(async () => {}),
+ }
+ vi.doMock('./homeSecAuthPlugin', () => ({
+ homeSecAuthPlugin: nativeAuthPlugin,
+ }))
+ vi.doMock('../runtime/nativeRuntime', () => ({
+ isIOSNativeApp: () => true,
+ }))
+ const nativeApiKeyStorage = await import('./apiKeyStorage')
+ const tokenProvider = await import('./tokenProvider')
+
+ // When: The shared auth recovery helpers save an API key
+ await nativeApiKeyStorage.saveApiKey(' native-secret ')
+ const stored = nativeApiKeyStorage.getStoredApiKey()
+ const ready = tokenProvider.isRuntimeAuthSessionReady()
+ await nativeApiKeyStorage.clearApiKey()
+
+ // Then: The iOS API client token source is updated without writing WebView storage
+ expect(nativeAuthPlugin.setApiToken).toHaveBeenCalledWith({ value: 'native-secret' })
+ expect(nativeAuthPlugin.clearApiToken).toHaveBeenCalledTimes(1)
+ expect(stored).toBe('native-secret')
+ expect(ready).toBe(true)
+ expect(tokenProvider.nativeAuthTokenProvider.getTokenSync()).toBeNull()
+ expect(window.sessionStorage.getItem(API_KEY_STORAGE_KEY)).toBeNull()
+ })
})
diff --git a/ui/src/api/apiKeyStorage.ts b/ui/src/api/apiKeyStorage.ts
index 875fd8c6..4d40a73c 100644
--- a/ui/src/api/apiKeyStorage.ts
+++ b/ui/src/api/apiKeyStorage.ts
@@ -1,38 +1,37 @@
import type { ApiRequestOptions } from './generated/client'
-
-const API_KEY_STORAGE_KEY = 'homesec.apiKey'
-
-export function saveApiKey(apiKey: string): void {
- if (typeof window === 'undefined') {
- return
+import {
+ BROWSER_AUTH_TOKEN_STORAGE_KEY,
+ clearPersistedRuntimeAuthSessionReady,
+ normalizeAuthToken,
+ persistRuntimeAuthSessionReady,
+ runtimeAuthTokenProvider,
+} from './tokenProvider'
+
+export const API_KEY_STORAGE_KEY = BROWSER_AUTH_TOKEN_STORAGE_KEY
+
+export async function saveApiKey(apiKey: string): Promise {
+ await runtimeAuthTokenProvider.setToken(apiKey)
+ if (getStoredApiKey()) {
+ await persistRuntimeAuthSessionReady()
}
-
- window.sessionStorage.setItem(API_KEY_STORAGE_KEY, apiKey)
}
export function getStoredApiKey(): string | null {
- if (typeof window === 'undefined') {
- return null
- }
- return window.sessionStorage.getItem(API_KEY_STORAGE_KEY)
+ return runtimeAuthTokenProvider.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)
+export async function clearApiKey(): Promise {
+ await runtimeAuthTokenProvider.clearToken()
+ await clearPersistedRuntimeAuthSessionReady()
}
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.test.ts b/ui/src/api/client.test.ts
index f213f126..16cbbc6a 100644
--- a/ui/src/api/client.test.ts
+++ b/ui/src/api/client.test.ts
@@ -41,6 +41,86 @@ describe('HomeSecApiClient.getCameras', () => {
})
})
+describe('HomeSecApiClient.registerMobileDevice', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('posts APNs registration payloads and parses redacted device records', async () => {
+ // Given: The mobile device endpoint returns a redacted registration record
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ id: 'dev_1',
+ platform: 'ios',
+ environment: 'sandbox',
+ bundle_id: 'com.levneiman.homesec',
+ device_name: "Lev's iPhone",
+ app_version: '1.0.0',
+ capabilities: { deep_links: true, rich_notifications: false },
+ enabled: true,
+ token_fingerprint: 'abcdef123456',
+ created_at: '2026-06-14T00:00:00Z',
+ updated_at: '2026-06-14T00:00:00Z',
+ last_seen_at: '2026-06-14T00:00:00Z',
+ last_push_at: null,
+ last_push_error: null,
+ }),
+ {
+ status: 201,
+ headers: { 'content-type': 'application/json' },
+ },
+ ),
+ )
+ const client = new HomeSecApiClient('http://localhost:8081')
+
+ // When: Registering a native iOS APNs device
+ const result = await client.registerMobileDevice({
+ platform: 'ios',
+ apns_token: 'raw-apns-token',
+ environment: 'sandbox',
+ bundle_id: 'com.levneiman.homesec',
+ device_name: "Lev's iPhone",
+ app_version: '1.0.0',
+ capabilities: { deep_links: true, rich_notifications: false },
+ })
+
+ // Then: The API client posts the raw token only to the registration endpoint
+ expect(result).toEqual({
+ id: 'dev_1',
+ platform: 'ios',
+ environment: 'sandbox',
+ bundle_id: 'com.levneiman.homesec',
+ device_name: "Lev's iPhone",
+ app_version: '1.0.0',
+ capabilities: { deep_links: true, rich_notifications: false },
+ enabled: true,
+ token_fingerprint: 'abcdef123456',
+ created_at: '2026-06-14T00:00:00Z',
+ updated_at: '2026-06-14T00:00:00Z',
+ last_seen_at: '2026-06-14T00:00:00Z',
+ last_push_at: null,
+ last_push_error: null,
+ httpStatus: 201,
+ })
+ expect(fetchMock).toHaveBeenCalledWith(
+ 'http://localhost:8081/api/v1/mobile/devices',
+ expect.objectContaining({
+ method: 'POST',
+ body: JSON.stringify({
+ platform: 'ios',
+ apns_token: 'raw-apns-token',
+ environment: 'sandbox',
+ bundle_id: 'com.levneiman.homesec',
+ device_name: "Lev's iPhone",
+ app_version: '1.0.0',
+ capabilities: { deep_links: true, rich_notifications: false },
+ }),
+ }),
+ )
+ })
+})
+
describe('HomeSecApiClient.getHealth', () => {
afterEach(() => {
vi.restoreAllMocks()
diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts
index 889086d5..73ec699c 100644
--- a/ui/src/api/client.ts
+++ b/ui/src/api/client.ts
@@ -36,9 +36,21 @@ import type {
RuntimeStatusResponse,
SetupStatusResponse,
StatsResponse,
+ MobileDeviceRegisterRequest,
+ MobileDeviceResponse,
} from './generated/types'
+import { isIOSNativeApp } from '../runtime/nativeRuntime'
import { JsonHttpClient } from './http'
+import { createBrowserServerBaseUrlProvider } from './serverBaseUrlProvider'
+import { NativeServerBaseUrlProvider } from './serverBaseUrlProvider'
+import type { ClientServerBaseUrlProvider } from './serverBaseUrlProvider'
+import {
+ hasAuthToken,
+ nativeAuthTokenProvider,
+ runtimeAuthTokenProvider,
+} from './tokenProvider'
+import type { AuthTokenProvider } from './tokenProvider'
import type { ApiSnapshot, ClipMediaTokenResponsePayload } from './parsing'
import {
parseCameraListResponse,
@@ -61,6 +73,7 @@ import {
parsePreflightResponse,
parsePostgresBackupRunResponse,
parsePostgresBackupStatusResponse,
+ parseMobileDeviceResponse,
parseTestConnectionResponse,
parseRuntimeReloadResponse,
parseRuntimeStatusResponse,
@@ -72,6 +85,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
@@ -93,12 +111,16 @@ export type FinalizeSnapshot = ApiSnapshot
export type PreflightSnapshot = ApiSnapshot
export type TestConnectionSnapshot = ApiSnapshot
export type ClipMediaTokenSnapshot = ApiSnapshot
+export type MobileDeviceSnapshot = ApiSnapshot
export class HomeSecApiClient implements GeneratedHomeSecClient {
private readonly httpClient: JsonHttpClient
- 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 {
@@ -597,12 +619,88 @@ export class HomeSecApiClient implements GeneratedHomeSecClient {
}
}
+ async registerMobileDevice(
+ payload: MobileDeviceRegisterRequest,
+ options: ApiRequestOptions = {},
+ ): Promise {
+ const response = await this.httpClient.requestJson('/api/v1/mobile/devices', {
+ ...options,
+ method: 'POST',
+ body: payload,
+ })
+
+ try {
+ return withHttpStatus(parseMobileDeviceResponse(response.payload), response.status)
+ } catch {
+ throw new APIError(
+ 'Invalid mobile device response payload',
+ response.status,
+ response.payload,
+ null,
+ )
+ }
+ }
+
resolvePath(path: string): string {
return this.httpClient.resolvePath(path)
}
}
-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 nativeServerBaseUrlProvider = new NativeServerBaseUrlProvider()
+export const runtimeServerBaseUrlProvider: ClientServerBaseUrlProvider = isIOSNativeApp()
+ ? nativeServerBaseUrlProvider
+ : browserServerBaseUrlProvider
+
+export const apiClient = new HomeSecApiClient(
+ DEFAULT_API_BASE_URL,
+ {
+ authTokenProvider: runtimeAuthTokenProvider,
+ serverBaseUrlProvider: runtimeServerBaseUrlProvider,
+ },
+)
+
+export async function hydrateRuntimeApiProviders(): Promise {
+ if (!isIOSNativeApp()) {
+ return
+ }
+
+ await Promise.all([
+ nativeAuthTokenProvider.hydrate(),
+ nativeServerBaseUrlProvider.hydrate(),
+ ])
+}
+
+export function hasConfiguredApiToken(): boolean {
+ return hasAuthToken(runtimeAuthTokenProvider)
+}
export { APIError, isAPIError, isUnauthorizedAPIError } from './errors'
export { clearApiKey, getStoredApiKey, hasStoredApiKey, saveApiKey } from './apiKeyStorage'
+export {
+ BROWSER_SERVER_BASE_URL_STORAGE_KEY,
+ BrowserServerBaseUrlProvider,
+ createBrowserServerBaseUrlProvider,
+ NativeServerBaseUrlProvider,
+ normalizeServerBaseUrl,
+} from './serverBaseUrlProvider'
+export {
+ BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY,
+ BROWSER_AUTH_TOKEN_STORAGE_KEY,
+ BrowserAuthTokenProvider,
+ browserAuthTokenProvider,
+ clearRuntimeAuthSessionReady,
+ hasAuthToken,
+ InMemoryAuthTokenProvider,
+ isRuntimeAuthSessionReady,
+ markRuntimeAuthSessionReady,
+ NativeAuthTokenProvider,
+ nativeAuthTokenProvider,
+ normalizeAuthToken,
+ persistRuntimeAuthSessionReady,
+ runtimeAuthTokenProvider,
+} from './tokenProvider'
+export type { AuthTokenProvider, SyncAuthTokenProvider } from './tokenProvider'
+export type { ClientServerBaseUrlProvider, ServerBaseUrlProvider } from './serverBaseUrlProvider'
diff --git a/ui/src/api/generated/client.ts b/ui/src/api/generated/client.ts
index 33f82d20..426abc80 100644
--- a/ui/src/api/generated/client.ts
+++ b/ui/src/api/generated/client.ts
@@ -36,6 +36,8 @@ import type {
PostgresBackupStatusResponse,
SetupStatusResponse,
StatsResponse,
+ MobileDeviceRegisterRequest,
+ MobileDeviceResponse,
} from './types'
export interface ApiRequestOptions {
@@ -124,4 +126,8 @@ export interface GeneratedHomeSecClient {
options?: ApiRequestOptions,
): Promise>
getClip(clipId: string, options?: ApiRequestOptions): Promise>
+ registerMobileDevice(
+ payload: MobileDeviceRegisterRequest,
+ options?: ApiRequestOptions,
+ ): Promise>
}
diff --git a/ui/src/api/generated/openapi.json b/ui/src/api/generated/openapi.json
index 1820d6cb..e2cd1457 100644
--- a/ui/src/api/generated/openapi.json
+++ b/ui/src/api/generated/openapi.json
@@ -1042,6 +1042,248 @@
"title": "MediaProfileResponse",
"type": "object"
},
+ "MobileDeviceCapabilities": {
+ "description": "Feature flags reported by the current iOS app build.",
+ "properties": {
+ "deep_links": {
+ "default": true,
+ "title": "Deep Links",
+ "type": "boolean"
+ },
+ "rich_notifications": {
+ "default": false,
+ "title": "Rich Notifications",
+ "type": "boolean"
+ }
+ },
+ "title": "MobileDeviceCapabilities",
+ "type": "object"
+ },
+ "MobileDevicePatchRequest": {
+ "properties": {
+ "app_version": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "App Version"
+ },
+ "capabilities": {
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/MobileDeviceCapabilities"
+ },
+ {
+ "type": "null"
+ }
+ ]
+ },
+ "device_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Device Name"
+ },
+ "enabled": {
+ "anyOf": [
+ {
+ "type": "boolean"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Enabled"
+ }
+ },
+ "title": "MobileDevicePatchRequest",
+ "type": "object"
+ },
+ "MobileDeviceRegisterRequest": {
+ "properties": {
+ "apns_token": {
+ "minLength": 1,
+ "title": "Apns Token",
+ "type": "string"
+ },
+ "app_version": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "App Version"
+ },
+ "bundle_id": {
+ "minLength": 1,
+ "title": "Bundle Id",
+ "type": "string"
+ },
+ "capabilities": {
+ "$ref": "#/components/schemas/MobileDeviceCapabilities"
+ },
+ "device_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Device Name"
+ },
+ "environment": {
+ "enum": [
+ "sandbox",
+ "production"
+ ],
+ "title": "Environment",
+ "type": "string"
+ },
+ "platform": {
+ "const": "ios",
+ "default": "ios",
+ "title": "Platform",
+ "type": "string"
+ }
+ },
+ "required": [
+ "apns_token",
+ "environment",
+ "bundle_id"
+ ],
+ "title": "MobileDeviceRegisterRequest",
+ "type": "object"
+ },
+ "MobileDeviceResponse": {
+ "properties": {
+ "app_version": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "App Version"
+ },
+ "bundle_id": {
+ "title": "Bundle Id",
+ "type": "string"
+ },
+ "capabilities": {
+ "$ref": "#/components/schemas/MobileDeviceCapabilities"
+ },
+ "created_at": {
+ "format": "date-time",
+ "title": "Created At",
+ "type": "string"
+ },
+ "device_name": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Device Name"
+ },
+ "enabled": {
+ "title": "Enabled",
+ "type": "boolean"
+ },
+ "environment": {
+ "enum": [
+ "sandbox",
+ "production"
+ ],
+ "title": "Environment",
+ "type": "string"
+ },
+ "id": {
+ "title": "Id",
+ "type": "string"
+ },
+ "last_push_at": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Push At"
+ },
+ "last_push_error": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Push Error"
+ },
+ "last_seen_at": {
+ "anyOf": [
+ {
+ "format": "date-time",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Last Seen At"
+ },
+ "platform": {
+ "const": "ios",
+ "title": "Platform",
+ "type": "string"
+ },
+ "token_fingerprint": {
+ "title": "Token Fingerprint",
+ "type": "string"
+ },
+ "updated_at": {
+ "format": "date-time",
+ "title": "Updated At",
+ "type": "string"
+ }
+ },
+ "required": [
+ "id",
+ "platform",
+ "environment",
+ "bundle_id",
+ "capabilities",
+ "enabled",
+ "token_fingerprint",
+ "created_at",
+ "updated_at"
+ ],
+ "title": "MobileDeviceResponse",
+ "type": "object"
+ },
"NotifierConfig": {
"description": "Notifier configuration entry.",
"properties": {
@@ -2925,6 +3167,188 @@
]
}
},
+ "/api/v1/mobile/devices": {
+ "get": {
+ "description": "List registered iOS devices without raw APNs material.",
+ "operationId": "list_mobile_devices_api_v1_mobile_devices_get",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "include_disabled",
+ "required": false,
+ "schema": {
+ "default": false,
+ "title": "Include Disabled",
+ "type": "boolean"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "items": {
+ "$ref": "#/components/schemas/MobileDeviceResponse"
+ },
+ "title": "Response List Mobile Devices Api V1 Mobile Devices Get",
+ "type": "array"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "List Mobile Devices",
+ "tags": [
+ "mobile"
+ ]
+ },
+ "post": {
+ "description": "Register or refresh an iOS APNs device.",
+ "operationId": "register_mobile_device_api_v1_mobile_devices_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MobileDeviceRegisterRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MobileDeviceResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Register Mobile Device",
+ "tags": [
+ "mobile"
+ ]
+ }
+ },
+ "/api/v1/mobile/devices/{device_id}": {
+ "delete": {
+ "description": "Disable a mobile device without hard-deleting it.",
+ "operationId": "delete_mobile_device_api_v1_mobile_devices__device_id__delete",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "device_id",
+ "required": true,
+ "schema": {
+ "title": "Device Id",
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MobileDeviceResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Delete Mobile Device",
+ "tags": [
+ "mobile"
+ ]
+ },
+ "patch": {
+ "description": "Update mutable mobile device metadata or enabled state.",
+ "operationId": "update_mobile_device_api_v1_mobile_devices__device_id__patch",
+ "parameters": [
+ {
+ "in": "path",
+ "name": "device_id",
+ "required": true,
+ "schema": {
+ "title": "Device Id",
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MobileDevicePatchRequest"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/MobileDeviceResponse"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Update Mobile Device",
+ "tags": [
+ "mobile"
+ ]
+ }
+ },
"/api/v1/onvif/discover": {
"post": {
"description": "Trigger WS-Discovery scan and return discovered ONVIF cameras.",
diff --git a/ui/src/api/generated/schema.ts b/ui/src/api/generated/schema.ts
index fa7efd94..4eb4c280 100644
--- a/ui/src/api/generated/schema.ts
+++ b/ui/src/api/generated/schema.ts
@@ -243,6 +243,54 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/api/v1/mobile/devices": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * List Mobile Devices
+ * @description List registered iOS devices without raw APNs material.
+ */
+ get: operations["list_mobile_devices_api_v1_mobile_devices_get"];
+ put?: never;
+ /**
+ * Register Mobile Device
+ * @description Register or refresh an iOS APNs device.
+ */
+ post: operations["register_mobile_device_api_v1_mobile_devices_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/api/v1/mobile/devices/{device_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ post?: never;
+ /**
+ * Delete Mobile Device
+ * @description Disable a mobile device without hard-deleting it.
+ */
+ delete: operations["delete_mobile_device_api_v1_mobile_devices__device_id__delete"];
+ options?: never;
+ head?: never;
+ /**
+ * Update Mobile Device
+ * @description Update mutable mobile device metadata or enabled state.
+ */
+ patch: operations["update_mobile_device_api_v1_mobile_devices__device_id__patch"];
+ trace?: never;
+ };
"/api/v1/onvif/discover": {
parameters: {
query?: never;
@@ -966,6 +1014,97 @@ export interface components {
/** Width */
width: number | null;
};
+ /**
+ * MobileDeviceCapabilities
+ * @description Feature flags reported by the current iOS app build.
+ */
+ MobileDeviceCapabilities: {
+ /**
+ * Deep Links
+ * @default true
+ */
+ deep_links: boolean;
+ /**
+ * Rich Notifications
+ * @default false
+ */
+ rich_notifications: boolean;
+ };
+ /** MobileDevicePatchRequest */
+ MobileDevicePatchRequest: {
+ /** App Version */
+ app_version?: string | null;
+ capabilities?: components["schemas"]["MobileDeviceCapabilities"] | null;
+ /** Device Name */
+ device_name?: string | null;
+ /** Enabled */
+ enabled?: boolean | null;
+ };
+ /** MobileDeviceRegisterRequest */
+ MobileDeviceRegisterRequest: {
+ /** Apns Token */
+ apns_token: string;
+ /** App Version */
+ app_version?: string | null;
+ /** Bundle Id */
+ bundle_id: string;
+ capabilities?: components["schemas"]["MobileDeviceCapabilities"];
+ /** Device Name */
+ device_name?: string | null;
+ /**
+ * Environment
+ * @enum {string}
+ */
+ environment: "sandbox" | "production";
+ /**
+ * Platform
+ * @default ios
+ * @constant
+ */
+ platform: "ios";
+ };
+ /** MobileDeviceResponse */
+ MobileDeviceResponse: {
+ /** App Version */
+ app_version?: string | null;
+ /** Bundle Id */
+ bundle_id: string;
+ capabilities: components["schemas"]["MobileDeviceCapabilities"];
+ /**
+ * Created At
+ * Format: date-time
+ */
+ created_at: string;
+ /** Device Name */
+ device_name?: string | null;
+ /** Enabled */
+ enabled: boolean;
+ /**
+ * Environment
+ * @enum {string}
+ */
+ environment: "sandbox" | "production";
+ /** Id */
+ id: string;
+ /** Last Push At */
+ last_push_at?: string | null;
+ /** Last Push Error */
+ last_push_error?: string | null;
+ /** Last Seen At */
+ last_seen_at?: string | null;
+ /**
+ * Platform
+ * @constant
+ */
+ platform: "ios";
+ /** Token Fingerprint */
+ token_fingerprint: string;
+ /**
+ * Updated At
+ * Format: date-time
+ */
+ updated_at: string;
+ };
/**
* NotifierConfig
* @description Notifier configuration entry.
@@ -1841,6 +1980,136 @@ export interface operations {
};
};
};
+ list_mobile_devices_api_v1_mobile_devices_get: {
+ parameters: {
+ query?: {
+ include_disabled?: boolean;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["MobileDeviceResponse"][];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ register_mobile_device_api_v1_mobile_devices_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["MobileDeviceRegisterRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["MobileDeviceResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ delete_mobile_device_api_v1_mobile_devices__device_id__delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ device_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["MobileDeviceResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ update_mobile_device_api_v1_mobile_devices__device_id__patch: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ device_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["MobileDevicePatchRequest"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["MobileDeviceResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
discover_onvif_cameras_api_v1_onvif_discover_post: {
parameters: {
query?: never;
diff --git a/ui/src/api/generated/types.ts b/ui/src/api/generated/types.ts
index ccb6d206..32c3afd4 100644
--- a/ui/src/api/generated/types.ts
+++ b/ui/src/api/generated/types.ts
@@ -47,4 +47,6 @@ export type ProbeRequest = components["schemas"]["ProbeRequest"]
export type ProbeResponse = components["schemas"]["ProbeResponse"]
export type MediaProfileResponse = components["schemas"]["MediaProfileResponse"]
export type DeviceInfoResponse = components["schemas"]["DeviceInfoResponse"]
+export type MobileDeviceRegisterRequest = components["schemas"]["MobileDeviceRegisterRequest"]
+export type MobileDeviceResponse = components["schemas"]["MobileDeviceResponse"]
export type ListClipsQuery = NonNullable
diff --git a/ui/src/api/homeSecAuthPlugin.ts b/ui/src/api/homeSecAuthPlugin.ts
new file mode 100644
index 00000000..47849eef
--- /dev/null
+++ b/ui/src/api/homeSecAuthPlugin.ts
@@ -0,0 +1,23 @@
+import { registerPlugin } from '@capacitor/core'
+
+export interface HomeSecAuthStoredValue {
+ value: string | null
+}
+
+export interface HomeSecAuthStoredFlag {
+ value: boolean
+}
+
+export interface HomeSecAuthPlugin {
+ getServerBaseUrl(): Promise
+ setServerBaseUrl(input: { value: string }): Promise
+ clearServerBaseUrl(): Promise
+ getApiToken(): Promise
+ setApiToken(input: { value: string }): Promise
+ clearApiToken(): Promise
+ getAuthDisabledReady(): Promise
+ setAuthDisabledReady(input: { value: boolean }): Promise
+ clearAuthDisabledReady(): Promise
+}
+
+export const homeSecAuthPlugin = registerPlugin('HomeSecAuth')
diff --git a/ui/src/api/hooks/useClipMediaUrl.ts b/ui/src/api/hooks/useClipMediaUrl.ts
index b04c033c..d1672ec2 100644
--- a/ui/src/api/hooks/useClipMediaUrl.ts
+++ b/ui/src/api/hooks/useClipMediaUrl.ts
@@ -1,7 +1,7 @@
import { useCallback, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
-import { apiClient, hasStoredApiKey } from '../client'
+import { apiClient, hasConfiguredApiToken } from '../client'
import type { ClipMediaTokenSnapshot } from '../client'
import { QUERY_KEYS } from './queryKeys'
@@ -45,7 +45,7 @@ export function computeTokenRefreshDelayMs(
export function useClipMediaUrl(clipId: string | undefined): ClipMediaUrlState {
const directMediaUrl = clipId ? apiClient.resolvePath(buildDirectMediaPath(clipId)) : null
- const shouldRequestToken = Boolean(clipId) && hasStoredApiKey()
+ const shouldRequestToken = Boolean(clipId) && hasConfiguredApiToken()
const tokenQuery = useQuery({
queryKey: QUERY_KEYS.clipMediaToken(clipId),
diff --git a/ui/src/api/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/parsing.ts b/ui/src/api/parsing.ts
index cbff4368..f7dc951b 100644
--- a/ui/src/api/parsing.ts
+++ b/ui/src/api/parsing.ts
@@ -31,6 +31,7 @@ import type {
RuntimeStatusResponse,
SetupStatusResponse,
StatsResponse,
+ MobileDeviceResponse,
} from './generated/types'
type JsonObject = Record
@@ -700,6 +701,60 @@ export function parseClipMediaTokenResponse(payload: unknown): ClipMediaTokenRes
}
}
+function parseMobileDeviceCapabilities(payload: unknown): MobileDeviceResponse['capabilities'] {
+ if (!isJsonObject(payload)) {
+ throw new Error('capabilities must be an object')
+ }
+
+ return {
+ deep_links: expectBoolean(payload.deep_links, 'capabilities.deep_links'),
+ rich_notifications: expectBoolean(
+ payload.rich_notifications,
+ 'capabilities.rich_notifications',
+ ),
+ }
+}
+
+function parseMobilePlatform(value: unknown, fieldName: string): MobileDeviceResponse['platform'] {
+ if (value === 'ios') {
+ return value
+ }
+ throw new Error(`${fieldName} must be ios`)
+}
+
+function parseAPNSEnvironment(
+ value: unknown,
+ fieldName: string,
+): MobileDeviceResponse['environment'] {
+ if (value === 'sandbox' || value === 'production') {
+ return value
+ }
+ throw new Error(`${fieldName} must be sandbox or production`)
+}
+
+export function parseMobileDeviceResponse(payload: unknown): MobileDeviceResponse {
+ if (!isJsonObject(payload)) {
+ throw new Error('Mobile device response is not a JSON object')
+ }
+
+ return {
+ id: expectString(payload.id, 'id'),
+ platform: parseMobilePlatform(payload.platform, 'platform'),
+ environment: parseAPNSEnvironment(payload.environment, 'environment'),
+ bundle_id: expectString(payload.bundle_id, 'bundle_id'),
+ device_name: expectNullableString(payload.device_name, 'device_name'),
+ app_version: expectNullableString(payload.app_version, 'app_version'),
+ capabilities: parseMobileDeviceCapabilities(payload.capabilities),
+ enabled: expectBoolean(payload.enabled, 'enabled'),
+ token_fingerprint: expectString(payload.token_fingerprint, 'token_fingerprint'),
+ created_at: expectString(payload.created_at, 'created_at'),
+ updated_at: expectString(payload.updated_at, 'updated_at'),
+ last_seen_at: expectNullableString(payload.last_seen_at, 'last_seen_at'),
+ last_push_at: expectNullableString(payload.last_push_at, 'last_push_at'),
+ last_push_error: expectNullableString(payload.last_push_error, 'last_push_error'),
+ }
+}
+
export function withHttpStatus(
payload: TPayload,
status: number,
diff --git a/ui/src/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..56e7ae2f
--- /dev/null
+++ b/ui/src/api/runtimeConfig.ts
@@ -0,0 +1,42 @@
+import { hydrateRuntimeApiProviders, runtimeServerBaseUrlProvider } 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 = runtimeServerBaseUrlProvider,
+}: InitializeApiRuntimeConfigOptions = {}): Promise {
+ await hydrateRuntimeApiProviders()
+ const config = await runtimeConfigSource.loadRuntimeConfig()
+ if (Object.prototype.hasOwnProperty.call(config, 'serverBaseUrl')) {
+ await serverBaseUrlProvider.setBaseUrl(config.serverBaseUrl ?? null)
+ }
+}
diff --git a/ui/src/api/serverBaseUrlProvider.test.ts b/ui/src/api/serverBaseUrlProvider.test.ts
new file mode 100644
index 00000000..59f9825a
--- /dev/null
+++ b/ui/src/api/serverBaseUrlProvider.test.ts
@@ -0,0 +1,125 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ BROWSER_SERVER_BASE_URL_STORAGE_KEY,
+ BrowserServerBaseUrlProvider,
+ NativeServerBaseUrlProvider,
+ normalizeServerBaseUrl,
+} from './serverBaseUrlProvider'
+import type { HomeSecAuthPlugin } from './homeSecAuthPlugin'
+
+type TestStorage = Pick & {
+ values: Map
+}
+
+function createNativePluginMock(initialBaseUrl: string | null = null): HomeSecAuthPlugin {
+ let baseUrl = initialBaseUrl
+ return {
+ getServerBaseUrl: async () => ({ value: baseUrl }),
+ setServerBaseUrl: async ({ value }) => {
+ baseUrl = value
+ },
+ clearServerBaseUrl: async () => {
+ baseUrl = null
+ },
+ getApiToken: async () => ({ value: null }),
+ setApiToken: async () => {},
+ clearApiToken: async () => {},
+ getAuthDisabledReady: async () => ({ value: false }),
+ setAuthDisabledReady: async () => {},
+ clearAuthDisabledReady: async () => {},
+ }
+}
+
+function createStorage(): TestStorage {
+ const values = new Map()
+ return {
+ 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/'
+ const pathUrl = 'https://homesec.example.com/homesec///'
+
+ // When / Then: URLs are trimmed and empty values stay unset
+ expect(normalizeServerBaseUrl(lanUrl)).toBe('http://192.168.1.10:8081')
+ expect(normalizeServerBaseUrl(httpsUrl)).toBe('https://homesec.example.com')
+ expect(normalizeServerBaseUrl(pathUrl)).toBe('https://homesec.example.com/homesec')
+ expect(normalizeServerBaseUrl(' ')).toBeNull()
+ expect(normalizeServerBaseUrl(null)).toBeNull()
+ })
+})
+
+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('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 and then clearing it
+ await provider.setBaseUrl(' ')
+ 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 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)
+ })
+})
+
+describe('NativeServerBaseUrlProvider', () => {
+ it('hydrates, updates, and clears server URL values through the native bridge', async () => {
+ // Given: A native bridge with a stored HomeSec server URL
+ const plugin = createNativePluginMock(' http://192.168.1.10:8081/// ')
+ const provider = new NativeServerBaseUrlProvider(plugin)
+
+ // When: Hydrating, updating, and clearing the native URL cache
+ await provider.hydrate()
+ const hydrated = provider.getBaseUrlSync()
+ await provider.setBaseUrl('https://homesec.example.com/')
+ const updated = await provider.getBaseUrl()
+ await provider.clearBaseUrl()
+ const cleared = provider.getBaseUrlSync()
+
+ // Then: Values are normalized and remain available synchronously after hydration
+ expect(hydrated).toBe('http://192.168.1.10:8081')
+ expect(updated).toBe('https://homesec.example.com')
+ expect(cleared).toBeNull()
+ })
+})
diff --git a/ui/src/api/serverBaseUrlProvider.ts b/ui/src/api/serverBaseUrlProvider.ts
new file mode 100644
index 00000000..3a204086
--- /dev/null
+++ b/ui/src/api/serverBaseUrlProvider.ts
@@ -0,0 +1,149 @@
+import { homeSecAuthPlugin, type HomeSecAuthPlugin } from './homeSecAuthPlugin'
+
+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?.getItem(this.storageKey)
+ if (stored !== null && stored !== undefined) {
+ return normalizeServerBaseUrl(stored)
+ }
+
+ return this.fallbackBaseUrl
+ }
+
+ setBaseUrlSync(value: string | null): void {
+ const storage = this.getStorage()
+ if (!storage) {
+ return
+ }
+
+ if (value === null) {
+ storage.removeItem(this.storageKey)
+ return
+ }
+
+ storage.setItem(this.storageKey, normalizeServerBaseUrl(value) ?? '')
+ }
+
+ 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 class NativeServerBaseUrlProvider implements ClientServerBaseUrlProvider {
+ private baseUrl: string | null = null
+ private hydrated = false
+ private readonly plugin: HomeSecAuthPlugin
+
+ constructor(plugin: HomeSecAuthPlugin = homeSecAuthPlugin) {
+ this.plugin = plugin
+ }
+
+ async hydrate(): Promise {
+ const result = await this.plugin.getServerBaseUrl()
+ this.baseUrl = normalizeServerBaseUrl(result.value)
+ this.hydrated = true
+ }
+
+ getBaseUrlSync(): string | null {
+ return this.baseUrl
+ }
+
+ async getBaseUrl(): Promise {
+ if (!this.hydrated) {
+ await this.hydrate()
+ }
+
+ return this.getBaseUrlSync()
+ }
+
+ async setBaseUrl(value: string | null): Promise {
+ const normalized = normalizeServerBaseUrl(value)
+ if (normalized) {
+ await this.plugin.setServerBaseUrl({ value: normalized })
+ } else {
+ await this.plugin.clearServerBaseUrl()
+ }
+
+ this.baseUrl = normalized
+ this.hydrated = true
+ }
+
+ async clearBaseUrl(): Promise {
+ await this.plugin.clearServerBaseUrl()
+ this.baseUrl = null
+ this.hydrated = true
+ }
+}
+
+export function createBrowserServerBaseUrlProvider(
+ fallbackBaseUrl: string | null | undefined,
+): BrowserServerBaseUrlProvider {
+ 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..bcde7ade
--- /dev/null
+++ b/ui/src/api/tokenProvider.test.ts
@@ -0,0 +1,269 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import {
+ BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY,
+ BROWSER_AUTH_TOKEN_STORAGE_KEY,
+ BrowserAuthTokenProvider,
+ InMemoryAuthTokenProvider,
+ NativeAuthTokenProvider,
+ normalizeAuthToken,
+ resolveAuthToken,
+ type AuthTokenProvider,
+} from './tokenProvider'
+import type { HomeSecAuthPlugin } from './homeSecAuthPlugin'
+
+type TestStorage = Pick & {
+ values: Map
+}
+
+function createNativePluginMock(
+ initial: { token?: string | null; authDisabledReady?: boolean } = {},
+): HomeSecAuthPlugin {
+ let token = initial.token ?? null
+ let authDisabledReady = initial.authDisabledReady ?? false
+ return {
+ getServerBaseUrl: vi.fn(async () => ({ value: 'https://homesec.example.com' })),
+ setServerBaseUrl: vi.fn(async () => {}),
+ clearServerBaseUrl: vi.fn(async () => {}),
+ getApiToken: vi.fn(async () => ({ value: token })),
+ setApiToken: vi.fn(async ({ value }) => {
+ token = value
+ }),
+ clearApiToken: vi.fn(async () => {
+ token = null
+ }),
+ getAuthDisabledReady: vi.fn(async () => ({ value: authDisabledReady })),
+ setAuthDisabledReady: vi.fn(async ({ value }) => {
+ authDisabledReady = value
+ }),
+ clearAuthDisabledReady: vi.fn(async () => {
+ authDisabledReady = false
+ }),
+ }
+}
+
+function createStorage(): TestStorage {
+ const values = new Map()
+ return {
+ 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 installWindowSessionStorageMock(): TestStorage {
+ const storage = createStorage()
+ vi.stubGlobal('window', {
+ sessionStorage: {
+ getItem: storage.getItem,
+ setItem: storage.setItem,
+ removeItem: storage.removeItem,
+ clear: (): void => {
+ storage.values.clear()
+ },
+ },
+ })
+ return storage
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.doUnmock('../runtime/nativeRuntime')
+})
+
+describe('BrowserAuthTokenProvider', () => {
+ it('sets, gets, and clears token values from storage', async () => {
+ // Given: A browser token provider backed by session storage
+ 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('InMemoryAuthTokenProvider', () => {
+ it('keeps token values in memory only', async () => {
+ // Given: A native-runtime token provider without browser storage
+ const provider = new InMemoryAuthTokenProvider()
+
+ // When: Persisting and then clearing a token
+ await provider.setToken(' native-secret ')
+ const stored = await provider.getToken()
+ await provider.clearToken()
+ const cleared = await provider.getToken()
+
+ // Then: Token values are normalized without depending on session storage
+ expect(stored).toBe('native-secret')
+ expect(provider.getTokenSync()).toBeNull()
+ expect(cleared).toBeNull()
+ })
+})
+
+describe('NativeAuthTokenProvider', () => {
+ it('hydrates token and auth-disabled readiness from the native bridge', async () => {
+ // Given: A native bridge with stored token and auth-disabled state
+ const plugin = createNativePluginMock({
+ token: ' native-secret ',
+ authDisabledReady: true,
+ })
+ const provider = new NativeAuthTokenProvider(plugin)
+
+ // When: Hydrating the native provider
+ await provider.hydrate()
+
+ // Then: Token and readiness are cached for synchronous route guards
+ expect(provider.getTokenSync()).toBe('native-secret')
+ expect(provider.isAuthDisabledReadySync()).toBe(true)
+ })
+
+ it('sets and clears native token values through the bridge', async () => {
+ // Given: A native provider backed by a bridge plugin
+ const plugin = createNativePluginMock()
+ const provider = new NativeAuthTokenProvider(plugin)
+
+ // When: Persisting then clearing a native token
+ await provider.setToken(' native-secret ')
+ const stored = provider.getTokenSync()
+ await provider.clearToken()
+ const cleared = provider.getTokenSync()
+
+ // Then: Writes go through the native bridge and update the sync cache
+ expect(plugin.setApiToken).toHaveBeenCalledWith({ value: 'native-secret' })
+ expect(stored).toBe('native-secret')
+ expect(plugin.clearApiToken).toHaveBeenCalledTimes(1)
+ expect(cleared).toBeNull()
+ })
+})
+
+describe('runtime auth session readiness', () => {
+ it('persists auth-disabled readiness without a token in native iOS mode', async () => {
+ // Given: The runtime is loaded in native iOS mode with a native auth bridge
+ vi.resetModules()
+ const storage = installWindowSessionStorageMock()
+ const nativePlugin = createNativePluginMock()
+ vi.doMock('./homeSecAuthPlugin', () => ({
+ homeSecAuthPlugin: nativePlugin,
+ }))
+ vi.doMock('../runtime/nativeRuntime', () => ({
+ isIOSNativeApp: () => true,
+ }))
+ const tokenProvider = await import('./tokenProvider')
+
+ // When: Setup persists an auth-disabled server as ready
+ await tokenProvider.persistRuntimeAuthSessionReady({ persistAuthDisabled: true })
+ const readyBeforeReload = tokenProvider.isRuntimeAuthSessionReady()
+ vi.resetModules()
+ vi.doMock('./homeSecAuthPlugin', () => ({
+ homeSecAuthPlugin: nativePlugin,
+ }))
+ const reloadedTokenProvider = await import('./tokenProvider')
+ await reloadedTokenProvider.nativeAuthTokenProvider.hydrate()
+ const readyAfterReload = reloadedTokenProvider.isRuntimeAuthSessionReady()
+
+ // Then: Readiness survives reload without persisting an API token
+ expect(readyBeforeReload).toBe(true)
+ expect(readyAfterReload).toBe(true)
+ expect(storage.values.get(BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY)).toBe('true')
+ expect(reloadedTokenProvider.nativeAuthTokenProvider.getTokenSync()).toBeNull()
+ })
+
+ it('hydrates protected native sessions after reload', async () => {
+ // Given: The runtime is loaded in native iOS mode with a native auth bridge
+ vi.resetModules()
+ const storage = installWindowSessionStorageMock()
+ const nativePlugin = createNativePluginMock()
+ vi.doMock('./homeSecAuthPlugin', () => ({
+ homeSecAuthPlugin: nativePlugin,
+ }))
+ vi.doMock('../runtime/nativeRuntime', () => ({
+ isIOSNativeApp: () => true,
+ }))
+ const tokenProvider = await import('./tokenProvider')
+
+ // When: Setup persists a protected server token through native storage
+ await tokenProvider.runtimeAuthTokenProvider.setToken('native-token')
+ await tokenProvider.persistRuntimeAuthSessionReady({ persistAuthDisabled: false })
+ const readyBeforeReload = tokenProvider.isRuntimeAuthSessionReady()
+ vi.resetModules()
+ vi.doMock('./homeSecAuthPlugin', () => ({
+ homeSecAuthPlugin: nativePlugin,
+ }))
+ const reloadedTokenProvider = await import('./tokenProvider')
+ await reloadedTokenProvider.nativeAuthTokenProvider.hydrate()
+ const readyAfterReload = reloadedTokenProvider.isRuntimeAuthSessionReady()
+
+ // Then: Token-backed readiness survives reload through the native bridge
+ expect(readyBeforeReload).toBe(true)
+ expect(readyAfterReload).toBe(true)
+ expect(storage.values.has(BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY)).toBe(false)
+ expect(reloadedTokenProvider.nativeAuthTokenProvider.getTokenSync()).toBe('native-token')
+ })
+})
+
+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..e1e2c7cc
--- /dev/null
+++ b/ui/src/api/tokenProvider.ts
@@ -0,0 +1,302 @@
+import type { ApiRequestOptions } from './generated/client'
+
+import { isIOSNativeApp } from '../runtime/nativeRuntime'
+import { homeSecAuthPlugin, type HomeSecAuthPlugin } from './homeSecAuthPlugin'
+
+export const BROWSER_AUTH_TOKEN_STORAGE_KEY = 'homesec.apiKey'
+export const BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY =
+ 'homesec.authDisabledSessionReady'
+
+export interface AuthTokenProvider {
+ getToken(): Promise
+ setToken(token: string | null): Promise
+ clearToken(): Promise
+}
+
+export interface SyncAuthTokenProvider extends AuthTokenProvider {
+ getTokenSync(): string | null
+ setTokenSync(token: string | null): void
+ clearTokenSync(): void
+}
+
+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 SyncAuthTokenProvider {
+ 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 class InMemoryAuthTokenProvider implements SyncAuthTokenProvider {
+ private token: string | null = null
+
+ getTokenSync(): string | null {
+ return this.token
+ }
+
+ setTokenSync(token: string | null): void {
+ this.token = normalizeAuthToken(token)
+ }
+
+ clearTokenSync(): void {
+ this.token = null
+ }
+
+ async getToken(): Promise {
+ return this.getTokenSync()
+ }
+
+ async setToken(token: string | null): Promise {
+ this.setTokenSync(token)
+ }
+
+ async clearToken(): Promise {
+ this.clearTokenSync()
+ }
+}
+
+export class NativeAuthTokenProvider implements SyncAuthTokenProvider {
+ private authDisabledReady = false
+ private hydrated = false
+ private readonly plugin: HomeSecAuthPlugin
+ private token: string | null = null
+
+ constructor(plugin: HomeSecAuthPlugin = homeSecAuthPlugin) {
+ this.plugin = plugin
+ }
+
+ async hydrate(): Promise {
+ const [tokenResult, authDisabledResult] = await Promise.all([
+ this.plugin.getApiToken(),
+ this.plugin.getAuthDisabledReady(),
+ ])
+ this.token = normalizeAuthToken(tokenResult.value)
+ this.authDisabledReady = authDisabledResult.value
+ this.hydrated = true
+ }
+
+ getTokenSync(): string | null {
+ return this.token
+ }
+
+ setTokenSync(token: string | null): void {
+ this.token = normalizeAuthToken(token)
+ this.hydrated = true
+ }
+
+ clearTokenSync(): void {
+ this.token = null
+ this.hydrated = true
+ }
+
+ async getToken(): Promise {
+ if (!this.hydrated) {
+ await this.hydrate()
+ }
+
+ return this.getTokenSync()
+ }
+
+ async setToken(token: string | null): Promise {
+ const normalized = normalizeAuthToken(token)
+ if (normalized) {
+ await this.plugin.setApiToken({ value: normalized })
+ } else {
+ await this.plugin.clearApiToken()
+ }
+
+ this.token = normalized
+ this.hydrated = true
+ }
+
+ async clearToken(): Promise {
+ await this.plugin.clearApiToken()
+ this.clearTokenSync()
+ }
+
+ isAuthDisabledReadySync(): boolean {
+ return this.authDisabledReady
+ }
+
+ async setAuthDisabledReady(ready: boolean): Promise {
+ if (ready) {
+ await this.plugin.setAuthDisabledReady({ value: true })
+ } else {
+ await this.plugin.clearAuthDisabledReady()
+ }
+
+ this.authDisabledReady = ready
+ this.hydrated = true
+ }
+
+ setAuthDisabledReadySync(ready: boolean): void {
+ this.authDisabledReady = ready
+ this.hydrated = true
+ }
+
+ clearAuthDisabledReadySync(): void {
+ this.setAuthDisabledReadySync(false)
+ }
+}
+
+export const browserAuthTokenProvider = new BrowserAuthTokenProvider()
+export const nativeAuthTokenProvider = new NativeAuthTokenProvider()
+export const runtimeAuthTokenProvider: SyncAuthTokenProvider = isIOSNativeApp()
+ ? nativeAuthTokenProvider
+ : browserAuthTokenProvider
+let nativeAuthSessionReady = false
+
+export function hasAuthToken(provider: SyncAuthTokenProvider): boolean {
+ return provider.getTokenSync() !== null
+}
+
+function hasPersistedAuthDisabledSessionReady(): boolean {
+ return getWindowSessionStorage()?.getItem(BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY) === 'true'
+}
+
+function persistAuthDisabledSessionReady(ready: boolean): void {
+ const storage = getWindowSessionStorage()
+ if (!storage) {
+ return
+ }
+
+ if (ready) {
+ storage.setItem(BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY, 'true')
+ return
+ }
+
+ storage.removeItem(BROWSER_AUTH_DISABLED_SESSION_READY_STORAGE_KEY)
+}
+
+export function markRuntimeAuthSessionReady(options: { persistAuthDisabled?: boolean } = {}): void {
+ if (isIOSNativeApp()) {
+ nativeAuthSessionReady = true
+ nativeAuthTokenProvider.setAuthDisabledReadySync(Boolean(options.persistAuthDisabled))
+ persistAuthDisabledSessionReady(Boolean(options.persistAuthDisabled))
+ }
+}
+
+export async function persistRuntimeAuthSessionReady(
+ options: { persistAuthDisabled?: boolean } = {},
+): Promise {
+ if (isIOSNativeApp()) {
+ nativeAuthSessionReady = true
+ await nativeAuthTokenProvider.setAuthDisabledReady(Boolean(options.persistAuthDisabled))
+ persistAuthDisabledSessionReady(Boolean(options.persistAuthDisabled))
+ return
+ }
+
+ persistAuthDisabledSessionReady(Boolean(options.persistAuthDisabled))
+}
+
+export async function clearPersistedRuntimeAuthSessionReady(): Promise {
+ if (isIOSNativeApp()) {
+ nativeAuthSessionReady = false
+ await nativeAuthTokenProvider.setAuthDisabledReady(false)
+ persistAuthDisabledSessionReady(false)
+ return
+ }
+
+ persistAuthDisabledSessionReady(false)
+}
+
+export function clearRuntimeAuthSessionReady(): void {
+ if (isIOSNativeApp()) {
+ nativeAuthSessionReady = false
+ nativeAuthTokenProvider.clearAuthDisabledReadySync()
+ persistAuthDisabledSessionReady(false)
+ }
+}
+
+export function isRuntimeAuthSessionReady(): boolean {
+ if (!isIOSNativeApp()) {
+ return true
+ }
+
+ return (
+ nativeAuthSessionReady ||
+ hasAuthToken(runtimeAuthTokenProvider) ||
+ nativeAuthTokenProvider.isAuthDisabledReadySync() ||
+ hasPersistedAuthDisabledSessionReady()
+ )
+}
+
+export async function resolveAuthToken(
+ explicitApiKey: ApiRequestOptions['apiKey'],
+ provider: AuthTokenProvider = browserAuthTokenProvider,
+): Promise {
+ if (explicitApiKey !== undefined) {
+ return normalizeAuthToken(explicitApiKey)
+ }
+
+ return provider.getToken()
+}
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..a7dbebf9
--- /dev/null
+++ b/ui/src/app/bootstrap.tsx
@@ -0,0 +1,47 @@
+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'
+import { NativeDeepLinkRouter } from '../runtime/nativeDeepLinks'
+
+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/app/layout/AppShell.test.tsx b/ui/src/app/layout/AppShell.test.tsx
index 541af7fb..1e519ba8 100644
--- a/ui/src/app/layout/AppShell.test.tsx
+++ b/ui/src/app/layout/AppShell.test.tsx
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, render, screen, within } from '@testing-library/react'
+import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import type { CameraResponse } from '../../api/generated/types'
@@ -43,7 +43,15 @@ function renderShell(
}>
Live route
} />
Events route} />
- Settings route} />
+
+ Server URL
+
+
+ )}
+ />
System route} />
@@ -114,4 +122,19 @@ describe('AppShell navigation', () => {
expect(systemStatus.getAttribute('href')).toBe('/system')
expect(systemStatus.className).not.toContain('app-shell__header-status--nominal')
})
+
+ it('marks the shell while a form control is focused for mobile keyboard layout', async () => {
+ // Given: App shell is mounted on a route with a form field
+ renderShell('/settings')
+
+ // When: A form control receives focus
+ screen.getByRole('textbox', { name: 'Server URL' }).focus()
+
+ // Then: The shell exposes focus state for CSS that hides fixed mobile nav
+ await waitFor(() => {
+ expect(document.querySelector('.app-shell')?.className).toContain(
+ 'app-shell--form-control-focused',
+ )
+ })
+ })
})
diff --git a/ui/src/app/layout/AppShell.tsx b/ui/src/app/layout/AppShell.tsx
index da8cc8eb..29511c4d 100644
--- a/ui/src/app/layout/AppShell.tsx
+++ b/ui/src/app/layout/AppShell.tsx
@@ -1,3 +1,4 @@
+import { useEffect, useState } from 'react'
import { NavLink, Outlet } from 'react-router-dom'
import { useCamerasQuery } from '../../api/hooks/useCamerasQuery'
@@ -19,10 +20,17 @@ const MOBILE_NAV_LINKS: readonly MobileBottomNavLink[] = [
{ to: '/settings', label: 'Settings' },
]
+const FORM_CONTROL_SELECTOR = 'input, textarea, select'
+
function navLinkClassName({ isActive }: { isActive: boolean }): string {
return isActive ? 'nav-link nav-link--active' : 'nav-link'
}
+function documentHasFocusedFormControl(): boolean {
+ return document.activeElement instanceof HTMLElement &&
+ document.activeElement.matches(FORM_CONTROL_SELECTOR)
+}
+
function systemStatusText(status: string | undefined, isError: boolean): string {
if (isError) {
return 'System needs attention'
@@ -38,6 +46,7 @@ function systemStatusText(status: string | undefined, isError: boolean): string
export function AppShell() {
const { theme, toggleTheme } = useTheme()
+ const [isFormControlFocused, setIsFormControlFocused] = useState(false)
const healthQuery = useHealthQuery()
const camerasQuery = useCamerasQuery()
const cameraIssue = cameraIssueSummary(camerasQuery.data)
@@ -45,9 +54,42 @@ export function AppShell() {
const systemStatusClassName = !cameraIssue && !healthQuery.isError && healthQuery.data?.status === 'healthy'
? 'app-shell__header-status app-shell__header-status--nominal'
: 'app-shell__header-status'
+ const appShellClassName = isFormControlFocused
+ ? 'app-shell app-shell--form-control-focused'
+ : 'app-shell'
+
+ useEffect(() => {
+ let focusOutTimer: number | undefined
+
+ const syncFocusedControlState = () => {
+ setIsFormControlFocused(documentHasFocusedFormControl())
+ }
+
+ const queueFocusedControlStateSync = () => {
+ if (focusOutTimer !== undefined) {
+ window.clearTimeout(focusOutTimer)
+ }
+ focusOutTimer = window.setTimeout(() => {
+ focusOutTimer = undefined
+ syncFocusedControlState()
+ }, 0)
+ }
+
+ document.addEventListener('focusin', syncFocusedControlState)
+ document.addEventListener('focusout', queueFocusedControlStateSync)
+ syncFocusedControlState()
+
+ return () => {
+ if (focusOutTimer !== undefined) {
+ window.clearTimeout(focusOutTimer)
+ }
+ document.removeEventListener('focusin', syncFocusedControlState)
+ document.removeEventListener('focusout', queueFocusedControlStateSync)
+ }
+ }, [])
return (
-
+
diff --git a/ui/src/app/nativeRuntime.test.ts b/ui/src/app/nativeRuntime.test.ts
new file mode 100644
index 00000000..d99e1653
--- /dev/null
+++ b/ui/src/app/nativeRuntime.test.ts
@@ -0,0 +1,61 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const capacitorMock = vi.hoisted(() => ({
+ getPlatform: vi.fn(() => 'web'),
+ isNativePlatform: vi.fn(() => false),
+}))
+
+vi.mock('@capacitor/core', () => ({
+ Capacitor: capacitorMock,
+}))
+
+import { isIOSNativeApp, isNativeApp } from './nativeRuntime'
+
+describe('native runtime detection', () => {
+ beforeEach(() => {
+ capacitorMock.getPlatform.mockReturnValue('web')
+ capacitorMock.isNativePlatform.mockReturnValue(false)
+ })
+
+ it('reports browser mode as non-native', () => {
+ // Given: Capacitor is running on the web platform
+ capacitorMock.getPlatform.mockReturnValue('web')
+ capacitorMock.isNativePlatform.mockReturnValue(false)
+
+ // When: The app checks the runtime mode
+ const native = isNativeApp()
+ const iosNative = isIOSNativeApp()
+
+ // Then: Browser mode is not treated as native iOS
+ expect(native).toBe(false)
+ expect(iosNative).toBe(false)
+ })
+
+ it('reports iOS Capacitor mode as native iOS', () => {
+ // Given: Capacitor is running inside the iOS native shell
+ capacitorMock.getPlatform.mockReturnValue('ios')
+ capacitorMock.isNativePlatform.mockReturnValue(true)
+
+ // When: The app checks the runtime mode
+ const native = isNativeApp()
+ const iosNative = isIOSNativeApp()
+
+ // Then: The iOS native shell is detected
+ expect(native).toBe(true)
+ expect(iosNative).toBe(true)
+ })
+
+ it('does not report non-iOS native platforms as iOS', () => {
+ // Given: Capacitor is running on a different native platform
+ capacitorMock.getPlatform.mockReturnValue('android')
+ capacitorMock.isNativePlatform.mockReturnValue(true)
+
+ // When: The app checks the runtime mode
+ const native = isNativeApp()
+ const iosNative = isIOSNativeApp()
+
+ // Then: Native and iOS-native detection remain distinct
+ expect(native).toBe(true)
+ expect(iosNative).toBe(false)
+ })
+})
diff --git a/ui/src/app/nativeRuntime.ts b/ui/src/app/nativeRuntime.ts
new file mode 100644
index 00000000..f18ba2fb
--- /dev/null
+++ b/ui/src/app/nativeRuntime.ts
@@ -0,0 +1 @@
+export { isIOSNativeApp, isNativeApp } from '../runtime/nativeRuntime'
diff --git a/ui/src/features/cameras/CamerasPage.tsx b/ui/src/features/cameras/CamerasPage.tsx
index 7fd90cd3..6c6981c1 100644
--- a/ui/src/features/cameras/CamerasPage.tsx
+++ b/ui/src/features/cameras/CamerasPage.tsx
@@ -42,12 +42,12 @@ export function CamerasPage() {
}
async function submitApiKey(apiKey: string): Promise {
- saveApiKey(apiKey)
+ await saveApiKey(apiKey)
await refreshAll()
}
async function clearStoredApiKey(): Promise {
- clearApiKey()
+ await clearApiKey()
await refreshAll()
}
diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx
index e9f1975e..bcae1268 100644
--- a/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx
+++ b/ui/src/features/cameras/components/CameraPreviewPanel.test.tsx
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { APIError } from '../../../api/client'
@@ -19,6 +19,7 @@ const {
hlsIsSupportedMock,
hlsLoadSourceMock,
hlsOnMock,
+ isIOSNativeAppMock,
} = vi.hoisted(() => ({
useCameraPreviewMock: vi.fn(),
usePushToTalkMock: vi.fn(),
@@ -28,6 +29,7 @@ const {
hlsOnMock: vi.fn(),
hlsDestroyMock: vi.fn(),
hlsIsSupportedMock: vi.fn(() => true),
+ isIOSNativeAppMock: vi.fn(() => false),
}))
vi.mock('../hooks/useCameraPreview', () => ({
@@ -38,6 +40,10 @@ vi.mock('../hooks/usePushToTalk', () => ({
usePushToTalk: (...args: unknown[]) => usePushToTalkMock(...args),
}))
+vi.mock('../../../runtime/nativeRuntime', () => ({
+ isIOSNativeApp: () => isIOSNativeAppMock(),
+}))
+
vi.mock('hls.js', () => {
function MockHls(this: Record) {
hlsConstructMock()
@@ -88,7 +94,10 @@ function mockIdlePushToTalk(overrides: Record = {}) {
})
}
-function mockReadyPreviewSession(playlistUrl: string = DEFAULT_PLAYLIST_URL) {
+function mockReadyPreviewSession(
+ playlistUrl: string = DEFAULT_PLAYLIST_URL,
+ overrides: Record = {},
+) {
useCameraPreviewMock.mockReturnValue({
status: {
camera_name: 'front',
@@ -120,6 +129,7 @@ function mockReadyPreviewSession(playlistUrl: string = DEFAULT_PLAYLIST_URL) {
start: vi.fn(),
stop: vi.fn(),
refreshStatus: vi.fn(),
+ ...overrides,
})
}
@@ -134,6 +144,8 @@ describe('CameraPreviewPanel', () => {
hlsDestroyMock.mockReset()
hlsIsSupportedMock.mockReset()
hlsIsSupportedMock.mockReturnValue(true)
+ isIOSNativeAppMock.mockReset()
+ isIOSNativeAppMock.mockReturnValue(false)
mockIdlePushToTalk()
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response('#EXTM3U', {
@@ -288,15 +300,47 @@ describe('CameraPreviewPanel', () => {
})
})
- it('uses hls.js before native HLS when both playback paths are available', async () => {
- // Given: Safari-like native HLS support and hls.js support are both available
+ it('tears down playback listeners when hls.js reports a fatal error', async () => {
+ // Given: Browser HLS playback is active and hls.js has registered a fatal-error handler
+ let fatalErrorHandler: ((event: unknown, data: { fatal: boolean }) => void) | null = null
+ hlsOnMock.mockImplementation((event: string, handler: unknown) => {
+ if (event === 'error' && typeof handler === 'function') {
+ fatalErrorHandler = handler as (event: unknown, data: { fatal: boolean }) => void
+ }
+ })
+ const removeDocumentListener = vi.spyOn(document, 'removeEventListener')
+ mockReadyPreviewSession()
+ render()
+
+ await waitFor(() => {
+ expect(hlsOnMock).toHaveBeenCalledWith('error', expect.any(Function))
+ expect(fatalErrorHandler).not.toBeNull()
+ })
+
+ // When: hls.js reports a fatal playback failure
+ act(() => {
+ fatalErrorHandler?.('error', { fatal: true })
+ })
+
+ // Then: The player shows recovery copy and tears down document/video resources immediately
+ await waitFor(() => {
+ expect(screen.getByText('Preview playback failed. Stop and start live view.')).toBeTruthy()
+ expect(removeDocumentListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
+ expect(hlsDestroyMock).toHaveBeenCalled()
+ expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled()
+ expect(HTMLMediaElement.prototype.load).toHaveBeenCalled()
+ })
+ })
+
+ it('uses hls.js before native HLS in browser mode when both playback paths are available', async () => {
+ // Given: Browser mode has hls.js support and Safari-like native HLS support
vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('maybe')
mockReadyPreviewSession()
// When: Rendering the preview panel
render()
- // Then: The player takes the hls.js path instead of short-circuiting to native HLS
+ // Then: Browser mode takes the hls.js path instead of short-circuiting to native HLS
await waitFor(() => {
expect(hlsConstructMock).toHaveBeenCalledTimes(1)
expect(hlsLoadSourceMock).toHaveBeenCalledWith(DEFAULT_PLAYLIST_URL)
@@ -304,6 +348,27 @@ describe('CameraPreviewPanel', () => {
})
})
+ it('uses native HLS before hls.js inside the iOS native app', async () => {
+ // Given: The Capacitor iOS app can play HLS natively and hls.js is also present
+ isIOSNativeAppMock.mockReturnValue(true)
+ vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('maybe')
+ mockReadyPreviewSession()
+
+ // When: Rendering the preview panel
+ const { container } = render()
+
+ // Then: The player assigns the playlist directly to the inline video element
+ await waitFor(() => {
+ const video = container.querySelector('video')
+ expect(video?.getAttribute('src')).toBe(DEFAULT_PLAYLIST_URL)
+ expect(hlsConstructMock).not.toHaveBeenCalled()
+ expect(video?.muted).toBe(true)
+ expect(video?.autoplay).toBe(true)
+ expect(video?.playsInline).toBe(true)
+ expect(video?.getAttribute('webkit-playsinline')).toBe('')
+ })
+ })
+
it('falls back to native HLS when hls.js is unavailable', async () => {
// Given: hls.js cannot run but the browser supports native HLS playback
hlsIsSupportedMock.mockReturnValue(false)
@@ -321,6 +386,91 @@ describe('CameraPreviewPanel', () => {
})
})
+ it('shows an actionable iOS playback error when native HLS fails', async () => {
+ // Given: The iOS native app has an active preview assigned through native HLS
+ isIOSNativeAppMock.mockReturnValue(true)
+ vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('maybe')
+ mockReadyPreviewSession(DEFAULT_PLAYLIST_URL, {
+ warning: 'Preview degraded: stale playlist warning',
+ })
+ const removeDocumentListener = vi.spyOn(document, 'removeEventListener')
+ const { container } = render()
+ const video = await waitFor(() => {
+ const currentVideo = container.querySelector('video')
+ expect(currentVideo?.getAttribute('src')).toBe(DEFAULT_PLAYLIST_URL)
+ return currentVideo
+ })
+
+ // When: WKWebView reports a native media playback error
+ video?.dispatchEvent(new Event('error'))
+
+ // Then: The live view replaces the blank player with homeowner-actionable recovery copy
+ await waitFor(() => {
+ expect(screen.getByText(
+ 'Live preview could not play in the iOS app. Stop and start live view; if it keeps failing, check server or VPN reachability.',
+ )).toBeTruthy()
+ expect(screen.queryByText('Preview degraded: stale playlist warning')).toBeNull()
+ expect(HTMLMediaElement.prototype.pause).toHaveBeenCalled()
+ expect(HTMLMediaElement.prototype.load).toHaveBeenCalled()
+ expect(video?.hasAttribute('src')).toBe(false)
+ expect(removeDocumentListener).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
+ })
+ })
+
+ it('shows an actionable iOS unsupported-player error', async () => {
+ // Given: The iOS native app cannot use hls.js or native HLS
+ isIOSNativeAppMock.mockReturnValue(true)
+ hlsIsSupportedMock.mockReturnValue(false)
+ vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue('')
+ mockReadyPreviewSession(DEFAULT_PLAYLIST_URL, {
+ warning: 'Preview degraded: stale playlist warning',
+ })
+
+ // When: Rendering the preview panel
+ render()
+
+ // Then: The placeholder explains the iOS playback limitation instead of staying blank
+ await waitFor(() => {
+ expect(screen.getByText(
+ 'This iOS app cannot play the live preview stream. Check the HomeSec preview configuration and try again.',
+ )).toBeTruthy()
+ expect(screen.queryByText('Preview degraded: stale playlist warning')).toBeNull()
+ })
+ })
+
+ it('surfaces preview hook errors before stale warning text', () => {
+ // Given: The hook reports a stop failure while status still carries an older warning
+ useCameraPreviewMock.mockReturnValue({
+ status: {
+ camera_name: 'front',
+ enabled: true,
+ state: 'degraded',
+ viewer_count: 0,
+ degraded_reason: 'Preview degraded: stale runtime warning',
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ },
+ session: null,
+ playlistUrl: null,
+ warning: 'Preview degraded: stale runtime warning',
+ error: new Error('stop failed'),
+ isPending: false,
+ isStarting: false,
+ isStopping: false,
+ start: vi.fn(),
+ stop: vi.fn(),
+ refreshStatus: vi.fn(),
+ })
+
+ // When: Rendering the preview panel after local media has been cleared
+ render()
+
+ // Then: The actionable hook failure is shown instead of the stale degraded warning
+ expect(screen.getByText('stop failed')).toBeTruthy()
+ expect(screen.queryByText('Preview degraded: stale runtime warning')).toBeNull()
+ })
+
it('renders attached previews with only a fullscreen playback control', async () => {
// Given: A ready preview session with playable live media
mockReadyPreviewSession()
diff --git a/ui/src/features/cameras/components/CameraPreviewPanel.tsx b/ui/src/features/cameras/components/CameraPreviewPanel.tsx
index b9953115..69e6b14a 100644
--- a/ui/src/features/cameras/components/CameraPreviewPanel.tsx
+++ b/ui/src/features/cameras/components/CameraPreviewPanel.tsx
@@ -4,6 +4,7 @@ import Hls from 'hls.js'
import { isAPIError } from '../../../api/client'
import { Button } from '../../../components/ui/Button'
import { StatusBadge } from '../../../components/ui/StatusBadge'
+import { isIOSNativeApp } from '../../../runtime/nativeRuntime'
import { describeUnknownError } from '../../shared/errorPresentation'
import { useCameraPreview } from '../hooks/useCameraPreview'
import { PushToTalkControl } from './PushToTalkControl'
@@ -12,6 +13,7 @@ const PLAYLIST_POLL_DELAY_MS = 500
const PLAYLIST_POLL_MAX_ATTEMPTS = 12
const PLAYBACK_RETRY_DELAY_MS = 1000
const PREVIEW_DISPLAY_STATUS_STATES = new Set(['starting', 'ready', 'degraded', 'stopping'])
+const HLS_MIME_TYPES = ['application/vnd.apple.mpegurl', 'application/x-mpegURL']
type WebKitFullscreenDocument = Document & {
webkitExitFullscreen?: () => Promise | void
@@ -68,6 +70,22 @@ function previewLabel(state: string | undefined): string {
}
}
+function canPlayNativeHls(video: HTMLVideoElement): boolean {
+ return HLS_MIME_TYPES.some((mimeType) => video.canPlayType(mimeType) !== '')
+}
+
+function previewPlaybackFailureMessage(isIOSNative: boolean): string {
+ return isIOSNative
+ ? 'Live preview could not play in the iOS app. Stop and start live view; if it keeps failing, check server or VPN reachability.'
+ : 'Preview playback failed. Stop and start live view.'
+}
+
+function previewUnsupportedMessage(isIOSNative: boolean): string {
+ return isIOSNative
+ ? 'This iOS app cannot play the live preview stream. Check the HomeSec preview configuration and try again.'
+ : 'This browser cannot play the live preview stream.'
+}
+
function startLabel(statusState: string | undefined): string {
if (statusState === 'ready' || statusState === 'degraded' || statusState === 'starting') {
return 'Show live view'
@@ -131,6 +149,7 @@ export function CameraPreviewPanel({
const [playlistReady, setPlaylistReady] = useState(false)
const [isPreviewFullscreen, setIsPreviewFullscreen] = useState(false)
const [playerError, setPlayerError] = useState(null)
+ const isIOSNative = isIOSNativeApp()
const effectiveState =
session && (!status || !PREVIEW_DISPLAY_STATUS_STATES.has(status.state))
? session.state
@@ -214,25 +233,27 @@ export function CameraPreviewPanel({
}, [])
useEffect(() => {
- const video = videoRef.current
- if (!video || !playlistUrl || !playlistReady) {
+ const videoElement = videoRef.current
+ if (!videoElement || !playlistUrl || !playlistReady) {
return
}
+ const activeVideo: HTMLVideoElement = videoElement
let hls: Hls | null = null
let keepPlaybackActive = true
+ let playbackCleanedUp = false
let resumeTimeoutId: number | null = null
let resumeIntervalId: number | null = null
setPlayerError(null)
- video.muted = true
- video.defaultMuted = true
- video.autoplay = true
- video.playsInline = true
- video.setAttribute('autoplay', '')
- video.setAttribute('muted', '')
- video.setAttribute('playsinline', '')
- video.setAttribute('webkit-playsinline', '')
+ activeVideo.muted = true
+ activeVideo.defaultMuted = true
+ activeVideo.autoplay = true
+ activeVideo.playsInline = true
+ activeVideo.setAttribute('autoplay', '')
+ activeVideo.setAttribute('muted', '')
+ activeVideo.setAttribute('playsinline', '')
+ activeVideo.setAttribute('webkit-playsinline', '')
const clearResumeTimeout = (): void => {
if (resumeTimeoutId === null) {
@@ -260,10 +281,10 @@ export function CameraPreviewPanel({
if (!keepPlaybackActive) {
return
}
- if (!video.paused && !video.ended) {
+ if (!activeVideo.paused && !activeVideo.ended) {
return
}
- void video.play().catch(() => {})
+ void activeVideo.play().catch(() => {})
}, 0)
}
@@ -274,7 +295,7 @@ export function CameraPreviewPanel({
if (!keepPlaybackActive || document.visibilityState === 'hidden') {
return
}
- if (video.paused || video.ended) {
+ if (activeVideo.paused || activeVideo.ended) {
requestPlayback()
}
}, PLAYBACK_RETRY_DELAY_MS)
@@ -286,42 +307,64 @@ export function CameraPreviewPanel({
}
}
- const cleanupPlayback = (): void => {
+ function releaseMediaElement(): void {
+ hls?.destroy()
+ hls = null
+ activeVideo.pause()
+ activeVideo.removeAttribute('src')
+ activeVideo.load()
+ }
+
+ function teardownPlayback(): void {
+ if (playbackCleanedUp) {
+ return
+ }
+ playbackCleanedUp = true
keepPlaybackActive = false
clearResumeTimeout()
clearResumeInterval()
- video.removeEventListener('pause', requestPlayback)
- video.removeEventListener('ended', requestPlayback)
- video.removeEventListener('loadedmetadata', requestPlayback)
- video.removeEventListener('loadeddata', requestPlayback)
- video.removeEventListener('canplay', requestPlayback)
- video.removeEventListener('canplaythrough', requestPlayback)
- video.removeEventListener('stalled', requestPlayback)
- video.removeEventListener('waiting', requestPlayback)
+ activeVideo.removeEventListener('pause', requestPlayback)
+ activeVideo.removeEventListener('ended', requestPlayback)
+ activeVideo.removeEventListener('loadedmetadata', requestPlayback)
+ activeVideo.removeEventListener('loadeddata', requestPlayback)
+ activeVideo.removeEventListener('canplay', requestPlayback)
+ activeVideo.removeEventListener('canplaythrough', requestPlayback)
+ activeVideo.removeEventListener('stalled', requestPlayback)
+ activeVideo.removeEventListener('waiting', requestPlayback)
+ activeVideo.removeEventListener('error', handleVideoError)
document.removeEventListener('visibilitychange', handleVisibilityChange)
- hls?.destroy()
- video.pause()
- video.removeAttribute('src')
- video.load()
+ releaseMediaElement()
}
- video.addEventListener('pause', requestPlayback)
- video.addEventListener('ended', requestPlayback)
- video.addEventListener('loadedmetadata', requestPlayback)
- video.addEventListener('loadeddata', requestPlayback)
- video.addEventListener('canplay', requestPlayback)
- video.addEventListener('canplaythrough', requestPlayback)
- video.addEventListener('stalled', requestPlayback)
- video.addEventListener('waiting', requestPlayback)
+ function handleVideoError(): void {
+ setPlayerError(previewPlaybackFailureMessage(isIOSNative))
+ teardownPlayback()
+ }
+
+ activeVideo.addEventListener('pause', requestPlayback)
+ activeVideo.addEventListener('ended', requestPlayback)
+ activeVideo.addEventListener('loadedmetadata', requestPlayback)
+ activeVideo.addEventListener('loadeddata', requestPlayback)
+ activeVideo.addEventListener('canplay', requestPlayback)
+ activeVideo.addEventListener('canplaythrough', requestPlayback)
+ activeVideo.addEventListener('stalled', requestPlayback)
+ activeVideo.addEventListener('waiting', requestPlayback)
+ activeVideo.addEventListener('error', handleVideoError)
document.addEventListener('visibilitychange', handleVisibilityChange)
+ if (isIOSNative && canPlayNativeHls(activeVideo)) {
+ activeVideo.src = playlistUrl
+ startPlaybackMonitor()
+ return teardownPlayback
+ }
+
if (Hls.isSupported()) {
hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
})
hls.loadSource(playlistUrl)
- hls.attachMedia(video)
+ hls.attachMedia(activeVideo)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
startPlaybackMonitor()
})
@@ -329,29 +372,26 @@ export function CameraPreviewPanel({
if (!data.fatal) {
return
}
- setPlayerError('Preview playback failed. Restart preview.')
- keepPlaybackActive = false
- clearResumeTimeout()
- clearResumeInterval()
- hls?.destroy()
- hls = null
+ setPlayerError(previewPlaybackFailureMessage(isIOSNative))
+ teardownPlayback()
})
startPlaybackMonitor()
- return cleanupPlayback
+ return teardownPlayback
}
- if (video.canPlayType('application/vnd.apple.mpegurl')) {
- video.src = playlistUrl
+ if (canPlayNativeHls(activeVideo)) {
+ activeVideo.src = playlistUrl
startPlaybackMonitor()
- return cleanupPlayback
+ return teardownPlayback
}
- setPlayerError('This browser cannot play the live preview stream.')
+ setPlayerError(previewUnsupportedMessage(isIOSNative))
+ teardownPlayback()
- return cleanupPlayback
- }, [playlistReady, playlistUrl])
+ return teardownPlayback
+ }, [isIOSNative, playlistReady, playlistUrl])
const toggleFullscreen = async (): Promise => {
const viewport = viewportRef.current
@@ -377,9 +417,6 @@ export function CameraPreviewPanel({
}
const statusMessage = useMemo(() => {
- if (warning) {
- return warning
- }
if (playerError) {
return playerError
}
@@ -389,6 +426,9 @@ export function CameraPreviewPanel({
}
return describeUnknownError(error)
}
+ if (warning) {
+ return warning
+ }
if (playlistUrl && !playlistReady) {
return 'Starting live view.'
}
diff --git a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx
index 865c12f9..155ee656 100644
--- a/ui/src/features/cameras/hooks/useCameraPreview.test.tsx
+++ b/ui/src/features/cameras/hooks/useCameraPreview.test.tsx
@@ -5,15 +5,67 @@ import { act, renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach, describe, expect, it, vi } from 'vitest'
+type MockNativeLifecycleState = {
+ isActive: boolean
+ isBackgrounded: boolean
+ pauseCount: number
+ resumeCount: number
+}
+
+const nativeLifecycleMock = vi.hoisted(() => ({
+ state: {
+ isActive: true,
+ isBackgrounded: false,
+ pauseCount: 0,
+ resumeCount: 0,
+ } as MockNativeLifecycleState,
+}))
+
+vi.mock('../../../runtime/nativeAppLifecycle', () => ({
+ useNativeAppLifecycleState: () => nativeLifecycleMock.state,
+}))
+
import { apiClient } from '../../../api/client'
import { useCameraPreview } from './useCameraPreview'
const PREVIEW_TEST_NOW_MS = Date.parse('2026-04-23T12:00:00.000Z')
+function resetNativeLifecycleState() {
+ nativeLifecycleMock.state = {
+ isActive: true,
+ isBackgrounded: false,
+ pauseCount: 0,
+ resumeCount: 0,
+ }
+}
+
+function setNativeLifecycleState(nextState: Partial) {
+ nativeLifecycleMock.state = {
+ ...nativeLifecycleMock.state,
+ ...nextState,
+ }
+}
+
function freezePreviewClock() {
vi.spyOn(Date, 'now').mockReturnValue(PREVIEW_TEST_NOW_MS)
}
+type Deferred = {
+ promise: Promise
+ resolve: (value: T) => void
+ reject: (error: unknown) => void
+}
+
+function deferred(): Deferred {
+ let resolve!: (value: T) => void
+ let reject!: (error: unknown) => void
+ const promise = new Promise((nextResolve, nextReject) => {
+ resolve = nextResolve
+ reject = nextReject
+ })
+ return { promise, resolve, reject }
+}
+
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
@@ -33,6 +85,7 @@ function createWrapper() {
describe('useCameraPreview', () => {
afterEach(() => {
+ resetNativeLifecycleState()
vi.restoreAllMocks()
vi.useRealTimers()
})
@@ -71,6 +124,62 @@ describe('useCameraPreview', () => {
})
})
+ it('ignores stale preview start failures after explicit stop', async () => {
+ // Given: A preview activation is still in flight
+ const previewStart = deferred>>()
+ vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 0,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ vi.spyOn(apiClient, 'ensureCameraPreviewActive').mockReturnValue(previewStart.promise)
+ vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({
+ accepted: true,
+ state: 'stopping',
+ httpStatus: 202,
+ })
+
+ const { result } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+
+ let startPromise!: Promise
+ act(() => {
+ startPromise = result.current.start()
+ })
+ await waitFor(() => {
+ expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledWith('front')
+ })
+
+ // When: The user stops preview before the start request rejects
+ await act(async () => {
+ await result.current.stop()
+ })
+ await waitFor(() => {
+ expect(result.current.session).toBeNull()
+ expect(result.current.error).toBeNull()
+ })
+ await act(async () => {
+ previewStart.reject(new Error('preview failed after stop'))
+ await startPromise
+ })
+
+ // Then: The stale start failure cannot repopulate preview errors
+ await waitFor(() => {
+ expect(result.current.session).toBeNull()
+ expect(result.current.playlistUrl).toBeNull()
+ expect(result.current.error).toBeNull()
+ })
+ })
+
it('keeps a fresh preview session when the follow-up status refetch fails', async () => {
// Given: An idle camera whose preview start succeeds but the invalidated status refresh fails
freezePreviewClock()
@@ -476,21 +585,150 @@ describe('useCameraPreview', () => {
expect(result.current.error).toBeNull()
})
- it('drops stale preview sessions after a newer terminal runtime status', async () => {
- // Given: A started preview session whose follow-up status says the runtime has already failed it
+ it('ignores stale token renewal failures after explicit stop', async () => {
+ // Given: A ready preview session with an in-flight token renewal
+ freezePreviewClock()
+ const realSetTimeout = window.setTimeout.bind(window)
+ const realClearTimeout = window.clearTimeout.bind(window)
+ let runScheduledRefresh: (() => void) | null = null
+ vi.spyOn(window, 'setTimeout').mockImplementation(((handler, timeout, ...args) => {
+ if (timeout === 5_000) {
+ runScheduledRefresh = () => {
+ if (typeof handler !== 'function') {
+ throw new Error('Expected refresh timer handler to be a function')
+ }
+ handler(...args)
+ }
+ return 99
+ }
+ return realSetTimeout(handler, timeout, ...args)
+ }) as typeof window.setTimeout)
+ vi.spyOn(window, 'clearTimeout').mockImplementation(((timeoutId) => {
+ if (timeoutId === 99) {
+ return
+ }
+ realClearTimeout(timeoutId)
+ }) as typeof window.clearTimeout)
+ const renewal = deferred>>()
+ vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 1,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ const ensurePreviewActive = vi
+ .spyOn(apiClient, 'ensureCameraPreviewActive')
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-1',
+ token_expires_at: '2026-04-23T12:00:10.000Z',
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ .mockReturnValueOnce(renewal.promise)
+ vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({
+ accepted: true,
+ state: 'stopping',
+ httpStatus: 202,
+ })
+
+ const { result } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+ await act(async () => {
+ await result.current.start()
+ })
+ await waitFor(() => {
+ expect(result.current.session?.token).toBe('preview-token-1')
+ expect(runScheduledRefresh).not.toBeNull()
+ })
+ await act(async () => {
+ runScheduledRefresh?.()
+ await Promise.resolve()
+ })
+ await waitFor(() => {
+ expect(ensurePreviewActive).toHaveBeenCalledTimes(2)
+ })
+
+ // When: The user stops preview before the renewal request rejects
+ await act(async () => {
+ await result.current.stop()
+ })
+ await waitFor(() => {
+ expect(result.current.session).toBeNull()
+ expect(result.current.error).toBeNull()
+ })
+ await act(async () => {
+ renewal.reject(new Error('token refresh failed after stop'))
+ await Promise.resolve()
+ })
+
+ // Then: The stale renewal failure cannot repopulate preview errors
+ await waitFor(() => {
+ expect(result.current.session).toBeNull()
+ expect(result.current.playlistUrl).toBeNull()
+ expect(result.current.error).toBeNull()
+ })
+ })
+
+ it('ignores stale token renewal failures after terminal status detaches preview', async () => {
+ // Given: A ready preview session with an in-flight token renewal
freezePreviewClock()
+ const realSetTimeout = window.setTimeout.bind(window)
+ const realClearTimeout = window.clearTimeout.bind(window)
+ let runScheduledRefresh: (() => void) | null = null
+ vi.spyOn(window, 'setTimeout').mockImplementation(((handler, timeout, ...args) => {
+ if (timeout === 5_000) {
+ runScheduledRefresh = () => {
+ if (typeof handler !== 'function') {
+ throw new Error('Expected refresh timer handler to be a function')
+ }
+ handler(...args)
+ }
+ return 99
+ }
+ return realSetTimeout(handler, timeout, ...args)
+ }) as typeof window.setTimeout)
+ vi.spyOn(window, 'clearTimeout').mockImplementation(((timeoutId) => {
+ if (timeoutId === 99) {
+ return
+ }
+ realClearTimeout(timeoutId)
+ }) as typeof window.clearTimeout)
+ const renewal = deferred>>()
vi.spyOn(apiClient, 'getCameraPreviewStatus')
.mockResolvedValueOnce({
camera_name: 'front',
enabled: true,
- state: 'idle',
- viewer_count: null,
+ state: 'ready',
+ viewer_count: 1,
degraded_reason: null,
last_error: null,
idle_shutdown_at: null,
httpStatus: 200,
})
.mockResolvedValueOnce({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 1,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ .mockResolvedValue({
camera_name: 'front',
enabled: true,
state: 'error',
@@ -502,7 +740,7 @@ describe('useCameraPreview', () => {
})
const ensurePreviewActive = vi
.spyOn(apiClient, 'ensureCameraPreviewActive')
- .mockResolvedValue({
+ .mockResolvedValueOnce({
camera_name: 'front',
state: 'ready',
viewer_count: 1,
@@ -513,38 +751,83 @@ describe('useCameraPreview', () => {
warning: null,
httpStatus: 200,
})
+ .mockReturnValueOnce(renewal.promise)
const { result } = renderHook(() => useCameraPreview('front'), {
wrapper: createWrapper(),
})
-
await waitFor(() => {
- expect(result.current.status?.state).toBe('idle')
+ expect(result.current.status?.state).toBe('ready')
})
-
- // When: Starting preview and allowing the invalidated status refetch to report a terminal error
await act(async () => {
- await expect(result.current.start()).resolves.toBeUndefined()
+ await result.current.start()
+ })
+ await waitFor(() => {
+ expect(result.current.session?.token).toBe('preview-token-1')
+ expect(runScheduledRefresh).not.toBeNull()
+ })
+ await act(async () => {
+ runScheduledRefresh?.()
+ await Promise.resolve()
+ })
+ await waitFor(() => {
+ expect(ensurePreviewActive).toHaveBeenCalledTimes(2)
})
- // Then: The stale session is dropped and its token-renewal timer is cancelled
+ // When: A status refresh detaches the preview before renewal rejects
+ await act(async () => {
+ await result.current.refreshStatus()
+ })
await waitFor(() => {
expect(result.current.status?.state).toBe('error')
+ expect(result.current.session).toBeNull()
+ expect(result.current.error).toBeNull()
+ })
+ await act(async () => {
+ renewal.reject(new Error('token refresh failed after terminal status'))
+ await Promise.resolve()
+ })
+
+ // Then: The stale renewal failure cannot hide the backend terminal status warning
+ await waitFor(() => {
expect(result.current.session).toBeNull()
expect(result.current.playlistUrl).toBeNull()
+ expect(result.current.warning).toBe('runtime worker exited with code 137')
+ expect(result.current.error).toBeNull()
})
- expect(ensurePreviewActive).toHaveBeenCalledTimes(1)
})
- it('keeps a dropped preview session cleared across later status refreshes', async () => {
- // Given: A preview session invalidated by a newer terminal runtime status
+ it('stops stale token renewal successes after terminal status detaches preview', async () => {
+ // Given: A ready preview session with an in-flight token renewal
freezePreviewClock()
+ const realSetTimeout = window.setTimeout.bind(window)
+ const realClearTimeout = window.clearTimeout.bind(window)
+ let runScheduledRefresh: (() => void) | null = null
+ vi.spyOn(window, 'setTimeout').mockImplementation(((handler, timeout, ...args) => {
+ if (timeout === 5_000) {
+ runScheduledRefresh = () => {
+ if (typeof handler !== 'function') {
+ throw new Error('Expected refresh timer handler to be a function')
+ }
+ handler(...args)
+ }
+ return 99
+ }
+ return realSetTimeout(handler, timeout, ...args)
+ }) as typeof window.setTimeout)
+ vi.spyOn(window, 'clearTimeout').mockImplementation(((timeoutId) => {
+ if (timeoutId === 99) {
+ return
+ }
+ realClearTimeout(timeoutId)
+ }) as typeof window.clearTimeout)
+ const renewal = deferred>>()
vi.spyOn(apiClient, 'getCameraPreviewStatus')
.mockResolvedValueOnce({
camera_name: 'front',
enabled: true,
- state: 'idle',
- viewer_count: null,
+ state: 'ready',
+ viewer_count: 1,
degraded_reason: null,
last_error: null,
idle_shutdown_at: null,
@@ -553,26 +836,26 @@ describe('useCameraPreview', () => {
.mockResolvedValueOnce({
camera_name: 'front',
enabled: true,
- state: 'error',
- viewer_count: 0,
+ state: 'ready',
+ viewer_count: 1,
degraded_reason: null,
- last_error: 'runtime worker exited with code 137',
+ last_error: null,
idle_shutdown_at: null,
httpStatus: 200,
})
- .mockResolvedValueOnce({
+ .mockResolvedValue({
camera_name: 'front',
enabled: true,
- state: 'ready',
- viewer_count: 2,
+ state: 'error',
+ viewer_count: 0,
degraded_reason: null,
- last_error: null,
+ last_error: 'runtime worker exited with code 137',
idle_shutdown_at: null,
httpStatus: 200,
})
const ensurePreviewActive = vi
.spyOn(apiClient, 'ensureCameraPreviewActive')
- .mockResolvedValue({
+ .mockResolvedValueOnce({
camera_name: 'front',
state: 'ready',
viewer_count: 1,
@@ -583,29 +866,197 @@ describe('useCameraPreview', () => {
warning: null,
httpStatus: 200,
})
+ .mockReturnValueOnce(renewal.promise)
+ const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({
+ accepted: true,
+ state: 'stopping',
+ httpStatus: 202,
+ })
const { result } = renderHook(() => useCameraPreview('front'), {
wrapper: createWrapper(),
})
-
await waitFor(() => {
- expect(result.current.status?.state).toBe('idle')
+ expect(result.current.status?.state).toBe('ready')
})
-
- // When: Starting preview, dropping the stale session, then refreshing status again
await act(async () => {
- await expect(result.current.start()).resolves.toBeUndefined()
+ await result.current.start()
})
-
await waitFor(() => {
- expect(result.current.status?.state).toBe('error')
- expect(result.current.session).toBeNull()
- expect(result.current.playlistUrl).toBeNull()
+ expect(result.current.session?.token).toBe('preview-token-1')
+ expect(runScheduledRefresh).not.toBeNull()
})
-
await act(async () => {
- await expect(result.current.refreshStatus()).resolves.toMatchObject({ state: 'ready' })
- })
+ runScheduledRefresh?.()
+ await Promise.resolve()
+ })
+ await waitFor(() => {
+ expect(ensurePreviewActive).toHaveBeenCalledTimes(2)
+ })
+
+ // When: A status refresh detaches the preview before renewal succeeds
+ await act(async () => {
+ await result.current.refreshStatus()
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('error')
+ expect(result.current.session).toBeNull()
+ })
+ await act(async () => {
+ renewal.resolve({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-2',
+ token_expires_at: '2026-04-23T12:01:10.000Z',
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-2',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ await Promise.resolve()
+ })
+
+ // Then: The stale renewal is cleaned up without reattaching local media
+ await waitFor(() => {
+ expect(stopPreview).toHaveBeenCalledWith('front')
+ expect(result.current.session).toBeNull()
+ expect(result.current.playlistUrl).toBeNull()
+ expect(result.current.warning).toBe('runtime worker exited with code 137')
+ expect(result.current.error).toBeNull()
+ })
+ })
+
+ it('drops stale preview sessions after a newer terminal runtime status', async () => {
+ // Given: A started preview session whose follow-up status says the runtime has already failed it
+ freezePreviewClock()
+ vi.spyOn(apiClient, 'getCameraPreviewStatus')
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ enabled: true,
+ state: 'idle',
+ viewer_count: null,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ enabled: true,
+ state: 'error',
+ viewer_count: 0,
+ degraded_reason: null,
+ last_error: 'runtime worker exited with code 137',
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ const ensurePreviewActive = vi
+ .spyOn(apiClient, 'ensureCameraPreviewActive')
+ .mockResolvedValue({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-1',
+ token_expires_at: '2026-04-23T12:00:10.000Z',
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+
+ const { result } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('idle')
+ })
+
+ // When: Starting preview and allowing the invalidated status refetch to report a terminal error
+ await act(async () => {
+ await expect(result.current.start()).resolves.toBeUndefined()
+ })
+
+ // Then: The stale session is dropped and its token-renewal timer is cancelled
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('error')
+ expect(result.current.session).toBeNull()
+ expect(result.current.playlistUrl).toBeNull()
+ })
+ expect(ensurePreviewActive).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps a dropped preview session cleared across later status refreshes', async () => {
+ // Given: A preview session invalidated by a newer terminal runtime status
+ freezePreviewClock()
+ vi.spyOn(apiClient, 'getCameraPreviewStatus')
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ enabled: true,
+ state: 'idle',
+ viewer_count: null,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ enabled: true,
+ state: 'error',
+ viewer_count: 0,
+ degraded_reason: null,
+ last_error: 'runtime worker exited with code 137',
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 2,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ const ensurePreviewActive = vi
+ .spyOn(apiClient, 'ensureCameraPreviewActive')
+ .mockResolvedValue({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-1',
+ token_expires_at: '2026-04-23T12:00:10.000Z',
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+
+ const { result } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('idle')
+ })
+
+ // When: Starting preview, dropping the stale session, then refreshing status again
+ await act(async () => {
+ await expect(result.current.start()).resolves.toBeUndefined()
+ })
+
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('error')
+ expect(result.current.session).toBeNull()
+ expect(result.current.playlistUrl).toBeNull()
+ })
+
+ await act(async () => {
+ await expect(result.current.refreshStatus()).resolves.toMatchObject({ state: 'ready' })
+ })
// Then: The hook keeps the stale session cleared until the user explicitly re-attaches
await waitFor(() => {
@@ -615,4 +1066,741 @@ describe('useCameraPreview', () => {
})
expect(ensurePreviewActive).toHaveBeenCalledTimes(1)
})
+
+ it('stops active preview on native background and refreshes status on resume', async () => {
+ // Given: An active native app preview session
+ freezePreviewClock()
+ const getPreviewStatus = vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 1,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ vi.spyOn(apiClient, 'ensureCameraPreviewActive').mockResolvedValue({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-1',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({
+ accepted: true,
+ state: 'stopping',
+ httpStatus: 202,
+ })
+
+ const { result, rerender } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+ await act(async () => {
+ await result.current.start()
+ })
+ await waitFor(() => {
+ expect(result.current.session?.token).toBe('preview-token-1')
+ })
+
+ // When: iOS backgrounds the app while preview is attached
+ setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 })
+ rerender()
+
+ // Then: The hook stops preview and suppresses background status requests
+ await waitFor(() => {
+ expect(stopPreview).toHaveBeenCalledWith('front')
+ expect(result.current.session).toBeNull()
+ })
+ const statusCallsAfterPause = getPreviewStatus.mock.calls.length
+ await act(async () => {
+ await expect(result.current.refreshStatus()).resolves.toBeNull()
+ })
+ expect(getPreviewStatus).toHaveBeenCalledTimes(statusCallsAfterPause)
+
+ // When: iOS resumes the app
+ setNativeLifecycleState({ isActive: true, isBackgrounded: false, resumeCount: 1 })
+ rerender()
+
+ // Then: The hook refreshes preview status without auto-attaching a new session
+ await waitFor(() => {
+ expect(getPreviewStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterPause)
+ })
+ expect(result.current.session).toBeNull()
+ })
+
+ it('clears local preview when a stop request rejects', async () => {
+ // Given: An active preview session whose server-side stop request will fail
+ vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 1,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ vi.spyOn(apiClient, 'ensureCameraPreviewActive').mockResolvedValue({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-1',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ const stopPreview = vi
+ .spyOn(apiClient, 'stopCameraPreview')
+ .mockRejectedValue(new Error('stop failed'))
+
+ const { result } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+ await act(async () => {
+ await result.current.start()
+ })
+ await waitFor(() => {
+ expect(result.current.playlistUrl).toContain('preview-token-1')
+ })
+
+ // When: The user stops preview and the backend request rejects
+ await act(async () => {
+ await expect(result.current.stop()).resolves.toBeUndefined()
+ })
+
+ // Then: Local media is detached even though the server-side stop failed
+ await waitFor(() => {
+ expect(stopPreview).toHaveBeenCalledWith('front')
+ expect(result.current.session).toBeNull()
+ expect(result.current.playlistUrl).toBeNull()
+ expect(result.current.error?.message).toBe('stop failed')
+ })
+ })
+
+ it('clears a stale stop error after preview restarts successfully', async () => {
+ // Given: A stop failure has detached the local preview and left a user-visible error
+ vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 1,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ vi.spyOn(apiClient, 'ensureCameraPreviewActive')
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-1',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-2',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-2',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ vi.spyOn(apiClient, 'stopCameraPreview').mockRejectedValue(new Error('stop failed'))
+
+ const { result } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+ await act(async () => {
+ await result.current.start()
+ })
+ await waitFor(() => {
+ expect(result.current.session?.token).toBe('preview-token-1')
+ })
+ await act(async () => {
+ await result.current.stop()
+ })
+ await waitFor(() => {
+ expect(result.current.error?.message).toBe('stop failed')
+ expect(result.current.session).toBeNull()
+ })
+
+ // When: A later preview start succeeds
+ await act(async () => {
+ await result.current.start()
+ })
+
+ // Then: The stale stop error is cleared once the new session is accepted
+ await waitFor(() => {
+ expect(result.current.session?.token).toBe('preview-token-2')
+ expect(result.current.error).toBeNull()
+ expect(result.current.playlistUrl).toContain('preview-token-2')
+ })
+ })
+
+ it('stops stale activation that resolves after explicit stop', async () => {
+ // Given: A preview session is active and a replacement activation is in flight
+ const lateActivation = deferred>>()
+ vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 1,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ vi.spyOn(apiClient, 'ensureCameraPreviewActive')
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-old',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-old',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ .mockReturnValueOnce(lateActivation.promise)
+ const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({
+ accepted: true,
+ state: 'stopping',
+ httpStatus: 202,
+ })
+
+ const { result } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+ await act(async () => {
+ await result.current.start()
+ })
+ await waitFor(() => {
+ expect(result.current.session?.token).toBe('preview-token-old')
+ })
+
+ let lateActivationPromise!: Promise
+ act(() => {
+ lateActivationPromise = result.current.start()
+ })
+ await waitFor(() => {
+ expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledTimes(2)
+ })
+
+ // When: The user stops preview before the late activation resolves
+ await act(async () => {
+ await result.current.stop()
+ })
+ await waitFor(() => {
+ expect(stopPreview).toHaveBeenCalledTimes(1)
+ expect(result.current.session).toBeNull()
+ })
+ await act(async () => {
+ lateActivation.resolve({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-late',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-late',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ await lateActivationPromise
+ })
+
+ // Then: The stale activation is stopped instead of leaving the backend preview active
+ await waitFor(() => {
+ expect(stopPreview).toHaveBeenCalledTimes(2)
+ expect(result.current.session).toBeNull()
+ expect(result.current.playlistUrl).toBeNull()
+ expect(result.current.error).toBeNull()
+ })
+ })
+
+ it('surfaces cleanup failure when stale activation resolves after explicit stop', async () => {
+ // Given: A preview session is active and a replacement activation is in flight
+ const lateActivation = deferred>>()
+ vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 1,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ vi.spyOn(apiClient, 'ensureCameraPreviewActive')
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-old',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-old',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ .mockReturnValueOnce(lateActivation.promise)
+ const stopPreview = vi
+ .spyOn(apiClient, 'stopCameraPreview')
+ .mockResolvedValueOnce({
+ accepted: true,
+ state: 'stopping',
+ httpStatus: 202,
+ })
+ .mockRejectedValueOnce(new Error('late cleanup stop failed'))
+
+ const { result } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+ await act(async () => {
+ await result.current.start()
+ })
+ await waitFor(() => {
+ expect(result.current.session?.token).toBe('preview-token-old')
+ })
+
+ let lateActivationPromise!: Promise
+ act(() => {
+ lateActivationPromise = result.current.start()
+ })
+ await waitFor(() => {
+ expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledTimes(2)
+ })
+
+ // When: Explicit stop succeeds, but cleanup for the late activation fails
+ await act(async () => {
+ await result.current.stop()
+ })
+ await act(async () => {
+ lateActivation.resolve({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-late',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-late',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ await lateActivationPromise
+ })
+
+ // Then: The cleanup failure is exposed while local media remains detached
+ await waitFor(() => {
+ expect(stopPreview).toHaveBeenCalledTimes(2)
+ expect(result.current.session).toBeNull()
+ expect(result.current.playlistUrl).toBeNull()
+ expect(result.current.error?.message).toBe('late cleanup stop failed')
+ })
+ })
+
+ it('stops preview start that resolves after native background', async () => {
+ // Given: Preview start is still in flight when native iOS backgrounds the app
+ const previewStart = deferred>>()
+ vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 0,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ vi.spyOn(apiClient, 'ensureCameraPreviewActive').mockReturnValue(previewStart.promise)
+ const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({
+ accepted: true,
+ state: 'stopping',
+ httpStatus: 202,
+ })
+
+ const { result, rerender } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+
+ let startPromise!: Promise
+ act(() => {
+ startPromise = result.current.start()
+ })
+ await waitFor(() => {
+ expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledWith('front')
+ })
+
+ // When: The app backgrounds before the preview start response resolves
+ setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 })
+ rerender()
+ await act(async () => {
+ previewStart.resolve({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-1',
+ token_expires_at: '2026-04-23T12:00:10.000Z',
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ await startPromise
+ })
+
+ // Then: The late preview session is stopped instead of being stored while backgrounded
+ await waitFor(() => {
+ expect(stopPreview).toHaveBeenCalledWith('front')
+ expect(result.current.session).toBeNull()
+ expect(result.current.playlistUrl).toBeNull()
+ })
+ })
+
+ it('stops stale activation that resolves after native background stop', async () => {
+ // Given: An active preview and a second activation request still in flight
+ const lateActivation = deferred>>()
+ vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 1,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ vi.spyOn(apiClient, 'ensureCameraPreviewActive')
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-old',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-old',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ .mockReturnValueOnce(lateActivation.promise)
+ const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({
+ accepted: true,
+ state: 'stopping',
+ httpStatus: 202,
+ })
+
+ const { result, rerender } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+ await act(async () => {
+ await result.current.start()
+ })
+ await waitFor(() => {
+ expect(result.current.session?.token).toBe('preview-token-old')
+ })
+
+ let lateActivationPromise!: Promise
+ act(() => {
+ lateActivationPromise = result.current.start()
+ })
+ await waitFor(() => {
+ expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledTimes(2)
+ })
+
+ // When: iOS backgrounds, completes its normal stop, then the late activation resolves
+ setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 })
+ rerender()
+ await waitFor(() => {
+ expect(stopPreview).toHaveBeenCalledTimes(1)
+ expect(result.current.session).toBeNull()
+ })
+
+ await act(async () => {
+ lateActivation.resolve({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-late',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-late',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ await lateActivationPromise
+ })
+
+ // Then: The late server activation is cleaned up while the app remains backgrounded
+ await waitFor(() => {
+ expect(stopPreview).toHaveBeenCalledTimes(2)
+ expect(result.current.session).toBeNull()
+ expect(result.current.playlistUrl).toBeNull()
+ })
+ })
+
+ it('does not let a stale preview start stop a newer resumed preview', async () => {
+ // Given: A preview start begins before background and a newer start succeeds after resume
+ const staleStart = deferred>>()
+ vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 0,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ vi.spyOn(apiClient, 'ensureCameraPreviewActive')
+ .mockReturnValueOnce(staleStart.promise)
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-new',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-new',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({
+ accepted: true,
+ state: 'stopping',
+ httpStatus: 202,
+ })
+
+ const { result, rerender } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+
+ let staleStartPromise!: Promise
+ act(() => {
+ staleStartPromise = result.current.start()
+ })
+ await waitFor(() => {
+ expect(apiClient.ensureCameraPreviewActive).toHaveBeenCalledTimes(1)
+ })
+
+ setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 })
+ rerender()
+ setNativeLifecycleState({ isActive: true, isBackgrounded: false, resumeCount: 1 })
+ rerender()
+ await act(async () => {
+ await result.current.start()
+ })
+ await waitFor(() => {
+ expect(result.current.session?.token).toBe('preview-token-new')
+ })
+
+ // When: The stale pre-background start resolves after the newer resumed start
+ await act(async () => {
+ staleStart.resolve({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-stale',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-stale',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ await staleStartPromise
+ })
+
+ // Then: The stale completion is ignored without clearing or stopping the newer session
+ expect(stopPreview).not.toHaveBeenCalled()
+ expect(result.current.session?.token).toBe('preview-token-new')
+ expect(result.current.playlistUrl).toContain('preview-token-new')
+ })
+
+ it('does not start a resumed preview while a background stop is still pending', async () => {
+ // Given: A preview session is active and background stop has not completed yet
+ freezePreviewClock()
+ const backgroundStop = deferred>>()
+ vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 1,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ const ensurePreviewActive = vi
+ .spyOn(apiClient, 'ensureCameraPreviewActive')
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-old',
+ token_expires_at: '2026-04-23T12:00:10.000Z',
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-old',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ .mockResolvedValueOnce({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-new',
+ token_expires_at: '2026-04-23T12:00:10.000Z',
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-new',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockReturnValue(backgroundStop.promise)
+
+ const { result, rerender } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+ await act(async () => {
+ await result.current.start()
+ })
+ await waitFor(() => {
+ expect(result.current.session?.token).toBe('preview-token-old')
+ })
+
+ setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 })
+ rerender()
+ await waitFor(() => {
+ expect(stopPreview).toHaveBeenCalledWith('front')
+ expect(result.current.isStopping).toBe(true)
+ })
+
+ // When: The app resumes and a start is requested before the background stop resolves
+ setNativeLifecycleState({ isActive: true, isBackgrounded: false, resumeCount: 1 })
+ rerender()
+ await act(async () => {
+ await result.current.start()
+ })
+
+ // Then: The hook waits for the stop to settle instead of racing a new attach against it
+ expect(ensurePreviewActive).toHaveBeenCalledTimes(1)
+ expect(result.current.session).toBeNull()
+
+ await act(async () => {
+ backgroundStop.resolve({
+ accepted: true,
+ state: 'stopping',
+ httpStatus: 202,
+ })
+ await Promise.resolve()
+ })
+ await waitFor(() => {
+ expect(result.current.session).toBeNull()
+ })
+
+ // When: The user starts preview after the old background stop is settled
+ await act(async () => {
+ await result.current.start()
+ })
+
+ // Then: A new preview session can attach normally
+ await waitFor(() => {
+ expect(ensurePreviewActive).toHaveBeenCalledTimes(2)
+ expect(result.current.session?.token).toBe('preview-token-new')
+ expect(result.current.playlistUrl).toContain('preview-token-new')
+ })
+ })
+
+ it('does not stop preview on transient native inactive transitions before background', async () => {
+ // Given: Preview is attached while iOS is active
+ vi.spyOn(apiClient, 'getCameraPreviewStatus').mockResolvedValue({
+ camera_name: 'front',
+ enabled: true,
+ state: 'ready',
+ viewer_count: 1,
+ degraded_reason: null,
+ last_error: null,
+ idle_shutdown_at: null,
+ httpStatus: 200,
+ })
+ vi.spyOn(apiClient, 'ensureCameraPreviewActive').mockResolvedValue({
+ camera_name: 'front',
+ state: 'ready',
+ viewer_count: 1,
+ token: 'preview-token-1',
+ token_expires_at: null,
+ playlist_url: '/api/v1/preview/cameras/front/playlist.m3u8?token=preview-token-1',
+ idle_timeout_s: 30,
+ warning: null,
+ httpStatus: 200,
+ })
+ const stopPreview = vi.spyOn(apiClient, 'stopCameraPreview').mockResolvedValue({
+ accepted: true,
+ state: 'stopping',
+ httpStatus: 202,
+ })
+
+ const { result, rerender } = renderHook(() => useCameraPreview('front'), {
+ wrapper: createWrapper(),
+ })
+ await waitFor(() => {
+ expect(result.current.status?.state).toBe('ready')
+ })
+ await act(async () => {
+ await result.current.start()
+ })
+ await waitFor(() => {
+ expect(result.current.session?.token).toBe('preview-token-1')
+ })
+
+ // When: iOS becomes inactive without the pause/background event
+ setNativeLifecycleState({ isActive: false, isBackgrounded: false })
+ rerender()
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ // Then: Preview remains attached until a real background pause is observed
+ expect(stopPreview).not.toHaveBeenCalled()
+ expect(result.current.session?.token).toBe('preview-token-1')
+ })
})
diff --git a/ui/src/features/cameras/hooks/useCameraPreview.ts b/ui/src/features/cameras/hooks/useCameraPreview.ts
index ba4d7d91..784e19ae 100644
--- a/ui/src/features/cameras/hooks/useCameraPreview.ts
+++ b/ui/src/features/cameras/hooks/useCameraPreview.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
@@ -8,6 +8,7 @@ import {
type PreviewStopSnapshot,
} from '../../../api/client'
import { QUERY_KEYS } from '../../../api/hooks/queryKeys'
+import { useNativeAppLifecycleState } from '../../../runtime/nativeAppLifecycle'
const PREVIEW_STATUS_REFRESH_MS = 5_000
const PREVIEW_TOKEN_REFRESH_LEEWAY_MS = 5_000
@@ -35,12 +36,38 @@ interface StoredPreviewSession {
statusRequestSeq: number
}
+interface PreviewActivation {
+ activationSeq: number
+ pauseCountAtRequest: number
+ snapshot: PreviewSessionSnapshot
+}
+
+interface PreviewActivationRequest {
+ activationSeq: number
+ pauseCountAtRequest: number
+}
+
+interface PreviewStopRequest {
+ requestSeq: number
+}
+
export function useCameraPreview(cameraName: string): CameraPreviewState {
const queryClient = useQueryClient()
+ const nativeLifecycle = useNativeAppLifecycleState()
+ const nativeLifecycleRef = useRef(nativeLifecycle)
const [sessionState, setSessionState] = useState(null)
+ const [startError, setStartError] = useState(null)
const [refreshError, setRefreshError] = useState(null)
+ const [stopError, setStopError] = useState(null)
const sessionStateRef = useRef(null)
const statusRequestSeqRef = useRef(0)
+ const sessionRequestSeqRef = useRef(0)
+ const stopInFlightSeqRef = useRef(null)
+ const latestStopRequestSeqRef = useRef(0)
+
+ useLayoutEffect(() => {
+ nativeLifecycleRef.current = nativeLifecycle
+ }, [nativeLifecycle])
const storeSession = useCallback((nextSession: PreviewSessionSnapshot) => {
const nextState = {
@@ -48,6 +75,8 @@ export function useCameraPreview(cameraName: string): CameraPreviewState {
receivedAtMs: Date.now(),
statusRequestSeq: statusRequestSeqRef.current,
}
+ setStopError(null)
+ setStartError(null)
sessionStateRef.current = nextState
setSessionState(nextState)
}, [])
@@ -57,12 +86,95 @@ export function useCameraPreview(cameraName: string): CameraPreviewState {
setSessionState(null)
}, [])
- const startMutation = useMutation({
- mutationFn: () => apiClient.ensureCameraPreviewActive(cameraName),
- onSuccess: async (nextSession) => {
- setRefreshError(null)
- storeSession(nextSession)
- await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) })
+ const beginSessionRequest = useCallback(() => {
+ const requestSeq = sessionRequestSeqRef.current + 1
+ sessionRequestSeqRef.current = requestSeq
+ return requestSeq
+ }, [])
+
+ const beginStopRequest = useCallback(() => {
+ const requestSeq = beginSessionRequest()
+ stopInFlightSeqRef.current = requestSeq
+ latestStopRequestSeqRef.current = requestSeq
+ return requestSeq
+ }, [beginSessionRequest])
+
+ const beginCleanupBoundary = useCallback(() => {
+ const requestSeq = beginSessionRequest()
+ latestStopRequestSeqRef.current = requestSeq
+ return requestSeq
+ }, [beginSessionRequest])
+
+ const finishStopRequest = useCallback((requestSeq: number) => {
+ if (stopInFlightSeqRef.current === requestSeq) {
+ stopInFlightSeqRef.current = null
+ }
+ }, [])
+
+ const storeActivationIfCurrent = useCallback(async (activation: PreviewActivation) => {
+ const isLatestActivation = activation.activationSeq === sessionRequestSeqRef.current
+ const wasSupersededByLatestStop =
+ !isLatestActivation
+ && activation.activationSeq < latestStopRequestSeqRef.current
+ && sessionRequestSeqRef.current === latestStopRequestSeqRef.current
+ const currentLifecycle = nativeLifecycleRef.current
+
+ const stopLateActivation = async (): Promise => {
+ clearSession()
+ const stopRequestSeq = beginStopRequest()
+ try {
+ await apiClient.stopCameraPreview(cameraName)
+ setRefreshError(null)
+ setStopError(null)
+ await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) })
+ } catch (nextError) {
+ setStopError(nextError as Error)
+ await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) })
+ return
+ } finally {
+ finishStopRequest(stopRequestSeq)
+ }
+ }
+
+ if (currentLifecycle.isBackgrounded) {
+ await stopLateActivation()
+ return
+ }
+
+ if (currentLifecycle.pauseCount !== activation.pauseCountAtRequest) {
+ if (isLatestActivation) {
+ await stopLateActivation()
+ }
+ return
+ }
+
+ if (!isLatestActivation) {
+ if (wasSupersededByLatestStop) {
+ await stopLateActivation()
+ }
+ return
+ }
+
+ storeSession(activation.snapshot)
+ await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) })
+ }, [beginStopRequest, cameraName, clearSession, finishStopRequest, queryClient, storeSession])
+
+ const startMutation = useMutation({
+ mutationFn: async ({ activationSeq, pauseCountAtRequest }) => {
+ const snapshot = await apiClient.ensureCameraPreviewActive(cameraName)
+ return { activationSeq, pauseCountAtRequest, snapshot }
+ },
+ onSuccess: async (activation) => {
+ if (activation.activationSeq === sessionRequestSeqRef.current) {
+ setStartError(null)
+ setRefreshError(null)
+ }
+ await storeActivationIfCurrent(activation)
+ },
+ onError: (nextError, activation) => {
+ if (activation.activationSeq === sessionRequestSeqRef.current && !nativeLifecycleRef.current.isBackgrounded) {
+ setStartError(nextError)
+ }
},
})
@@ -79,38 +191,91 @@ export function useCameraPreview(cameraName: string): CameraPreviewState {
&& (nextStatus.enabled === false
|| (!PREVIEW_SESSION_ACTIVE_STATES.has(nextStatus.state) && !startMutation.isPending))
) {
+ beginCleanupBoundary()
clearSession()
+ setStartError(null)
setRefreshError(null)
+ setStopError(null)
}
return nextStatus
},
staleTime: PREVIEW_STATUS_REFRESH_MS,
- refetchInterval: sessionState ? PREVIEW_STATUS_REFRESH_MS : false,
+ enabled: nativeLifecycle.isActive,
+ refetchInterval: nativeLifecycle.isActive && sessionState ? PREVIEW_STATUS_REFRESH_MS : false,
})
- const stopMutation = useMutation({
+ const stopMutation = useMutation({
mutationFn: () => apiClient.stopCameraPreview(cameraName),
- onSuccess: async () => {
+ onSuccess: async (_snapshot, request) => {
+ if (request.requestSeq !== sessionRequestSeqRef.current) {
+ return
+ }
+ setStartError(null)
setRefreshError(null)
+ setStopError(null)
clearSession()
await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) })
},
+ onSettled: (_snapshot, _error, request) => {
+ finishStopRequest(request.requestSeq)
+ },
})
+ const refetchStatus = statusQuery.refetch
+ const stopPreview = stopMutation.mutateAsync
const session = sessionState?.snapshot ?? null
+ const stop = useCallback(async () => {
+ const requestSeq = beginStopRequest()
+ clearSession()
+ setStartError(null)
+ setStopError(null)
+ try {
+ await stopPreview({ requestSeq })
+ } catch (nextError) {
+ if (requestSeq === sessionRequestSeqRef.current) {
+ setStopError(nextError as Error)
+ }
+ return
+ }
+ }, [beginStopRequest, clearSession, stopPreview])
+
const refreshSession = useCallback(async () => {
+ if (nativeLifecycle.isBackgrounded || stopInFlightSeqRef.current !== null) {
+ return
+ }
+ const activationSeq = beginSessionRequest()
+ const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount
try {
- const nextSession = await apiClient.ensureCameraPreviewActive(cameraName)
- setRefreshError(null)
- storeSession(nextSession)
- await queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cameraPreview(cameraName) })
+ const snapshot = await apiClient.ensureCameraPreviewActive(cameraName)
+ if (activationSeq === sessionRequestSeqRef.current) {
+ setRefreshError(null)
+ }
+ await storeActivationIfCurrent({ activationSeq, pauseCountAtRequest, snapshot })
} catch (nextError) {
- setRefreshError(nextError as Error)
+ if (activationSeq === sessionRequestSeqRef.current && !nativeLifecycleRef.current.isBackgrounded) {
+ setRefreshError(nextError as Error)
+ }
+ }
+ }, [beginSessionRequest, cameraName, nativeLifecycle.isBackgrounded, storeActivationIfCurrent])
+
+ useEffect(() => {
+ if (!nativeLifecycle.isBackgrounded || sessionStateRef.current === null) {
+ return
+ }
+
+ void stop()
+ }, [nativeLifecycle.isBackgrounded, stop])
+
+ useEffect(() => {
+ if (!nativeLifecycle.isActive || nativeLifecycle.resumeCount === 0) {
+ return
}
- }, [cameraName, queryClient, storeSession])
+
+ void refetchStatus()
+ }, [nativeLifecycle.isActive, nativeLifecycle.resumeCount, refetchStatus])
useEffect(() => {
- if (session?.token_expires_at == null || stopMutation.isPending) {
+ if (nativeLifecycle.isBackgrounded || session?.token_expires_at == null || stopMutation.isPending) {
return
}
@@ -141,7 +306,13 @@ export function useCameraPreview(cameraName: string): CameraPreviewState {
return () => {
window.clearTimeout(timeoutId)
}
- }, [session?.token_expires_at, refreshError, refreshSession, stopMutation.isPending])
+ }, [
+ nativeLifecycle.isBackgrounded,
+ session?.token_expires_at,
+ refreshError,
+ refreshSession,
+ stopMutation.isPending,
+ ])
const warning =
session?.warning
@@ -150,8 +321,8 @@ export function useCameraPreview(cameraName: string): CameraPreviewState {
?? null
const playlistUrl = session ? apiClient.resolvePath(session.playlist_url) : null
- const error = (startMutation.error
- ?? stopMutation.error
+ const error = (startError
+ ?? stopError
?? refreshError
?? statusQuery.error
?? null) as Error | null
@@ -169,21 +340,28 @@ export function useCameraPreview(cameraName: string): CameraPreviewState {
isStarting: startMutation.isPending,
isStopping: stopMutation.isPending,
start: async () => {
- try {
- await startMutation.mutateAsync()
- } catch {
+ if (
+ !nativeLifecycle.isActive
+ || nativeLifecycle.isBackgrounded
+ || stopInFlightSeqRef.current !== null
+ ) {
return
}
- },
- stop: async () => {
+ const activationSeq = beginSessionRequest()
+ const pauseCountAtRequest = nativeLifecycleRef.current.pauseCount
+ setStartError(null)
try {
- await stopMutation.mutateAsync()
+ await startMutation.mutateAsync({ activationSeq, pauseCountAtRequest })
} catch {
return
}
},
+ stop,
refreshStatus: async () => {
- const result = await statusQuery.refetch()
+ if (nativeLifecycle.isBackgrounded) {
+ return null
+ }
+ const result = await refetchStatus()
return result.data ?? null
},
}
diff --git a/ui/src/features/cameras/hooks/usePushToTalk.test.tsx b/ui/src/features/cameras/hooks/usePushToTalk.test.tsx
index 66a47ec0..ecda728f 100644
--- a/ui/src/features/cameras/hooks/usePushToTalk.test.tsx
+++ b/ui/src/features/cameras/hooks/usePushToTalk.test.tsx
@@ -3,12 +3,48 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+type MockNativeLifecycleState = {
+ isActive: boolean
+ isBackgrounded: boolean
+ pauseCount: number
+ resumeCount: number
+}
+
+const nativeLifecycleMock = vi.hoisted(() => ({
+ state: {
+ isActive: true,
+ isBackgrounded: false,
+ pauseCount: 0,
+ resumeCount: 0,
+ } as MockNativeLifecycleState,
+}))
+
+vi.mock('../../../runtime/nativeAppLifecycle', () => ({
+ useNativeAppLifecycleState: () => nativeLifecycleMock.state,
+}))
+
import { apiClient } from '../../../api/client'
import type { TalkSessionResponse, TalkStatusResponse } from '../../../api/generated/types'
import { usePushToTalk } from './usePushToTalk'
type TalkStatusSnapshot = TalkStatusResponse & { httpStatus: number }
+function resetNativeLifecycleState() {
+ nativeLifecycleMock.state = {
+ isActive: true,
+ isBackgrounded: false,
+ pauseCount: 0,
+ resumeCount: 0,
+ }
+}
+
+function setNativeLifecycleState(nextState: Partial) {
+ nativeLifecycleMock.state = {
+ ...nativeLifecycleMock.state,
+ ...nextState,
+ }
+}
+
const idleStatus: TalkStatusResponse = {
camera_name: 'front',
enabled: true,
@@ -242,6 +278,7 @@ describe('usePushToTalk', () => {
})
afterEach(() => {
+ resetNativeLifecycleState()
cleanup()
vi.restoreAllMocks()
})
@@ -756,4 +793,78 @@ describe('usePushToTalk', () => {
expect(lastGainNode?.gain.value).toBe(0)
expect(lastGainNode?.connect).toHaveBeenCalled()
})
+
+ it('stops active talk on native background and refreshes status on resume', async () => {
+ // Given: An active native app push-to-talk stream
+ const { stream, track } = createMediaStream()
+ installBrowserFakes(vi.fn().mockResolvedValue(stream))
+ const { result, rerender } = renderHook(() => usePushToTalk('front'))
+ await waitFor(() => expect(result.current.canStart).toBe(true))
+ void act(() => {
+ void result.current.start()
+ })
+ await waitFor(() => expect(sockets).toHaveLength(1))
+ sockets[0].open()
+ sockets[0].message(JSON.stringify({ type: 'ready' }))
+ await waitFor(() => expect(result.current.isStreaming).toBe(true))
+
+ // When: iOS backgrounds the app during an active talk session
+ setNativeLifecycleState({ isActive: false, isBackgrounded: true, pauseCount: 1 })
+ rerender()
+
+ // Then: The hook sends the stop frame, tears down media, and stops the backend session
+ await waitFor(() => {
+ expect(apiClient.stopCameraTalkSession).toHaveBeenCalledWith('front', 'tk_123')
+ })
+ expect(sockets[0].sent).toContain(JSON.stringify({ type: 'stop' }))
+ expect(sockets[0].lastClose).toEqual({ code: 1000, reason: 'Talk stopped' })
+ expect(track.stop).toHaveBeenCalled()
+ await waitFor(() => expect(result.current.isStreaming).toBe(false))
+ expect(result.current.canStart).toBe(false)
+ const statusCallsAfterPause = vi.mocked(apiClient.getCameraTalkStatus).mock.calls.length
+
+ await act(async () => {
+ await result.current.refreshStatus()
+ })
+ expect(apiClient.getCameraTalkStatus).toHaveBeenCalledTimes(statusCallsAfterPause)
+
+ // When: iOS resumes the app
+ setNativeLifecycleState({ isActive: true, isBackgrounded: false, resumeCount: 1 })
+ rerender()
+
+ // Then: The hook refreshes talk status for the foregrounded route
+ await waitFor(() => {
+ expect(vi.mocked(apiClient.getCameraTalkStatus).mock.calls.length).toBeGreaterThan(
+ statusCallsAfterPause,
+ )
+ })
+ })
+
+ it('does not stop active talk on transient native inactive transitions before background', async () => {
+ // Given: An active push-to-talk stream while iOS is active
+ const { stream, track } = createMediaStream()
+ installBrowserFakes(vi.fn().mockResolvedValue(stream))
+ const { result, rerender } = renderHook(() => usePushToTalk('front'))
+ await waitFor(() => expect(result.current.canStart).toBe(true))
+ void act(() => {
+ void result.current.start()
+ })
+ await waitFor(() => expect(sockets).toHaveLength(1))
+ sockets[0].open()
+ sockets[0].message(JSON.stringify({ type: 'ready' }))
+ await waitFor(() => expect(result.current.isStreaming).toBe(true))
+
+ // When: iOS becomes inactive without the pause/background event
+ setNativeLifecycleState({ isActive: false, isBackgrounded: false })
+ rerender()
+ await act(async () => {
+ await Promise.resolve()
+ })
+
+ // Then: The talk stream remains active until actual backgrounding
+ expect(apiClient.stopCameraTalkSession).not.toHaveBeenCalled()
+ expect(sockets[0].readyState).toBe(FakeWebSocket.OPEN)
+ expect(track.stop).not.toHaveBeenCalled()
+ expect(result.current.isStreaming).toBe(true)
+ })
})
diff --git a/ui/src/features/cameras/hooks/usePushToTalk.ts b/ui/src/features/cameras/hooks/usePushToTalk.ts
index a59f013b..b4ae6daf 100644
--- a/ui/src/features/cameras/hooks/usePushToTalk.ts
+++ b/ui/src/features/cameras/hooks/usePushToTalk.ts
@@ -13,6 +13,7 @@ import type {
TalkState,
TalkStatusResponse,
} from '../../../api/generated/types'
+import { useNativeAppLifecycleState } from '../../../runtime/nativeAppLifecycle'
const DEFAULT_TALK_INPUT: TalkInputFormat = {
codec: 'pcm_s16le',
@@ -236,6 +237,8 @@ function nextStatusFromState(
}
export function usePushToTalk(cameraName: string): PushToTalkState {
+ const nativeLifecycle = useNativeAppLifecycleState()
+ const nativeLifecycleRef = useRef(nativeLifecycle)
const [status, setStatus] = useState(null)
const [session, setSession] = useState(null)
const [error, setError] = useState(null)
@@ -255,6 +258,10 @@ export function usePushToTalk(cameraName: string): PushToTalkState {
const pendingSessionIdRef = useRef(null)
const statusRequestGenerationRef = useRef(0)
+ useEffect(() => {
+ nativeLifecycleRef.current = nativeLifecycle
+ }, [nativeLifecycle])
+
const cleanupSocketAndAudio = useCallback(async () => {
const socket = socketRef.current
socketRef.current = null
@@ -269,6 +276,9 @@ export function usePushToTalk(cameraName: string): PushToTalkState {
}, [])
const refreshStatus = useCallback(async () => {
+ if (nativeLifecycleRef.current.isBackgrounded) {
+ return
+ }
const generation = statusRequestGenerationRef.current + 1
statusRequestGenerationRef.current = generation
setIsPending(true)
@@ -420,7 +430,13 @@ export function usePushToTalk(cameraName: string): PushToTalkState {
)
const start = useCallback(async () => {
- if (startInFlightRef.current || isStreaming || isStopping) {
+ if (
+ !nativeLifecycleRef.current.isActive
+ || nativeLifecycleRef.current.isBackgrounded
+ || startInFlightRef.current
+ || isStreaming
+ || isStopping
+ ) {
return
}
const generation = startGenerationRef.current + 1
@@ -530,7 +546,13 @@ export function usePushToTalk(cameraName: string): PushToTalkState {
setIsStarting(false)
}
}
- }, [cameraName, cleanupSocketAndAudio, isStopping, isStreaming, openTalkSocket])
+ }, [
+ cameraName,
+ cleanupSocketAndAudio,
+ isStopping,
+ isStreaming,
+ openTalkSocket,
+ ])
const stop = useCallback(async () => {
const activeSession = sessionRef.current
@@ -588,7 +610,9 @@ export function usePushToTalk(cameraName: string): PushToTalkState {
setIsStreaming(false)
setIsStarting(false)
setIsStopping(false)
- void refreshStatus()
+ if (!nativeLifecycleRef.current.isBackgrounded) {
+ void refreshStatus()
+ }
return () => {
mountedRef.current = false
statusRequestGenerationRef.current += 1
@@ -608,9 +632,42 @@ export function usePushToTalk(cameraName: string): PushToTalkState {
}
}, [cameraName, cleanupSocketAndAudio, refreshStatus])
+ useEffect(() => {
+ if (!nativeLifecycle.isBackgrounded) {
+ return
+ }
+
+ void stop()
+ }, [nativeLifecycle.isBackgrounded, stop])
+
+ useEffect(() => {
+ if (!nativeLifecycle.isActive || nativeLifecycle.resumeCount === 0) {
+ return
+ }
+
+ void refreshStatus()
+ }, [nativeLifecycle.isActive, nativeLifecycle.resumeCount, refreshStatus])
+
const canStart = useMemo(
- () => !isPending && !isStarting && !isStopping && !isStreaming && statusAllowsStart(status, cameraName),
- [cameraName, isPending, isStarting, isStopping, isStreaming, status],
+ () => (
+ nativeLifecycle.isActive
+ && !nativeLifecycle.isBackgrounded
+ && !isPending
+ && !isStarting
+ && !isStopping
+ && !isStreaming
+ && statusAllowsStart(status, cameraName)
+ ),
+ [
+ cameraName,
+ isPending,
+ isStarting,
+ isStopping,
+ isStreaming,
+ nativeLifecycle.isActive,
+ nativeLifecycle.isBackgrounded,
+ status,
+ ],
)
return {
diff --git a/ui/src/features/clips/ClipDetailPage.test.tsx b/ui/src/features/clips/ClipDetailPage.test.tsx
index 018be1ac..96a1c9e1 100644
--- a/ui/src/features/clips/ClipDetailPage.test.tsx
+++ b/ui/src/features/clips/ClipDetailPage.test.tsx
@@ -5,7 +5,7 @@ import { cleanup, render, screen, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
-import type { ClipListSnapshot } from '../../api/client'
+import { APIError, type ClipListSnapshot } from '../../api/client'
import type { ClipResponse } from '../../api/generated/types'
import { QUERY_KEYS } from '../../api/hooks/queryKeys'
import type { useClipMediaUrl } from '../../api/hooks/useClipMediaUrl'
@@ -45,10 +45,14 @@ function renderDetail({
route = '/events/clip-2?detected=any',
clip = makeClip('clip-2'),
cachedClips,
+ clipQuery = {},
+ mediaQuery = {},
}: {
route?: string
- clip?: ClipResponse
+ clip?: ClipResponse | undefined
cachedClips?: ClipResponse[]
+ clipQuery?: Partial>
+ mediaQuery?: Partial>
} = {}) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -72,6 +76,7 @@ function renderDetail({
isFetching: false,
error: null,
refetch: vi.fn().mockResolvedValue(undefined),
+ ...clipQuery,
} as unknown as ReturnType)
useClipMediaUrlMock.mockReturnValue({
@@ -81,6 +86,7 @@ function renderDetail({
isPending: false,
error: null,
refresh: vi.fn().mockResolvedValue('/api/v1/clips/clip-2/media'),
+ ...mediaQuery,
} as unknown as ReturnType)
render(
@@ -119,7 +125,7 @@ describe('ClipDetailPage', () => {
expect(screen.getByRole('heading', { name: 'Package Drop' })).toBeTruthy()
expect(screen.getByText('Package left near the front door.')).toBeTruthy()
expect(screen.getByText('person, package')).toBeTruthy()
- expect(screen.getByRole('link', { name: 'Back to events' }).getAttribute('href')).toBe(
+ expect(screen.getByRole('link', { name: 'Back to Events' }).getAttribute('href')).toBe(
'/events?detected=any',
)
expect(screen.getByRole('link', { name: 'Previous event' }).getAttribute('href')).toBe(
@@ -145,4 +151,89 @@ describe('ClipDetailPage', () => {
expect(previous.getAttribute('aria-disabled')).toBe('true')
expect(next.getAttribute('aria-disabled')).toBe('true')
})
+
+ it('opens notification deep links on the event detail page with a list fallback', () => {
+ // Given: iOS opens an event detail route from a notification
+ renderDetail({
+ route: '/events/clip-2?from=notification',
+ })
+
+ // When: The event detail renders
+ const backToEvents = screen.getByRole('link', { name: 'Back to Events' })
+
+ // Then: The detail content opens and the list fallback drops notification-only routing state
+ expect(screen.getByRole('heading', { name: 'Package Drop' })).toBeTruthy()
+ expect(screen.getByText('Package left near the front door.')).toBeTruthy()
+ expect(backToEvents.getAttribute('href')).toBe('/events')
+ expect(useClipMediaUrlMock).toHaveBeenCalledWith('clip-2')
+ })
+
+ it('strips notification source state from neighbor navigation', () => {
+ // Given: A notification opens an event with cached neighboring events
+ renderDetail({
+ route: '/events/clip-2?from=notification&detected=any',
+ cachedClips: [
+ makeClip('clip-1', { summary: 'Earlier package event.' }),
+ makeClip('clip-2'),
+ makeClip('clip-3', { summary: 'Next package event.' }),
+ ],
+ })
+
+ // When: Navigating around the cached event window
+ const previous = screen.getByRole('link', { name: 'Previous event' })
+ const next = screen.getByRole('link', { name: 'Next event' })
+
+ // Then: Neighbor routes preserve list filters without carrying notification-only source state
+ expect(previous.getAttribute('href')).toBe('/events/clip-1?detected=any')
+ expect(next.getAttribute('href')).toBe('/events/clip-3?detected=any')
+ })
+
+ it('shows a notification-specific fallback when a cached event no longer exists', () => {
+ // Given: A notification points at an event that the API no longer has but React Query has cached
+ const missingEventError = new APIError(
+ 'Clip not found',
+ 404,
+ { detail: 'Clip not found' },
+ 'CLIP_NOT_FOUND',
+ )
+ renderDetail({
+ route: '/events/deleted-clip?from=notification',
+ clip: makeClip('deleted-clip', { summary: 'Stale cached summary.' }),
+ clipQuery: {
+ error: missingEventError,
+ },
+ })
+
+ // When: The detail route handles the missing event response
+ const fallback = screen.getByRole('heading', { name: 'Event no longer available' })
+
+ // Then: The page stays useful instead of rendering a blank detail view
+ expect(fallback).toBeTruthy()
+ expect(screen.getByText(/opened from this notification is no longer available/i)).toBeTruthy()
+ expect(screen.getByRole('link', { name: 'Back to Events' }).getAttribute('href')).toBe('/events')
+ expect(screen.queryByText('Event video')).toBeNull()
+ expect(screen.queryByText('Stale cached summary.')).toBeNull()
+ expect(useClipMediaUrlMock).toHaveBeenCalledWith(undefined)
+ })
+
+ it('keeps event metadata visible when playback is unavailable', () => {
+ // Given: Event metadata loads but the media URL cannot be prepared
+ renderDetail({
+ mediaQuery: {
+ mediaUrl: null,
+ error: new Error('Media file is missing'),
+ },
+ })
+
+ // When: The detail page renders the unavailable playback state
+ const summary = screen.getByLabelText('Event summary')
+
+ // Then: Review metadata remains visible alongside the playback problem
+ expect(screen.getByText('Event video is not available for playback.')).toBeTruthy()
+ expect(within(summary).getByRole('heading', { name: 'Package Drop' })).toBeTruthy()
+ expect(within(summary).getByText('Package left near the front door.')).toBeTruthy()
+ expect(within(summary).getByText('front_door')).toBeTruthy()
+ expect(within(summary).getByText('High')).toBeTruthy()
+ expect(within(summary).getByText('Media file is missing')).toBeTruthy()
+ })
})
diff --git a/ui/src/features/clips/ClipDetailPage.tsx b/ui/src/features/clips/ClipDetailPage.tsx
index 579101bc..c58b1894 100644
--- a/ui/src/features/clips/ClipDetailPage.tsx
+++ b/ui/src/features/clips/ClipDetailPage.tsx
@@ -4,6 +4,7 @@ import { Link, useLocation, useParams } from 'react-router-dom'
import {
clearApiKey,
+ isAPIError,
isUnauthorizedAPIError,
saveApiKey,
type ClipListSnapshot,
@@ -42,11 +43,43 @@ interface NeighborEvents {
}
function eventDetailPath(clipId: string, routeSearch: string): string {
- return `/events/${encodeURIComponent(clipId)}${routeSearch}`
+ return `/events/${encodeURIComponent(clipId)}${eventNavigationSearch(routeSearch)}`
+}
+
+function eventNavigationSearch(routeSearch: string): string {
+ const params = new URLSearchParams(routeSearch)
+ params.delete('from')
+ const search = params.toString()
+ return search ? `?${search}` : ''
}
function eventListPath(routeSearch: string): string {
- return `/events${routeSearch}`
+ return `/events${eventNavigationSearch(routeSearch)}`
+}
+
+function isNotificationOpen(searchParams: URLSearchParams): boolean {
+ return searchParams.get('from') === 'notification'
+}
+
+function isMissingEventError(error: unknown): boolean {
+ return isAPIError(error) && error.status === 404
+}
+
+function describeClipLoadError(error: unknown, openedFromNotification: boolean): string {
+ if (isMissingEventError(error)) {
+ return openedFromNotification
+ ? 'The event opened from this notification is no longer available. It may have been deleted or cleaned up.'
+ : 'This event is no longer available. It may have been deleted or cleaned up.'
+ }
+
+ return describeClipError(error)
+}
+
+function clipLoadErrorTitle(error: unknown, openedFromNotification: boolean): string {
+ if (isMissingEventError(error)) {
+ return 'Event no longer available'
+ }
+ return openedFromNotification ? 'Notification event could not load' : 'Event could not load'
}
function findClipWindow(
@@ -92,15 +125,19 @@ export function ClipDetailPage() {
const location = useLocation()
const queryClient = useQueryClient()
const clipQuery = useClipQuery(clipId)
- const mediaQuery = useClipMediaUrl(clipId)
+ const missingEvent = isMissingEventError(clipQuery.error)
+ const clip = missingEvent ? undefined : clipQuery.data
+ const mediaQuery = useClipMediaUrl(clip?.id)
+ const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search])
+ const openedFromNotification = isNotificationOpen(searchParams)
const unauthorized = isUnauthorizedAPIError(clipQuery.error)
- const clip = clipQuery.data
const externalLink = clip ? resolveClipExternalLink(clip) : null
const viewUrlLink = clip ? resolveClipViewLink(clip) : null
const externalStorageLink = externalLink && externalLink !== viewUrlLink ? externalLink : null
+ const backToEventsPath = eventListPath(location.search)
const listQuery = useMemo(
- () => parseClipsQuery(new URLSearchParams(location.search)),
- [location.search],
+ () => parseClipsQuery(searchParams),
+ [searchParams],
)
const clipWindow = useMemo(
() => findClipWindow(queryClient, clipId, listQuery),
@@ -123,14 +160,14 @@ export function ClipDetailPage() {
}, [mediaQuery.mediaUrl])
async function submitApiKey(apiKey: string): Promise {
- saveApiKey(apiKey)
+ await saveApiKey(apiKey)
playbackRefreshAttempts.current = 0
await clipQuery.refetch()
await mediaQuery.refresh()
}
async function clearStoredApiKey(): Promise {
- clearApiKey()
+ await clearApiKey()
await clipQuery.refetch()
}
@@ -159,9 +196,14 @@ export function ClipDetailPage() {
{clip ? `${clip.camera} - ${formatTimestamp(clip.created_at)}` : 'Recorded security event'}
-
+
+
+ Back to Events
+
+
+
{!clipId ? (
@@ -169,7 +211,7 @@ export function ClipDetailPage() {
title="Invalid event request"
description={(
<>
- Missing event ID. Return to
events.
+ Missing event ID. Return to
events.
>
)}
tone="error"
@@ -196,8 +238,8 @@ export function ClipDetailPage() {
{clipQuery.error && !unauthorized ? (
) : null}
@@ -252,9 +294,6 @@ export function ClipDetailPage() {
-
- Back to events
-
{neighbors.previous ? (
{
- saveApiKey(apiKey)
+ await saveApiKey(apiKey)
await Promise.all([clipsQuery.refetch(), camerasQuery.refetch()])
}
async function clearStoredApiKey(): Promise
{
- clearApiKey()
+ await clearApiKey()
await Promise.all([clipsQuery.refetch(), camerasQuery.refetch()])
}
diff --git a/ui/src/features/live/LivePage.tsx b/ui/src/features/live/LivePage.tsx
index 6b3b6f27..aa139ea6 100644
--- a/ui/src/features/live/LivePage.tsx
+++ b/ui/src/features/live/LivePage.tsx
@@ -38,12 +38,12 @@ export function LivePage() {
}
async function submitApiKey(apiKey: string): Promise {
- saveApiKey(apiKey)
+ await saveApiKey(apiKey)
await camerasQuery.refetch()
}
async function clearStoredApiKey(): Promise {
- clearApiKey()
+ await clearApiKey()
await camerasQuery.refetch()
}
diff --git a/ui/src/features/native-setup/NativeSetupPage.test.tsx b/ui/src/features/native-setup/NativeSetupPage.test.tsx
new file mode 100644
index 00000000..5923fce5
--- /dev/null
+++ b/ui/src/features/native-setup/NativeSetupPage.test.tsx
@@ -0,0 +1,302 @@
+// @vitest-environment happy-dom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+
+import {
+ BROWSER_AUTH_TOKEN_STORAGE_KEY,
+ InMemoryAuthTokenProvider,
+} from '../../api/tokenProvider'
+import { BROWSER_SERVER_BASE_URL_STORAGE_KEY } from '../../api/serverBaseUrlProvider'
+import { NativeSetupPage, type NativeSetupPageProps } 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 createTestQueryClient(): QueryClient {
+ return new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ })
+}
+
+function renderNativeSetup(
+ props: NativeSetupPageProps = {},
+ queryClient: QueryClient = createTestQueryClient(),
+ setupState: unknown = undefined,
+): QueryClient {
+ const initialEntry =
+ setupState === undefined
+ ? '/native-setup'
+ : {
+ pathname: '/native-setup',
+ state: setupState,
+ }
+
+ render(
+
+
+
+ } />
+ Live route} />
+ Event route} />
+
+
+ ,
+ )
+ return queryClient
+}
+
+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('clears stale token input when the validated server URL changes', async () => {
+ // Given: User validated a protected server and entered its API token
+ const user = userEvent.setup()
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD))
+ .mockResolvedValueOnce(unauthorizedResponse())
+ renderNativeSetup()
+ await user.type(screen.getByLabelText('Server URL'), 'https://homesec.example.com')
+ await user.click(screen.getByRole('button', { name: 'Check server' }))
+ await screen.findByText('Server reachable')
+ await user.type(screen.getByLabelText('API token'), 'token-for-first-server')
+
+ // When: The server URL field changes before saving
+ await user.clear(screen.getByLabelText('Server URL'))
+ await user.type(screen.getByLabelText('Server URL'), 'https://homesec-new.example.com')
+
+ // Then: The previous server token cannot be saved against the new server
+ expect((screen.getByLabelText('API token') as HTMLInputElement).value).toBe('')
+ })
+
+ it('can save tokens through an in-memory provider without writing browser token storage', async () => {
+ // Given: Native setup uses an in-memory token provider
+ const user = userEvent.setup()
+ const authTokenProvider = new InMemoryAuthTokenProvider()
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD))
+ .mockResolvedValueOnce(unauthorizedResponse())
+ .mockResolvedValueOnce(jsonResponse([]))
+ renderNativeSetup({ authTokenProvider })
+
+ // When: User validates and saves a protected server
+ await user.type(screen.getByLabelText('Server URL'), 'https://homesec.example.com')
+ await user.click(screen.getByRole('button', { name: 'Check server' }))
+ await screen.findByText('Server reachable')
+ await user.type(screen.getByLabelText('API token'), 'native-token')
+ await user.click(screen.getByRole('button', { name: 'Save and continue' }))
+
+ // Then: The token is available to API clients through the provider only
+ await waitFor(() => {
+ expect(screen.getByText('Live route')).toBeTruthy()
+ })
+ expect(await authTokenProvider.getToken()).toBe('native-token')
+ expect(window.sessionStorage.getItem(BROWSER_AUTH_TOKEN_STORAGE_KEY)).toBeNull()
+ })
+
+ it('clears cached API data after saving a server URL', async () => {
+ // Given: Cached data from a previous HomeSec server
+ const user = userEvent.setup()
+ const queryClient = createTestQueryClient()
+ queryClient.setQueryData(['cameras'], [{ name: 'old-server-camera' }])
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD))
+ .mockResolvedValueOnce(unauthorizedResponse())
+ .mockResolvedValueOnce(jsonResponse([]))
+ renderNativeSetup({}, queryClient)
+
+ // When: User validates and saves a new server
+ await user.type(screen.getByLabelText('Server URL'), 'https://homesec.example.com')
+ await user.click(screen.getByRole('button', { name: 'Check server' }))
+ await screen.findByText('Server reachable')
+ await user.type(screen.getByLabelText('API token'), 'token-123')
+ await user.click(screen.getByRole('button', { name: 'Save and continue' }))
+
+ // Then: Server-agnostic React Query cache entries cannot leak across servers
+ await waitFor(() => {
+ expect(screen.getByText('Live route')).toBeTruthy()
+ })
+ expect(queryClient.getQueryData(['cameras'])).toBeUndefined()
+ })
+
+ it('returns to the requested route after native setup succeeds', async () => {
+ // Given: Setup was opened by a guard for an event deep link
+ const user = userEvent.setup()
+ vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(jsonResponse(HEALTH_PAYLOAD))
+ .mockResolvedValueOnce(unauthorizedResponse())
+ .mockResolvedValueOnce(jsonResponse([]))
+ renderNativeSetup(
+ {},
+ createTestQueryClient(),
+ { nativeSetupReturnTo: '/events/clip-42?camera=front' },
+ )
+
+ // When: User completes setup
+ await user.type(screen.getByLabelText('Server URL'), 'https://homesec.example.com')
+ await user.click(screen.getByRole('button', { name: 'Check server' }))
+ await screen.findByText('Server reachable')
+ await user.type(screen.getByLabelText('API token'), 'token-123')
+ await user.click(screen.getByRole('button', { name: 'Save and continue' }))
+
+ // Then: The original route intent wins over the default Live destination
+ await waitFor(() => {
+ expect(screen.getByText('Event route')).toBeTruthy()
+ })
+ })
+
+ it('warns and allows continuing when auth-disabled mode is detectable', async () => {
+ // Given: Camera list succeeds without an API token and an old token is stored
+ const user = userEvent.setup()
+ 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..10c43d28
--- /dev/null
+++ b/ui/src/features/native-setup/NativeSetupPage.tsx
@@ -0,0 +1,258 @@
+import { useState, type FormEvent } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { useLocation, useNavigate } from 'react-router-dom'
+
+import {
+ HomeSecApiClient,
+ isAPIError,
+ isUnauthorizedAPIError,
+ persistRuntimeAuthSessionReady,
+ runtimeAuthTokenProvider,
+ runtimeServerBaseUrlProvider,
+} 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.'
+}
+
+function nativeSetupReturnTo(state: unknown): string {
+ if (!state || typeof state !== 'object') {
+ return '/live'
+ }
+
+ const returnTo = (state as { nativeSetupReturnTo?: unknown }).nativeSetupReturnTo
+ if (
+ typeof returnTo !== 'string' ||
+ !returnTo.startsWith('/') ||
+ returnTo.startsWith('//') ||
+ returnTo === '/native-setup'
+ ) {
+ return '/live'
+ }
+
+ return returnTo
+}
+
+export function NativeSetupPage({
+ authTokenProvider = runtimeAuthTokenProvider,
+ createClient = (baseUrl: string) => new HomeSecApiClient(baseUrl),
+ serverBaseUrlProvider = runtimeServerBaseUrlProvider,
+}: NativeSetupPageProps = {}) {
+ const navigate = useNavigate()
+ const location = useLocation()
+ const queryClient = useQueryClient()
+ 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)
+ setApiToken('')
+ 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)
+ await persistRuntimeAuthSessionReady({ persistAuthDisabled: authDisabled })
+ queryClient.clear()
+ navigate(nativeSetupReturnTo(location.state), { 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..8cddee5c
--- /dev/null
+++ b/ui/src/features/native-setup/nativeSetup.css
@@ -0,0 +1,83 @@
+.native-setup-page {
+ min-height: 100vh;
+ min-height: 100dvh;
+ overflow-y: auto;
+ padding:
+ calc(var(--space-6) + var(--safe-area-inset-top))
+ calc(var(--space-6) + var(--safe-area-inset-right))
+ calc(var(--space-6) + var(--safe-area-inset-bottom))
+ calc(var(--space-6) + var(--safe-area-inset-left));
+ display: grid;
+ place-items: center;
+ scroll-padding-bottom: calc(var(--space-6) + var(--safe-area-inset-bottom));
+ -webkit-overflow-scrolling: touch;
+}
+
+.native-setup-panel {
+ 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:
+ calc(var(--space-4) + var(--safe-area-inset-top))
+ calc(var(--space-4) + var(--safe-area-inset-right))
+ calc(var(--space-4) + var(--safe-area-inset-bottom))
+ calc(var(--space-4) + var(--safe-area-inset-left));
+ align-items: stretch;
+ scroll-padding-bottom: calc(var(--space-4) + var(--safe-area-inset-bottom));
+ }
+
+ .native-setup-panel {
+ 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..1eb1dd7a
--- /dev/null
+++ b/ui/src/features/native-setup/nativeSetup.test.ts
@@ -0,0 +1,98 @@
+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 pathUrl = 'https://homesec.example.com/homesec///'
+ const lanUrl = 'http://192.168.1.10:8081/'
+ const localHostUrl = 'http://homesec.local:8081/'
+ const singleLabelUrl = 'http://homesec:8081/'
+ const missingScheme = 'homesec.local:8081'
+
+ // When: Validating each value
+ const httpsResult = validateNativeSetupServerUrl(httpsUrl)
+ const pathResult = validateNativeSetupServerUrl(pathUrl)
+ const lanResult = validateNativeSetupServerUrl(lanUrl)
+ const localHostResult = validateNativeSetupServerUrl(localHostUrl)
+ const singleLabelResult = validateNativeSetupServerUrl(singleLabelUrl)
+ const missingSchemeResult = validateNativeSetupServerUrl(missingScheme)
+
+ // Then: Supported URLs normalize and invalid input returns an actionable message
+ expect(httpsResult).toEqual({
+ ok: true,
+ value: {
+ serverBaseUrl: 'https://homesec.example.com',
+ isPlainHttp: false,
+ },
+ })
+ expect(pathResult).toEqual({
+ ok: true,
+ value: {
+ serverBaseUrl: 'https://homesec.example.com/homesec',
+ isPlainHttp: false,
+ },
+ })
+ expect(lanResult).toEqual({
+ ok: true,
+ value: {
+ serverBaseUrl: 'http://192.168.1.10:8081',
+ isPlainHttp: true,
+ },
+ })
+ expect(localHostResult).toEqual({
+ ok: true,
+ value: {
+ serverBaseUrl: 'http://homesec.local:8081',
+ isPlainHttp: true,
+ },
+ })
+ expect(singleLabelResult).toEqual({
+ ok: true,
+ value: {
+ serverBaseUrl: 'http://homesec:8081',
+ isPlainHttp: true,
+ },
+ })
+ expect(missingSchemeResult).toEqual({
+ ok: false,
+ message: 'Only http:// and https:// server URLs are supported.',
+ })
+ })
+
+ it('rejects public plain-HTTP hosts', () => {
+ // Given: Plain-HTTP URLs outside the local network
+ const publicHostname = 'http://homesec.example.com'
+ const publicIp = 'http://8.8.8.8:8081'
+
+ // When: Validating setup input
+ const hostnameResult = validateNativeSetupServerUrl(publicHostname)
+ const ipResult = validateNativeSetupServerUrl(publicIp)
+
+ // Then: The setup flow requires HTTPS for public hosts
+ expect(hostnameResult).toEqual({
+ ok: false,
+ message: 'Plain HTTP is only supported for local network HomeSec servers. Use HTTPS for public hosts.',
+ })
+ expect(ipResult).toEqual({
+ ok: false,
+ message: 'Plain HTTP is only supported for local network HomeSec servers. Use HTTPS for public hosts.',
+ })
+ })
+
+ it('rejects blank server URLs', () => {
+ // Given: A blank server URL
+ const input = ' '
+
+ // 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..644ddbfd
--- /dev/null
+++ b/ui/src/features/native-setup/nativeSetup.ts
@@ -0,0 +1,125 @@
+import { normalizeServerBaseUrl } from '../../api/serverBaseUrlProvider'
+
+export interface NativeSetupServerUrl {
+ serverBaseUrl: string
+ isPlainHttp: boolean
+}
+
+export type NativeSetupServerUrlValidation =
+ | {
+ ok: true
+ value: NativeSetupServerUrl
+ }
+ | {
+ ok: false
+ message: string
+ }
+
+function isPrivateIPv4Address(hostname: string): boolean {
+ const octets = hostname.split('.').map((part) => Number(part))
+ if (
+ octets.length !== 4 ||
+ octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)
+ ) {
+ return false
+ }
+
+ const [first = 0, second = 0] = octets
+ return (
+ first === 10 ||
+ first === 127 ||
+ (first === 100 && second >= 64 && second <= 127) ||
+ (first === 169 && second === 254) ||
+ (first === 172 && second >= 16 && second <= 31) ||
+ (first === 192 && second === 168)
+ )
+}
+
+function isPrivateIPv6Address(hostname: string): boolean {
+ const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase()
+ if (normalized === '::1') {
+ return true
+ }
+
+ const [firstHextetRaw] = normalized.split(':')
+ const firstHextet = Number.parseInt(firstHextetRaw ?? '', 16)
+ if (Number.isNaN(firstHextet)) {
+ return false
+ }
+
+ return (firstHextet & 0xfe00) === 0xfc00 || (firstHextet & 0xffc0) === 0xfe80
+}
+
+function isLocalPlainHttpHost(hostname: string): boolean {
+ const normalized = hostname.toLowerCase()
+ if (
+ normalized === 'localhost' ||
+ normalized.endsWith('.localhost') ||
+ normalized.endsWith('.local')
+ ) {
+ return true
+ }
+
+ if (normalized.includes(':')) {
+ return isPrivateIPv6Address(normalized)
+ }
+
+ if (isPrivateIPv4Address(normalized)) {
+ return true
+ }
+
+ return !normalized.includes('.')
+}
+
+export function validateNativeSetupServerUrl(input: string): NativeSetupServerUrlValidation {
+ const normalized = normalizeServerBaseUrl(input)
+ if (!normalized) {
+ 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.',
+ }
+ }
+
+ if (parsed.protocol === 'http:' && !isLocalPlainHttpHost(parsed.hostname)) {
+ return {
+ ok: false,
+ message: 'Plain HTTP is only supported for local network HomeSec servers. Use HTTPS for public hosts.',
+ }
+ }
+
+ parsed.hash = ''
+ parsed.search = ''
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '')
+
+ return {
+ ok: true,
+ value: {
+ serverBaseUrl: parsed.toString().replace(/\/+$/, ''),
+ isPlainHttp: parsed.protocol === 'http:',
+ },
+ }
+}
diff --git a/ui/src/features/system/SystemPage.tsx b/ui/src/features/system/SystemPage.tsx
index e481edf1..d33a8e72 100644
--- a/ui/src/features/system/SystemPage.tsx
+++ b/ui/src/features/system/SystemPage.tsx
@@ -48,12 +48,12 @@ export function SystemPage() {
}
async function submitApiKey(apiKey: string): Promise {
- saveApiKey(apiKey)
+ await saveApiKey(apiKey)
await Promise.all([statsQuery.refetch(), backupStatusQuery.refetch()])
}
async function clearStoredApiKey(): Promise {
- clearApiKey()
+ await clearApiKey()
await Promise.all([statsQuery.refetch(), backupStatusQuery.refetch()])
}
diff --git a/ui/src/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')!,
+})
diff --git a/ui/src/routes/AppRouter.test.tsx b/ui/src/routes/AppRouter.test.tsx
index f0ef1f0c..e1e9fabc 100644
--- a/ui/src/routes/AppRouter.test.tsx
+++ b/ui/src/routes/AppRouter.test.tsx
@@ -1,14 +1,33 @@
// @vitest-environment happy-dom
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, useLocation } from 'react-router-dom'
import { ThemeProvider } from '../app/providers/ThemeProvider'
import { AppRouter } from './AppRouter'
-const useHealthQueryMock = vi.fn()
-const useCamerasQueryMock = vi.fn()
+const routeMocks = vi.hoisted(() => ({
+ getBaseUrlSync: vi.fn<() => string | null>(() => null),
+ isRuntimeAuthSessionReady: vi.fn<() => boolean>(() => true),
+ isIOSNativeApp: vi.fn<() => boolean>(() => false),
+ useCamerasQuery: vi.fn(),
+ useHealthQuery: vi.fn(),
+}))
+
+const useHealthQueryMock = routeMocks.useHealthQuery
+const useCamerasQueryMock = routeMocks.useCamerasQuery
+
+vi.mock('../api/client', () => ({
+ runtimeServerBaseUrlProvider: {
+ getBaseUrlSync: () => routeMocks.getBaseUrlSync(),
+ },
+ isRuntimeAuthSessionReady: () => routeMocks.isRuntimeAuthSessionReady(),
+}))
+
+vi.mock('../runtime/nativeRuntime', () => ({
+ isIOSNativeApp: () => routeMocks.isIOSNativeApp(),
+}))
vi.mock('../api/hooks/useHealthQuery', () => ({
useHealthQuery: () => useHealthQueryMock(),
@@ -46,6 +65,19 @@ vi.mock('../features/setup/SetupPage', () => ({
SetupPage: () => Setup Page
,
}))
+vi.mock('../features/native-setup/NativeSetupPage', () => ({
+ NativeSetupPage: () => {
+ const location = useLocation()
+ const state = location.state as { nativeSetupReturnTo?: string } | null
+ return (
+ <>
+ Native Setup Page
+ {state?.nativeSetupReturnTo ?? ''}
+ >
+ )
+ },
+}))
+
function LocationProbe() {
const location = useLocation()
return {`${location.pathname}${location.search}`}
@@ -72,10 +104,19 @@ function renderRouter(initialPath: string) {
}
describe('AppRouter route cleanup', () => {
+ beforeEach(() => {
+ routeMocks.getBaseUrlSync.mockReturnValue(null)
+ routeMocks.isRuntimeAuthSessionReady.mockReturnValue(true)
+ routeMocks.isIOSNativeApp.mockReturnValue(false)
+ })
+
afterEach(() => {
cleanup()
useHealthQueryMock.mockReset()
useCamerasQueryMock.mockReset()
+ routeMocks.getBaseUrlSync.mockReset()
+ routeMocks.isRuntimeAuthSessionReady.mockReset()
+ routeMocks.isIOSNativeApp.mockReset()
})
it('redirects the root route to Live', async () => {
@@ -104,6 +145,87 @@ 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 iOS shell routes to native setup until a server URL is configured', async () => {
+ // Given: The iOS shell starts without a configured HomeSec server URL
+ routeMocks.isIOSNativeApp.mockReturnValue(true)
+ routeMocks.getBaseUrlSync.mockReturnValue(null)
+
+ // When: User opens the default shell route
+ renderRouter('/live')
+
+ // Then: The setup route is reached before shell API queries mount
+ await waitFor(() => {
+ expect(screen.getByTestId('location').textContent).toBe('/native-setup')
+ })
+ expect(screen.getByText('Native Setup Page')).toBeTruthy()
+ expect(screen.getByTestId('native-setup-return-to').textContent).toBe('/live')
+ expect(useHealthQueryMock).not.toHaveBeenCalled()
+ expect(useCamerasQueryMock).not.toHaveBeenCalled()
+ })
+
+ it('redirects iOS shell routes to native setup when the auth session was lost', async () => {
+ // Given: The iOS shell retained a server URL but lost in-memory auth state
+ routeMocks.isIOSNativeApp.mockReturnValue(true)
+ routeMocks.getBaseUrlSync.mockReturnValue('https://homesec.example.com')
+ routeMocks.isRuntimeAuthSessionReady.mockReturnValue(false)
+
+ // When: User opens a protected shell route after a WebView reload
+ renderRouter('/events')
+
+ // Then: The setup route is reached before unauthenticated API queries mount
+ await waitFor(() => {
+ expect(screen.getByTestId('location').textContent).toBe('/native-setup')
+ })
+ expect(screen.getByText('Native Setup Page')).toBeTruthy()
+ expect(screen.getByTestId('native-setup-return-to').textContent).toBe('/events')
+ expect(useHealthQueryMock).not.toHaveBeenCalled()
+ expect(useCamerasQueryMock).not.toHaveBeenCalled()
+ })
+
+ it('preserves native setup return intent for deep links with filters', async () => {
+ // Given: The iOS shell needs setup before opening a deep-linked event
+ routeMocks.isIOSNativeApp.mockReturnValue(true)
+ routeMocks.getBaseUrlSync.mockReturnValue('https://homesec.example.com')
+ routeMocks.isRuntimeAuthSessionReady.mockReturnValue(false)
+
+ // When: User opens a protected route with list context
+ renderRouter('/events/clip-42?camera=front')
+
+ // Then: Setup receives enough state to return to the intended route
+ await waitFor(() => {
+ expect(screen.getByTestId('location').textContent).toBe('/native-setup')
+ })
+ expect(screen.getByTestId('native-setup-return-to').textContent).toBe(
+ '/events/clip-42?camera=front',
+ )
+ })
+
+ it('allows iOS shell routes after a server URL is configured', async () => {
+ // Given: The iOS shell already has a HomeSec server URL
+ routeMocks.isIOSNativeApp.mockReturnValue(true)
+ routeMocks.getBaseUrlSync.mockReturnValue('https://homesec.example.com')
+ routeMocks.isRuntimeAuthSessionReady.mockReturnValue(true)
+
+ // When: User opens the native shell route
+ renderRouter('/live')
+
+ // Then: The app shell renders instead of returning to setup
+ await waitFor(() => {
+ expect(screen.getByTestId('location').textContent).toBe('/live')
+ })
+ expect(screen.getByText('Live Page')).toBeTruthy()
+ })
+
it('redirects the old cameras route to Settings camera setup', async () => {
// Given: User opens the old top-level camera management route
renderRouter('/cameras')
diff --git a/ui/src/routes/AppRouter.tsx b/ui/src/routes/AppRouter.tsx
index 8d0a2337..0613dfa3 100644
--- a/ui/src/routes/AppRouter.tsx
+++ b/ui/src/routes/AppRouter.tsx
@@ -1,10 +1,14 @@
import { Navigate, Route, Routes, useLocation, useParams } from 'react-router-dom'
+import { isRuntimeAuthSessionReady, runtimeServerBaseUrlProvider } from '../api/client'
import { AppShell } from '../app/layout/AppShell'
+import { isIOSNativeApp } from '../runtime/nativeRuntime'
+import { useNativePushRegistration } from '../runtime/nativePushRegistration'
import { CamerasPage } from '../features/cameras/CamerasPage'
import { ClipDetailPage } from '../features/clips/ClipDetailPage'
import { ClipsPage } from '../features/clips/ClipsPage'
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'
@@ -21,11 +25,35 @@ function RedirectClipDetailToEvent() {
return
}
+function NativeSetupGuard() {
+ const location = useLocation()
+ const serverBaseUrl = runtimeServerBaseUrlProvider.getBaseUrlSync()
+ const nativeSetupRequired = isIOSNativeApp() && (!serverBaseUrl || !isRuntimeAuthSessionReady())
+
+ useNativePushRegistration({
+ enabled: isIOSNativeApp() && !nativeSetupRequired,
+ registrationKey: serverBaseUrl ?? 'ios-native',
+ })
+
+ if (nativeSetupRequired) {
+ return (
+
+ )
+ }
+
+ return
+}
+
export function AppRouter() {
return (
} />
- }>
+ } />
+ }>
} />
} />
} />
diff --git a/ui/src/runtime/homeSecDevicePlugin.ts b/ui/src/runtime/homeSecDevicePlugin.ts
new file mode 100644
index 00000000..38c59cd3
--- /dev/null
+++ b/ui/src/runtime/homeSecDevicePlugin.ts
@@ -0,0 +1,16 @@
+import { registerPlugin } from '@capacitor/core'
+
+export type HomeSecAPNSEnvironment = 'sandbox' | 'production'
+
+export interface HomeSecDeviceRegistrationInfo {
+ apnsEnvironment: HomeSecAPNSEnvironment
+ appVersion: string | null
+ bundleId: string
+ deviceName: string | null
+}
+
+export interface HomeSecDevicePlugin {
+ getRegistrationInfo(): Promise
+}
+
+export const homeSecDevicePlugin = registerPlugin('HomeSecDevice')
diff --git a/ui/src/runtime/nativeAppLifecycle.ts b/ui/src/runtime/nativeAppLifecycle.ts
new file mode 100644
index 00000000..cfcc8038
--- /dev/null
+++ b/ui/src/runtime/nativeAppLifecycle.ts
@@ -0,0 +1,96 @@
+import { useEffect, useState } from 'react'
+import { App } from '@capacitor/app'
+import type { PluginListenerHandle } from '@capacitor/core'
+
+import { isIOSNativeApp } from './nativeRuntime'
+
+export interface NativeAppLifecycleState {
+ isActive: boolean
+ isBackgrounded: boolean
+ pauseCount: number
+ resumeCount: number
+}
+
+const ACTIVE_BROWSER_LIFECYCLE_STATE: NativeAppLifecycleState = {
+ isActive: true,
+ isBackgrounded: false,
+ pauseCount: 0,
+ resumeCount: 0,
+}
+
+export function useNativeAppLifecycleState(): NativeAppLifecycleState {
+ const isIOS = isIOSNativeApp()
+ const [state, setState] = useState(ACTIVE_BROWSER_LIFECYCLE_STATE)
+
+ useEffect(() => {
+ if (!isIOS) {
+ return
+ }
+
+ let cancelled = false
+ const handles: PluginListenerHandle[] = []
+
+ const trackHandle = async (listener: Promise): Promise => {
+ const handle = await listener.catch(() => null)
+ if (handle === null) {
+ return
+ }
+ if (cancelled) {
+ void handle.remove()
+ return
+ }
+ handles.push(handle)
+ }
+
+ void App.getState()
+ .then((appState) => {
+ if (!cancelled) {
+ setState((previous) => ({
+ ...previous,
+ isActive: appState.isActive,
+ isBackgrounded: appState.isActive ? false : previous.isBackgrounded,
+ }))
+ }
+ })
+ .catch(() => {})
+
+ void trackHandle(
+ App.addListener('appStateChange', (appState) => {
+ setState((previous) => ({
+ ...previous,
+ isActive: appState.isActive,
+ isBackgrounded: appState.isActive ? false : previous.isBackgrounded,
+ }))
+ }),
+ )
+ void trackHandle(
+ App.addListener('pause', () => {
+ setState((previous) => ({
+ ...previous,
+ isBackgrounded: true,
+ isActive: false,
+ pauseCount: previous.pauseCount + 1,
+ }))
+ }),
+ )
+ void trackHandle(
+ App.addListener('resume', () => {
+ setState((previous) => ({
+ ...previous,
+ isBackgrounded: false,
+ isActive: true,
+ resumeCount: previous.resumeCount + 1,
+ }))
+ }),
+ )
+
+ return () => {
+ cancelled = true
+ handles.forEach((handle) => {
+ void handle.remove()
+ })
+ }
+ }, [isIOS])
+
+ return isIOS ? state : ACTIVE_BROWSER_LIFECYCLE_STATE
+}
diff --git a/ui/src/runtime/nativeDeepLinkRoutes.ts b/ui/src/runtime/nativeDeepLinkRoutes.ts
new file mode 100644
index 00000000..498e63a5
--- /dev/null
+++ b/ui/src/runtime/nativeDeepLinkRoutes.ts
@@ -0,0 +1,72 @@
+const HOMESEC_DEEP_LINK_SCHEME = 'homesec:'
+const DEFAULT_DEEP_LINK_ROUTE = '/live'
+
+const ALLOWED_DEEP_LINK_ROUTE_PREFIXES = [
+ '/live',
+ '/events',
+ '/settings',
+ '/system',
+ '/cameras',
+ '/clips',
+ '/dashboard',
+ '/home',
+]
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function routeIsAllowed(route: string): boolean {
+ return ALLOWED_DEEP_LINK_ROUTE_PREFIXES.some((prefix) => {
+ return route === prefix || route.startsWith(`${prefix}/`)
+ })
+}
+
+function pathnameFromUrl(url: URL): string {
+ const pathSegments = [url.hostname, url.pathname.replace(/^\/+/, '')].filter(Boolean)
+ return `/${pathSegments.join('/')}`.replace(/\/{2,}/g, '/')
+}
+
+export function parseNativeDeepLinkRoute(rawUrl: string): string | null {
+ let url: URL
+ try {
+ url = new URL(rawUrl)
+ } catch {
+ return null
+ }
+
+ if (url.protocol !== HOMESEC_DEEP_LINK_SCHEME) {
+ return null
+ }
+
+ const pathname = pathnameFromUrl(url)
+ if (!routeIsAllowed(pathname)) {
+ return DEFAULT_DEEP_LINK_ROUTE
+ }
+
+ return `${pathname}${url.search}${url.hash}`
+}
+
+export function parseNativeNotificationRoute(data: unknown): string | null {
+ if (!isRecord(data) || typeof data.route !== 'string') {
+ return null
+ }
+
+ const route = data.route.trim()
+ if (!route.startsWith('/') || route.startsWith('//')) {
+ return null
+ }
+
+ let url: URL
+ try {
+ url = new URL(route, 'https://homesec.local')
+ } catch {
+ return null
+ }
+
+ if (!routeIsAllowed(url.pathname)) {
+ return DEFAULT_DEEP_LINK_ROUTE
+ }
+
+ return `${url.pathname}${url.search}${url.hash}`
+}
diff --git a/ui/src/runtime/nativeDeepLinks.test.tsx b/ui/src/runtime/nativeDeepLinks.test.tsx
new file mode 100644
index 00000000..8b2fb0c9
--- /dev/null
+++ b/ui/src/runtime/nativeDeepLinks.test.tsx
@@ -0,0 +1,438 @@
+// @vitest-environment happy-dom
+
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
+import type { ActionPerformed } from '@capacitor/push-notifications'
+
+const nativeRuntimeMock = vi.hoisted(() => ({
+ isIOSNativeApp: vi.fn<() => boolean>(() => false),
+}))
+
+vi.mock('./nativeRuntime', () => ({
+ isIOSNativeApp: () => nativeRuntimeMock.isIOSNativeApp(),
+}))
+
+import { parseNativeDeepLinkRoute, parseNativeNotificationRoute } from './nativeDeepLinkRoutes'
+import { NativeDeepLinkRouter } from './nativeDeepLinks'
+
+type DeepLinkEvent = {
+ url?: string | null
+}
+
+type TestNativeDeepLinkApp = {
+ getLaunchUrl: () => Promise
+ addListener: (
+ eventName: 'appUrlOpen',
+ listenerFunc: (event: DeepLinkEvent) => void,
+ ) => Promise<{ remove: () => Promise }>
+}
+
+type TestNativePushNotifications = {
+ addListener: (
+ eventName: 'pushNotificationActionPerformed',
+ listenerFunc: (event: ActionPerformed) => void,
+ ) => Promise<{ remove: () => Promise }>
+}
+
+function LocationProbe() {
+ const location = useLocation()
+ return {`${location.pathname}${location.search}${location.hash}`}
+}
+
+function createNativePushNotifications() {
+ let pushActionListener: ((event: ActionPerformed) => void) | null = null
+ const remove = vi.fn(async () => {})
+ const pushNotifications: TestNativePushNotifications = {
+ addListener: vi.fn(async (
+ eventName: 'pushNotificationActionPerformed',
+ listenerFunc: (event: ActionPerformed) => void,
+ ) => {
+ expect(eventName).toBe('pushNotificationActionPerformed')
+ pushActionListener = listenerFunc
+ return { remove }
+ }),
+ }
+
+ return {
+ pushNotifications,
+ emitAction(data: unknown) {
+ pushActionListener?.({
+ actionId: 'tap',
+ notification: {
+ id: 'notif_1',
+ data,
+ },
+ })
+ },
+ remove,
+ }
+}
+
+function createNativeDeepLinkApp(launchUrl?: string | null) {
+ let appUrlOpenListener: ((event: DeepLinkEvent) => void) | null = null
+ const remove = vi.fn(async () => {})
+ const app = {
+ getLaunchUrl: vi.fn(async () => (launchUrl === undefined ? null : { url: launchUrl })),
+ addListener: vi.fn(async (
+ eventName: 'appUrlOpen',
+ listenerFunc: (event: DeepLinkEvent) => void,
+ ) => {
+ expect(eventName).toBe('appUrlOpen')
+ appUrlOpenListener = listenerFunc
+ return { remove }
+ }),
+ }
+
+ return {
+ app,
+ emitUrlOpen(rawUrl: string) {
+ appUrlOpenListener?.({ url: rawUrl })
+ },
+ remove,
+ }
+}
+
+function createDeferredNativeDeepLinkApp() {
+ let appUrlOpenListener: ((event: DeepLinkEvent) => void) | null = null
+ let resolveListener: ((handle: { remove: () => Promise }) => void) | null = null
+ const remove = vi.fn(async () => {})
+ const listenerPromise = new Promise<{ remove: () => Promise }>((resolve) => {
+ resolveListener = resolve
+ })
+ const app = {
+ getLaunchUrl: vi.fn(async () => null),
+ addListener: vi.fn((
+ eventName: 'appUrlOpen',
+ listenerFunc: (event: DeepLinkEvent) => void,
+ ) => {
+ expect(eventName).toBe('appUrlOpen')
+ appUrlOpenListener = listenerFunc
+ return listenerPromise
+ }),
+ }
+
+ return {
+ app,
+ emitUrlOpen(rawUrl: string) {
+ appUrlOpenListener?.({ url: rawUrl })
+ },
+ resolveListener() {
+ resolveListener?.({ remove })
+ },
+ remove,
+ }
+}
+
+function renderNativeDeepLinkRouter(
+ app: TestNativeDeepLinkApp,
+ initialPath = '/live',
+ pushNotifications = createNativePushNotifications().pushNotifications,
+) {
+ render(
+
+
+
+ } />
+
+ ,
+ )
+}
+
+function renderToggleableNativeDeepLinkRouter(
+ app: TestNativeDeepLinkApp,
+ initialPath = '/live',
+ pushNotifications = createNativePushNotifications().pushNotifications,
+) {
+ function Harness({ enabled }: { enabled: boolean }) {
+ return (
+
+ {enabled ? (
+
+ ) : null}
+
+ } />
+
+
+ )
+ }
+
+ const result = render()
+ return {
+ ...result,
+ disableRouter() {
+ result.rerender()
+ },
+ }
+}
+
+describe('parseNativeDeepLinkRoute', () => {
+ it('translates homesec event links into React routes', () => {
+ // Given: A notification deep link with route and source context
+ const route = parseNativeDeepLinkRoute('homesec://events/test-id?from=notification')
+
+ // Then: The custom scheme is stripped and the React route is preserved
+ expect(route).toBe('/events/test-id?from=notification')
+ })
+
+ it('preserves triple-slash path links, query strings, and hashes', () => {
+ // Given: A custom-scheme URL using path form instead of host form
+ const route = parseNativeDeepLinkRoute('homesec:///events/clip-42?camera=front#summary')
+
+ // Then: The parser keeps the route details needed by React Router
+ expect(route).toBe('/events/clip-42?camera=front#summary')
+ })
+
+ it('preserves query strings on allowed top-level routes', () => {
+ // Given: A custom-scheme URL for a top-level route with query context
+ const route = parseNativeDeepLinkRoute('homesec://events?from=notification')
+
+ // Then: Route validation accepts the pathname before preserving the query
+ expect(route).toBe('/events?from=notification')
+ })
+
+ it('preserves hashes on allowed top-level routes', () => {
+ // Given: A custom-scheme URL for a top-level route with a hash target
+ const route = parseNativeDeepLinkRoute('homesec://live#camera-front')
+
+ // Then: Route validation accepts the pathname before preserving the hash
+ expect(route).toBe('/live#camera-front')
+ })
+
+ it('ignores non-HomeSec URLs', () => {
+ // Given: A URL that was not issued for the HomeSec app scheme
+ const route = parseNativeDeepLinkRoute('https://homesec.example.com/events/test-id')
+
+ // Then: The native listener leaves unrelated URLs alone
+ expect(route).toBeNull()
+ })
+
+ it('falls back safely for unsupported HomeSec routes', () => {
+ // Given: A HomeSec-scheme URL that does not map to a known app route
+ const route = parseNativeDeepLinkRoute('homesec://admin/secrets?token=leak')
+
+ // Then: The app opens a safe default route instead of an arbitrary path
+ expect(route).toBe('/live')
+ })
+})
+
+describe('parseNativeNotificationRoute', () => {
+ it('accepts APNs payload event routes', () => {
+ // Given: A plain APNs payload with the HomeSec event route
+ const route = parseNativeNotificationRoute({
+ route: '/events/clip-123?from=notification',
+ })
+
+ // Then: The app-relative route is safe to pass to React Router
+ expect(route).toBe('/events/clip-123?from=notification')
+ })
+
+ it('falls back safely for unsupported APNs payload routes', () => {
+ // Given: A notification payload attempts to open an unsupported app route
+ const route = parseNativeNotificationRoute({ route: '/admin/secrets' })
+
+ // Then: The native router falls back to the default safe route
+ expect(route).toBe('/live')
+ })
+
+ it('ignores missing or non-relative APNs payload routes', () => {
+ // Given: Notification payloads without a valid app-relative route
+ const missingRoute = parseNativeNotificationRoute({})
+ const externalRoute = parseNativeNotificationRoute({
+ route: 'https://homesec.example.com/events/clip-123',
+ })
+ const protocolRelativeRoute = parseNativeNotificationRoute({
+ route: '//homesec.example.com/events/clip-123',
+ })
+
+ // Then: The app ignores them rather than navigating to untrusted content
+ expect(missingRoute).toBeNull()
+ expect(externalRoute).toBeNull()
+ expect(protocolRelativeRoute).toBeNull()
+ })
+})
+
+describe('NativeDeepLinkRouter', () => {
+ beforeEach(() => {
+ nativeRuntimeMock.isIOSNativeApp.mockReturnValue(true)
+ })
+
+ afterEach(() => {
+ cleanup()
+ nativeRuntimeMock.isIOSNativeApp.mockReset()
+ })
+
+ it('routes a cold-start launch URL into the React app', async () => {
+ // Given: iOS launched the app from a notification deep link
+ const { app } = createNativeDeepLinkApp('homesec://events/test-id?from=notification')
+
+ // When: The deep-link router mounts
+ renderNativeDeepLinkRouter(app, '/live')
+
+ // Then: The launch URL becomes the active React route
+ await waitFor(() => {
+ expect(screen.getByTestId('location').textContent).toBe(
+ '/events/test-id?from=notification',
+ )
+ })
+ expect(app.getLaunchUrl).toHaveBeenCalledTimes(1)
+ expect(app.addListener).toHaveBeenCalledTimes(1)
+ })
+
+ it('routes warm appUrlOpen events into the React app', async () => {
+ // Given: The app is already open and listening for URL events
+ const nativeApp = createNativeDeepLinkApp()
+ renderNativeDeepLinkRouter(nativeApp.app, '/live')
+ await waitFor(() => {
+ expect(nativeApp.app.addListener).toHaveBeenCalledTimes(1)
+ })
+
+ // When: iOS sends a custom-scheme URL to the running app
+ await act(async () => {
+ nativeApp.emitUrlOpen('homesec://events/clip-99?from=notification')
+ })
+
+ // Then: React Router navigates to the event detail route
+ expect(screen.getByTestId('location').textContent).toBe(
+ '/events/clip-99?from=notification',
+ )
+ })
+
+ it('routes notification tap actions into the React app', async () => {
+ // Given: The app has started and registered a push notification action listener
+ const nativeApp = createNativeDeepLinkApp()
+ const nativePush = createNativePushNotifications()
+ renderNativeDeepLinkRouter(nativeApp.app, '/live', nativePush.pushNotifications)
+ await waitFor(() => {
+ expect(nativePush.pushNotifications.addListener).toHaveBeenCalledTimes(1)
+ })
+
+ // When: iOS reports that the user tapped a HomeSec APNs notification
+ await act(async () => {
+ nativePush.emitAction({ route: '/events/push-clip?from=notification' })
+ })
+
+ // Then: React Router opens the event detail route from the payload
+ expect(screen.getByTestId('location').textContent).toBe(
+ '/events/push-clip?from=notification',
+ )
+ })
+
+ it('falls back to Live for unsupported notification tap routes', async () => {
+ // Given: The app receives a notification action with an unsupported route
+ const nativeApp = createNativeDeepLinkApp()
+ const nativePush = createNativePushNotifications()
+ renderNativeDeepLinkRouter(nativeApp.app, '/events', nativePush.pushNotifications)
+ await waitFor(() => {
+ expect(nativePush.pushNotifications.addListener).toHaveBeenCalledTimes(1)
+ })
+
+ // When: The notification action is delivered
+ await act(async () => {
+ nativePush.emitAction({ route: '/admin/secrets' })
+ })
+
+ // Then: The app navigates to a safe default route
+ expect(screen.getByTestId('location').textContent).toBe('/live')
+ })
+
+ it('falls back to Live for unsupported HomeSec appUrlOpen routes', async () => {
+ // Given: The app receives an invalid route under the HomeSec scheme
+ const nativeApp = createNativeDeepLinkApp()
+ renderNativeDeepLinkRouter(nativeApp.app, '/events')
+ await waitFor(() => {
+ expect(nativeApp.app.addListener).toHaveBeenCalledTimes(1)
+ })
+
+ // When: The invalid route opens
+ await act(async () => {
+ nativeApp.emitUrlOpen('homesec://admin')
+ })
+
+ // Then: The app navigates to a safe default route
+ expect(screen.getByTestId('location').textContent).toBe('/live')
+ })
+
+ it('does not register native listeners outside iOS native mode', () => {
+ // Given: The React app is running in the browser
+ nativeRuntimeMock.isIOSNativeApp.mockReturnValue(false)
+ const { app } = createNativeDeepLinkApp('homesec://events/test-id')
+
+ // When: The deep-link router mounts
+ renderNativeDeepLinkRouter(app, '/live')
+
+ // Then: Capacitor deep-link APIs are not invoked
+ expect(app.getLaunchUrl).not.toHaveBeenCalled()
+ expect(app.addListener).not.toHaveBeenCalled()
+ expect(screen.getByTestId('location').textContent).toBe('/live')
+ })
+
+ it('removes the appUrlOpen listener on unmount', async () => {
+ // Given: The deep-link router registered a native listener
+ const nativeApp = createNativeDeepLinkApp()
+ const nativePush = createNativePushNotifications()
+ renderNativeDeepLinkRouter(nativeApp.app, '/live', nativePush.pushNotifications)
+ await waitFor(() => {
+ expect(nativeApp.app.addListener).toHaveBeenCalledTimes(1)
+ expect(nativePush.pushNotifications.addListener).toHaveBeenCalledTimes(1)
+ })
+
+ // When: React unmounts the router
+ cleanup()
+
+ // Then: The native listener is removed
+ expect(nativeApp.remove).toHaveBeenCalledTimes(1)
+ expect(nativePush.remove).toHaveBeenCalledTimes(1)
+ })
+
+ it('ignores stale appUrlOpen events after cleanup', async () => {
+ // Given: Native listener registration captured a callback but has not resolved yet
+ const nativeApp = createDeferredNativeDeepLinkApp()
+ const nativePush = createNativePushNotifications()
+ const view = renderToggleableNativeDeepLinkRouter(
+ nativeApp.app,
+ '/live',
+ nativePush.pushNotifications,
+ )
+ await waitFor(() => {
+ expect(nativeApp.app.addListener).toHaveBeenCalledTimes(1)
+ expect(nativePush.pushNotifications.addListener).toHaveBeenCalledTimes(1)
+ })
+
+ // When: React removes the deep-link router before the native listener resolves
+ view.disableRouter()
+ await act(async () => {
+ nativeApp.emitUrlOpen('homesec://events/stale')
+ nativeApp.resolveListener()
+ })
+
+ // Then: The stale native callback does not navigate after cleanup
+ expect(screen.getByTestId('location').textContent).toBe('/live')
+ expect(nativeApp.remove).toHaveBeenCalledTimes(1)
+ expect(nativePush.remove).toHaveBeenCalledTimes(1)
+ })
+
+ it('ignores stale notification tap actions after cleanup', async () => {
+ // Given: Native notification action registration captured a callback
+ const nativeApp = createNativeDeepLinkApp()
+ const nativePush = createNativePushNotifications()
+ const view = renderToggleableNativeDeepLinkRouter(
+ nativeApp.app,
+ '/live',
+ nativePush.pushNotifications,
+ )
+ await waitFor(() => {
+ expect(nativePush.pushNotifications.addListener).toHaveBeenCalledTimes(1)
+ })
+
+ // When: React removes the router before a stale notification action arrives
+ view.disableRouter()
+ await act(async () => {
+ nativePush.emitAction({ route: '/events/stale-push?from=notification' })
+ })
+
+ // Then: The stale native callback does not navigate after cleanup
+ expect(screen.getByTestId('location').textContent).toBe('/live')
+ expect(nativePush.remove).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/ui/src/runtime/nativeDeepLinks.tsx b/ui/src/runtime/nativeDeepLinks.tsx
new file mode 100644
index 00000000..53e6e3a4
--- /dev/null
+++ b/ui/src/runtime/nativeDeepLinks.tsx
@@ -0,0 +1,125 @@
+import { useCallback, useEffect, useRef } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { App } from '@capacitor/app'
+import { PushNotifications } from '@capacitor/push-notifications'
+import type { PluginListenerHandle } from '@capacitor/core'
+import type { ActionPerformed } from '@capacitor/push-notifications'
+
+import { parseNativeDeepLinkRoute, parseNativeNotificationRoute } from './nativeDeepLinkRoutes'
+import { isIOSNativeApp } from './nativeRuntime'
+
+interface NativeDeepLinkEvent {
+ url?: string | null
+}
+
+interface NativeDeepLinkApp {
+ getLaunchUrl: () => Promise
+ addListener: (
+ eventName: 'appUrlOpen',
+ listenerFunc: (event: NativeDeepLinkEvent) => void,
+ ) => Promise
+}
+
+interface NativePushNotificationActions {
+ addListener: (
+ eventName: 'pushNotificationActionPerformed',
+ listenerFunc: (notification: ActionPerformed) => void,
+ ) => Promise
+}
+
+export function NativeDeepLinkRouter({
+ app = App,
+ pushNotifications = PushNotifications,
+}: {
+ app?: NativeDeepLinkApp
+ pushNotifications?: NativePushNotificationActions
+}) {
+ const navigate = useNavigate()
+ const navigateRef = useRef(navigate)
+ const isIOS = isIOSNativeApp()
+
+ useEffect(() => {
+ navigateRef.current = navigate
+ }, [navigate])
+
+ const navigateToDeepLink = useCallback((
+ rawUrl: string | null | undefined,
+ options: { replace: boolean },
+ ) => {
+ if (!rawUrl) {
+ return
+ }
+ const route = parseNativeDeepLinkRoute(rawUrl)
+ if (route === null) {
+ return
+ }
+ navigateRef.current(route, { replace: options.replace })
+ }, [])
+
+ const navigateToNotificationRoute = useCallback((
+ action: ActionPerformed,
+ options: { replace: boolean },
+ ) => {
+ const route = parseNativeNotificationRoute(action.notification.data)
+ if (route === null) {
+ return
+ }
+ navigateRef.current(route, { replace: options.replace })
+ }, [])
+
+ useEffect(() => {
+ if (!isIOS) {
+ return
+ }
+
+ let cancelled = false
+ const handles: PluginListenerHandle[] = []
+
+ void app.getLaunchUrl()
+ .then((event) => {
+ if (!cancelled) {
+ navigateToDeepLink(event?.url, { replace: true })
+ }
+ })
+ .catch(() => {})
+
+ void app.addListener('appUrlOpen', (event) => {
+ if (cancelled) {
+ return
+ }
+ navigateToDeepLink(event.url, { replace: false })
+ })
+ .then((nextHandle) => {
+ if (cancelled) {
+ void nextHandle.remove()
+ return
+ }
+ handles.push(nextHandle)
+ })
+ .catch(() => {})
+
+ void pushNotifications.addListener('pushNotificationActionPerformed', (action) => {
+ if (cancelled) {
+ return
+ }
+ navigateToNotificationRoute(action, { replace: false })
+ })
+ .then((nextHandle) => {
+ if (cancelled) {
+ void nextHandle.remove()
+ return
+ }
+ handles.push(nextHandle)
+ })
+ .catch(() => {})
+
+ return () => {
+ cancelled = true
+ for (const handle of handles) {
+ void handle.remove()
+ }
+ }
+ }, [app, isIOS, navigateToDeepLink, navigateToNotificationRoute, pushNotifications])
+
+ return null
+}
diff --git a/ui/src/runtime/nativePushRegistration.test.ts b/ui/src/runtime/nativePushRegistration.test.ts
new file mode 100644
index 00000000..4eaa1fb5
--- /dev/null
+++ b/ui/src/runtime/nativePushRegistration.test.ts
@@ -0,0 +1,318 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type {
+ PermissionStatus,
+ RegistrationError,
+ Token,
+} from '@capacitor/push-notifications'
+import type { PluginListenerHandle } from '@capacitor/core'
+
+import {
+ BROWSER_AUTH_TOKEN_STORAGE_KEY,
+ BROWSER_SERVER_BASE_URL_STORAGE_KEY,
+} from '../api/client'
+import type { MobileDeviceRegisterRequest } from '../api/generated/types'
+import type { HomeSecDevicePlugin } from './homeSecDevicePlugin'
+import {
+ registerNativePushDevice,
+ resetNativePushRegistrationForTests,
+ useNativePushRegistration,
+ type NativePushRegistrationOptions,
+} from './nativePushRegistration'
+
+type PushAdapter = NonNullable
+type PushRegistrationMode = 'error' | 'success'
+type TestStorage = Pick & {
+ values: Map
+}
+
+function listenerHandle(): PluginListenerHandle {
+ return {
+ remove: vi.fn(async () => {}),
+ }
+}
+
+function createPushAdapter({
+ initialPermission = 'granted',
+ mode = 'success',
+ requestedPermission = 'granted',
+}: {
+ initialPermission?: PermissionStatus['receive']
+ mode?: PushRegistrationMode
+ requestedPermission?: PermissionStatus['receive']
+} = {}): PushAdapter {
+ const registrationListeners: Array<(token: Token) => void> = []
+ const registrationErrorListeners: Array<(error: RegistrationError) => void> = []
+
+ return {
+ addListener: vi.fn(async (eventName: string, listener: unknown) => {
+ if (eventName === 'registration') {
+ registrationListeners.push(listener as (token: Token) => void)
+ }
+ if (eventName === 'registrationError') {
+ registrationErrorListeners.push(listener as (error: RegistrationError) => void)
+ }
+ return listenerHandle()
+ }),
+ checkPermissions: vi.fn(async () => ({ receive: initialPermission })),
+ register: vi.fn(async () => {
+ queueMicrotask(() => {
+ if (mode === 'success') {
+ registrationListeners.forEach((listener) => listener({ value: 'apns-token-123' }))
+ return
+ }
+ registrationErrorListeners.forEach((listener) =>
+ listener({ error: 'registration rejected' }),
+ )
+ })
+ }),
+ requestPermissions: vi.fn(async () => ({ receive: requestedPermission })),
+ }
+}
+
+function createDevicePlugin(): HomeSecDevicePlugin {
+ return {
+ getRegistrationInfo: vi.fn(async () => ({
+ apnsEnvironment: 'sandbox' as const,
+ appVersion: '1.0.0',
+ bundleId: 'com.levneiman.homesec',
+ deviceName: "Lev's iPhone",
+ })),
+ }
+}
+
+function createRegistrationClient() {
+ return {
+ registerMobileDevice: vi.fn(async (payload: MobileDeviceRegisterRequest) => ({
+ id: 'dev_1',
+ platform: 'ios' as const,
+ environment: payload.environment,
+ bundle_id: payload.bundle_id,
+ device_name: payload.device_name ?? null,
+ app_version: payload.app_version ?? null,
+ capabilities: payload.capabilities ?? {
+ deep_links: true,
+ rich_notifications: false,
+ },
+ enabled: true,
+ token_fingerprint: 'abcdef123456',
+ created_at: '2026-06-14T00:00:00Z',
+ updated_at: '2026-06-14T00:00:00Z',
+ last_seen_at: '2026-06-14T00:00:00Z',
+ last_push_at: null,
+ last_push_error: null,
+ httpStatus: 201,
+ })),
+ }
+}
+
+function installWindowSessionStorageMock(): TestStorage {
+ const storage: TestStorage = {
+ values: new Map(),
+ getItem: (key: string): string | null => storage.values.get(key) ?? null,
+ setItem: (key: string, value: string): void => {
+ storage.values.set(key, value)
+ },
+ removeItem: (key: string): void => {
+ storage.values.delete(key)
+ },
+ }
+ vi.stubGlobal('window', { sessionStorage: storage })
+ return storage
+}
+
+function mobileDeviceResponse() {
+ return {
+ id: 'dev_1',
+ platform: 'ios' as const,
+ environment: 'sandbox' as const,
+ bundle_id: 'com.levneiman.homesec',
+ device_name: "Lev's iPhone",
+ app_version: '1.0.0',
+ capabilities: {
+ deep_links: true,
+ rich_notifications: false,
+ },
+ enabled: true,
+ token_fingerprint: 'abcdef123456',
+ created_at: '2026-06-14T00:00:00Z',
+ updated_at: '2026-06-14T00:00:00Z',
+ last_seen_at: '2026-06-14T00:00:00Z',
+ last_push_at: null,
+ last_push_error: null,
+ }
+}
+
+describe('native push registration', () => {
+ beforeEach(() => {
+ resetNativePushRegistrationForTests()
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+ })
+
+ it('skips registration outside iOS native mode', async () => {
+ // Given: The app is running outside the iOS native shell
+ const pushNotifications = createPushAdapter()
+ const client = createRegistrationClient()
+
+ // When: Native push registration runs
+ const result = await registerNativePushDevice({
+ client,
+ isIOSNative: () => false,
+ pushNotifications,
+ })
+
+ // Then: No permission prompt, APNs registration, or backend request is attempted
+ expect(result).toEqual({ status: 'skipped', reason: 'not_ios_native' })
+ expect(pushNotifications.checkPermissions).not.toHaveBeenCalled()
+ expect(client.registerMobileDevice).not.toHaveBeenCalled()
+ })
+
+ it('posts the APNs registration result to HomeSec when permission is granted', async () => {
+ // Given: iOS has notification permission and APNs returns a token
+ const pushNotifications = createPushAdapter()
+ const devicePlugin = createDevicePlugin()
+ const client = createRegistrationClient()
+
+ // When: Native push registration runs
+ const result = await registerNativePushDevice({
+ client,
+ devicePlugin,
+ isIOSNative: () => true,
+ pushNotifications,
+ })
+
+ // Then: The device is registered with redacted app/device metadata and current capabilities
+ expect(result).toEqual({ status: 'registered' })
+ expect(client.registerMobileDevice).toHaveBeenCalledWith({
+ platform: 'ios',
+ apns_token: 'apns-token-123',
+ environment: 'sandbox',
+ bundle_id: 'com.levneiman.homesec',
+ device_name: "Lev's iPhone",
+ app_version: '1.0.0',
+ capabilities: {
+ deep_links: true,
+ rich_notifications: false,
+ },
+ })
+ })
+
+ it('uses the configured runtime API client when no client is injected', async () => {
+ // Given: Native setup has stored a server URL and API token in the runtime providers
+ const storage = installWindowSessionStorageMock()
+ storage.setItem(BROWSER_SERVER_BASE_URL_STORAGE_KEY, 'http://192.168.1.10:8081')
+ storage.setItem(BROWSER_AUTH_TOKEN_STORAGE_KEY, 'secret-token')
+ const pushNotifications = createPushAdapter()
+ const devicePlugin = createDevicePlugin()
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify(mobileDeviceResponse()), {
+ status: 201,
+ headers: { 'content-type': 'application/json' },
+ }),
+ )
+
+ // When: Native push registration runs without an injected test client
+ const result = await registerNativePushDevice({
+ devicePlugin,
+ isIOSNative: () => true,
+ pushNotifications,
+ })
+
+ // Then: Registration posts through the runtime-configured HomeSec origin with auth
+ expect(result).toEqual({ status: 'registered' })
+ expect(fetchSpy).toHaveBeenCalledTimes(1)
+ expect(fetchSpy.mock.calls[0]?.[0]).toBe(
+ 'http://192.168.1.10:8081/api/v1/mobile/devices',
+ )
+ expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({
+ headers: {
+ Accept: 'application/json',
+ Authorization: 'Bearer secret-token',
+ 'content-type': 'application/json',
+ },
+ })
+ })
+
+ it('requests permission once and skips backend registration when denied', async () => {
+ // Given: iOS has not prompted yet and the user denies notification permission
+ const pushNotifications = createPushAdapter({
+ initialPermission: 'prompt',
+ requestedPermission: 'denied',
+ })
+ const client = createRegistrationClient()
+
+ // When: Native push registration runs
+ const result = await registerNativePushDevice({
+ client,
+ isIOSNative: () => true,
+ pushNotifications,
+ })
+
+ // Then: The denial is handled without APNs registration or a backend request
+ expect(result).toEqual({ status: 'skipped', reason: 'permission_not_granted' })
+ expect(pushNotifications.requestPermissions).toHaveBeenCalledTimes(1)
+ expect(pushNotifications.register).not.toHaveBeenCalled()
+ expect(client.registerMobileDevice).not.toHaveBeenCalled()
+ })
+
+ it('does not cache denied permission as a completed registration', async () => {
+ // Given: The first startup sees denied permission and a later startup has permission
+ const deniedPushNotifications = createPushAdapter({ initialPermission: 'denied' })
+ const grantedPushNotifications = createPushAdapter()
+ const devicePlugin = createDevicePlugin()
+ const client = createRegistrationClient()
+
+ const { rerender } = renderHook(
+ ({ pushNotifications }) =>
+ useNativePushRegistration({
+ client,
+ devicePlugin,
+ enabled: true,
+ isIOSNative: () => true,
+ pushNotifications,
+ registrationKey: 'same-device',
+ }),
+ { initialProps: { pushNotifications: deniedPushNotifications } },
+ )
+
+ await waitFor(() => {
+ expect(deniedPushNotifications.checkPermissions).toHaveBeenCalledTimes(1)
+ })
+
+ // When: The hook runs again for the same device key after permission becomes available
+ await act(async () => {
+ rerender({ pushNotifications: grantedPushNotifications })
+ })
+
+ // Then: The second attempt is allowed to register the device
+ await waitFor(() => {
+ expect(client.registerMobileDevice).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ it('handles APNs registration errors without posting a device', async () => {
+ // Given: APNs registration fails after notification permission is granted
+ const pushNotifications = createPushAdapter({ mode: 'error' })
+ const devicePlugin = createDevicePlugin()
+ const client = createRegistrationClient()
+
+ // When: Native push registration runs
+ const result = await registerNativePushDevice({
+ client,
+ devicePlugin,
+ isIOSNative: () => true,
+ pushNotifications,
+ })
+
+ // Then: The failure is reported and the raw token registration endpoint is not called
+ expect(result).toEqual({ status: 'failed', reason: 'registration rejected' })
+ expect(client.registerMobileDevice).not.toHaveBeenCalled()
+ })
+})
diff --git a/ui/src/runtime/nativePushRegistration.ts b/ui/src/runtime/nativePushRegistration.ts
new file mode 100644
index 00000000..68545990
--- /dev/null
+++ b/ui/src/runtime/nativePushRegistration.ts
@@ -0,0 +1,245 @@
+import { useEffect } from 'react'
+import { PushNotifications } from '@capacitor/push-notifications'
+import type {
+ PushNotificationsPlugin,
+ RegistrationError,
+ Token,
+} from '@capacitor/push-notifications'
+import type { PluginListenerHandle } from '@capacitor/core'
+
+import { apiClient, type HomeSecApiClient } from '../api/client'
+import type { MobileDeviceRegisterRequest } from '../api/generated/types'
+import { homeSecDevicePlugin, type HomeSecDevicePlugin } from './homeSecDevicePlugin'
+import { isIOSNativeApp } from './nativeRuntime'
+
+type MobileDeviceRegistrationClient = Pick
+type NativePushNotifications = Pick<
+ PushNotificationsPlugin,
+ 'addListener' | 'checkPermissions' | 'register' | 'requestPermissions'
+>
+
+type NativePushRegistrationStatus = 'failed' | 'registered' | 'skipped'
+
+export interface NativePushRegistrationResult {
+ reason?: string
+ status: NativePushRegistrationStatus
+}
+
+export interface NativePushRegistrationOptions {
+ client?: MobileDeviceRegistrationClient
+ devicePlugin?: HomeSecDevicePlugin
+ isIOSNative?: () => boolean
+ pushNotifications?: NativePushNotifications
+ timeoutMs?: number
+}
+
+export interface UseNativePushRegistrationOptions extends NativePushRegistrationOptions {
+ enabled: boolean
+ registrationKey: string
+}
+
+const completedRegistrationKeys = new Set()
+const inFlightRegistrations = new Map>()
+
+function describeError(error: unknown): string {
+ if (error instanceof Error && error.message.trim().length > 0) {
+ return error.message
+ }
+ if (typeof error === 'string' && error.trim().length > 0) {
+ return error
+ }
+ return 'APNs registration failed'
+}
+
+function nullableTrimmed(value: string | null | undefined): string | null {
+ const normalized = value?.trim() ?? ''
+ return normalized.length > 0 ? normalized : null
+}
+
+function shouldRequestPermission(receive: string): boolean {
+ return receive === 'prompt' || receive === 'prompt-with-rationale'
+}
+
+async function hasPushPermission(pushNotifications: NativePushNotifications): Promise {
+ let permission = await pushNotifications.checkPermissions()
+ if (shouldRequestPermission(permission.receive)) {
+ permission = await pushNotifications.requestPermissions()
+ }
+ return permission.receive === 'granted'
+}
+
+async function requestAPNSToken(
+ pushNotifications: NativePushNotifications,
+ timeoutMs: number,
+): Promise {
+ let registrationHandle: PluginListenerHandle | null = null
+ let registrationErrorHandle: PluginListenerHandle | null = null
+
+ return await new Promise((resolve, reject) => {
+ let settled = false
+ const timer = globalThis.setTimeout(() => {
+ rejectOnce(new Error('APNs registration timed out'))
+ }, timeoutMs)
+
+ function cleanup(): void {
+ globalThis.clearTimeout(timer)
+ void registrationHandle?.remove()
+ void registrationErrorHandle?.remove()
+ }
+
+ function resolveOnce(token: Token): void {
+ if (settled) {
+ return
+ }
+ const value = token.value.trim()
+ if (!value) {
+ rejectOnce(new Error('APNs registration returned an empty token'))
+ return
+ }
+ settled = true
+ cleanup()
+ resolve(value)
+ }
+
+ function rejectOnce(error: Error): void {
+ if (settled) {
+ return
+ }
+ settled = true
+ cleanup()
+ reject(error)
+ }
+
+ async function register(): Promise {
+ registrationHandle = await pushNotifications.addListener('registration', resolveOnce)
+ registrationErrorHandle = await pushNotifications.addListener(
+ 'registrationError',
+ (error: RegistrationError) => {
+ rejectOnce(new Error(error.error || 'APNs registration failed'))
+ },
+ )
+ await pushNotifications.register()
+ }
+
+ void register().catch((error: unknown) => rejectOnce(new Error(describeError(error))))
+ })
+}
+
+function buildMobileDeviceRegistration(
+ apnsToken: string,
+ info: Awaited>,
+): MobileDeviceRegisterRequest {
+ return {
+ platform: 'ios',
+ apns_token: apnsToken,
+ environment: info.apnsEnvironment,
+ bundle_id: info.bundleId,
+ device_name: nullableTrimmed(info.deviceName),
+ app_version: nullableTrimmed(info.appVersion),
+ capabilities: {
+ deep_links: true,
+ rich_notifications: false,
+ },
+ }
+}
+
+export async function registerNativePushDevice(
+ options: NativePushRegistrationOptions = {},
+): Promise {
+ const isIOSNative = options.isIOSNative ?? isIOSNativeApp
+ if (!isIOSNative()) {
+ return { status: 'skipped', reason: 'not_ios_native' }
+ }
+
+ try {
+ const pushNotifications = options.pushNotifications ?? PushNotifications
+ if (!(await hasPushPermission(pushNotifications))) {
+ return { status: 'skipped', reason: 'permission_not_granted' }
+ }
+
+ const devicePlugin = options.devicePlugin ?? homeSecDevicePlugin
+ const [info, apnsToken] = await Promise.all([
+ devicePlugin.getRegistrationInfo(),
+ requestAPNSToken(pushNotifications, options.timeoutMs ?? 30_000),
+ ])
+ const client = options.client ?? apiClient
+ await client.registerMobileDevice(buildMobileDeviceRegistration(apnsToken, info))
+ return { status: 'registered' }
+ } catch (error) {
+ const reason = describeError(error)
+ if (reason.includes('plugin is not implemented on web')) {
+ return { status: 'skipped', reason: 'push_plugin_unavailable' }
+ }
+ return { status: 'failed', reason }
+ }
+}
+
+function registerOnceForKey(
+ registrationKey: string,
+ options: NativePushRegistrationOptions,
+): Promise {
+ if (completedRegistrationKeys.has(registrationKey)) {
+ return Promise.resolve({ status: 'skipped', reason: 'already_registered' })
+ }
+
+ const existing = inFlightRegistrations.get(registrationKey)
+ if (existing) {
+ return existing
+ }
+
+ const registration = registerNativePushDevice(options).then((result) => {
+ inFlightRegistrations.delete(registrationKey)
+ if (result.status === 'registered') {
+ completedRegistrationKeys.add(registrationKey)
+ }
+ return result
+ })
+ inFlightRegistrations.set(registrationKey, registration)
+ return registration
+}
+
+export function resetNativePushRegistrationForTests(): void {
+ completedRegistrationKeys.clear()
+ inFlightRegistrations.clear()
+}
+
+export function useNativePushRegistration({
+ client,
+ devicePlugin,
+ enabled,
+ isIOSNative,
+ pushNotifications,
+ registrationKey,
+ timeoutMs,
+}: UseNativePushRegistrationOptions): void {
+ useEffect(() => {
+ if (!enabled) {
+ return
+ }
+
+ let cancelled = false
+ void registerOnceForKey(registrationKey, {
+ client,
+ devicePlugin,
+ isIOSNative,
+ pushNotifications,
+ timeoutMs,
+ }).then((result) => {
+ if (!cancelled && result.status === 'failed') {
+ console.warn(`iOS push registration failed: ${result.reason ?? 'unknown error'}`)
+ }
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [
+ client,
+ devicePlugin,
+ enabled,
+ isIOSNative,
+ pushNotifications,
+ registrationKey,
+ timeoutMs,
+ ])
+}
diff --git a/ui/src/runtime/nativeRuntime.ts b/ui/src/runtime/nativeRuntime.ts
new file mode 100644
index 00000000..521377e1
--- /dev/null
+++ b/ui/src/runtime/nativeRuntime.ts
@@ -0,0 +1,9 @@
+import { Capacitor } from '@capacitor/core'
+
+export function isNativeApp(): boolean {
+ return Capacitor.isNativePlatform()
+}
+
+export function isIOSNativeApp(): boolean {
+ return isNativeApp() && Capacitor.getPlatform() === 'ios'
+}
diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css
index f641e138..1d7f5749 100644
--- a/ui/src/styles/global.css
+++ b/ui/src/styles/global.css
@@ -11,6 +11,10 @@ body,
min-height: 100%;
}
+html {
+ scroll-padding-bottom: var(--mobile-content-bottom-inset);
+}
+
body {
min-width: 320px;
font-family: var(--font-sans);
@@ -32,10 +36,13 @@ a:hover {
}
.app-shell {
+ --app-shell-inline-padding: clamp(var(--space-3), 2.5vw, var(--space-6));
+
position: relative;
display: grid;
grid-template-rows: auto 1fr;
min-height: 100vh;
+ min-height: 100dvh;
}
.app-shell__background {
@@ -56,7 +63,11 @@ a:hover {
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--surface-1) 94%, transparent);
backdrop-filter: blur(12px);
- padding: var(--space-3) clamp(var(--space-3), 2.5vw, var(--space-6));
+ padding:
+ calc(var(--space-3) + var(--safe-area-inset-top))
+ calc(var(--app-shell-inline-padding) + var(--safe-area-inset-right))
+ var(--space-3)
+ calc(var(--app-shell-inline-padding) + var(--safe-area-inset-left));
}
.app-shell__brand-link {
@@ -148,9 +159,12 @@ a:hover {
.app-shell__content {
position: relative;
z-index: 1;
- width: min(1220px, calc(100% - 3rem));
+ width: min(
+ 1220px,
+ calc(100% - 3rem - var(--safe-area-inset-left) - var(--safe-area-inset-right))
+ );
margin: 0 auto;
- padding: var(--space-6) 0 var(--space-7);
+ padding: var(--space-6) 0 var(--mobile-content-bottom-inset);
}
.mobile-bottom-nav {
@@ -283,6 +297,8 @@ a:hover {
}
.field-label {
+ display: grid;
+ gap: 0.4rem;
min-width: 0;
color: var(--text-secondary);
font-size: 0.85rem;
@@ -291,6 +307,7 @@ a:hover {
.input {
width: 100%;
min-width: 0;
+ min-height: 2.75rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface-1) 85%, transparent);
@@ -865,6 +882,13 @@ a:hover {
font-size: 1.25rem;
}
+.clip-detail-header-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: var(--space-2);
+}
+
.clip-detail-summary__text {
margin: 0;
color: var(--text-primary);
@@ -1557,7 +1581,10 @@ a:hover {
}
.app-shell__content {
- width: min(100% - 2rem, 1180px);
+ width: min(
+ 1180px,
+ calc(100% - 2rem - var(--safe-area-inset-left) - var(--safe-area-inset-right))
+ );
padding-top: var(--space-5);
}
@@ -1575,12 +1602,20 @@ a:hover {
}
@media (max-width: 620px) {
+ .app-shell {
+ --app-shell-inline-padding: var(--space-4);
+ }
+
.app-shell__nav {
display: none;
}
.app-shell__topbar {
- padding: var(--space-3) var(--space-4);
+ padding:
+ calc(var(--space-3) + var(--safe-area-inset-top))
+ calc(var(--app-shell-inline-padding) + var(--safe-area-inset-right))
+ var(--space-3)
+ calc(var(--app-shell-inline-padding) + var(--safe-area-inset-left));
}
.app-shell__header {
@@ -1605,35 +1640,61 @@ a:hover {
}
.app-shell__content {
- padding-bottom: calc(var(--space-7) + 4.5rem);
+ padding-bottom: var(--mobile-content-bottom-inset);
}
.mobile-bottom-nav {
position: fixed;
- left: var(--space-3);
- right: var(--space-3);
- bottom: var(--space-3);
+ left: calc(var(--mobile-bottom-nav-gap) + var(--safe-area-inset-left));
+ right: calc(var(--mobile-bottom-nav-gap) + var(--safe-area-inset-right));
+ bottom: calc(var(--mobile-bottom-nav-gap) + var(--safe-area-inset-bottom));
z-index: 10;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.35rem;
+ min-height: var(--mobile-bottom-nav-height);
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--surface-1) 94%, transparent);
box-shadow: var(--shadow);
padding: 0.35rem;
backdrop-filter: blur(10px);
+ transform: translateY(0);
+ transition:
+ transform var(--duration-normal) ease,
+ opacity var(--duration-fast) ease;
+ }
+
+ .app-shell--form-control-focused .mobile-bottom-nav {
+ display: none;
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(calc(
+ 100%
+ + var(--mobile-bottom-nav-gap)
+ + var(--safe-area-inset-bottom)
+ ));
}
.mobile-nav-link {
display: grid;
place-items: center;
min-height: 3rem;
+ min-width: 0;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: 0.82rem;
font-weight: 600;
+ overflow-wrap: anywhere;
+ padding: 0.35rem 0.45rem;
+ }
+
+ .input,
+ .button,
+ .media-panel__viewport,
+ .camera-preview__viewport {
+ scroll-margin-bottom: calc(var(--mobile-content-bottom-inset) + var(--space-3));
}
.mobile-nav-link--active {
@@ -1684,6 +1745,12 @@ a:hover {
flex-direction: column;
}
+ .clip-detail-header-actions {
+ display: grid;
+ width: 100%;
+ grid-template-columns: 1fr;
+ }
+
.camera-form-grid {
grid-template-columns: 1fr;
}
@@ -1755,3 +1822,18 @@ a:hover {
justify-content: flex-start;
}
}
+
+@supports selector(body:has(input:focus)) {
+ @media (max-width: 620px) {
+ body:has(input:focus, textarea:focus, select:focus) .mobile-bottom-nav {
+ display: none;
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(calc(
+ 100%
+ + var(--mobile-bottom-nav-gap)
+ + var(--safe-area-inset-bottom)
+ ));
+ }
+ }
+}
diff --git a/ui/src/styles/tokens.css b/ui/src/styles/tokens.css
index bdf8b18b..710872e7 100644
--- a/ui/src/styles/tokens.css
+++ b/ui/src/styles/tokens.css
@@ -14,6 +14,14 @@
--space-6: 2rem;
--space-7: 3rem;
+ --safe-area-inset-top: env(safe-area-inset-top, 0px);
+ --safe-area-inset-right: env(safe-area-inset-right, 0px);
+ --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
+ --safe-area-inset-left: env(safe-area-inset-left, 0px);
+ --mobile-bottom-nav-height: 3.75rem;
+ --mobile-bottom-nav-gap: var(--space-3);
+ --mobile-content-bottom-inset: var(--space-7);
+
--duration-fast: 130ms;
--duration-normal: 220ms;
@@ -36,6 +44,17 @@
--gradient-end: #f5f9ff;
}
+@media (max-width: 620px) {
+ :root {
+ --mobile-content-bottom-inset: calc(
+ var(--mobile-bottom-nav-height)
+ + var(--mobile-bottom-nav-gap)
+ + var(--safe-area-inset-bottom)
+ + var(--space-5)
+ );
+ }
+}
+
:root[data-theme='dark'] {
--surface-0: #0c111a;
--surface-1: #141c28;
diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json
index 8a67f62f..6ac41954 100644
--- a/ui/tsconfig.node.json
+++ b/ui/tsconfig.node.json
@@ -22,5 +22,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
- "include": ["vite.config.ts"]
+ "include": ["vite.config.ts", "capacitor.config.ts"]
}
diff --git a/uv.lock b/uv.lock
index aedfa46a..db8e4b47 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 2
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.12' and sys_platform == 'win32'",
@@ -842,6 +842,63 @@ toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
+[[package]]
+name = "cryptography"
+version = "49.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+ { name = "typing-extensions", marker = "python_full_version < '3.11'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
+ { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
+ { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
+ { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
+ { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
+ { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
+ { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
+ { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
+ { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
+ { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
+ { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
+ { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
+ { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
+ { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
+ { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
+ { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" },
+ { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" },
+]
+
[[package]]
name = "cycler"
version = "0.12.1"
@@ -1221,6 +1278,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
+[[package]]
+name = "h2"
+version = "4.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "hpack" },
+ { name = "hyperframe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
+]
+
[[package]]
name = "homesec"
version = "1.11.0"
@@ -1230,10 +1300,12 @@ dependencies = [
{ name = "alembic" },
{ name = "anyio" },
{ name = "asyncpg" },
+ { name = "cryptography" },
{ name = "dropbox" },
{ name = "fastapi" },
{ name = "fire" },
{ name = "greenlet" },
+ { name = "httpx", extra = ["http2"] },
{ name = "onvif-zeep-async" },
{ name = "opencv-python" },
{ name = "paho-mqtt" },
@@ -1268,10 +1340,12 @@ requires-dist = [
{ name = "alembic", specifier = ">=1.13.0" },
{ name = "anyio", specifier = ">=4.0.0" },
{ name = "asyncpg", specifier = ">=0.29.0" },
+ { name = "cryptography", specifier = ">=46.0.3" },
{ name = "dropbox", specifier = ">=12.0.2" },
{ name = "fastapi", specifier = ">=0.111.0" },
{ name = "fire", specifier = ">=0.7.1" },
{ name = "greenlet", specifier = ">=3.3.0" },
+ { name = "httpx", extras = ["http2"], specifier = ">=0.28.1" },
{ name = "onvif-zeep-async", specifier = ">=4.0.0" },
{ name = "opencv-python", specifier = ">=4.12.0.88" },
{ name = "paho-mqtt", specifier = ">=2.1.0" },
@@ -1300,6 +1374,15 @@ dev = [
{ name = "types-pyyaml", specifier = ">=6.0.12.20250915" },
]
+[[package]]
+name = "hpack"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
+]
+
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -1328,6 +1411,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
+[package.optional-dependencies]
+http2 = [
+ { name = "h2" },
+]
+
+[[package]]
+name = "hyperframe"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
+]
+
[[package]]
name = "idna"
version = "3.11"