mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
docs(iOS): add 2026 security audit report and domain-specific findings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
(cherry picked from commit 673d732dc6)
This commit is contained in:
238
apps/ios/AUDIT-REPORT-2026.md
Normal file
238
apps/ios/AUDIT-REPORT-2026.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# OpenClaw iOS App - Comprehensive Audit Report 2026
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/` (63 files, ~16,244 LOC), `apps/ios/Tests/` (25 files, 1,884 LOC)
|
||||
**Deployment Target:** iOS 18.0 / watchOS 11.0 | Swift 6.0 (strict concurrency: complete)
|
||||
**Audit Team:** 5 specialized Opus 4.6 agents (Concurrency, API Modernization, Architecture, UI/UX, Security)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OpenClaw iOS app is a **well-engineered codebase** that has adopted many 2026 best practices: Swift 6 strict concurrency, the Observation framework (`@Observable`), `NavigationStack`, Keychain credential storage, and TLS certificate pinning. However, the audit identified **9 critical findings**, **17 high findings**, **29 medium findings**, and **25 low findings** across 5 audit domains.
|
||||
|
||||
### Overall Health Score: **B+** (74/100)
|
||||
|
||||
| Domain | Score | Grade | Key Issue |
|
||||
|--------|-------|-------|-----------|
|
||||
| Swift 6 Concurrency | 78/100 | B+ | 3 data race risks, 5 unsafe patterns |
|
||||
| iOS 26 API Modernization | 82/100 | A- | 1 deprecated framework, 4 dead code paths |
|
||||
| Architecture & Code Quality | 62/100 | C+ | 2 god objects, 11.6% test coverage ratio |
|
||||
| UI/UX & Accessibility | 65/100 | C+ | Zero Dynamic Type, zero localization |
|
||||
| Security & Performance | 85/100 | A | No critical vulns, 3 high storage issues |
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings (9)
|
||||
|
||||
### Concurrency (3)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| CON-C1 | `GatewayTLSFingerprintProbe` data race: `objc_sync_enter` with unsynchronized `didFinish`/`session`/`task` reads in `start()` | `Gateway/GatewayConnectionController.swift:992-1058` | Crash/undefined behavior |
|
||||
| CON-C2 | `PhotoCaptureDelegate` & `MovieFileDelegate` unsynchronized `didResume` flag can double-resume `CheckedContinuation` | `Camera/CameraController.swift:260-339` | Fatal crash (debug), UB (release) |
|
||||
| CON-C3 | `GatewayDiagnostics.logWritesSinceCheck` uses `nonisolated(unsafe)` suppressing all compiler race checks | `Gateway/GatewaySettingsStore.swift:358` | Silent data race |
|
||||
|
||||
### Architecture (2)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| ARC-C1 | `NodeAppModel` is a 2,787 LOC god object with ~17 responsibilities | `Model/NodeAppModel.swift` | Untestable, unmaintainable |
|
||||
| ARC-C2 | `TalkModeManager` is a 2,153 LOC god object centralizing speech, audio, PTT, and gateway comms | `Voice/TalkModeManager.swift` | Same as above |
|
||||
|
||||
### UI/UX (3)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| UIX-C1 | `RootCanvas` voiceWakeToast animations ignore `accessibilityReduceMotion` | `RootCanvas.swift:159-167` | Accessibility violation |
|
||||
| UIX-C2 | `TalkOrbOverlay` perpetual pulse animations ignore `accessibilityReduceMotion` | `Voice/TalkOrbOverlay.swift:15-26` | Vestibular disorder risk |
|
||||
| UIX-C3 | `CameraFlashOverlay` has no VoiceOver announcement and no reduced motion check | `RootCanvas.swift:405-429` | Accessibility violation, photosensitivity |
|
||||
|
||||
### API Modernization (1)
|
||||
|
||||
| ID | Finding | File | Risk |
|
||||
|----|---------|------|------|
|
||||
| API-C1 | `NetService` usage (deprecated since iOS 16, removed in future SDKs) while `NWBrowser` already used for discovery | `Gateway/GatewayServiceResolver.swift`, `Gateway/GatewayConnectionController.swift:560-657` | Future SDK breakage |
|
||||
|
||||
---
|
||||
|
||||
## High Findings (17)
|
||||
|
||||
### Concurrency (5)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| CON-H1 | `ScreenRecordService` `UncheckedSendableBox<T>` wraps any T as Sendable, silencing compiler | `Screen/ScreenRecordService.swift:4-11` |
|
||||
| CON-H2 | `WatchMessagingService` `@unchecked Sendable` with `WCSession` property reads unprotected | `Services/WatchMessagingService.swift:23-28` |
|
||||
| CON-H3 | `LocationService` stores `CheckedContinuation` as instance vars with `nonisolated` delegate callbacks hopping to `@MainActor` | `Location/LocationService.swift:13-14` |
|
||||
| CON-H4 | `LiveNotificationCenter` wraps non-Sendable `UNUserNotificationCenter` in `@unchecked Sendable` | `Services/NotificationService.swift:18-58` |
|
||||
| CON-H5 | `NetworkStatusService` is `@unchecked Sendable` but stateless - unnecessary annotation | `Device/NetworkStatusService.swift:5` |
|
||||
|
||||
### Security (3)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| SEC-H1 | TLS fingerprints stored in UserDefaults (backup-extractable trust anchor) | `OpenClawKit/GatewayTLSPinning.swift:19-38` |
|
||||
| SEC-H2 | `KeychainStore` update path doesn't enforce `kSecAttrAccessible` on existing items | `Gateway/KeychainStore.swift:20-37` |
|
||||
| SEC-H3 | Gateway connection metadata (host/port/topology) in UserDefaults | `Gateway/GatewaySettingsStore.swift:170-217` |
|
||||
|
||||
### Architecture (2)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| ARC-H1 | 3 oversized files: `GatewayConnectionController` (1,058 LOC), `SettingsTab` (1,032 LOC), `OnboardingWizardView` (884 LOC) | Various |
|
||||
| ARC-H2 | 17 source modules with zero test coverage; 11.6% test LOC ratio | See gap analysis |
|
||||
|
||||
### UI/UX (5)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| UIX-H1 | Zero Dynamic Type support (no `@ScaledMetric`, no `dynamicTypeSize`) | All view files |
|
||||
| UIX-H2 | Zero localization infrastructure (all hardcoded English) | All source files |
|
||||
| UIX-H3 | Zero haptic feedback in entire app | All source files |
|
||||
| UIX-H4 | OnboardingWizardView missing accessibility labels on mode selection rows | `Onboarding/OnboardingWizardView.swift` |
|
||||
| UIX-H5 | `GatewayTrustPromptAlert` and `DeepLinkAgentPromptAlert` use deprecated `Alert` API | `Gateway/GatewayTrustPromptAlert.swift` |
|
||||
|
||||
### API Modernization (2)
|
||||
|
||||
| ID | Finding | File |
|
||||
|----|---------|------|
|
||||
| API-H1 | Dead `#available(iOS 15/18)` checks (deployment target is iOS 18.0) | `OpenClawApp.swift:344`, `Camera/CameraController.swift:222-249` |
|
||||
| API-H2 | `UNUserNotificationCenter` callback APIs wrapped in continuations instead of native async | `OpenClawApp.swift:429-462` |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Themes
|
||||
|
||||
### 1. God Object Pattern
|
||||
`NodeAppModel` (2,787 LOC) and `TalkModeManager` (2,153 LOC) together represent **30%** of the entire codebase. Both have `// swiftlint:disable` suppressions acknowledging the problem. This is the single highest-impact improvement opportunity.
|
||||
|
||||
### 2. Inconsistent Synchronization Primitives
|
||||
The codebase uses 4 different synchronization mechanisms: `NSLock` (6 usages), `OSAllocatedUnfairLock` (1 usage), `objc_sync_enter/exit` (1 usage), and `DispatchQueue` serialization (7 usages). Standardizing on `OSAllocatedUnfairLock` + actors would improve consistency and safety.
|
||||
|
||||
### 3. UserDefaults Overuse
|
||||
~70+ direct `UserDefaults.standard` reads/writes with raw string keys across the codebase. TLS fingerprints, gateway metadata, and connection details stored in UserDefaults should be in Keychain. Non-sensitive preferences lack a typed key registry.
|
||||
|
||||
### 4. Missing Accessibility Infrastructure
|
||||
Dynamic Type, localization, and haptic feedback are completely absent. Three views ignore `accessibilityReduceMotion`. This represents the largest gap relative to Apple's 2026 HIG expectations.
|
||||
|
||||
### 5. Test Coverage Gaps
|
||||
11.6% test LOC ratio with 17 untested modules. The gateway reconnect state machine (most complex logic), background lifecycle, onboarding flow, and TalkModeManager have minimal or zero test coverage.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gap Analysis (Top 15 Gaps)
|
||||
|
||||
| Module | Source LOC | Test LOC | Coverage |
|
||||
|--------|-----------|----------|----------|
|
||||
| `NodeAppModel.swift` | 2,787 | 478 (invoke only) | Partial - reconnect/background/deep links untested |
|
||||
| `TalkModeManager.swift` | 2,153 | 31 (config only) | Minimal |
|
||||
| `GatewayConnectionController.swift` | 1,058 | 226 | Partial - no TLS/Bonjour/autoconnect tests |
|
||||
| `SettingsTab.swift` | 1,032 | 8 (smoke) | Smoke only |
|
||||
| `OnboardingWizardView.swift` | 884 | 0 | None |
|
||||
| `OpenClawApp.swift` | 541 | 0 | None |
|
||||
| `RootCanvas.swift` | 429 | 8 (smoke) | Smoke only |
|
||||
| `GatewayOnboardingView.swift` | 371 | 0 | None |
|
||||
| `WatchMessagingService.swift` | 284 | 0 | None |
|
||||
| `ContactsService.swift` | 210 | 0 | None |
|
||||
| `LocationService.swift` | 177 | 0 | None |
|
||||
| `PhotoLibraryService.swift` | 164 | 0 | None |
|
||||
| `CalendarService.swift` | 135 | 0 | None |
|
||||
| `RemindersService.swift` | 133 | 0 | None |
|
||||
| `MotionService.swift` | 100 | 0 | None |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Action Plan
|
||||
|
||||
### Phase 1: Critical Fixes (Immediate)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 1 | Fix `PhotoCaptureDelegate`/`MovieFileDelegate` `didResume` synchronization (CON-C2) | Small | Prevents crashes |
|
||||
| 2 | Fix `GatewayTLSFingerprintProbe` data race (CON-C1) | Small | Prevents undefined behavior |
|
||||
| 3 | Add `accessibilityReduceMotion` checks to `RootCanvas` and `TalkOrbOverlay` (UIX-C1, C2, C3) | Small | Accessibility compliance |
|
||||
| 4 | Replace `nonisolated(unsafe)` in `GatewayDiagnostics` (CON-C3) | Small | Compiler safety |
|
||||
|
||||
### Phase 2: High-Priority Improvements (Next Sprint)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 5 | Move TLS fingerprints to Keychain (SEC-H1) | Medium | Security hardening |
|
||||
| 6 | Fix `KeychainStore` update accessibility enforcement (SEC-H2) | Small | Security correctness |
|
||||
| 7 | Migrate `NetService` to Network framework (API-C1) | Large | Future-proofing |
|
||||
| 8 | Remove dead `#available` checks (API-H1) | Small | Code cleanup |
|
||||
| 9 | Replace `UNUserNotificationCenter` callbacks with async APIs (API-H2) | Small | Modernization |
|
||||
| 10 | Add `@ScaledMetric` Dynamic Type support to key views (UIX-H1) | Medium | Accessibility |
|
||||
|
||||
### Phase 3: Architecture Refactoring (Planned)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 11 | Split `NodeAppModel` into 5-6 focused types (ARC-C1) | Large | Testability, maintainability |
|
||||
| 12 | Split `TalkModeManager` into 3-4 focused types (ARC-C2) | Large | Same |
|
||||
| 13 | Extract `SettingsTab` into section sub-views (ARC-H1) | Medium | Maintainability |
|
||||
| 14 | Create typed UserDefaults key registry | Medium | Type safety |
|
||||
| 15 | Add test coverage for gateway reconnect state machine | Large | Regression safety |
|
||||
| 16 | Add test coverage for background lifecycle management | Medium | Regression safety |
|
||||
|
||||
### Phase 4: Polish & Hardening (Opportunistic)
|
||||
|
||||
| # | Action | Effort | Impact |
|
||||
|---|--------|--------|--------|
|
||||
| 17 | Add localization infrastructure with `String(localized:)` (UIX-H2) | Large | International users |
|
||||
| 18 | Add haptic feedback to key interactions (UIX-H3) | Small | UX polish |
|
||||
| 19 | Standardize on `OSAllocatedUnfairLock` across codebase | Small | Consistency |
|
||||
| 20 | Replace Combine `Timer.publish`/`onReceive` with async patterns | Small | Modernization |
|
||||
| 21 | Add keyboard shortcuts for iPad (UIX-M5) | Small | iPad UX |
|
||||
| 22 | Gate `ELEVENLABS_API_KEY` env var behind `#if DEBUG` (SEC-M3) | Small | Security |
|
||||
| 23 | Enforce minimum interval between deep link prompts (SEC-M5) | Small | Security |
|
||||
| 24 | Add HMAC verification to QR setup codes (SEC-M6) | Medium | Security |
|
||||
|
||||
---
|
||||
|
||||
## Positive Patterns Worth Preserving
|
||||
|
||||
1. **Observation framework adoption** - Zero `ObservableObject` usage; consistent `@Observable` + `@Environment` throughout
|
||||
2. **Protocol-based DI** - `NodeServiceProtocols.swift` defines clean interfaces for all device capabilities with default implementations
|
||||
3. **Keychain for credentials** - Tokens, passwords, instance IDs stored with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`
|
||||
4. **TLS certificate pinning** - TOFU model with SHA-256 fingerprint verification and user confirmation
|
||||
5. **`CameraController` as actor** - Exemplary Swift concurrency pattern for hardware resource management
|
||||
6. **Dual WebSocket sessions** - Node/operator separation provides good privilege scoping
|
||||
7. **Non-persistent WKWebView** - Canvas prevents session data leakage
|
||||
8. **Swift 6 strict concurrency** - Enabled project-wide with `SWIFT_STRICT_CONCURRENCY: complete`
|
||||
9. **`@Sendable` service protocols** - All service protocols correctly require `Sendable` conformance
|
||||
10. **Deep link confirmation** - Agent deep links require explicit user approval with length limits
|
||||
|
||||
---
|
||||
|
||||
## OWASP Mobile Top 10 Summary
|
||||
|
||||
| Category | Status |
|
||||
|----------|--------|
|
||||
| M1: Improper Credential Usage | PASS |
|
||||
| M2: Inadequate Supply Chain Security | PASS |
|
||||
| M3: Insecure Authentication/Authorization | PASS |
|
||||
| M4: Insufficient Input/Output Validation | PASS |
|
||||
| M5: Insecure Communication | PASS (note: HTTP allowed in web views) |
|
||||
| M6: Inadequate Privacy Controls | PASS (note: location sent over TLS) |
|
||||
| M7: Insufficient Binary Protections | N/A |
|
||||
| M8: Security Misconfiguration | PASS (notes: H-1, H-3) |
|
||||
| M9: Insecure Data Storage | PASS (notes: H-1, H-3, M-2) |
|
||||
| M10: Insufficient Cryptography | PASS |
|
||||
|
||||
---
|
||||
|
||||
## Detailed Reports
|
||||
|
||||
Individual audit reports with full code snippets and line-by-line analysis:
|
||||
|
||||
- [`audit-concurrency.md`](./audit-concurrency.md) - Swift 6 strict concurrency (20 findings)
|
||||
- [`audit-api-modernization.md`](./audit-api-modernization.md) - iOS 26 API modernization (19 findings)
|
||||
- [`audit-architecture.md`](./audit-architecture.md) - Architecture & test coverage (16 findings)
|
||||
- [`audit-uiux.md`](./audit-uiux.md) - UI/UX & accessibility (24 findings)
|
||||
- [`audit-security.md`](./audit-security.md) - Security & performance (18 findings)
|
||||
|
||||
---
|
||||
|
||||
*Generated by OpenClaw iOS Audit Team (5x Opus 4.6 agents) on 2026-03-02*
|
||||
478
apps/ios/audit-api-modernization.md
Normal file
478
apps/ios/audit-api-modernization.md
Normal file
@@ -0,0 +1,478 @@
|
||||
# iOS API Modernization Audit Report
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Auditor:** API Modernization Expert (Claude Opus 4.6)
|
||||
**Scope:** All Swift source files in `apps/ios/Sources/`, `apps/ios/WatchExtension/Sources/`, and `apps/ios/ShareExtension/`
|
||||
**Deployment Target:** iOS 18.0 / watchOS 11.0
|
||||
**Swift Version:** 6.0 (strict concurrency: complete)
|
||||
**Xcode Version:** 16.0
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OpenClaw iOS codebase is well-maintained and has already adopted many modern Swift and iOS patterns. The Observation framework (`@Observable`, `@Bindable`, `@Environment(ModelType.self)`) is used consistently throughout. `NavigationStack` is used instead of the deprecated `NavigationView`. Swift 6 strict concurrency is enabled project-wide.
|
||||
|
||||
However, there are several areas where deprecated APIs remain in use, unnecessary availability checks exist (dead code given iOS 18.0 deployment target), and legacy callback-based APIs are wrapped in continuations where native async alternatives are available.
|
||||
|
||||
### Summary by Severity
|
||||
|
||||
| Severity | Count | Description |
|
||||
|----------|-------|-------------|
|
||||
| Critical | 1 | Deprecated `NetService` usage (removed in future SDKs) |
|
||||
| High | 4 | Dead availability-check code, legacy callback wrapping |
|
||||
| Medium | 8 | Callback APIs with async alternatives, legacy patterns |
|
||||
| Low | 6 | Minor modernization opportunities, style improvements |
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: `NetService` Usage (Deprecated Since iOS 16)
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Gateway/GatewayServiceResolver.swift` (entire file)
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionController.swift` (lines ~560-657)
|
||||
|
||||
**Current Code:**
|
||||
`GatewayServiceResolver` is built entirely on `NetService` and `NetServiceDelegate`, which have been deprecated since iOS 16. `GatewayConnectionController` uses `NetService` for Bonjour resolution in `resolveBonjourServiceToHostPort`.
|
||||
|
||||
**Risk:** Apple may remove `NetService` entirely in a future SDK. The app already uses `NWBrowser` (Network framework) for discovery in `GatewayDiscoveryModel.swift`, creating an inconsistency where discovery uses the modern API but resolution falls back to the deprecated one.
|
||||
|
||||
**Recommended Replacement:** Migrate to `NWConnection` for TCP connection establishment and use the endpoint information from `NWBrowser` results directly, eliminating the need for a separate `NetService`-based resolver. The `NWBrowser.Result` already provides `NWEndpoint` values that can be used with `NWConnection` without resolution.
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: Unnecessary `#available(iOS 15.0, *)` Check
|
||||
|
||||
**File:** `apps/ios/Sources/OpenClawApp.swift`, line 344
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
if #available(iOS 15.0, *) { ... }
|
||||
```
|
||||
|
||||
**Issue:** The deployment target is iOS 18.0, so this check is always true. The code inside the `#available` block executes unconditionally, and the compiler may warn about this.
|
||||
|
||||
**Recommended Fix:** Remove the `#available` check and keep only the body.
|
||||
|
||||
### H-2: Dead `AVAssetExportSession` Fallback Code
|
||||
|
||||
**File:** `apps/ios/Sources/Camera/CameraController.swift`, lines ~222-249
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
if #available(iOS 18.0, tvOS 18.0, visionOS 2.0, *) {
|
||||
try await exportSession.export(to: fileURL, as: .mp4)
|
||||
} else {
|
||||
exportSession.outputURL = fileURL
|
||||
exportSession.outputFileType = .mp4
|
||||
await exportSession.export()
|
||||
// ...legacy error check...
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `else` branch is dead code since the deployment target is iOS 18.0. The `#available` check is always true.
|
||||
|
||||
**Recommended Fix:** Remove the `#available` check and the `else` branch entirely. Use only the modern `export(to:as:)` API.
|
||||
|
||||
### H-3: Callback-Based `UNUserNotificationCenter` APIs Wrapped in Continuations
|
||||
|
||||
**File:** `apps/ios/Sources/OpenClawApp.swift`, lines ~429-462
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
let settings = await withCheckedContinuation { cont in
|
||||
center.getNotificationSettings { settings in
|
||||
cont.resume(returning: settings)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `UNUserNotificationCenter` has had native async APIs since iOS 15:
|
||||
- `center.notificationSettings()` (replaces `getNotificationSettings`)
|
||||
- `center.notificationCategories()` (replaces `getNotificationCategories`)
|
||||
- `try await center.add(request)` (replaces `add(_:completionHandler:)`)
|
||||
|
||||
The Watch app (`WatchInboxStore.swift`, line 161) already correctly uses the modern async pattern: `await center.notificationSettings()`.
|
||||
|
||||
**Recommended Fix:** Replace all `withCheckedContinuation` wrappers around `UNUserNotificationCenter` with their native async equivalents.
|
||||
|
||||
### H-4: `NSItemProvider.loadItem` Callback Pattern in Share Extension
|
||||
|
||||
**File:** `apps/ios/ShareExtension/ShareViewController.swift`, lines ~501-547
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
await withCheckedContinuation { continuation in
|
||||
provider.loadItem(forTypeIdentifier: typeIdentifier, options: nil) { item, _ in
|
||||
// ...
|
||||
continuation.resume(returning: ...)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NSItemProvider` has had modern async alternatives since iOS 16:
|
||||
- `try await provider.loadItem(forTypeIdentifier:)` for basic loading
|
||||
- `try await provider.loadDataRepresentation(for:)` with `UTType` parameter
|
||||
- `try await provider.loadFileRepresentation(for:)`
|
||||
|
||||
Three separate methods (`loadURLValue`, `loadTextValue`, `loadDataValue`) all wrap callbacks in continuations.
|
||||
|
||||
**Recommended Fix:** Adopt the modern `NSItemProvider` async APIs, using `UTType` parameters instead of string identifiers where possible.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: `CLLocationManager` Delegate Pattern vs Modern `CLLocationUpdate` API
|
||||
|
||||
**File:** `apps/ios/Sources/Location/LocationService.swift` (entire file)
|
||||
|
||||
**Current Code:** Uses `CLLocationManagerDelegate` with:
|
||||
- `startUpdatingLocation()` / `stopUpdatingLocation()`
|
||||
- `startMonitoringSignificantLocationChanges()`
|
||||
- `requestWhenInUseAuthorization()` / `requestAlwaysAuthorization()`
|
||||
- `locationManager(_:didUpdateLocations:)` delegate callback
|
||||
|
||||
**Modern Alternative (iOS 17+):**
|
||||
- `CLLocationUpdate.liveUpdates()` async sequence for continuous location
|
||||
- `CLMonitor` for region monitoring and significant location changes
|
||||
- `CLLocationManager.requestWhenInUseAuthorization()` still required for authorization, but updates are consumed via async sequences
|
||||
|
||||
**Impact:** The delegate pattern works but requires more boilerplate and is harder to compose with async/await code.
|
||||
|
||||
**Recommended Fix:** Migrate `startLocationUpdates` to use `CLLocationUpdate.liveUpdates()` and consider `CLMonitor` for significant location changes. Keep the authorization request methods as-is (no async alternative for those).
|
||||
|
||||
### M-2: `CMMotionActivityManager` and `CMPedometer` Callback Wrapping
|
||||
|
||||
**File:** `apps/ios/Sources/Motion/MotionService.swift`, lines 23-81
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
activityManager.queryActivityStarting(from: startDate, to: endDate, to: OperationQueue.main) { activities, error in
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** CoreMotion APIs still use callbacks; there are no native async versions. However, wrapping in `withCheckedThrowingContinuation` is currently the correct approach.
|
||||
|
||||
**Recommended Fix:** No change needed at this time. Monitor for async CoreMotion APIs in future SDK releases.
|
||||
|
||||
### M-3: `EKEventStore.fetchReminders` Callback Wrapping
|
||||
|
||||
**File:** `apps/ios/Sources/Reminders/RemindersService.swift`, lines 20-45
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
store.fetchReminders(matching: predicate) { reminders in
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** EventKit still uses callbacks for `fetchReminders`. The continuation wrapper is the correct approach for now.
|
||||
|
||||
**Recommended Fix:** No change needed. This is the standard pattern for callback-based EventKit APIs.
|
||||
|
||||
### M-4: `PHImageManager.requestImage` Synchronous Callback Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift`, line ~82
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
let options = PHImageRequestOptions()
|
||||
options.isSynchronous = true
|
||||
// ...
|
||||
imageManager.requestImage(for: asset, targetSize: size, contentMode: .aspectFill, options: options) { image, _ in
|
||||
resultImage = image
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** Uses `isSynchronous = true` which blocks the calling thread. Modern iOS apps should prefer async image loading. Consider using `PHImageManager`'s async image loading or the newer `PHPickerViewController` patterns for user-initiated selection.
|
||||
|
||||
**Recommended Fix:** If this code runs on a background thread (inside an actor), the synchronous pattern is acceptable for simplicity. Consider wrapping in a continuation if thread blocking becomes an issue.
|
||||
|
||||
### M-5: `NotificationCenter` Observer Callback Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakeManager.swift`, lines 105-113
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
self.userDefaultsObserver = NotificationCenter.default.addObserver(
|
||||
forName: UserDefaults.didChangeNotification,
|
||||
object: UserDefaults.standard,
|
||||
queue: .main,
|
||||
using: { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.handleUserDefaultsDidChange()
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Modern Alternative (iOS 15+):**
|
||||
```swift
|
||||
// Use async notification sequence
|
||||
for await _ in NotificationCenter.default.notifications(named: UserDefaults.didChangeNotification) {
|
||||
self.handleUserDefaultsDidChange()
|
||||
}
|
||||
```
|
||||
|
||||
**Also in:** `apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift`, line 55 (uses `onReceive` with Combine publisher -- see M-8).
|
||||
|
||||
**Recommended Fix:** Replace callback-based observers with `NotificationCenter.default.notifications(named:)` async sequences in a `.task` modifier or dedicated Task.
|
||||
|
||||
### M-6: `DispatchQueue.asyncAfter` Usage
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Gateway/TCPProbe.swift`, line 39
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionController.swift`, line ~1016
|
||||
- `apps/ios/ShareExtension/ShareViewController.swift`, line 142
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
queue.asyncAfter(deadline: .now() + timeoutSeconds) { finish(false) }
|
||||
```
|
||||
|
||||
**Issue:** `DispatchQueue.asyncAfter` is a legacy GCD pattern. In Swift concurrency, `Task.sleep(nanoseconds:)` or `Task.sleep(for:)` is preferred. However, in `TCPProbe`, the GCD pattern is used within an `NWConnection` state handler context where a DispatchQueue is already in use, making it acceptable.
|
||||
|
||||
**Recommended Fix:**
|
||||
- `TCPProbe.swift`: Acceptable as-is (NWConnection requires a DispatchQueue).
|
||||
- `GatewayConnectionController.swift`: Replace with `Task.sleep` pattern.
|
||||
- `ShareViewController.swift`: Replace with `Task.sleep` + `MainActor.run`.
|
||||
|
||||
### M-7: `objc_sync_enter`/`objc_sync_exit` and `objc_setAssociatedObject`
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift`, lines ~1039-1040, ~653
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
objc_sync_enter(connection)
|
||||
// ...
|
||||
objc_sync_exit(connection)
|
||||
```
|
||||
and
|
||||
```swift
|
||||
objc_setAssociatedObject(service, &resolvedKey, resolvedBox, .OBJC_ASSOCIATION_RETAIN)
|
||||
```
|
||||
|
||||
**Issue:** These are Objective-C runtime patterns. Swift has modern alternatives:
|
||||
- `OSAllocatedUnfairLock` (iOS 16+) or `Mutex` (proposed) for synchronization
|
||||
- Property wrappers or Swift-native patterns for associated state
|
||||
|
||||
Note: `TCPProbe.swift` correctly uses `OSAllocatedUnfairLock` already.
|
||||
|
||||
**Recommended Fix:** Replace `objc_sync_enter`/`objc_sync_exit` with `OSAllocatedUnfairLock`. For `objc_setAssociatedObject`, this will naturally be eliminated when migrating away from `NetService` (see C-1).
|
||||
|
||||
### M-8: Combine `Timer.publish` and `onReceive` Usage
|
||||
|
||||
**Files:**
|
||||
- `apps/ios/Sources/Onboarding/OnboardingWizardView.swift`, line ~72 (`Timer.publish`)
|
||||
- `apps/ios/Sources/Settings/VoiceWakeWordsSettingsView.swift`, line 55 (`.onReceive(NotificationCenter.default.publisher(...))`)
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
@State private var autoAdvanceTimer = Timer.publish(every: 5.5, on: .main, in: .common).autoconnect()
|
||||
// ...
|
||||
.onReceive(self.autoAdvanceTimer) { _ in ... }
|
||||
```
|
||||
|
||||
**Issue:** `Timer.publish` is a Combine pattern. Modern SwiftUI alternatives include:
|
||||
- `.task { while !Task.isCancelled { ... try? await Task.sleep(...) } }` for recurring timers
|
||||
- `TimelineView(.periodic(from:, by:))` for UI-driven periodic updates
|
||||
|
||||
**Recommended Fix:** Replace `Timer.publish` with a `.task`-based loop using `Task.sleep`. Replace `onReceive(NotificationCenter.default.publisher(...))` with `.task` + `NotificationCenter.default.notifications(named:)` async sequence.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: `@unchecked Sendable` on `WatchConnectivityReceiver`
|
||||
|
||||
**File:** `apps/ios/WatchExtension/Sources/WatchConnectivityReceiver.swift`, line 21
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
final class WatchConnectivityReceiver: NSObject, @unchecked Sendable { ... }
|
||||
```
|
||||
|
||||
**Issue:** `@unchecked Sendable` bypasses the compiler's sendability checks. The class holds a `WCSession?` and `WatchInboxStore` reference. Since `WatchInboxStore` is `@MainActor @Observable`, the receiver should ideally be restructured to use actor isolation or be marked `@MainActor`.
|
||||
|
||||
**Recommended Fix:** Consider making `WatchConnectivityReceiver` `@MainActor` or using an actor to protect shared state. The `WCSessionDelegate` methods dispatch to `@MainActor` already.
|
||||
|
||||
### L-2: `@unchecked Sendable` on `ScreenRecordService`
|
||||
|
||||
**File:** `apps/ios/Sources/Screen/ScreenRecordService.swift`
|
||||
|
||||
**Current Code:** Uses `@unchecked Sendable` with manual `NSLock`-based `CaptureState` synchronization.
|
||||
|
||||
**Issue:** Manual lock-based synchronization is error-prone. An actor would provide compiler-verified thread safety.
|
||||
|
||||
**Recommended Fix:** Consider converting `ScreenRecordService` to an actor, or at minimum replace `NSLock` with `OSAllocatedUnfairLock` for consistency with other parts of the codebase (e.g., `TCPProbe.swift`).
|
||||
|
||||
### L-3: `NSLock` Usage in `AudioBufferQueue`
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakeManager.swift`, lines 15-38
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
private final class AudioBufferQueue: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NSLock` is a valid synchronization primitive but `OSAllocatedUnfairLock` (iOS 16+) is more efficient and is already used elsewhere in the codebase.
|
||||
|
||||
**Recommended Fix:** Replace `NSLock` with `OSAllocatedUnfairLock` for consistency and performance. Note: this class is intentionally `@unchecked Sendable` because it runs on a realtime audio thread where actor isolation is not appropriate -- the manual lock pattern is correct here; just the lock type could be modernized.
|
||||
|
||||
### L-4: `DateFormatter` Usage Instead of `.formatted()`
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayDiscoveryDebugLogView.swift`, lines 49-67
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
private static let timeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss"
|
||||
return formatter
|
||||
}()
|
||||
```
|
||||
|
||||
**Issue:** Since iOS 15, Swift provides `Date.formatted()` with `FormatStyle` which is more type-safe and concise. The `WatchInboxView.swift` already uses the modern pattern: `updatedAt.formatted(date: .omitted, time: .shortened)`.
|
||||
|
||||
**Recommended Fix:** Replace `DateFormatter` with `Date.formatted(.dateTime.hour().minute().second())` for the time format and `Date.ISO8601FormatStyle` for ISO formatting.
|
||||
|
||||
### L-5: `UIScreen.main.bounds` Usage
|
||||
|
||||
**File:** `apps/ios/ShareExtension/ShareViewController.swift`, line 31
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
self.preferredContentSize = CGSize(width: UIScreen.main.bounds.width, height: 420)
|
||||
```
|
||||
|
||||
**Issue:** `UIScreen.main` is deprecated in iOS 16. In an extension context, `view.window?.windowScene?.screen` may not be available at `viewDidLoad` time, so the deprecation is harder to address here.
|
||||
|
||||
**Recommended Fix:** Since this is a share extension with limited lifecycle, this is acceptable. If refactoring, consider using trait collection or a fixed width, since the system manages extension sizing.
|
||||
|
||||
### L-6: String-Based `NSSortDescriptor` Key Path
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift`
|
||||
|
||||
**Current Code:**
|
||||
```swift
|
||||
NSSortDescriptor(key: "creationDate", ascending: false)
|
||||
```
|
||||
|
||||
**Issue:** String-based key paths are not type-safe. While Photos framework requires `NSSortDescriptor`, this is a known limitation of the framework.
|
||||
|
||||
**Recommended Fix:** No change needed. The Photos framework API requires `NSSortDescriptor` with string keys.
|
||||
|
||||
---
|
||||
|
||||
## Positive Findings (Already Modern)
|
||||
|
||||
The following modern patterns are already correctly adopted throughout the codebase:
|
||||
|
||||
| Pattern | Status | Files |
|
||||
|---------|--------|-------|
|
||||
| `@Observable` (Observation framework) | Adopted | `NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `ScreenController`, `VoiceWakeManager`, `TalkModeManager`, `WatchInboxStore` |
|
||||
| `@Environment(ModelType.self)` | Adopted | All views consistently use this pattern |
|
||||
| `@Bindable` for two-way bindings | Adopted | `WatchInboxView`, various settings views |
|
||||
| `NavigationStack` (not `NavigationView`) | Adopted | All navigation uses `NavigationStack` |
|
||||
| Modern `onChange(of:) { _, newValue in }` | Adopted | All `onChange` modifiers use the two-parameter variant |
|
||||
| `NWBrowser` (Network framework) | Adopted | `GatewayDiscoveryModel` for Bonjour discovery |
|
||||
| `NWPathMonitor` (Network framework) | Adopted | `NetworkStatusService` |
|
||||
| `DataScannerViewController` (VisionKit) | Adopted | `QRScannerView` for QR code scanning |
|
||||
| `PhotosPicker` (PhotosUI) | Adopted | `OnboardingWizardView` |
|
||||
| `OSAllocatedUnfairLock` | Adopted | `TCPProbe` |
|
||||
| Swift 6 strict concurrency | Adopted | Project-wide `SWIFT_STRICT_CONCURRENCY: complete` |
|
||||
| `actor` isolation | Adopted | `CameraController` uses `actor` |
|
||||
| `@ObservationIgnored` | Adopted | `NodeAppModel` for non-tracked properties |
|
||||
| `OSLog` / `Logger` | Adopted | Throughout the codebase |
|
||||
| `async`/`await` | Adopted | Pervasive throughout the codebase |
|
||||
| No `ObservableObject` / `@StateObject` | Correct | No legacy `ObservableObject` usage found |
|
||||
|
||||
---
|
||||
|
||||
## Prioritized Action Plan
|
||||
|
||||
### Phase 1: Critical (Immediate)
|
||||
1. **Migrate `NetService` to Network framework** (C-1) -- `GatewayServiceResolver` and `GatewayConnectionController` Bonjour resolution
|
||||
|
||||
### Phase 2: High (Next Sprint)
|
||||
2. **Remove dead `#available` checks** (H-1, H-2) -- `OpenClawApp.swift`, `CameraController.swift`
|
||||
3. **Replace `UNUserNotificationCenter` callback wrappers** (H-3) -- `OpenClawApp.swift`
|
||||
4. **Modernize `NSItemProvider` loading in Share Extension** (H-4) -- `ShareViewController.swift`
|
||||
|
||||
### Phase 3: Medium (Planned)
|
||||
5. **Migrate `CLLocationManager` delegate to `CLLocationUpdate`** (M-1) -- `LocationService.swift`
|
||||
6. **Replace `DispatchQueue.asyncAfter`** (M-6) -- `GatewayConnectionController.swift`, `ShareViewController.swift`
|
||||
7. **Replace `objc_sync` with `OSAllocatedUnfairLock`** (M-7) -- `GatewayConnectionController.swift`
|
||||
8. **Replace Combine `Timer.publish` and `onReceive`** (M-8) -- `OnboardingWizardView.swift`, `VoiceWakeWordsSettingsView.swift`
|
||||
9. **Replace callback-based `NotificationCenter` observers** (M-5) -- `VoiceWakeManager.swift`
|
||||
|
||||
### Phase 4: Low (Opportunistic)
|
||||
10. **Replace `NSLock` with `OSAllocatedUnfairLock`** (L-3) -- `VoiceWakeManager.swift`
|
||||
11. **Modernize `DateFormatter` to `FormatStyle`** (L-4) -- `GatewayDiscoveryDebugLogView.swift`
|
||||
12. **Address `@unchecked Sendable` patterns** (L-1, L-2) -- `WatchConnectivityReceiver`, `ScreenRecordService`
|
||||
|
||||
---
|
||||
|
||||
## Files Not Requiring Changes
|
||||
|
||||
The following files were audited and found to use modern patterns appropriately:
|
||||
|
||||
- `apps/ios/Sources/RootView.swift`
|
||||
- `apps/ios/Sources/RootTabs.swift`
|
||||
- `apps/ios/Sources/RootCanvas.swift`
|
||||
- `apps/ios/Sources/Model/NodeAppModel+Canvas.swift`
|
||||
- `apps/ios/Sources/Model/NodeAppModel+WatchNotifyNormalization.swift`
|
||||
- `apps/ios/Sources/Chat/ChatSheet.swift`
|
||||
- `apps/ios/Sources/Chat/IOSGatewayChatTransport.swift`
|
||||
- `apps/ios/Sources/Voice/VoiceTab.swift`
|
||||
- `apps/ios/Sources/Voice/VoiceWakePreferences.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayDiscoveryModel.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewaySettingsStore.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayHealthMonitor.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectConfig.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayConnectionIssue.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewaySetupCode.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift`
|
||||
- `apps/ios/Sources/Gateway/GatewayTrustPromptAlert.swift`
|
||||
- `apps/ios/Sources/Gateway/DeepLinkAgentPromptAlert.swift`
|
||||
- `apps/ios/Sources/Gateway/KeychainStore.swift`
|
||||
- `apps/ios/Sources/Screen/ScreenTab.swift`
|
||||
- `apps/ios/Sources/Screen/ScreenWebView.swift`
|
||||
- `apps/ios/Sources/Onboarding/GatewayOnboardingView.swift`
|
||||
- `apps/ios/Sources/Onboarding/OnboardingStateStore.swift`
|
||||
- `apps/ios/Sources/Status/StatusPill.swift`
|
||||
- `apps/ios/Sources/Status/StatusGlassCard.swift`
|
||||
- `apps/ios/Sources/Status/StatusActivityBuilder.swift`
|
||||
- `apps/ios/Sources/Status/GatewayStatusBuilder.swift`
|
||||
- `apps/ios/Sources/Status/GatewayActionsDialog.swift`
|
||||
- `apps/ios/Sources/Status/VoiceWakeToast.swift`
|
||||
- `apps/ios/Sources/Device/DeviceInfoHelper.swift`
|
||||
- `apps/ios/Sources/Device/DeviceStatusService.swift`
|
||||
- `apps/ios/Sources/Device/NetworkStatusService.swift`
|
||||
- `apps/ios/Sources/Device/NodeDisplayName.swift`
|
||||
- `apps/ios/Sources/Services/NodeServiceProtocols.swift`
|
||||
- `apps/ios/Sources/Services/WatchMessagingService.swift`
|
||||
- `apps/ios/Sources/Services/NotificationService.swift`
|
||||
- `apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift`
|
||||
- `apps/ios/Sources/Capabilities/NodeCapabilityRouter.swift`
|
||||
- `apps/ios/Sources/SessionKey.swift`
|
||||
- `apps/ios/Sources/Calendar/CalendarService.swift`
|
||||
- `apps/ios/Sources/Contacts/ContactsService.swift`
|
||||
- `apps/ios/Sources/EventKit/EventKitAuthorization.swift`
|
||||
- `apps/ios/Sources/Location/SignificantLocationMonitor.swift`
|
||||
- `apps/ios/WatchExtension/Sources/OpenClawWatchApp.swift`
|
||||
- `apps/ios/WatchExtension/Sources/WatchInboxStore.swift`
|
||||
- `apps/ios/WatchExtension/Sources/WatchInboxView.swift`
|
||||
- `apps/ios/WatchApp/` (asset catalog only)
|
||||
324
apps/ios/audit-architecture.md
Normal file
324
apps/ios/audit-architecture.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# iOS App Architecture, Code Quality & Test Coverage Audit
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/` (63 files, 16,244 LOC) and `apps/ios/Tests/` (25 files, 1,884 LOC)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
OpenClawApp (@main)
|
||||
|
|
||||
+----------------+----------------+
|
||||
| |
|
||||
NodeAppModel GatewayConnectionController
|
||||
(@Observable) (@Observable)
|
||||
[God Object] [Discovery + Connect]
|
||||
| |
|
||||
+-----------+-----------+ GatewayDiscoveryModel
|
||||
| | | | | GatewaySettingsStore
|
||||
| | | | | GatewayHealthMonitor
|
||||
| | | | |
|
||||
v v v v v
|
||||
Screen Voice Camera Services Gateway Sessions
|
||||
Ctrl Wake Ctrl (proto) (node + operator)
|
||||
Talk
|
||||
Mode
|
||||
|
||||
UI Layer (SwiftUI):
|
||||
RootCanvas -> ScreenWebView + StatusPill + Overlays
|
||||
RootTabs -> ScreenTab, VoiceTab, SettingsTab
|
||||
Onboarding -> OnboardingWizardView, QRScannerView
|
||||
Chat -> ChatSheet (wraps OpenClawChatUI package)
|
||||
|
||||
Service Layer (protocols in NodeServiceProtocols.swift):
|
||||
CameraServicing, ScreenRecordingServicing, LocationServicing,
|
||||
DeviceStatusServicing, PhotosServicing, ContactsServicing,
|
||||
CalendarServicing, RemindersServicing, MotionServicing,
|
||||
WatchMessagingServicing
|
||||
|
||||
Routing: NodeCapabilityRouter (command -> handler dictionary)
|
||||
|
||||
Shared Packages: OpenClawKit, OpenClawChatUI, OpenClawProtocol, SwabbleKit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Findings by Severity
|
||||
|
||||
### CRITICAL
|
||||
|
||||
#### C1. NodeAppModel is a God Object (2,787 LOC)
|
||||
- **File:** `Sources/Model/NodeAppModel.swift`
|
||||
- **Lines:** 1-2787 (entire file)
|
||||
- **Description:** NodeAppModel concentrates ~17 distinct responsibilities in a single 2,787-line class:
|
||||
1. Gateway WebSocket lifecycle (two sessions: node + operator)
|
||||
2. Gateway reconnect state machine with exponential backoff
|
||||
3. Background task management (grace periods, leases, suppression)
|
||||
4. Deep link handling and agent prompt routing
|
||||
5. Voice wake coordination (suspend/resume around other audio)
|
||||
6. Talk mode coordination
|
||||
7. Camera HUD state management
|
||||
8. Screen recording state
|
||||
9. Canvas/A2UI invoke handling (present, hide, navigate, evalJS, snapshot, push, reset)
|
||||
10. Camera invoke handling (list, snap, clip)
|
||||
11. Location invoke handling
|
||||
12. Device/Photos/Contacts/Calendar/Reminders/Motion invoke handling
|
||||
13. Watch messaging and notification mirroring
|
||||
14. Push notification (APNs) token management
|
||||
15. Share extension relay configuration
|
||||
16. Branding/config refresh from gateway
|
||||
17. Session key management and agent selection
|
||||
- **Impact:** Extremely difficult to test in isolation, reason about, or modify safely. The file already has `// swiftlint:disable type_body_length file_length` which indicates a known but unaddressed problem.
|
||||
- **Recommendation:** Extract at least these into separate types:
|
||||
- `GatewayConnectionLoop` (reconnect state machine, background lease management)
|
||||
- `NodeInvokeDispatcher` (all `handleXxxInvoke` methods, currently ~600 LOC)
|
||||
- `VoiceAudioCoordinator` (voice wake + talk mode suspend/resume logic)
|
||||
- `BackgroundLifecycleManager` (grace periods, suppression, leases)
|
||||
- `DeepLinkHandler` (agent prompt, deep link parsing/routing)
|
||||
- `PushNotificationManager` (APNs token, notification authorization)
|
||||
|
||||
#### C2. TalkModeManager is a God Object (2,153 LOC)
|
||||
- **File:** `Sources/Voice/TalkModeManager.swift`
|
||||
- **Lines:** 1-2153 (entire file, saved to disk due to size)
|
||||
- **Description:** TalkModeManager contains speech recognition, audio playback, gateway communication, provider API key management, push-to-talk state machine, and TTS. The file header acknowledges this: "This file intentionally centralizes talk mode state + behavior. It's large, and splitting would force `private` -> `fileprivate` across many members."
|
||||
- **Impact:** The `private` -> `fileprivate` concern is valid but solvable with extensions in the same file or a dedicated module with `internal` access.
|
||||
- **Recommendation:** Extract `TalkAudioPlayer`, `TalkSpeechRecognitionEngine`, `TalkConfigLoader`, `TalkPTTStateMachine` into separate files.
|
||||
|
||||
---
|
||||
|
||||
### HIGH
|
||||
|
||||
#### H1. GatewayConnectionController is oversized (1,058 LOC)
|
||||
- **File:** `Sources/Gateway/GatewayConnectionController.swift`
|
||||
- **Lines:** 1-1058
|
||||
- **Description:** Exceeds the 500 LOC guideline. Mixes discovery coordination, TLS fingerprint verification, Bonjour service resolution, loopback IP detection, URL building, capability/command/permission registration, and auto-connect logic.
|
||||
- **Recommendation:** Extract `GatewayTLSVerifier`, `LoopbackHostDetector` (static utility), and `GatewayCapabilityRegistrar` (caps/commands/permissions).
|
||||
|
||||
#### H2. SettingsTab is oversized (1,032 LOC)
|
||||
- **File:** `Sources/Settings/SettingsTab.swift`
|
||||
- **Lines:** 1-1032
|
||||
- **Description:** A single monolithic SwiftUI view with ~30 `@AppStorage` properties and multiple nested sections.
|
||||
- **Recommendation:** Extract section views: `GatewaySettingsSection`, `VoiceSettingsSection`, `DeviceSettingsSection`, `AdvancedSettingsSection`.
|
||||
|
||||
#### H3. OnboardingWizardView is oversized (884 LOC)
|
||||
- **File:** `Sources/Onboarding/OnboardingWizardView.swift`
|
||||
- **Lines:** 1-884
|
||||
- **Description:** Multi-step wizard with QR scanning, manual connection, photo picker, and pairing logic all in one view.
|
||||
- **Recommendation:** Extract per-step views: `OnboardingWelcomeStep`, `OnboardingConnectStep`, `OnboardingAuthStep`.
|
||||
|
||||
#### H4. Heavy UserDefaults coupling (no abstraction layer)
|
||||
- **Files:** `NodeAppModel.swift`, `GatewayConnectionController.swift`, `SettingsTab.swift`, `GatewaySettingsStore.swift`, `RootCanvas.swift`
|
||||
- **Description:** `UserDefaults.standard` is accessed directly throughout the codebase (~70+ direct reads/writes with raw string keys). There is no typed key registry or wrapper, so:
|
||||
- Key typos compile silently
|
||||
- Default values are duplicated (e.g., `"camera.enabled"` checked with fallback `true` in two places)
|
||||
- Testing requires the `withUserDefaults` helper which mutates the shared `UserDefaults.standard`
|
||||
- **Recommendation:** Create a `Settings` enum with typed keys (similar to `VoiceWakePreferences`) and use dependency injection for `UserDefaults`.
|
||||
|
||||
#### H5. Significant test coverage gaps for critical paths
|
||||
- **Description:** Several critical modules have zero test coverage. See the Test Coverage Gap Analysis table below.
|
||||
- **Impact:** Changes to gateway connection lifecycle, background task management, voice/talk coordination, and canvas interaction cannot be regression-tested.
|
||||
|
||||
---
|
||||
|
||||
### MEDIUM
|
||||
|
||||
#### M1. Inconsistent module boundary patterns
|
||||
- **Description:** Some modules use proper protocol-based DI (camera, screen recording, location, device status, photos, contacts, calendar, reminders, motion, watch messaging via `NodeServiceProtocols.swift`), while others use concrete types directly:
|
||||
- `VoiceWakeManager` and `TalkModeManager` are concrete, not protocol-backed
|
||||
- `GatewayHealthMonitor` is concrete (but has testable init with sleep injection)
|
||||
- `ScreenController` is concrete with no protocol
|
||||
- `NotificationCentering` protocol exists but is ad hoc (not in `NodeServiceProtocols.swift`)
|
||||
- **Recommendation:** Add protocols for `VoiceWakeServicing`, `TalkModeServicing`, `ScreenControlling` to enable test doubles.
|
||||
|
||||
#### M2. Closure-based wiring instead of protocol conformance
|
||||
- **Files:** `NodeAppModel.swift:178-216`, `ScreenController.swift:14-18`
|
||||
- **Description:** `ScreenController.onDeepLink` and `ScreenController.onA2UIAction` are closure properties rather than delegate protocols. Similarly, `VoiceWakeManager.configure(onCommand:)` uses a closure. This makes the dependency graph harder to trace.
|
||||
- **Recommendation:** Consider delegate protocols for clearer contracts, or at minimum document the callback contracts.
|
||||
|
||||
#### M3. OpenClawApp.swift mixes concerns (541 LOC)
|
||||
- **File:** `Sources/OpenClawApp.swift`
|
||||
- **Lines:** 1-541
|
||||
- **Description:** Contains three distinct concerns in one file:
|
||||
1. `OpenClawAppDelegate` (push notifications, background tasks)
|
||||
2. `WatchPromptNotificationBridge` (notification category management, 200+ LOC)
|
||||
3. `OpenClawApp` (SwiftUI app entry point)
|
||||
- **Recommendation:** Extract `WatchPromptNotificationBridge` to its own file.
|
||||
|
||||
#### M4. GatewayDiagnostics embedded in GatewaySettingsStore file
|
||||
- **File:** `Sources/Gateway/GatewaySettingsStore.swift:352-448`
|
||||
- **Description:** `GatewayDiagnostics` enum (file-based logging) is defined at the bottom of `GatewaySettingsStore.swift` with no relation to settings storage.
|
||||
- **Recommendation:** Move to its own file `Gateway/GatewayDiagnostics.swift`.
|
||||
|
||||
#### M5. Duplicate code patterns in invoke handlers
|
||||
- **File:** `Sources/Model/NodeAppModel.swift:1213-1358`
|
||||
- **Description:** Every `handleXxxInvoke` method follows the same pattern: decode params -> call service -> encode payload -> return response. The 12 invoke handlers repeat this boilerplate with minor variations. The `default:` case error response is duplicated 9 times verbatim.
|
||||
- **Recommendation:** Create a generic `invokeServiceMethod<P: Decodable, R: Encodable>` helper that handles the decode-call-encode-response cycle.
|
||||
|
||||
#### M6. No formal error domain or error catalog
|
||||
- **Description:** Errors are constructed ad hoc using `NSError(domain:code:userInfo:)` with inconsistent domains ("Screen", "Gateway", "Camera", "NodeAppModel", "GatewayHealthMonitor", "VoiceWake") and magic number codes. Only `CameraController.CameraError` uses a proper Swift error enum.
|
||||
- **Recommendation:** Define a unified `OpenClawIOSError` enum with cases for each domain, or at minimum use consistent error domains and documented code ranges.
|
||||
|
||||
#### M7. Two gateway sessions managed in parallel without shared state machine
|
||||
- **File:** `Sources/Model/NodeAppModel.swift:96-98`
|
||||
- **Description:** `nodeGateway` and `operatorGateway` are two independent `GatewayNodeSession` instances with separate reconnect loops. Their connected states (`gatewayConnected`, `operatorConnected`) are tracked independently, but the UI only shows one "gateway status". Disconnect/reconnect of one does not coordinate with the other.
|
||||
- **Recommendation:** Extract a `DualGatewaySessionManager` that manages both sessions' lifecycles as a coordinated unit.
|
||||
|
||||
---
|
||||
|
||||
### LOW
|
||||
|
||||
#### L1. `RootView.swift` is a trivial wrapper (7 LOC)
|
||||
- **File:** `Sources/RootView.swift`
|
||||
- **Description:** Contains only `struct RootView: View { var body: some View { RootCanvas() } }`. This adds an unnecessary layer of indirection.
|
||||
- **Recommendation:** Remove and use `RootCanvas` directly, or document why the indirection exists.
|
||||
|
||||
#### L2. Access control could be tighter
|
||||
- **Description:** Many types use default `internal` access where `private` or `fileprivate` would be more appropriate. For example:
|
||||
- `NodeAppModel.gatewayStatusText`, `nodeStatusText`, `operatorStatusText` are `var` (settable) from outside
|
||||
- `GatewayDiscoveryModel.gateways` is `var` (not `private(set)`)
|
||||
- `VoiceWakeManager.isEnabled`, `isListening` are publicly settable
|
||||
- **Recommendation:** Prefer `private(set)` for observable properties that should only be modified internally.
|
||||
|
||||
#### L3. `#if DEBUG` test hooks pattern
|
||||
- **Files:** `GatewayConnectionController.swift:929-989`, `VoiceWakeManager.swift:477-483`, `NodeAppModel.swift` (via `_test_` prefixed methods)
|
||||
- **Description:** Test hooks are exposed via `#if DEBUG` extensions with `_test_` prefixes. While functional, this pollutes the type's API surface.
|
||||
- **Recommendation:** This is a reasonable pattern for host-app tests. Consider using `@_spi(Testing)` when available in Swift 6 for cleaner separation.
|
||||
|
||||
#### L4. Naming inconsistency: `ThrowingContinuationSupport`
|
||||
- **File:** `Sources/OpenClawApp.swift:459`
|
||||
- **Description:** References `ThrowingContinuationSupport.resumeVoid` which appears to be defined in OpenClawKit. The name is verbose; a simple extension on `CheckedContinuation` would be more idiomatic.
|
||||
|
||||
#### L5. `GatewayTLSFingerprintProbe` uses `objc_sync_enter/exit` instead of a lock
|
||||
- **File:** `Sources/Gateway/GatewayConnectionController.swift:1039-1040`
|
||||
- **Description:** `objc_sync_enter(self)` / `objc_sync_exit(self)` is an Objective-C runtime synchronization primitive. Modern Swift code should use `NSLock`, `os_unfair_lock`, or `Mutex` (Swift 6).
|
||||
- **Recommendation:** Replace with `NSLock` or `Mutex` for consistency with other lock usage (e.g., `NotificationInvokeLatch` uses `NSLock`).
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gap Analysis
|
||||
|
||||
| Source File | LOC | Test File | Test LOC | Coverage |
|
||||
|---|---|---|---|---|
|
||||
| `Model/NodeAppModel.swift` | 2787 | `NodeAppModelInvokeTests.swift` | 478 | **Partial** - invoke dispatch only; no tests for reconnect, background, deep links |
|
||||
| `Voice/TalkModeManager.swift` | 2153 | `TalkModeConfigParsingTests.swift` | 31 | **Minimal** - config parsing only; no PTT, speech, or playback tests |
|
||||
| `Gateway/GatewayConnectionController.swift` | 1058 | `GatewayConnectionControllerTests.swift` + `GatewayConnectionSecurityTests.swift` | 226 | **Partial** - security + basic flow; no TLS probe, Bonjour resolve, or autoconnect tests |
|
||||
| `Settings/SettingsTab.swift` | 1032 | `SwiftUIRenderSmokeTests.swift` (1 test) | ~8 | **Smoke only** - verifies view hierarchy builds |
|
||||
| `Onboarding/OnboardingWizardView.swift` | 884 | None | 0 | **None** |
|
||||
| `OpenClawApp.swift` | 541 | None | 0 | **None** - WatchPromptNotificationBridge untested |
|
||||
| `Voice/VoiceWakeManager.swift` | 483 | `VoiceWakeManagerStateTests.swift` + `VoiceWakeManagerExtractCommandTests.swift` | 144 | **Good** - state transitions + command extraction |
|
||||
| `Gateway/GatewaySettingsStore.swift` | 448 | `GatewaySettingsStoreTests.swift` | 197 | **Good** |
|
||||
| `RootCanvas.swift` | 429 | `SwiftUIRenderSmokeTests.swift` | ~8 | **Smoke only** |
|
||||
| `Onboarding/GatewayOnboardingView.swift` | 371 | None | 0 | **None** |
|
||||
| `Screen/ScreenRecordService.swift` | 350 | `ScreenRecordServiceTests.swift` | 32 | **Minimal** |
|
||||
| `Camera/CameraController.swift` | 339 | `CameraControllerClampTests.swift` + `CameraControllerErrorTests.swift` | 38 | **Minimal** - clamp/error only; no capture flow tests |
|
||||
| `Services/WatchMessagingService.swift` | 284 | None (mock in NodeAppModelInvokeTests) | 0 | **None** |
|
||||
| `Screen/ScreenController.swift` | 267 | `ScreenControllerTests.swift` | 87 | **Good** |
|
||||
| `Contacts/ContactsService.swift` | 210 | None | 0 | **None** |
|
||||
| `Screen/ScreenWebView.swift` | 193 | None | 0 | **None** |
|
||||
| `Gateway/GatewayDiscoveryModel.swift` | 181 | `GatewayDiscoveryModelTests.swift` | 22 | **Minimal** |
|
||||
| `Location/LocationService.swift` | 177 | None | 0 | **None** |
|
||||
| `Media/PhotoLibraryService.swift` | 164 | None | 0 | **None** |
|
||||
| `Chat/IOSGatewayChatTransport.swift` | 142 | `IOSGatewayChatTransportTests.swift` | 30 | **Minimal** |
|
||||
| `Calendar/CalendarService.swift` | 135 | None | 0 | **None** |
|
||||
| `Reminders/RemindersService.swift` | 133 | None | 0 | **None** |
|
||||
| `Motion/MotionService.swift` | 100 | None | 0 | **None** |
|
||||
| `Model/NodeAppModel+WatchNotifyNormalization.swift` | 103 | `VoiceWakeGatewaySyncTests.swift` (partial) | 22 | **Minimal** |
|
||||
| `Model/NodeAppModel+Canvas.swift` | 59 | None | 0 | **None** |
|
||||
| `Gateway/GatewayHealthMonitor.swift` | 85 | None | 0 | **None** |
|
||||
| `Gateway/KeychainStore.swift` | 48 | `KeychainStoreTests.swift` | 22 | **Minimal** |
|
||||
| `Onboarding/OnboardingStateStore.swift` | 52 | `OnboardingStateStoreTests.swift` | 57 | **Good** |
|
||||
| `Gateway/GatewayConnectionIssue.swift` | 71 | `GatewayConnectionIssueTests.swift` | 33 | **Good** |
|
||||
| `SessionKey.swift` | 23 | Tested via `NodeAppModelInvokeTests` | - | **Good** (indirectly) |
|
||||
| `Settings/SettingsNetworkingHelpers.swift` | 40 | `SettingsNetworkingHelpersTests.swift` | 50 | **Good** |
|
||||
| `Voice/VoiceWakePreferences.swift` | 44 | `VoiceWakePreferencesTests.swift` | 38 | **Good** |
|
||||
| `Device/NodeDisplayName.swift` | 48 | Tested via GatewayConnectionControllerTests | - | **Partial** |
|
||||
|
||||
### Coverage Summary
|
||||
- **63 source files**, **25 test files** (24 test + 1 helper)
|
||||
- **17 source modules with zero test coverage** (service implementations, onboarding views, several gateway files)
|
||||
- **Test LOC ratio:** 1,884 / 16,244 = **11.6%** (low for a production app)
|
||||
- **Test framework:** Swift Testing (`@Test`, `#expect`) -- modern and correct
|
||||
- **Test patterns:** Good use of mocks (MockWatchMessagingService), `withUserDefaults` helper for isolation, `_test_` hooks for internal access. SwiftUI render smoke tests validate view hierarchy construction.
|
||||
|
||||
### Critical Untested Paths
|
||||
1. **Gateway reconnect state machine** - the most complex logic in the app (background lease, pairing pause, backoff) has zero tests
|
||||
2. **Background lifecycle management** - grace periods, suppression, wake handling untested
|
||||
3. **Onboarding flow** - 1,255 LOC across 3 files with zero tests
|
||||
4. **Push notification handling** - APNs registration, silent push, background refresh untested
|
||||
5. **TalkModeManager** - 2,153 LOC with only 31 LOC of config parsing tests
|
||||
|
||||
---
|
||||
|
||||
## Dependency Injection Assessment
|
||||
|
||||
### Well-Injected (protocol-based, testable)
|
||||
All services in `NodeServiceProtocols.swift` are protocol-based with default production implementations:
|
||||
- `CameraServicing` -> `CameraController`
|
||||
- `ScreenRecordingServicing` -> `ScreenRecordService`
|
||||
- `LocationServicing` -> `LocationService`
|
||||
- `DeviceStatusServicing` -> `DeviceStatusService`
|
||||
- `PhotosServicing` -> `PhotoLibraryService`
|
||||
- `ContactsServicing` -> `ContactsService`
|
||||
- `CalendarServicing` -> `CalendarService`
|
||||
- `RemindersServicing` -> `RemindersService`
|
||||
- `MotionServicing` -> `MotionService`
|
||||
- `WatchMessagingServicing` -> `WatchMessagingService`
|
||||
- `NotificationCentering` -> `LiveNotificationCenter`
|
||||
|
||||
`NodeAppModel.init()` accepts all of these via parameters with defaults -- excellent DI pattern.
|
||||
|
||||
### Not Injected (hardcoded dependencies)
|
||||
- `GatewaySettingsStore` - static enum, not injectable. Tests must use real `UserDefaults`/Keychain.
|
||||
- `GatewayDiagnostics` - static enum with file I/O, not injectable.
|
||||
- `GatewayDiscoveryModel` - concrete class created inside `GatewayConnectionController.init`.
|
||||
- `GatewayHealthMonitor` - created internally by `NodeAppModel` (but has testable init).
|
||||
- `VoiceWakeManager` - created internally, injected into SwiftUI environment.
|
||||
- `TalkModeManager` - injected via `NodeAppModel.init` parameter (good).
|
||||
- `ScreenController` - injected via `NodeAppModel.init` parameter (good).
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Patterns
|
||||
|
||||
### Observation Framework Usage
|
||||
The app uses Swift's `Observation` framework (`@Observable`) consistently:
|
||||
- `NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `VoiceWakeManager`, `TalkModeManager`, `ScreenController` are all `@Observable`.
|
||||
- SwiftUI views access them via `@Environment(Type.self)`.
|
||||
- No legacy `ObservableObject` / `@StateObject` patterns found -- this is correct per CLAUDE.md guidance.
|
||||
|
||||
### Environment Propagation
|
||||
```
|
||||
OpenClawApp
|
||||
|-- @State NodeAppModel -> .environment(appModel)
|
||||
|-- @State GatewayConnectionController -> .environment(gatewayController)
|
||||
|-- appModel.voiceWake (VoiceWakeManager) -> .environment(appModel.voiceWake)
|
||||
```
|
||||
This is clean, though `voiceWake` being both a property of `NodeAppModel` AND injected separately into the environment creates a potential consistency issue if they ever diverge.
|
||||
|
||||
---
|
||||
|
||||
## Architectural Strengths
|
||||
|
||||
1. **Strong protocol-based DI for services** - `NodeServiceProtocols.swift` defines clean interfaces for all device capabilities, enabling easy mocking in tests.
|
||||
2. **Modern Swift 6 / Observation adoption** - No legacy `ObservableObject` patterns; strict concurrency enabled.
|
||||
3. **NodeCapabilityRouter** - Clean command-routing pattern that decouples command registration from handling.
|
||||
4. **Dual gateway session architecture** - Separating node (device capabilities) from operator (chat/config) connections is architecturally sound.
|
||||
5. **GatewayConnectConfig** - Single source of truth struct for connection parameters.
|
||||
6. **Consistent input validation** - Nearly every string input is trimmed and empty-checked.
|
||||
7. **Keychain-based credential storage** - Sensitive data (tokens, passwords) stored in Keychain, not UserDefaults.
|
||||
8. **`CameraController` uses actor isolation** - Correct concurrency pattern for hardware resource.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Refactoring Priority
|
||||
|
||||
1. **[CRITICAL]** Split `NodeAppModel` into 5-6 focused types (highest ROI for testability)
|
||||
2. **[CRITICAL]** Split `TalkModeManager` into 3-4 focused types
|
||||
3. **[HIGH]** Add tests for gateway reconnect state machine
|
||||
4. **[HIGH]** Add tests for background lifecycle management
|
||||
5. **[HIGH]** Extract `SettingsTab` into section views
|
||||
6. **[MEDIUM]** Create typed `UserDefaults` key registry
|
||||
7. **[MEDIUM]** Unify error handling with a proper error catalog
|
||||
8. **[MEDIUM]** Extract duplicate invoke handler boilerplate
|
||||
399
apps/ios/audit-concurrency.md
Normal file
399
apps/ios/audit-concurrency.md
Normal file
@@ -0,0 +1,399 @@
|
||||
# Swift 6 Concurrency Audit: OpenClaw iOS App
|
||||
|
||||
**Scope:** `apps/ios/Sources/` (63 files, ~15K LOC)
|
||||
**Date:** 2026-03-02
|
||||
**Auditor:** Concurrency Auditor Agent
|
||||
|
||||
---
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Files audited | 63 |
|
||||
| `@MainActor` classes | 8 |
|
||||
| `actor` types | 1 (`CameraController`) |
|
||||
| `@unchecked Sendable` types | 9 |
|
||||
| `@preconcurrency` imports | 2 (`UserNotifications`, `WatchConnectivity`) |
|
||||
| `@preconcurrency` conformances | 2 (`UNUserNotificationCenterDelegate`, `NetServiceDelegate`) |
|
||||
| `nonisolated(unsafe)` usages | 1 |
|
||||
| `NSLock` usages | 6 |
|
||||
| `DispatchQueue` usages | 7 |
|
||||
| `objc_sync_enter/exit` usages | 1 |
|
||||
| `CheckedContinuation` usages | ~25 |
|
||||
| `@Observable` (Observation framework) types | 6 |
|
||||
| `ObservableObject` types | 0 |
|
||||
|
||||
### Overall Assessment
|
||||
|
||||
The codebase is in **good shape for Swift 6 strict concurrency**. The major model types use `@MainActor` + `@Observable` (Observation framework), there are zero `ObservableObject` usages, and the actor model is applied consistently. There are no `@Sendable` annotations missing on closure parameters in any obvious way, and the use of `@unchecked Sendable` is confined to genuine low-level synchronization wrappers. However, there are several areas that warrant attention.
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: `GatewayTLSFingerprintProbe` uses `objc_sync_enter` + `@unchecked Sendable` with unsynchronized `didFinish` read
|
||||
|
||||
**File:** `Gateway/GatewayConnectionController.swift:992-1058`
|
||||
**Severity:** Critical (potential data race)
|
||||
|
||||
```swift
|
||||
private final class GatewayTLSFingerprintProbe: NSObject, URLSessionDelegate, @unchecked Sendable {
|
||||
private var didFinish = false // line 996
|
||||
private var session: URLSession? // line 997
|
||||
private var task: URLSessionWebSocketTask? // line 998
|
||||
...
|
||||
private func finish(_ fingerprint: String?) {
|
||||
objc_sync_enter(self) // line 1039
|
||||
defer { objc_sync_exit(self) }
|
||||
guard !self.didFinish else { return }
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `start()` method (line 1006) reads and writes `self.session` and `self.task` without any lock. The `DispatchQueue.global().asyncAfter` timeout on line 1016 calls `finish()` from a background queue while `start()` sets properties on the caller's thread. Additionally, `URLSession` delegate callbacks arrive on an arbitrary delegate queue (nil was passed for `delegateQueue`), which means `urlSession(_:didReceive:completionHandler:)` and `finish()` can race.
|
||||
|
||||
**Recommendation:** Replace `objc_sync_enter/exit` with `NSLock` or `OSAllocatedUnfairLock`. Ensure all mutable state (`didFinish`, `session`, `task`) is accessed under the lock. Better yet, convert to an `actor` since this is a short-lived async operation. Alternatively, use `OSAllocatedUnfairLock<State>` wrapping a struct.
|
||||
|
||||
---
|
||||
|
||||
### C-2: `PhotoCaptureDelegate` and `MovieFileDelegate` lack synchronization on `didResume`
|
||||
|
||||
**File:** `Camera/CameraController.swift:260-339`
|
||||
**Severity:** Critical (potential double continuation resume)
|
||||
|
||||
```swift
|
||||
private final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
|
||||
private let continuation: CheckedContinuation<Data, Error>
|
||||
private var didResume = false // NOT thread-safe
|
||||
|
||||
func photoOutput(...) {
|
||||
guard !self.didResume else { return } // line 273
|
||||
self.didResume = true
|
||||
...
|
||||
}
|
||||
func photoOutput(...didFinishCaptureFor...) {
|
||||
guard let error else { return }
|
||||
guard !self.didResume else { return } // line 303
|
||||
self.didResume = true
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `AVCapturePhotoCaptureDelegate` callbacks can arrive on different queues. The `didResume` flag is a plain `Bool` with no synchronization. If `didFinishProcessingPhoto` and `didFinishCaptureFor` are called concurrently (possible under certain error conditions), both could read `didResume` as `false` and resume the continuation twice, which is a fatal crash in debug builds and undefined behavior in release.
|
||||
|
||||
**Recommendation:** Protect `didResume` with `OSAllocatedUnfairLock<Bool>` or `NSLock`. The same issue applies to `MovieFileDelegate` on line 309.
|
||||
|
||||
---
|
||||
|
||||
### C-3: `GatewayDiagnostics.logWritesSinceCheck` is `nonisolated(unsafe)` static var
|
||||
|
||||
**File:** `Gateway/GatewaySettingsStore.swift:358`
|
||||
**Severity:** Critical (data race)
|
||||
|
||||
```swift
|
||||
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||
```
|
||||
|
||||
**Issue:** This counter is read and written inside `queue.async {}` blocks on `GatewayDiagnostics.queue`, but `nonisolated(unsafe)` tells the compiler to skip checking. The access is actually serialized by the private `DispatchQueue`, so it is functionally safe -- however, `nonisolated(unsafe)` is a red flag for Swift 6 audits because it permanently suppresses the compiler's data-race safety checks.
|
||||
|
||||
**Recommendation:** Replace with proper synchronization visible to the compiler. Either:
|
||||
1. Make it a local variable inside the `DispatchQueue` closure scope, or
|
||||
2. Wrap in `OSAllocatedUnfairLock<Int>` or a dedicated `actor`, or
|
||||
3. Since all accesses are on `GatewayDiagnostics.queue`, convert to a `@Sendable`-safe pattern that doesn't require `nonisolated(unsafe)`.
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: `ScreenRecordService` is `@unchecked Sendable` but holds no state -- its inner `CaptureState` synchronizes via NSLock but `UncheckedSendableBox` silences Sendable checks
|
||||
|
||||
**File:** `Screen/ScreenRecordService.swift:4-11`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
final class ScreenRecordService: @unchecked Sendable {
|
||||
private struct UncheckedSendableBox<T>: @unchecked Sendable {
|
||||
let value: T
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `UncheckedSendableBox` wraps **any** `T` (including non-Sendable types like `CMSampleBuffer`) and marks it `@unchecked Sendable`. This is used to pass `CMSampleBuffer` across threads in the capture handler. While `CMSampleBuffer` is effectively thread-safe for read-only access, this pattern silences the compiler completely and could mask future issues if the box is used for other types.
|
||||
|
||||
**Recommendation:** Use `nonisolated(unsafe) let value: T` instead if on Swift 6.2+, or document the specific thread-safety invariant. Consider constraining `T: Sendable` on the generic and handling `CMSampleBuffer` separately with a targeted unsafe annotation.
|
||||
|
||||
### H-2: `WatchMessagingService` is `@unchecked Sendable` with mutable `replyHandler` protected only by NSLock
|
||||
|
||||
**File:** `Services/WatchMessagingService.swift:23-28`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
final class WatchMessagingService: NSObject, WatchMessagingServicing, @unchecked Sendable {
|
||||
private let replyHandlerLock = NSLock()
|
||||
private var replyHandler: (@Sendable (WatchQuickReplyEvent) -> Void)?
|
||||
```
|
||||
|
||||
**Issue:** While the `replyHandler` is properly protected by `NSLock`, the `session` property (`WCSession?`) is accessed from both the main thread (via delegate callbacks forwarded with `@preconcurrency`) and potentially from WatchConnectivity's internal threads. The `WCSession` properties like `isPaired`, `isWatchAppInstalled`, `isReachable` are read in `status(for:)` without synchronization and could race with delegate callbacks.
|
||||
|
||||
**Recommendation:** Convert to an `actor` or ensure all `WCSession` property reads happen on a specific isolation context. The lock properly protects `replyHandler`, so this is a moderate risk.
|
||||
|
||||
### H-3: `LocationService` stores `CheckedContinuation` as instance vars without synchronization between `nonisolated` delegate callbacks and `@MainActor` methods
|
||||
|
||||
**File:** `Location/LocationService.swift:13-14, 136-176`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class LocationService: NSObject, CLLocationManagerDelegate, LocationServiceCommon {
|
||||
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
```
|
||||
|
||||
The delegate methods are marked `nonisolated`:
|
||||
```swift
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
if let cont = self.authContinuation { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `nonisolated` delegate methods create `Task { @MainActor in }` to hop back to the main actor before accessing continuations. This is the correct pattern. However, there is a subtle race: if two delegate callbacks arrive in rapid succession, both could queue `@MainActor` tasks, and the second one would find the continuation already `nil`. This is handled (the `if let` guards), but the pattern is fragile. More importantly, `CLLocationManager` requires its delegate methods to be called on the queue it was created on. Since the class is `@MainActor`, the manager is created on main, and iOS should deliver delegate callbacks on main -- making the `nonisolated` annotation somewhat misleading.
|
||||
|
||||
**Recommendation:** Since `CLLocationManager` delivers callbacks on the thread/queue of the delegate's assigned queue (main in this case), the `nonisolated` annotation is technically unnecessary and may confuse future maintainers. Consider removing `nonisolated` and letting `@MainActor` inheritance apply. This would also let the compiler verify the continuation access is safe.
|
||||
|
||||
### H-4: `LiveNotificationCenter` is `@unchecked Sendable` wrapping a non-Sendable `UNUserNotificationCenter`
|
||||
|
||||
**File:** `Services/NotificationService.swift:18-58`
|
||||
**Severity:** High
|
||||
|
||||
```swift
|
||||
struct LiveNotificationCenter: NotificationCentering, @unchecked Sendable {
|
||||
private let center: UNUserNotificationCenter
|
||||
```
|
||||
|
||||
**Issue:** `UNUserNotificationCenter` is not `Sendable`. Wrapping it in `@unchecked Sendable` silences the compiler. In practice, `UNUserNotificationCenter.current()` returns a singleton that is thread-safe, so this is functionally fine -- but the compiler cannot verify this.
|
||||
|
||||
**Recommendation:** This is acceptable given `UNUserNotificationCenter.current()` is a thread-safe singleton. Document the invariant with a comment explaining why `@unchecked Sendable` is safe here. Alternatively, access the center via `UNUserNotificationCenter.current()` each time instead of storing it.
|
||||
|
||||
### H-5: `NetworkStatusService` is `@unchecked Sendable` but has no mutable state
|
||||
|
||||
**File:** `Device/NetworkStatusService.swift:5`
|
||||
**Severity:** High (misleading annotation)
|
||||
|
||||
```swift
|
||||
final class NetworkStatusService: @unchecked Sendable {
|
||||
```
|
||||
|
||||
**Issue:** `NetworkStatusService` has no stored properties at all. It creates `NWPathMonitor` locally in each method call. The `@unchecked Sendable` is unnecessary because a stateless final class is inherently `Sendable`.
|
||||
|
||||
**Recommendation:** Remove `@unchecked` -- just conform to `Sendable` directly. The class has no mutable state and is `final`, so it qualifies for automatic Sendable conformance.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: `TalkModeManager` `pttCompletion` continuation stored as instance var could leak
|
||||
|
||||
**File:** `Voice/TalkModeManager.swift:43`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
private var pttCompletion: CheckedContinuation<OpenClawTalkPTTStopPayload, Never>?
|
||||
```
|
||||
|
||||
**Issue:** If `pttCompletion` is set but the manager is deinitialized or the PTT session is interrupted without resuming it, the continuation will leak. `CheckedContinuation` logs a warning in debug builds when it is never resumed, and in production the caller will hang indefinitely.
|
||||
|
||||
**Recommendation:** Add a `deinit` or cleanup path that resumes `pttCompletion` with a default/error value. Also verify that all code paths that set `pttCompletion` eventually resume it (including error paths, cancellation, and mode changes).
|
||||
|
||||
### M-2: Heavy use of `Task { @MainActor in }` hops in code that is already `@MainActor`
|
||||
|
||||
**Files:** Multiple (OpenClawApp.swift:30-47, NodeAppModel.swift:179-207, etc.)
|
||||
**Severity:** Medium (performance/clarity)
|
||||
|
||||
```swift
|
||||
// In OpenClawAppDelegate which is already @MainActor:
|
||||
Task { @MainActor in
|
||||
model.updateAPNsDeviceToken(token)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** When code is already on `@MainActor`, creating `Task { @MainActor in }` is redundant in terms of isolation but does defer execution to the next event loop tick. If the intent is immediate execution, this is a performance anti-pattern. If the intent is deferral, it should be documented.
|
||||
|
||||
**Recommendation:** Where immediate execution is intended, call the method directly. Where deferral is intentional, add a comment explaining why. In Swift 6.2 with `nonisolated(nonsending)` defaults, these patterns will behave differently.
|
||||
|
||||
### M-3: `GatewayDiscoveryModel` browser callbacks use closures that capture `self` without explicit `@Sendable`
|
||||
|
||||
**File:** `Gateway/GatewayDiscoveryModel.swift:60-96`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||
...
|
||||
onState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state // MainActor state access
|
||||
},
|
||||
onResults: { [weak self] results in
|
||||
guard let self else { return }
|
||||
self.gatewaysByDomain[domain] = results.compactMap { ... }
|
||||
```
|
||||
|
||||
**Issue:** These closures capture `self` (a `@MainActor` `@Observable` class) and mutate its state. If `GatewayDiscoveryBrowserSupport.makeBrowser` dispatches these callbacks on a background queue (which NWBrowser does by default), this would be a main-actor isolation violation. The callbacks access `@MainActor`-isolated properties without explicitly hopping to the main actor.
|
||||
|
||||
**Recommendation:** Verify that `GatewayDiscoveryBrowserSupport.makeBrowser` dispatches callbacks on the main queue. If not, wrap the callback bodies in `Task { @MainActor in ... }` or `await MainActor.run { ... }`. This is a potential data race if callbacks arrive off-main.
|
||||
|
||||
### M-4: `withObservationTracking` + `onChange` pattern in `GatewayConnectionController.observeDiscovery()` could miss updates
|
||||
|
||||
**File:** `Gateway/GatewayConnectionController.swift:293-305`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
private func observeDiscovery() {
|
||||
withObservationTracking {
|
||||
_ = self.discovery.gateways
|
||||
_ = self.discovery.statusText
|
||||
_ = self.discovery.debugLog
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery() // re-register
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** The `onChange` handler in `withObservationTracking` fires at most once per registration. The recursive re-registration inside `Task { @MainActor in }` means there is a window between when the `onChange` fires and when the new tracking is registered where changes could be missed. In practice, the `Task` hop is fast, but under heavy load or if the main actor queue is busy, rapid changes to `discovery.gateways` could be dropped.
|
||||
|
||||
**Recommendation:** This is a known limitation of `withObservationTracking` outside SwiftUI. Consider using `AsyncStream` or `Combine` publisher from the discovery model instead, which provides continuous observation without re-registration gaps.
|
||||
|
||||
### M-5: `GatewayServiceResolver` does not protect `didFinish` flag with a lock
|
||||
|
||||
**File:** `Gateway/GatewayServiceResolver.swift:9, 41-47`
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
private var didFinish = false
|
||||
|
||||
private func finish(result: ...) {
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** `NetServiceDelegate` callbacks can theoretically arrive on multiple threads (depending on how the service is scheduled). The `didFinish` flag is not synchronized. If `netServiceDidResolveAddress` and `netService(_:didNotResolve:)` are called concurrently, `finish` could be called twice.
|
||||
|
||||
**Recommendation:** Add `NSLock` protection or use `OSAllocatedUnfairLock<Bool>` for `didFinish`. Alternatively, ensure the service is always scheduled on the main run loop (which `BonjourServiceResolverSupport.start` may already do).
|
||||
|
||||
### M-6: `ContactsService`, `CalendarService`, `RemindersService`, `MotionService`, `PhotoLibraryService` conform to `Sendable` protocols but are plain classes without actor isolation
|
||||
|
||||
**Files:** Various service files
|
||||
**Severity:** Medium
|
||||
|
||||
```swift
|
||||
final class ContactsService: ContactsServicing { ... }
|
||||
// ContactsServicing: Sendable
|
||||
```
|
||||
|
||||
**Issue:** These classes have no mutable stored properties and are `final`, which technically makes them safe to mark `Sendable`. However, they don't explicitly declare `Sendable` conformance -- they inherit it through their protocol conformances (`ContactsServicing: Sendable`). The Swift 6 compiler will flag this because a `final class` without explicit `Sendable` or `@unchecked Sendable` conformance cannot implicitly satisfy `Sendable` requirements from protocols unless it is provably safe (no mutable state).
|
||||
|
||||
**Recommendation:** Since these classes are stateless and `final`, add explicit `: Sendable` conformance or verify they compile cleanly under strict concurrency.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: `@preconcurrency import UserNotifications` and `@preconcurrency import WatchConnectivity` suppress Sendable warnings
|
||||
|
||||
**Files:** `OpenClawApp.swift:7`, `Services/WatchMessagingService.swift:4`
|
||||
**Severity:** Low
|
||||
|
||||
**Issue:** `@preconcurrency` imports suppress sendability diagnostics for types from those modules. As Apple updates these frameworks for Sendable conformance in newer SDKs, the `@preconcurrency` should be removed to benefit from the compiler's checks.
|
||||
|
||||
**Recommendation:** Periodically check if these frameworks have been updated with Sendable annotations in newer Xcode versions and remove `@preconcurrency` when possible.
|
||||
|
||||
### L-2: `VoiceWakeManager.makeRecognitionResultHandler()` returns `@Sendable` closure that captures `[weak self]` correctly
|
||||
|
||||
**File:** `Voice/VoiceWakeManager.swift:301-313`
|
||||
**Severity:** Low (informational -- this is well done)
|
||||
|
||||
The recognition result handler correctly captures `[weak self]` and hops to `@MainActor` before accessing any state. This is a good pattern.
|
||||
|
||||
### L-3: `CameraController` is an `actor` -- exemplary usage
|
||||
|
||||
**File:** `Camera/CameraController.swift:5`
|
||||
**Severity:** Low (informational -- this is well done)
|
||||
|
||||
`CameraController` is the only `actor` in the codebase. It properly uses `nonisolated static` for pure functions and `async` for all state-mutating operations. This is a model for how other services could be structured.
|
||||
|
||||
### L-4: Several `Task { }` in `@MainActor` context don't explicitly annotate `@MainActor`
|
||||
|
||||
**Files:** Multiple
|
||||
**Severity:** Low
|
||||
|
||||
```swift
|
||||
// Inside @MainActor class:
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = await self.connectDiscoveredGateway(target)
|
||||
}
|
||||
```
|
||||
|
||||
**Issue:** In Swift 6.0, an unstructured `Task { }` created from `@MainActor` context inherits the actor context. However, in Swift 6.2 with `nonisolated(nonsending)` defaults, this behavior may change. Explicitly annotating `Task { @MainActor in }` makes the intent clear and forward-compatible.
|
||||
|
||||
**Recommendation:** Add explicit `@MainActor` annotation to `Task { }` blocks in `@MainActor` types where main-actor isolation is required.
|
||||
|
||||
### L-5: Consider migrating `NSLock` to `OSAllocatedUnfairLock` for better performance
|
||||
|
||||
**Files:** Multiple (6 usages)
|
||||
**Severity:** Low
|
||||
|
||||
`OSAllocatedUnfairLock` (available since iOS 16) is faster than `NSLock` for short critical sections. The existing `NSLock` usages in `AudioBufferQueue`, `NotificationInvokeLatch`, `CaptureState`, etc. are all protecting brief property accesses and would benefit from the switch.
|
||||
|
||||
**Recommendation:** Migrate `NSLock` to `OSAllocatedUnfairLock` where deployment target allows (iOS 16+). `TCPProbe.swift` already uses `OSAllocatedUnfairLock` -- apply the same pattern to other files.
|
||||
|
||||
### L-6: `NodeAppModel` is very large (~1500+ lines) which makes concurrency reasoning difficult
|
||||
|
||||
**File:** `Model/NodeAppModel.swift`
|
||||
**Severity:** Low (maintainability)
|
||||
|
||||
**Issue:** The large file size with many Task/async operations, multiple gateway sessions, and deeply nested closures makes it harder to reason about concurrency invariants. All state is `@MainActor` which is safe, but the complexity makes it harder to verify no accidental non-isolated access exists.
|
||||
|
||||
**Recommendation:** Consider splitting into smaller focused files (already noted with `NodeAppModel+Canvas.swift` and `NodeAppModel+WatchNotifyNormalization.swift` extensions). Further decomposition would improve auditability.
|
||||
|
||||
---
|
||||
|
||||
## Positive Patterns Found
|
||||
|
||||
1. **Consistent `@MainActor` + `@Observable` usage**: All major model types (`NodeAppModel`, `GatewayConnectionController`, `GatewayDiscoveryModel`, `TalkModeManager`, `VoiceWakeManager`, `ScreenController`) use the Observation framework with `@MainActor` isolation. Zero `ObservableObject` usages.
|
||||
|
||||
2. **Zero `@Sendable` protocol conformance issues**: All service protocols (`CameraServicing`, `LocationServicing`, `DeviceStatusServicing`, etc.) correctly require `Sendable`.
|
||||
|
||||
3. **`CameraController` as `actor`**: Properly models concurrent camera access.
|
||||
|
||||
4. **`@Sendable` closures in callback APIs**: Callback closures (e.g., `onCommand` in `VoiceWakeManager`, `replyHandler` in `WatchMessagingService`) are properly annotated `@Sendable`.
|
||||
|
||||
5. **`CheckedContinuation` usage**: All continuation usages properly handle the single-resume invariant with `didResume`/`finished` flags (though some lack synchronization -- see C-2 and M-5).
|
||||
|
||||
6. **No `DispatchQueue.main.async` for UI updates**: All UI-related state mutations go through `@MainActor` or `Task { @MainActor in }`, not legacy GCD patterns.
|
||||
|
||||
7. **`ThrowingContinuationSupport.resumeVoid`**: Custom helper for void continuations reduces boilerplate and potential mistakes.
|
||||
|
||||
---
|
||||
|
||||
## Swift 6.2 / iOS 26 Forward-Compatibility Notes
|
||||
|
||||
1. **`nonisolated(nonsending)` default**: Several `nonisolated` functions and closures may need `@concurrent` annotation if they are intended to run off the caller's actor. Review all `nonisolated` methods.
|
||||
|
||||
2. **Default `@MainActor` isolation**: If the project opts into Swift 6.2's `MainActorByDefault`, most explicit `@MainActor` annotations become redundant. The current architecture is well-positioned for this.
|
||||
|
||||
3. **`@preconcurrency` removal**: As Apple frameworks adopt Sendable, remove `@preconcurrency` imports for `UserNotifications` and `WatchConnectivity`.
|
||||
|
||||
4. **`sending` parameter keyword**: New `sending` keyword in Swift 6.2 may replace some `@Sendable` closure annotations for parameters that are consumed (not stored).
|
||||
376
apps/ios/audit-security.md
Normal file
376
apps/ios/audit-security.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# iOS App Security, Networking & Performance Audit
|
||||
|
||||
**Date:** 2026-03-02
|
||||
**Scope:** `apps/ios/Sources/`, `apps/shared/OpenClawKit/Sources/` (security-relevant shared code), `apps/ios/project.yml`, entitlements
|
||||
**Auditor:** Security & Performance Audit Agent
|
||||
|
||||
---
|
||||
|
||||
## 1. Security Posture Overview
|
||||
|
||||
The OpenClaw iOS app demonstrates a **generally strong security posture** for a local-network gateway client. Key strengths include:
|
||||
|
||||
- **Keychain usage for credentials:** Gateway tokens, passwords, instance IDs, and API keys are stored in Keychain (not UserDefaults).
|
||||
- **TLS certificate pinning:** SHA-256 certificate fingerprint pinning is implemented for gateway WebSocket connections via `GatewayTLSPinningSession`.
|
||||
- **Trust-on-first-use (TOFU) with user confirmation:** New gateway TLS fingerprints require explicit user approval before trust is established.
|
||||
- **Deep link confirmation:** Agent deep links (the `openclaw://` URL scheme) require user confirmation before execution, with message length limits.
|
||||
- **Web view security:** The canvas WKWebView uses `.nonPersistent()` data store and validates that A2UI action messages originate only from trusted/local-network URLs.
|
||||
- **Input sanitization:** Consistent `.trimmingCharacters(in: .whitespacesAndNewlines)` throughout, input length limits on contacts/calendar/photos queries.
|
||||
- **Permission gating:** All hardware capabilities (camera, location, microphone, contacts, calendar, photos) check authorization status before access.
|
||||
- **No hardcoded secrets:** No API keys, tokens, or credentials are hardcoded in the source.
|
||||
- **Swift 6 strict concurrency:** Enabled project-wide (`SWIFT_STRICT_CONCURRENCY: complete`), reducing data race risks.
|
||||
|
||||
---
|
||||
|
||||
## 2. Critical Severity Findings
|
||||
|
||||
*No critical vulnerabilities identified.*
|
||||
|
||||
The app does not store plaintext passwords in UserDefaults, does not embed secrets, does not disable ATS globally, and does not allow arbitrary code execution from untrusted sources. The attack surface is primarily local-network, which limits remote exploitation vectors.
|
||||
|
||||
---
|
||||
|
||||
## 3. High Severity Findings
|
||||
|
||||
### H-1: TLS Fingerprints Stored in UserDefaults Instead of Keychain
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:19-38`
|
||||
**Severity:** HIGH
|
||||
|
||||
`GatewayTLSStore` stores TLS certificate fingerprints in `UserDefaults(suiteName: "ai.openclaw.shared")`. While fingerprints themselves are not secrets, they serve as the trust anchor for the TLS pinning system. An attacker with access to the device backup (unencrypted iTunes/Finder backup) or a compromised app extension sharing the same suite could modify these fingerprints and redirect gateway connections to a malicious server.
|
||||
|
||||
**Exploit scenario:** An attacker with physical or backup access modifies the stored fingerprint for a known gateway stableID, then performs a MITM attack on the LAN. The app connects using the attacker's fingerprint as the expected pin.
|
||||
|
||||
**Recommended fix:** Store TLS fingerprints in Keychain with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (matching the existing `KeychainStore` pattern). This prevents backup extraction and cross-device compromise.
|
||||
|
||||
---
|
||||
|
||||
### H-2: KeychainStore Update Path Does Not Set Accessibility Level
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/KeychainStore.swift:20-37`
|
||||
**Severity:** HIGH
|
||||
|
||||
In `saveString()`, when the item already exists (`SecItemUpdate` succeeds), the update does not set or enforce the `kSecAttrAccessible` attribute. Only new items (via `SecItemAdd`) get `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. If a Keychain item was originally created with a less restrictive accessibility level (e.g., during a migration or by an older version), it retains that weaker level after updates.
|
||||
|
||||
**Exploit scenario:** An older app version or a migration path creates a Keychain item without specifying `kSecAttrAccessible` (defaults to `kSecAttrAccessibleWhenUnlocked`). After upgrading, the item retains the old accessibility level, potentially making it accessible via iCloud Keychain sync.
|
||||
|
||||
**Recommended fix:** Before `SecItemUpdate`, delete and re-add the item with the correct accessibility attribute, or explicitly include `kSecAttrAccessible` in the update query attributes. Example:
|
||||
|
||||
```swift
|
||||
// Delete-then-add pattern for consistent accessibility
|
||||
SecItemDelete(query as CFDictionary)
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = data
|
||||
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H-3: Gateway Connection Metadata in UserDefaults
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:170-217`
|
||||
**Severity:** HIGH
|
||||
|
||||
Last-known gateway connection details (host, port, TLS flag, stableID, connection kind) are stored in `UserDefaults.standard`. This data reveals which gateway servers the user connects to, their network topology, and connection preferences. UserDefaults are included in unencrypted device backups and can be read by MDM profiles or forensic tools.
|
||||
|
||||
**Affected keys:** `gateway.last.kind`, `gateway.last.host`, `gateway.last.port`, `gateway.last.tls`, `gateway.last.stableID`, `gateway.manual.host`, `gateway.manual.port`, `gateway.manual.tls`, `gateway.manual.clientId`, `gateway.clientIdOverride.*`, `gateway.selectedAgentId.*`.
|
||||
|
||||
**Recommended fix:** Move gateway connection metadata that reveals network topology to Keychain or use `NSFileProtectionCompleteUntilFirstUserAuthentication` on a dedicated plist file in the app's data directory.
|
||||
|
||||
---
|
||||
|
||||
## 4. Medium Severity Findings
|
||||
|
||||
### M-1: `NSAllowsArbitraryLoadsInWebContent` Enabled
|
||||
|
||||
**File:** `apps/ios/project.yml:110`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
```yaml
|
||||
NSAppTransportSecurity:
|
||||
NSAllowsArbitraryLoadsInWebContent: true
|
||||
```
|
||||
|
||||
This disables ATS protections for WKWebView content. While necessary for the canvas to load user-specified URLs from the gateway (including local-network HTTP servers), it means the web view can load insecure HTTP resources. The `ScreenController.navigate()` method does filter out loopback URLs but does not enforce HTTPS for remote URLs.
|
||||
|
||||
**Exploit scenario:** A gateway instructs the canvas to load an HTTP URL on a public network. The content is intercepted/modified via MITM.
|
||||
|
||||
**Recommended fix:** This is largely an accepted risk given the product's design (canvas loads gateway-specified URLs). Consider adding a user-visible indicator when the canvas is loading non-HTTPS content, and log a warning.
|
||||
|
||||
---
|
||||
|
||||
### M-2: Diagnostic Log File Written to Documents Directory Without Protection
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:359-448`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
`GatewayDiagnostics` writes logs to `Documents/openclaw-gateway.log`. The Documents directory is accessible via iTunes file sharing (if enabled) and is included in device backups. Log entries include timestamps and gateway connection events which could reveal usage patterns.
|
||||
|
||||
Logs are written with `privacy: .public` in the `os.Logger` calls, meaning they are also visible in `Console.app` sysdiagnose captures without redaction.
|
||||
|
||||
**Recommended fix:** Write diagnostic logs to `Library/Caches/` instead (excluded from backups), apply `NSFileProtectionCompleteUntilFirstUserAuthentication`, and consider using `privacy: .private` or `privacy: .auto` for log messages that may contain sensitive connection details.
|
||||
|
||||
---
|
||||
|
||||
### M-3: Environment Variable Fallback for ElevenLabs API Key
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/TalkModeManager.swift:991-992`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
```swift
|
||||
ProcessInfo.processInfo.environment["ELEVENLABS_API_KEY"]
|
||||
```
|
||||
|
||||
The talk mode manager reads API keys from environment variables as a fallback. While environment variables are not accessible to other apps on iOS, they persist in process memory and could be captured in crash reports. This pattern is more suitable for development/debugging and should not ship in production builds.
|
||||
|
||||
**Recommended fix:** Gate this fallback behind `#if DEBUG` to prevent production builds from reading API keys from environment variables.
|
||||
|
||||
---
|
||||
|
||||
### M-4: Instance ID Dual-Storage Creates Sync Risk
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:291-312`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
`ensureStableInstanceID()` maintains the instance ID in both Keychain and UserDefaults (`node.instanceId`). If either store is cleared independently (e.g., Keychain reset during device restore without backup, or UserDefaults cleared by storage pressure), the sync logic may create a new UUID, effectively orphaning the device's gateway registration.
|
||||
|
||||
While this is a robustness concern rather than a direct vulnerability, an attacker who can clear UserDefaults (e.g., via an MDM-deployed configuration profile) could force a device identity reset.
|
||||
|
||||
**Recommended fix:** Designate Keychain as the single source of truth and only mirror to UserDefaults for read convenience. Document the recovery flow for identity reset.
|
||||
|
||||
---
|
||||
|
||||
### M-5: No Rate Limiting on Deep Link Agent Prompts
|
||||
|
||||
**File:** `apps/ios/Sources/Model/NodeAppModel.swift:43-45, 92`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
The `IOSDeepLinkAgentPolicy` defines `maxMessageChars = 20000` and `maxUnkeyedConfirmChars = 240`, and there is a `lastAgentDeepLinkPromptAt` timestamp. However, without a minimum interval check, a malicious webpage or app could rapidly fire `openclaw://` deep links, creating a flood of confirmation dialogs that degrade UX and potentially trick users into accepting a malicious prompt through fatigue.
|
||||
|
||||
**Recommended fix:** Enforce a minimum interval (e.g., 5 seconds) between successive deep link prompts, silently dropping duplicates. The `lastAgentDeepLinkPromptAt` field exists but its enforcement should be verified.
|
||||
|
||||
---
|
||||
|
||||
### M-6: QR Code Parsing Accepts Multiple Formats Without Strict Validation
|
||||
|
||||
**File:** `apps/ios/Sources/Onboarding/QRScannerView.swift:63-85`
|
||||
**Severity:** MEDIUM
|
||||
|
||||
The QR scanner tries two parsing strategies: `GatewayConnectDeepLink.fromSetupCode(payload)` (base64url JSON) and `DeepLinkParser.parse(url)` (URL format). The `GatewaySetupCode.decode()` method (`apps/ios/Sources/Gateway/GatewaySetupCode.swift`) accepts arbitrary base64-encoded JSON payloads that decode into `GatewaySetupPayload`. There is no signature verification or HMAC on the QR code content.
|
||||
|
||||
**Exploit scenario:** An attacker places a malicious QR code that encodes a gateway URL pointing to their controlled server. When scanned, the user is prompted to connect to the attacker's gateway.
|
||||
|
||||
**Mitigating factors:** The TLS trust prompt still fires for new gateways, requiring explicit user approval of the certificate fingerprint.
|
||||
|
||||
**Recommended fix:** Consider adding an HMAC or signing mechanism to QR setup codes so the app can verify they were generated by the user's own gateway. At minimum, clearly display the gateway URL/host to the user during the onboarding flow.
|
||||
|
||||
---
|
||||
|
||||
### M-7: WebSocket Maximum Message Size Set to 16 MB
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:55`
|
||||
**Severity:** MEDIUM (Performance/DoS)
|
||||
|
||||
```swift
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
```
|
||||
|
||||
A malicious or compromised gateway could send a 16 MB WebSocket message, causing a significant memory spike on the iOS device.
|
||||
|
||||
**Recommended fix:** Evaluate whether 16 MB is necessary. Consider progressive parsing or streaming for large payloads. Add a sanity check on incoming message size.
|
||||
|
||||
---
|
||||
|
||||
## 5. Low Severity Findings
|
||||
|
||||
### L-1: Location Data Sent Over Gateway WebSocket Without End-to-End Encryption
|
||||
|
||||
**File:** `apps/ios/Sources/Location/SignificantLocationMonitor.swift:21-38`
|
||||
**Severity:** LOW
|
||||
|
||||
Significant location updates (lat, lon, accuracy) are sent as JSON over the gateway WebSocket. While the WebSocket uses TLS (wss://), the gateway server itself can read the location data in plaintext. This is by design (the gateway processes location for hooks), but users should be informed that location data is accessible to the gateway process.
|
||||
|
||||
**Recommended fix:** Document this clearly in privacy documentation. Consider allowing users to configure location precision (rounding to neighborhood-level vs. exact coordinates).
|
||||
|
||||
---
|
||||
|
||||
### L-2: Camera Photo/Video Base64 Encoding in Memory
|
||||
|
||||
**Files:** `apps/ios/Sources/Camera/CameraController.swift:84`, `apps/ios/Sources/Media/PhotoLibraryService.swift:105`
|
||||
**Severity:** LOW
|
||||
|
||||
Camera captures and photo library images are base64-encoded in memory before being sent over the gateway. For large images (up to 1600px wide at 0.9 quality), this means the raw image data plus the base64 string (33% larger) coexist in memory briefly.
|
||||
|
||||
**Mitigating factors:** The app already clamps max width to 1600px and applies quality compression. Temporary files are cleaned up via `defer` blocks.
|
||||
|
||||
**Recommended fix:** Consider streaming base64 encoding or using a memory-mapped approach for very large payloads. Current implementation is adequate for the existing size limits.
|
||||
|
||||
---
|
||||
|
||||
### L-3: Screen Recording Output Path User-Controllable
|
||||
|
||||
**File:** `apps/ios/Sources/Screen/ScreenRecordService.swift:103-109`
|
||||
**Severity:** LOW
|
||||
|
||||
The `makeOutputURL` method accepts an optional `outPath` parameter. If this comes from a gateway command, a malicious gateway could specify a path outside the app's sandbox (which iOS would block) or overwrite files within the sandbox.
|
||||
|
||||
**Mitigating factors:** iOS sandbox prevents writing outside the app container. The `defer` cleanup in the caller should handle temporary files.
|
||||
|
||||
**Recommended fix:** Validate that `outPath` is within the app's temporary or documents directory before using it. Reject absolute paths that don't start with the app's known writable directories.
|
||||
|
||||
---
|
||||
|
||||
### L-4: Voice Wake Preferences Stored in UserDefaults
|
||||
|
||||
**File:** `apps/ios/Sources/Voice/VoiceWakePreferences.swift:23-29`
|
||||
**Severity:** LOW
|
||||
|
||||
Trigger words and voice wake enabled state are stored in `UserDefaults.standard`. While trigger words are not sensitive per se, they reveal user behavior patterns.
|
||||
|
||||
**Recommended fix:** Acceptable for non-sensitive preferences. No action needed unless trigger words become user-configurable sensitive phrases.
|
||||
|
||||
---
|
||||
|
||||
### L-5: `nonisolated(unsafe)` in GatewayDiagnostics
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:358`
|
||||
**Severity:** LOW
|
||||
|
||||
```swift
|
||||
nonisolated(unsafe) private static var logWritesSinceCheck = 0
|
||||
```
|
||||
|
||||
This counter is accessed from the `DispatchQueue` without the lock that protects the file I/O. While this is a benign data race (used only for approximate frequency gating), it could theoretically cause the log size check to be skipped or double-triggered.
|
||||
|
||||
**Recommended fix:** Move the counter into the `queue.async` block or use an atomic counter.
|
||||
|
||||
---
|
||||
|
||||
### L-6: `objc_sync_enter` Used for Synchronization
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift:1039-1040`
|
||||
**Severity:** LOW
|
||||
|
||||
`GatewayTLSFingerprintProbe.finish()` uses `objc_sync_enter/exit` for synchronization. This is the Objective-C `@synchronized` primitive. While functional, modern Swift best practice prefers `OSAllocatedUnfairLock` (as used correctly in `TCPProbe`), `NSLock`, or actor isolation.
|
||||
|
||||
**Recommended fix:** Replace with `OSAllocatedUnfairLock` for consistency with the rest of the codebase.
|
||||
|
||||
---
|
||||
|
||||
### L-7: No Certificate Revocation Checking
|
||||
|
||||
**File:** `apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift:59-96`
|
||||
**Severity:** LOW
|
||||
|
||||
The TLS pinning implementation checks the certificate fingerprint but does not perform OCSP or CRL revocation checking. For self-signed certificates (typical in local gateway setups), this is expected. For publicly-signed certificates, revocation checking would add defense in depth.
|
||||
|
||||
**Recommended fix:** For the current use case (self-signed gateway certs on LAN), this is acceptable. If public CA certificates are used in future, consider enabling revocation checking via `SecTrustSetOptions`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Performance Concerns
|
||||
|
||||
### P-1: ISO8601DateFormatter Created Per Log Entry
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewaySettingsStore.swift:422-424`
|
||||
**Severity:** LOW
|
||||
|
||||
`GatewayDiagnostics.log()` creates a new `ISO8601DateFormatter` for every log call. `ISO8601DateFormatter` is relatively expensive to initialize.
|
||||
|
||||
**Recommended fix:** Cache a static formatter instance (thread-safety is acceptable for `ISO8601DateFormatter` as it is immutable after configuration).
|
||||
|
||||
---
|
||||
|
||||
### P-2: Observation Tracking Re-registration Pattern
|
||||
|
||||
**File:** `apps/ios/Sources/Gateway/GatewayConnectionController.swift:293-305`
|
||||
**Severity:** LOW
|
||||
|
||||
The `observeDiscovery()` method uses `withObservationTracking` and recursively calls itself in the `onChange` closure. This is the standard Swift Observation pattern, but each change creates a new `Task` and re-registers tracking. Under rapid discovery state changes, this could create a burst of Task allocations.
|
||||
|
||||
**Mitigating factors:** Discovery state changes are infrequent (Bonjour events).
|
||||
|
||||
**Recommended fix:** Consider debouncing or coalescing rapid state changes.
|
||||
|
||||
---
|
||||
|
||||
### P-3: Synchronous Photo Library Access
|
||||
|
||||
**File:** `apps/ios/Sources/Media/PhotoLibraryService.swift:69-71`
|
||||
**Severity:** MEDIUM (Performance)
|
||||
|
||||
```swift
|
||||
options.isSynchronous = true
|
||||
```
|
||||
|
||||
`PHImageManager.requestImage` is called synchronously, which blocks the calling thread until the image is loaded and decoded. For network-backed assets (iCloud Photo Library), this could block for seconds.
|
||||
|
||||
**Recommended fix:** Use asynchronous image loading with a continuation wrapper to avoid blocking.
|
||||
|
||||
---
|
||||
|
||||
### P-4: Camera Clip Base64 Encoding of Video Data
|
||||
|
||||
**File:** `apps/ios/Sources/Camera/CameraController.swift:89-140`
|
||||
**Severity:** LOW
|
||||
|
||||
Video clips (up to 60 seconds) are fully loaded into memory as `Data` and then base64-encoded. A 60-second medium-quality MP4 could be 10-30 MB, producing a 13-40 MB base64 string in memory.
|
||||
|
||||
**Mitigating factors:** Default duration is 3 seconds, keeping typical payloads small. The 60-second max is enforced at `CameraController.clampDurationMs`.
|
||||
|
||||
**Recommended fix:** Consider a streaming upload mechanism for clips longer than ~10 seconds.
|
||||
|
||||
---
|
||||
|
||||
## 7. OWASP Mobile Top 10 2024 Checklist
|
||||
|
||||
| # | OWASP Category | Status | Notes |
|
||||
|---|---------------|--------|-------|
|
||||
| M1 | Improper Credential Usage | **PASS** | Credentials stored in Keychain with `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. No hardcoded secrets. API keys from env vars gated to dev builds (recommended). |
|
||||
| M2 | Inadequate Supply Chain Security | **PASS** | Dependencies are version-pinned via Package.resolved. SwiftLint and SwiftFormat enforce code quality. |
|
||||
| M3 | Insecure Authentication/Authorization | **PASS** | Gateway authentication uses token + password stored in Keychain. TLS pinning prevents MITM. Deep links require user confirmation. |
|
||||
| M4 | Insufficient Input/Output Validation | **PASS** | Input trimming and length limits applied consistently. QR code parsing has two validated paths. Calendar/contacts sanitize inputs. |
|
||||
| M5 | Insecure Communication | **PASS with notes** | TLS required for non-loopback connections. Certificate pinning implemented. `NSAllowsArbitraryLoadsInWebContent` allows HTTP in web views (accepted risk for canvas). |
|
||||
| M6 | Inadequate Privacy Controls | **PASS with notes** | All sensitive permissions have usage descriptions. Location data sent to gateway in plaintext over TLS. Photo library access checks authorization. Logging uses `privacy: .public` for some potentially sensitive data. |
|
||||
| M7 | Insufficient Binary Protections | **N/A** | Standard Xcode compilation. No jailbreak detection implemented (acceptable for non-financial app). |
|
||||
| M8 | Security Misconfiguration | **PASS with notes** | TLS fingerprints in UserDefaults (H-1). Gateway metadata in UserDefaults (H-3). Entitlements minimal (only `aps-environment`). |
|
||||
| M9 | Insecure Data Storage | **PASS with notes** | Credentials in Keychain (good). Gateway connection metadata in UserDefaults (H-3). Diagnostic logs in Documents directory (M-2). |
|
||||
| M10 | Insufficient Cryptography | **PASS** | SHA-256 for certificate fingerprinting via CryptoKit. No custom/weak crypto implementations. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Summary of Recommendations by Priority
|
||||
|
||||
### Immediate (High)
|
||||
1. **H-1:** Move TLS fingerprint storage from UserDefaults to Keychain
|
||||
2. **H-2:** Fix KeychainStore to enforce accessibility level on updates (delete + re-add)
|
||||
3. **H-3:** Move gateway connection metadata out of UserDefaults
|
||||
|
||||
### Short-term (Medium)
|
||||
4. **M-3:** Gate `ELEVENLABS_API_KEY` env var fallback behind `#if DEBUG`
|
||||
5. **M-2:** Move diagnostic logs to Caches directory, apply file protection
|
||||
6. **M-5:** Enforce minimum interval between deep link prompts
|
||||
7. **M-6:** Add HMAC/signature to QR setup codes
|
||||
8. **M-7:** Evaluate reducing WebSocket max message size from 16 MB
|
||||
9. **P-3:** Convert synchronous photo library loading to async
|
||||
|
||||
### Long-term (Low / Hardening)
|
||||
10. **L-6:** Replace `objc_sync_enter` with `OSAllocatedUnfairLock`
|
||||
11. **L-3:** Validate screen recording output paths
|
||||
12. **P-1:** Cache ISO8601DateFormatter instances
|
||||
13. **M-1:** Add indicator for non-HTTPS canvas content
|
||||
14. **L-5:** Fix `nonisolated(unsafe)` data race in log counter
|
||||
|
||||
---
|
||||
|
||||
## 9. Positive Security Patterns Worth Preserving
|
||||
|
||||
- **TOFU with explicit user confirmation** for TLS fingerprints is a pragmatic and user-friendly approach for self-signed certificates.
|
||||
- **Dual WebSocket sessions** (node + operator) with separate role scoping provides good privilege separation.
|
||||
- **`websiteDataStore = .nonPersistent()`** for the canvas WKWebView prevents session data leakage.
|
||||
- **Origin validation in `CanvasA2UIActionMessageHandler`** (checking `isTrustedCanvasUIURL` and `isLocalNetworkCanvasURL`) is a strong defense against arbitrary web content triggering actions.
|
||||
- **Loopback URL rejection** in `ScreenController.navigate()` prevents SSRF-like attacks from the gateway.
|
||||
- **Autoconnect only to previously trusted gateways** (stored TLS pin required) prevents connecting to rogue gateways after TOFU.
|
||||
- **Permission checks before hardware access** with clear error messages is well-implemented.
|
||||
- **`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`** is the correct Keychain accessibility level for this use case (device-local, available after first unlock for background operation).
|
||||
318
apps/ios/audit-uiux.md
Normal file
318
apps/ios/audit-uiux.md
Normal file
@@ -0,0 +1,318 @@
|
||||
# iOS App UI/UX & Accessibility Audit
|
||||
|
||||
**Audit Date:** 2026-03-02
|
||||
**Scope:** All SwiftUI view files in `apps/ios/Sources/`
|
||||
**Reference Standards:** Apple HIG (iOS 26), WCAG 2.1 AA, Liquid Glass design language, SwiftUI accessibility best practices
|
||||
|
||||
---
|
||||
|
||||
## UI/UX Health Overview
|
||||
|
||||
The OpenClaw iOS app demonstrates a well-structured SwiftUI codebase with several accessibility-conscious patterns already in place. The app uses the modern `@Observable` / `Observation` framework consistently, respects `accessibilityReduceMotion`, responds to `colorSchemeContrast`, and provides accessibility labels on key interactive elements. However, there are significant gaps in Dynamic Type support, localization readiness, haptic feedback, and iPad adaptivity that should be addressed before the next major release.
|
||||
|
||||
**Strengths:**
|
||||
- Good use of `@Environment(\.accessibilityReduceMotion)` in animation-heavy views (RootTabs, StatusPill)
|
||||
- `StatusGlassCard` correctly responds to `colorSchemeContrast` for increased visibility
|
||||
- `StatusPill` has proper `accessibilityLabel`, `accessibilityValue`, and `accessibilityHint`
|
||||
- `TalkOrbOverlay` uses `accessibilityElement(children: .combine)` to present a single VoiceOver element
|
||||
- Consistent use of `@Observable` macro (Observation framework) over legacy `ObservableObject`
|
||||
- Glass material effects on overlays (`.ultraThinMaterial`) with light/dark mode awareness
|
||||
|
||||
**Weaknesses:**
|
||||
- Zero Dynamic Type support (no `@ScaledMetric`, no `dynamicTypeSize` environment usage)
|
||||
- Zero localization infrastructure (no `NSLocalizedString`, `String(localized:)`, or `.strings` files)
|
||||
- Zero haptic feedback across the entire app
|
||||
- Several views lack accessibility labels entirely
|
||||
- Hardcoded dimensions in TalkOrbOverlay will break on small screens
|
||||
- SettingsTab is a monolithic ~650 LOC file
|
||||
- No iPad-specific layout adaptations
|
||||
- `RootCanvas` voiceWakeToast animation does not respect `reduceMotion` (unlike `RootTabs`)
|
||||
|
||||
---
|
||||
|
||||
## Critical Findings
|
||||
|
||||
### C-1: RootCanvas animations ignore `accessibilityReduceMotion`
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:159-167`
|
||||
**Description:** The `voiceWakeToastText` animation in `RootCanvas` uses hardcoded `.spring()` and `.easeOut()` animations without checking `@Environment(\.accessibilityReduceMotion)`. The sibling `RootTabs` view correctly guards the same toast animation with `reduceMotion ? .none : .spring(...)`.
|
||||
|
||||
**Impact:** Users who require reduced motion will see unexpected animations in the canvas view.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// In RootCanvas, add the environment property:
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
// Then guard animations:
|
||||
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
self.voiceWakeToastText = trimmed
|
||||
}
|
||||
// ...
|
||||
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
|
||||
self.voiceWakeToastText = nil
|
||||
}
|
||||
```
|
||||
|
||||
### C-2: TalkOrbOverlay perpetual animations ignore `accessibilityReduceMotion`
|
||||
|
||||
**File:** `Sources/Voice/TalkOrbOverlay.swift:15-26`
|
||||
**Description:** The pulsing ring animations use `.repeatForever(autoreverses: false)` without checking `reduceMotion`. These are high-frequency, continuous animations that can cause discomfort for users with vestibular disorders.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
// Replace pulse animations with:
|
||||
if !reduceMotion {
|
||||
Circle()
|
||||
.scaleEffect(self.pulse ? 1.15 : 0.96)
|
||||
.animation(.easeOut(duration: 1.3).repeatForever(autoreverses: false), value: self.pulse)
|
||||
}
|
||||
```
|
||||
|
||||
### C-3: CameraFlashOverlay has no accessibility announcement
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:405-429`
|
||||
**Description:** `CameraFlashOverlay` flashes the screen white at 85% opacity. VoiceOver users have no indication that a photo was taken. There is no `AccessibilityNotification.Announcement` posted, and the flash itself could trigger photosensitive reactions.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// Post an accessibility announcement:
|
||||
AccessibilityNotification.Announcement("Photo captured").post()
|
||||
|
||||
// Add prefers-reduced-motion check to skip or soften the flash:
|
||||
if reduceMotion {
|
||||
// Skip flash, or use subtle opacity change
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## High Findings
|
||||
|
||||
### H-1: Zero Dynamic Type support across the entire app
|
||||
|
||||
**Files:** All view files in `Sources/`
|
||||
**Description:** No view uses `@ScaledMetric`, `@Environment(\.dynamicTypeSize)`, or `ContentSizeCategory`. All hardcoded font sizes and dimensions (e.g., `font(.system(size: 16))` in `OverlayButton`, `font(.system(size: 12))` in monospaced debug text, `frame(width: 320, height: 320)` in TalkOrbOverlay) will not scale with the user's preferred text size. Apple's HIG strongly recommends supporting Dynamic Type for all text.
|
||||
|
||||
**Key locations:**
|
||||
- `Sources/RootCanvas.swift:358` - OverlayButton uses fixed `size: 16`
|
||||
- `Sources/Voice/TalkOrbOverlay.swift:16,23,39` - Fixed 320pt and 190pt circles
|
||||
- `Sources/Status/StatusPill.swift:52` - Fixed `width: 9, height: 9` indicator dot
|
||||
- `Sources/Gateway/GatewayDiscoveryDebugLogView.swift:24` - Fixed `font(.callout)`
|
||||
- `Sources/Gateway/GatewayOnboardingView.swift:345-346` - Fixed `.system(size: 12)` monospaced text
|
||||
|
||||
**Recommended Fix:** Use semantic font styles (`.body`, `.headline`, etc.) instead of fixed sizes where possible. For custom dimensions, use `@ScaledMetric`:
|
||||
```swift
|
||||
@ScaledMetric(relativeTo: .body) private var orbSize: CGFloat = 190
|
||||
@ScaledMetric(relativeTo: .caption) private var dotSize: CGFloat = 9
|
||||
```
|
||||
|
||||
### H-2: OnboardingWizardView missing accessibility labels on interactive elements
|
||||
|
||||
**File:** `Sources/Onboarding/OnboardingWizardView.swift`
|
||||
**Description:** Multiple interactive elements lack accessibility labels:
|
||||
- `OnboardingModeRow` (line 861-884): Radio-style selection buttons have no `accessibilityAddTraits(.isButton)` or clear selection state announcement. VoiceOver users cannot tell which mode is selected.
|
||||
- Gateway list connect buttons (line 453-465): `ProgressView` and "Resolving..." text lack accessibility context.
|
||||
- QR scanner action (line 319-326): "Scan QR Code" button label is good, but the status line below it (line 340-345) is not connected as an accessibility value.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// OnboardingModeRow:
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAddTraits(self.selected ? [.isButton, .isSelected] : .isButton)
|
||||
.accessibilityLabel("\(self.title), \(self.subtitle)")
|
||||
.accessibilityValue(self.selected ? "Selected" : "Not selected")
|
||||
```
|
||||
|
||||
### H-3: No localization infrastructure
|
||||
|
||||
**Files:** All source files
|
||||
**Description:** The entire app uses hardcoded English strings with no localization wrapping. No `NSLocalizedString`, `String(localized:)`, `.strings`/`.stringsdict` files, or `LocalizedStringKey` usage was found. This makes the app inaccessible to non-English speakers and violates Apple's HIG recommendation to support multiple languages.
|
||||
|
||||
**Key examples:**
|
||||
- `Sources/Settings/SettingsTab.swift`: All section headers, labels, help text
|
||||
- `Sources/Onboarding/OnboardingWizardView.swift`: "Welcome", "Connected", all step descriptions
|
||||
- `Sources/Status/StatusPill.swift`: "Connected", "Connecting...", "Error", "Offline"
|
||||
- `Sources/Voice/VoiceTab.swift`: All list labels
|
||||
|
||||
**Recommended Fix:** Wrap all user-facing strings in `String(localized:)` or use `LocalizedStringResource`. Create a `Localizable.xcstrings` catalog.
|
||||
|
||||
### H-4: No haptic feedback anywhere in the app
|
||||
|
||||
**Files:** All source files
|
||||
**Description:** No `UIImpactFeedbackGenerator`, `UINotificationFeedbackGenerator`, `UISelectionFeedbackGenerator`, or `.sensoryFeedback()` modifier usage found. Key interaction points that would benefit from haptics:
|
||||
- Gateway connection success/failure
|
||||
- Voice wake trigger detection
|
||||
- Talk mode orb tap
|
||||
- QR code successfully scanned
|
||||
- Toggle state changes in Settings
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
// iOS 17+ SwiftUI modifier:
|
||||
.sensoryFeedback(.success, trigger: appModel.gatewayServerName != nil)
|
||||
|
||||
// For Talk orb tap:
|
||||
.sensoryFeedback(.impact(weight: .medium), trigger: tapCount)
|
||||
```
|
||||
|
||||
### H-5: GatewayTrustPromptAlert uses deprecated `Alert` API
|
||||
|
||||
**File:** `Sources/Gateway/GatewayTrustPromptAlert.swift:17-35`
|
||||
**Description:** Uses the deprecated `Alert(title:message:primaryButton:secondaryButton:)` initializer pattern. This API was deprecated in iOS 15 in favor of the `alert(_:isPresented:actions:message:)` modifier. Same issue in `DeepLinkAgentPromptAlert.swift:15-33`.
|
||||
|
||||
**Recommended Fix:** Migrate to the modern `alert` modifier with `@ViewBuilder` actions.
|
||||
|
||||
---
|
||||
|
||||
## Medium Findings
|
||||
|
||||
### M-1: SettingsTab is a monolithic view (~650+ LOC)
|
||||
|
||||
**File:** `Sources/Settings/SettingsTab.swift`
|
||||
**Description:** SettingsTab contains the entire settings UI, including gateway connection, device features, advanced debug options, agent picker, and reset logic. The file has a `// swiftlint:disable type_body_length` comment acknowledging this. This makes the view hard to maintain and test.
|
||||
|
||||
**Recommended Fix:** Extract into focused sub-views:
|
||||
- `GatewaySettingsSection`
|
||||
- `DeviceFeaturesSection`
|
||||
- `AdvancedSettingsSection`
|
||||
- `DeviceInfoSection`
|
||||
|
||||
### M-2: No empty states for VoiceTab when disconnected
|
||||
|
||||
**File:** `Sources/Voice/VoiceTab.swift`
|
||||
**Description:** VoiceTab always shows the same status labels regardless of gateway connection state. When disconnected, it should show a clear empty state explaining that voice features require a gateway connection, with a CTA to connect.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
if appModel.gatewayServerName == nil {
|
||||
ContentUnavailableView(
|
||||
"Not Connected",
|
||||
systemImage: "antenna.radiowaves.left.and.right.slash",
|
||||
description: Text("Connect to a gateway to use voice features."))
|
||||
}
|
||||
```
|
||||
|
||||
### M-3: No loading/error states in GatewayQuickSetupSheet
|
||||
|
||||
**File:** `Sources/Gateway/GatewayQuickSetupSheet.swift`
|
||||
**Description:** When `bestCandidate` is nil and no gateways are found, the sheet shows a text message but no visual indicator that discovery is actively running. No retry button or activity indicator is shown during the discovery phase.
|
||||
|
||||
### M-4: OverlayButton touch target may be too small
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:348-403`
|
||||
**Description:** `OverlayButton` uses `padding(10)` around a 16pt icon, resulting in a ~36pt touch target. Apple HIG recommends a minimum of 44pt x 44pt for touch targets.
|
||||
|
||||
**Recommended Fix:**
|
||||
```swift
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
// or increase padding to at least 14pt
|
||||
```
|
||||
|
||||
### M-5: No keyboard shortcut support
|
||||
|
||||
**Files:** All view files
|
||||
**Description:** No `.keyboardShortcut()` modifiers found anywhere. iPad users with external keyboards have no keyboard navigation shortcuts for common actions like opening chat, settings, or toggling voice.
|
||||
|
||||
### M-6: TalkOrbOverlay hardcoded dimensions break on small screens
|
||||
|
||||
**File:** `Sources/Voice/TalkOrbOverlay.swift:16,23,39`
|
||||
**Description:** The pulse rings are hardcoded at 320pt width/height, and the inner orb at 190pt. On iPhone SE (320pt logical width), the rings will extend beyond screen bounds. On iPad, the orb will appear relatively small.
|
||||
|
||||
**Recommended Fix:** Use `GeometryReader` or `@ScaledMetric` for adaptive sizing:
|
||||
```swift
|
||||
GeometryReader { proxy in
|
||||
let size = min(proxy.size.width, proxy.size.height) * 0.65
|
||||
Circle().frame(width: size, height: size)
|
||||
}
|
||||
```
|
||||
|
||||
### M-7: ScreenTab error overlay not accessible
|
||||
|
||||
**File:** `Sources/Screen/ScreenTab.swift:12-21`
|
||||
**Description:** The error text overlay appears only when `errorText` is set and the gateway is disconnected, but there is no VoiceOver announcement when the error appears or disappears. Screen reader users may not notice the error.
|
||||
|
||||
### M-8: No pull-to-refresh on any list view
|
||||
|
||||
**Files:** `Sources/Voice/VoiceTab.swift`, `Sources/Gateway/GatewayDiscoveryDebugLogView.swift`
|
||||
**Description:** List views do not support `.refreshable {}` for pull-to-refresh, which is a standard iOS interaction pattern.
|
||||
|
||||
---
|
||||
|
||||
## Low Findings
|
||||
|
||||
### L-1: Inconsistent glass card styling between RootTabs and RootCanvas
|
||||
|
||||
**Files:** `Sources/RootTabs.swift`, `Sources/RootCanvas.swift`
|
||||
**Description:** `RootTabs` shows `StatusPill` without the `brighten` parameter (defaults to false), while `RootCanvas.CanvasContent` passes `brighten` based on color scheme. This can cause visual inconsistency if both code paths are reachable.
|
||||
|
||||
### L-2: VoiceWakeToast hardcoded top offset
|
||||
|
||||
**Files:** `Sources/RootTabs.swift:47`, `Sources/RootCanvas.swift:329`
|
||||
**Description:** `.safeAreaPadding(.top, 58)` is a magic number that assumes the StatusPill height. If the pill height changes (e.g., with Dynamic Type), the toast will overlap.
|
||||
|
||||
### L-3: No app-wide tint/accent color configuration
|
||||
|
||||
**Files:** `Sources/OpenClawApp.swift`
|
||||
**Description:** No `.tint()` or `accentColor` is set at the app level. The default blue accent is used for buttons and toggles, but the app uses `appModel.seamColor` for some elements. This creates visual inconsistency.
|
||||
|
||||
### L-4: ConnectionStatusBox uses hardcoded monospaced font size
|
||||
|
||||
**File:** `Sources/Onboarding/GatewayOnboardingView.swift:345-346`
|
||||
**Description:** `.font(.system(size: 12, weight: .regular, design: .monospaced))` will not scale with Dynamic Type.
|
||||
|
||||
### L-5: DateFormatter instances in GatewayDiscoveryDebugLogView are not locale-aware
|
||||
|
||||
**File:** `Sources/Gateway/GatewayDiscoveryDebugLogView.swift:49-53`
|
||||
**Description:** `DateFormatter` with hardcoded `dateFormat = "HH:mm:ss"` does not respect the user's locale for time formatting. Should use `.dateStyle`/`.timeStyle` or `formatted()`.
|
||||
|
||||
### L-6: No transition animations on sheet presentations
|
||||
|
||||
**File:** `Sources/RootCanvas.swift:92-111`
|
||||
**Description:** The `.sheet(item:)` presentations for settings, chat, and quick setup use default sheet transitions. Custom `presentationDetents` could improve the UX for smaller sheets like Quick Setup.
|
||||
|
||||
### L-7: Onboarding wizard duplicate padding
|
||||
|
||||
**File:** `Sources/Onboarding/OnboardingWizardView.swift:344-346`
|
||||
**Description:** The welcome step has duplicate `.padding(.horizontal, 24)` on the status line (lines 344 and 345), which doubles the intended padding.
|
||||
|
||||
### L-8: No VoiceOver rotor actions
|
||||
|
||||
**Files:** All view files
|
||||
**Description:** No `.accessibilityAction(named:)` or custom rotor items are defined. Power VoiceOver users could benefit from custom actions for common operations.
|
||||
|
||||
---
|
||||
|
||||
## Accessibility Compliance Checklist
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|---|---|---|
|
||||
| VoiceOver labels on all interactive elements | Partial | Overlay buttons, StatusPill, ChatSheet close, SettingsTab close have labels. OnboardingModeRow, gateway list items, many settings toggles missing. |
|
||||
| VoiceOver hints for non-obvious actions | Partial | StatusPill has hint. Most buttons lack hints. |
|
||||
| VoiceOver value for stateful elements | Partial | StatusPill has value. Toggle states auto-announced by SwiftUI. OnboardingModeRow selection not announced. |
|
||||
| Dynamic Type support | Missing | No `@ScaledMetric`, no `dynamicTypeSize` environment, fixed font sizes throughout. |
|
||||
| Reduce Motion respected | Partial | RootTabs and StatusPill respect it. RootCanvas, TalkOrbOverlay, CameraFlashOverlay do not. |
|
||||
| Increased Contrast support | Partial | StatusGlassCard adjusts border for increased contrast. Other views do not check. |
|
||||
| Color not sole indicator | Pass | Status uses both color dots and text labels. |
|
||||
| Minimum touch target 44pt | Partial | Standard buttons OK. OverlayButton (~36pt) and StatusPill dot are undersized. |
|
||||
| Keyboard navigation (iPad) | Missing | No keyboard shortcuts defined. |
|
||||
| Localization readiness | Missing | All strings hardcoded in English. |
|
||||
| Haptic feedback | Missing | No haptic feedback in any interaction. |
|
||||
| iPad layout adaptation | Missing | No `horizontalSizeClass` or iPad-specific layouts. |
|
||||
| Dark mode support | Pass | Uses semantic colors, materials, and `.preferredColorScheme(.dark)` for canvas. |
|
||||
| Safe area handling | Pass | Correct use of `.ignoresSafeArea()` for screen, `.safeAreaPadding()` for overlays. |
|
||||
| Error state announcements | Missing | No `AccessibilityNotification.Announcement` for state changes. |
|
||||
| Focus management | Partial | `@FocusState` used in VoiceWakeWordsSettingsView. No focus management in onboarding. |
|
||||
|
||||
---
|
||||
|
||||
## Summary by Priority
|
||||
|
||||
| Priority | Count | Key Themes |
|
||||
|---|---|---|
|
||||
| Critical | 3 | Reduce Motion violations, flash accessibility |
|
||||
| High | 5 | Dynamic Type, localization, haptics, deprecated APIs, missing labels |
|
||||
| Medium | 8 | Monolithic views, empty states, touch targets, iPad, hardcoded sizes |
|
||||
| Low | 8 | Styling consistency, magic numbers, locale formatting, polish |
|
||||
Reference in New Issue
Block a user