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 +

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

{serverError}

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

{tokenError}

: null} + +
+
+
+ ) +} diff --git a/ui/src/features/native-setup/nativeSetup.css b/ui/src/features/native-setup/nativeSetup.css new file mode 100644 index 00000000..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"