docs: add ESP32 and iOS agent architecture reference

- reference/esp32-agent/: DESIGN, HARDWARE, API_COMPAT, ARCHITECTURE, README

- reference/ios-agent/: DESIGN, API_COMPAT, UI_DESIGN, ARCHITECTURE, README

- Both mirror Android agent architecture, UI design, and heartbeat flow

- Architecture-only (no code) — avoids submodule conflicts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
datadunia
2026-07-06 14:20:10 +07:00
parent 4af46dadad
commit 84689b208e
10 changed files with 3565 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
# iOS Agent — Architecture Reference
**Status**: ARCHITECTURE ONLY (no code)
**Location**: `reference/ios-agent/` (NOT a submodule)
**Mirror**: Android Agent (`apps/android-agent/`)
## Overview
This directory contains the architecture design for an iOS WireGuard agent that mirrors the Android agent's architecture, UI design, colors, and heartbeat flow.
## Why `reference/` not `apps/`?
If iOS agent is implemented in the future, it will be added as `apps/ios-agent/`. This `reference/` directory stores the architecture docs separately to avoid conflicts.
## Purpose
- Document the iOS agent architecture (mirrors Android)
- Define UI design specs (colors, layout, components)
- API contract (shared with server-core)
- Track implementation status and TODOs
## Architecture Mirror: Android → iOS
| Android Component | iOS Equivalent | Notes |
|-------------------|----------------|-------|
| `AgentService.kt` | `PacketTunnelProvider` | Foreground service → NetworkExtension |
| `TunnelManager.kt` | `WireGuardAdapter` | VPNService → NEPacketTunnelProvider |
| `Heartbeat.kt` | `HeartbeatService` | HTTP loop (same) |
| `Provisioning.kt` | `ProvisioningService` | HTTP provisioning (same) |
| `ConfigStorage.kt` | `KeychainStorage` | SharedPreferences → Keychain |
| `Encryptor.kt` | `CryptoManager` | JCE → CryptoKit |
| `MainActivity.kt` | `ContentView` | Activity → SwiftUI |
| `SettingsActivity.kt` | `SettingsView` | Activity → SwiftUI |
| `BootReceiver.kt` | `BGAppRefreshTask` | Boot broadcast → Background Tasks |
| `LogBuffer.kt` | `LogStore` | In-memory → CoreData |
## Files
| File | Description |
|------|-------------|
| `docs/DESIGN.md` | Technical design + UI specs |
| `docs/ARCHITECTURE.md` | Module architecture |
| `docs/API_COMPAT.md` | Server API contract |
| `docs/UI_DESIGN.md` | UI design specs (colors, layout) |
## Quick Start
When implementing:
1. Create Xcode project: `ios-agent`
2. Add NetworkExtension capability
3. Use `wireguard-apple` library for WireGuard
4. Follow architecture in `docs/ARCHITECTURE.md`
5. Match UI design in `docs/UI_DESIGN.md`
## License
Proprietary - NexusGuard
+365
View File
@@ -0,0 +1,365 @@
# iOS Agent — API Compatibility Reference
**Status**: API CONTRACT
**Mirror**: Android Agent (`apps/android-agent/`)
## Overview
The iOS agent communicates with the server via two HTTP endpoints:
1. **Provisioning**: `POST /api/v1/provision` — Initial key exchange
2. **Heartbeat**: `POST /api/v1/heartbeat` — Periodic status + config sync
## 1. Provisioning
### Request
```
POST /api/v1/provision
Content-Type: application/json
```
```json
{
"token": "string (required)",
"hwid": "string (required)"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `token` | string | Yes | Registration token (single-use) |
| `hwid` | string | Yes | Hardware ID (iOS identifierForVendor) |
### Response (Success)
```
HTTP/1.1 200 OK
Content-Type: application/json
```
```json
{
"encrypted_config": "base64-encoded-bytes"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `encrypted_config` | string | AES-256-GCM encrypted ConfigPayload |
### Response (Error)
```json
{
"error": "string"
}
```
| Status | Error | Description |
|--------|-------|-------------|
| 400 | `"invalid token format"` | Token is not valid UUID |
| 400 | `"token already used"` | Token was already consumed |
| 400 | `"invalid hardware id"` | HWID is empty or invalid |
| 403 | `"token expired"` | Token has expired (24h TTL) |
| 500 | `"provisioning failed"` | Server internal error |
### Decrypted ConfigPayload
After decrypting `encrypted_config`, the agent receives:
```json
{
"device_id": "string (UUID)",
"private_key": "string (hex, 64 chars)",
"preshared_key": "string (hex, 64 chars)",
"internal_ip": "string (e.g. 10.172.21.2)",
"server_pub": "string (hex, 64 chars)",
"endpoint": "string (e.g. italy-twenty.gl.at.ply.gg:59750)",
"dns": "string (e.g. 1.1.1.1)",
"allowed_ips": "string (e.g. 0.0.0.0/0)",
"server_wg_ip": "string (e.g. 10.172.21.1)"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `device_id` | string | Device UUID (use in heartbeat) |
| `private_key` | string | WireGuard private key (hex) |
| `preshared_key` | string | Pre-shared key (hex) |
| `internal_ip` | string | Device IP address |
| `server_pub` | string | Server WireGuard public key (hex) |
| `endpoint` | string | Server WireGuard endpoint |
| `dns` | string | DNS server IP |
| `allowed_ips` | string | Allowed IPs (e.g. "0.0.0.0/0" for full tunnel) |
| `server_wg_ip` | string | Server WireGuard IP |
## 2. Heartbeat
### Request
```
POST /api/v1/heartbeat
Content-Type: application/json
```
```json
{
"device_id": "string (required)",
"status": "string (optional)",
"state": "string (optional)",
"tunnel_up": "boolean (optional)",
"last_handshake": "string (optional, ISO 8601)"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `device_id` | string | Yes | Device UUID |
| `status` | string | No | "connected", "recovering", "stopped" |
| `state` | string | No | Free-form state description |
| `tunnel_up` | boolean | No | WireGuard tunnel status |
| `last_handshake` | string | No | ISO 8601 timestamp of last handshake |
### Response (Success)
```
HTTP/1.1 200 OK
Content-Type: application/json
```
```json
{
"device_id": "string",
"status": "ok",
"internal_ip": "string",
"private_key": "string (hex)",
"preshared_key": "string (hex)",
"server_pub": "string (hex)",
"endpoint": "string",
"server_wg_ip": "string",
"dns": "string",
"allowed_ips": "string",
"forwards_hash": "string"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `device_id` | string | Device UUID |
| `status` | string | "ok" or error |
| `internal_ip` | string | Device IP (may change) |
| `private_key` | string | WireGuard private key (may rotate) |
| `preshared_key` | string | Pre-shared key (may rotate) |
| `server_pub` | string | Server public key |
| `endpoint` | string | Server endpoint |
| `server_wg_ip` | string | Server WireGuard IP |
| `dns` | string | DNS server |
| `allowed_ips` | string | Allowed IPs |
| `forwards_hash` | string | Hash of port forwards (for change detection) |
### Response (Error)
```json
{
"error": "string"
}
```
| Status | Error | Description |
|--------|-------|-------------|
| 400 | `"invalid device id"` | Device ID is not valid UUID |
| 403 | `"device is suspended"` | Device is suspended by admin |
| 500 | `"heartbeat failed"` | Server internal error |
## 3. Crypto Protocol
### Key Derivation
```
key = SHA256(hwid + salt)
```
- `hwid`: Hardware ID (iOS `identifierForVendor`)
- `salt`: Server's `SERVER_SALT` environment variable
- Output: 32-byte AES key
### Encryption (Server Side)
```
plaintext = ConfigPayload JSON
nonce = random 12 bytes
ciphertext = AES-256-GCM-Encrypt(key, nonce, plaintext)
encrypted_config = nonce + ciphertext
```
### Decryption (Agent Side)
```swift
let combined = Data(base64Encoded: encryptedConfig)!
let nonce = combined.prefix(12)
let ciphertext = combined.dropFirst(12)
let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext)
let plaintext = try AES.GCM.open(sealedBox, using: key)
let json = try JSONSerialization.jsonObject(with: plaintext)
```
### Implementation Notes
- Use `CryptoKit` for AES-256-GCM
- Use `CryptoKit` for SHA-256 key derivation
- Nonce size: 12 bytes (fixed for GCM)
- Tag size: 16 bytes (default for GCM)
## 4. Config Change Detection
### Heartbeat Hash Comparison
The server returns `forwards_hash` in heartbeat response. Agent should:
1. Store last received `forwards_hash` in UserDefaults
2. Compare with current hash
3. If different, trigger config reload
```swift
let lastHash = UserDefaults.standard.string(forKey: "config_hash") ?? ""
if lastHash != response.forwardsHash {
// Config changed, rebuild tunnel
tunnelManager.rebuildTunnel(with: newConfig)
UserDefaults.standard.set(response.forwardsHash, forKey: "config_hash")
}
```
## 5. Hardware ID
### iOS identifierForVendor
```swift
import UIKit
let hwid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
```
### Alternative: Custom Serial
```swift
func getHardwareId() -> String {
// Use Keychain to persist custom HWID
if let savedHwid = KeychainManager.get(key: "hwid") {
return savedHwid
}
let newHwid = UUID().uuidString
KeychainManager.set(key: "hwid", value: newHwid)
return newHwid
}
```
## 6. Versioning
### Current Version
- API Version: v1 (implicit, no version in URL)
- Agent Version: 1.0.0
### Compatibility Policy
- **Breaking Changes**: Major version bump (v2)
- **New Fields**: Added without version bump (agent ignores unknown fields)
- **Deprecation**: 6-month notice before removal
### Agent Behavior
- Agent ignores unknown fields in server response
- Agent sends only required fields in request
- Agent handles missing optional fields gracefully
## 7. Examples
### Provisioning Flow
```swift
// 1. Get hardware ID
let hwid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"
// 2. Send provisioning request
let body = ["token": token, "hwid": hwid]
let request = URLRequest(url: URL(string: "\(serverUrl)/api/v1/provision")!)
// 3. Parse encrypted config
let encrypted = try JSONDecoder().decode([String: String].self, from: data)["encrypted_config"]!
// 4. Decrypt config
let config = try CryptoManager.decryptConfig(encrypted: encrypted, hwid: hwid, salt: salt)
// 5. Store in Keychain
KeychainStorage.save(config)
// 6. Apply to WireGuard
try await tunnelManager.startTunnel(config: config)
```
### Heartbeat Flow
```swift
// 1. Get tunnel status
let tunnelUp = tunnelManager.isTunnelUp()
let lastHandshake = tunnelManager.getLastHandshake()
// 2. Send heartbeat
let body: [String: Any] = [
"device_id": config.deviceId,
"tunnel_up": tunnelUp,
"last_handshake": lastHandshake?.iso8601String ?? "1970-01-01T00:00:00Z"
]
// 3. Check for config changes
let response = try JSONDecoder().decode(HeartbeatResponse.self, from: data)
if config.configHash != response.forwardsHash {
// Config changed, reload
let newConfig = try parseConfig(from: response)
tunnelManager.rebuildTunnel(with: newConfig)
KeychainStorage.save(newConfig)
}
```
## 8. Error Handling
### HTTP Retries
```swift
func sendRequest(url: URL, body: Data, maxRetries: Int = 3) async throws -> Data {
for attempt in 1...maxRetries {
do {
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 {
return data
}
} catch {
if attempt == maxRetries {
throw error
}
try await Task.sleep(nanoseconds: UInt64(attempt * 1_000_000_000))
}
}
throw AgentError.heartbeatFailed("Max retries exceeded")
}
```
### Timeout Settings
- Connect timeout: 10 seconds
- Read timeout: 30 seconds
- Write timeout: 10 seconds
## 9. Security Notes
- All communication over HTTPS (TLS 1.2+)
- Registration token is single-use (consumed on first provisioning)
- Device can re-provision by obtaining new token
- Keys never logged or transmitted in plaintext
- WireGuard keys stored in iOS Keychain (hardware-backed)
+474
View File
@@ -0,0 +1,474 @@
# iOS Agent — Module Architecture
**Status**: ARCHITECTURE ONLY
**Mirror**: Android Agent (`apps/android-agent/`)
## Overview
This document defines the module architecture for the iOS WireGuard agent. The agent uses NetworkExtension (NEPacketTunnelProvider) for tunnel management and mirrors the Android agent's architecture.
## System Architecture
```
┌─────────────────────────────────────────────────────────┐
│ iOS (SwiftUI) │
├─────────────────────────────────────────────────────────┤
│ App Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ContentView│ │SettingsView│ │LogView │ │PortView │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ ┌────┴─────────────┴────────────┴─────────────┴────┐ │
│ │ AgentManager │ │
│ │ (orchestrates all components) │ │
│ └──────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Service Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Provisioning│ │Heartbeat│ │TunnelMgr │ │ConfigMgr│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ ┌────┴─────────────┴────────────┴─────────────┴────┐ │
│ │ CryptoManager │ │
│ │ AES-256-GCM (CryptoKit) │ │
│ └──────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ NetworkExtension Layer │
│ ┌──────────────────────────────────────────────────┐ │
│ │ PacketTunnelProvider │ │
│ │ (WireGuard tunnel via wireguard-apple) │ │
│ └──────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Storage Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Keychain │ │ UserDefaults│ │ CoreData│ │ FileMgr │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Task Architecture
```
iOS Threads/Tasks
├── Main Thread # UI updates
├── AgentManager # Orchestration (async/await)
├── HeartbeatTask # HTTP heartbeat (30s interval)
├── TunnelManager # WireGuard tunnel management
└── BackgroundTask # BGAppRefreshTask (background refresh)
```
## State Machine
```
┌─────────────┐
│ BOOT │
└──────┬──────┘
┌─────────────┐
│ APP_INIT │ ◄──────────────────────────────┐
└──────┬──────┘ │
│ loaded │
▼ │
┌─────────────┐ fail ┌─────────────┐│
│ PROVISION │───────────────►│ APP_RETRY ││
└──────┬──────┘ └──────┬──────┘│
│ success │ │
▼ └───────┘
┌─────────────┐
│ TUNNEL_UP │
└──────┬──────┘
┌─────────────┐
│ HEARTBEAT │ ◄─── 30s interval
└──────┬──────┘
│ config_changed
┌─────────────┐
│ TUNNEL_REBUILD │
└──────┬──────┘
└──► HEARTBEAT
```
## Module Design
### 1. AgentManager
**Responsibilities**:
- Orchestrate all agent components
- Manage agent lifecycle (start/stop)
- Handle state transitions
**Key Properties**:
```swift
class AgentManager: ObservableObject {
@Published var state: AgentState = .disconnected
@Published var tunnelInfo: TunnelInfo?
@Published var logs: [LogEntry] = []
private let provisioning: ProvisioningService
private let heartbeat: HeartbeatService
private let tunnel: TunnelManager
private let config: KeychainStorage
}
```
**Key Methods**:
```swift
func startAgent() async
func stopAgent()
func rebuildTunnel(with config: WireGuardConfig) async
```
### 2. PacketTunnelProvider
**Responsibilities**:
- Handle NetworkExtension tunnel lifecycle
- Start/stop WireGuard tunnel
- Report tunnel status
**Key Methods**:
```swift
override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void)
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void)
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?)
```
**WireGuard Integration**:
```swift
// Uses wireguard-apple library
let adapter = WireGuardAdapter(with: self) { logLevel, message in
Logger.log(level: logLevel, message: message)
}
adapter.start(tunnelConfiguration: tunnelConfig) { error in
if let error = error {
completionHandler(error)
} else {
completionHandler(nil)
}
}
```
### 3. TunnelManager
**Responsibilities**:
- Build WireGuard config from stored keys
- Start/stop tunnel via PacketTunnelProvider
- Monitor tunnel state
- Track last handshake time
**Key Methods**:
```swift
func startTunnel(config: WireGuardConfig) async throws
func stopTunnel()
func isTunnelUp() -> Bool
func getLastHandshake() -> Date?
```
**Config Build**:
```swift
func buildTunnelConfig(from config: WireGuardConfig) -> TunnelConfiguration {
let interface = InterfaceConfiguration(
privateKey: config.privateKey,
addresses: [config.internalIp],
dns: [config.dns]
)
let peer = PeerConfiguration(
publicKey: config.serverPub,
allowedIPs: config.allowedIps,
endpoint: config.endpoint,
preSharedKey: config.presharedKey,
persistentKeepAlive: 25
)
return TunnelConfiguration(interface: interface, peers: [peer])
}
```
### 4. HeartbeatService
**Responsibilities**:
- HTTP POST to `/api/v1/heartbeat` every 30s
- Send device status + tunnel state
- Receive config sync (detect changes)
- Trigger tunnel rebuild if config changed
**Key Methods**:
```swift
func startHeartbeatLoop()
func stopHeartbeat()
func sendHeartbeat(config: WireGuardConfig, tunnelUp: Bool, lastHandshake: Date?) async -> WireGuardConfig?
```
**HTTP Request**:
```json
POST /api/v1/heartbeat
Content-Type: application/json
{
"device_id": "uuid",
"status": "connected",
"tunnel_up": true,
"last_handshake": "2026-06-26T09:00:00Z"
}
```
**HTTP Response**:
```json
{
"device_id": "uuid",
"status": "ok",
"internal_ip": "10.172.21.2",
"private_key": "hex",
"preshared_key": "hex",
"server_pub": "hex",
"endpoint": "italy-twenty.gl.at.ply.gg:59750",
"dns": "1.1.1.1",
"allowed_ips": "0.0.0.0/0",
"server_wg_ip": "10.172.21.1",
"forwards_hash": "hash-string"
}
```
### 5. ProvisioningService
**Responsibilities**:
- HTTP POST to `/api/v1/provision`
- Parse encrypted config response
- Decrypt config via CryptoManager
- Store config in Keychain
**Key Methods**:
```swift
func provision(serverUrl: String, token: String, hwid: String) async -> WireGuardConfig?
func decryptConfig(encrypted: String, hwid: String, salt: String) -> WireGuardConfig?
```
**HTTP Request**:
```json
POST /api/v1/provision
Content-Type: application/json
{
"token": "registration-token",
"hwid": "ios-hardware-id"
}
```
**HTTP Response**:
```json
{
"encrypted_config": "base64-encoded-bytes"
}
```
### 6. CryptoManager
**Responsibilities**:
- Key derivation: SHA-256(hwid + salt)
- AES-256-GCM decryption
- Secure memory handling
**Key Methods**:
```swift
func deriveKey(token: String, hwid: String) -> SymmetricKey
func decrypt(encoded: String, key: SymmetricKey) -> Data?
```
**Algorithm**:
```swift
// Key Derivation
let input = "\(token)\(hwid)".data(using: .utf8)!
let hash = SHA256.hash(data: input)
let key = SymmetricKey(data: hash)
// Decryption
let combined = Data(base64Encoded: encoded)!
let nonce = combined.prefix(12)
let ciphertext = combined.dropFirst(12)
let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext)
let plaintext = try AES.GCM.open(sealedBox, using: key)
```
### 7. KeychainStorage
**Responsibilities**:
- Store WireGuard keys in Keychain
- Store agent config in UserDefaults
- Secure key access
**Key Methods**:
```swift
func saveConfig(_ config: AgentConfig)
func loadConfig() -> AgentConfig?
func saveWireGuardConfig(_ config: WireGuardConfig)
func loadWireGuardConfig() -> WireGuardConfig?
func clearAll()
```
**Storage Strategy**:
- **Keychain**: WireGuard keys (private_key, preshared_key)
- **UserDefaults**: Non-sensitive config (server_url, device_id, endpoint)
- **File**: Log entries (optional)
### 8. LogStore
**Responsibilities**:
- Buffer log entries in memory
- Persist to CoreData (optional)
- Provide log stream for UI
**Key Methods**:
```swift
func add(level: LogLevel, tag: String, message: String)
func clear()
func getLogs() -> [LogEntry]
```
**Log Levels**:
```swift
enum LogLevel: String {
case debug = "DEBUG"
case info = "INFO"
case warn = "WARN"
case error = "ERROR"
}
```
## Data Structures
### WireGuardConfig
```swift
struct WireGuardConfig: Codable {
let deviceId: String
let privateKey: String
let presharedKey: String
let internalIp: String
let serverPub: String
let endpoint: String
let dns: String
let allowedIps: String
let serverWgIp: String
let configHash: String
let forwardsHash: String
}
```
### AgentConfig
```swift
struct AgentConfig: Codable {
let serverUrl: String
let registrationToken: String
let isProvisioned: Bool
}
```
### AgentState
```swift
enum AgentState: String {
case disconnected = "disconnected"
case connecting = "connecting"
case connected = "connected"
case failed = "failed"
}
```
### LogEntry
```swift
struct LogEntry: Identifiable {
let id = UUID()
let timestamp: Date
let level: LogLevel
let tag: String
let message: String
}
```
## Error Handling
### Error Types
```swift
enum AgentError: Error {
case wifiNotConnected
case provisioningFailed(String)
case heartbeatFailed(String)
case cryptoDecryptionFailed
case configInvalid
case tunnelInitFailed
case keychainError(OSStatus)
}
```
### Recovery Strategy
| Error | Recovery |
|-------|----------|
| WiFi not connected | Retry 3x, then pause heartbeat |
| HTTP provision fail | Retry 3x, then notify user |
| HTTP heartbeat fail | Log only, continue |
| Crypto decrypt fail | Clear config, re-provision |
| Tunnel init fail | Retry 3x, then notify user |
| Keychain error | Use UserDefaults fallback |
## Background Tasks
### BGAppRefreshTask
iOS doesn't have boot receiver like Android. Use Background Tasks:
```swift
// Register task
BGAppRefreshTaskRequest.register(forTaskWithIdentifier: "com.nexusguard.agent.refresh", using: nil) {
task in
self.handleBackgroundRefresh(task: task as! BGAppRefreshTask)
}
// Schedule task
let request = BGAppRefreshTaskRequest(identifier: "com.nexusguard.agent.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) // 30 minutes
try BGTaskScheduler.shared.submit(request)
```
### Background Modes
Add to `Info.plist`:
```xml
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
<string>vpn-api</string>
</array>
```
## Dependencies
- **wireguard-apple**: WireGuard tunnel library
- **CryptoKit**: AES-256-GCM encryption
- **NetworkExtension**: VPN tunnel management
- **BackgroundTasks**: Background refresh
- **SwiftUI**: UI framework
- **Combine**: Reactive state management
## Android → iOS Mapping
| Android | iOS | Notes |
|---------|-----|-------|
| `LifecycleService` | `PacketTunnelProvider` | Background service |
| `startForeground()` | `NEPacketTunnelProvider` | Always-on tunnel |
| `SharedPreferences` | `Keychain + UserDefaults` | Config storage |
| `BroadcastReceiver` | `BGAppRefreshTask` | Background triggers |
| `lifecycleScope` | `Task {}` | Coroutine/async |
| `OkHttp` | `URLSession` | HTTP client |
| `Base64` | `Data(base64Encoded:)` | Encoding |
| `Log.d/i/w/e` | `os_log` or `print` | Logging |
+425
View File
@@ -0,0 +1,425 @@
# iOS Agent — Technical Design Document
**Version**: 1.0.0
**Date**: 2026-07-06
**Status**: Draft
**Mirror**: Android Agent (`apps/android-agent/`)
## 1. Executive Summary
NexusGuard iOS Agent is an iOS-based WireGuard client for mobile devices. It mirrors the Android agent's architecture, UI design, and heartbeat flow, using iOS-native APIs (NetworkExtension, CryptoKit, Keychain).
## 2. Goals & Non-Goals
### Goals
- iOS-based WireGuard tunnel (NetworkExtension)
- Auto-provisioning via HTTP (same API as Android/ESP32)
- Heartbeat monitoring with config sync (30s interval)
- Keychain-based config storage (hardware-backed encryption)
- SwiftUI UI matching Android agent design
- Background operation via NetworkExtension
### Non-Goals
- gRPC support (not needed for mobile)
- Port forwarding (userspace agent only)
- Jailbroken device support
- macOS/iPadOS support (iPhone only for v1)
## 3. Architecture
### 3.1 System Architecture
```
┌─────────────────────────────────────────────────────────┐
│ iOS (SwiftUI) │
├─────────────────────────────────────────────────────────┤
│ App Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ContentView│ │SettingsView│ │LogView │ │PortView │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ ┌────┴─────────────┴────────────┴─────────────┴────┐ │
│ │ AgentManager │ │
│ │ (orchestrates all components) │ │
│ └──────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Service Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │Provisioning│ │Heartbeat│ │TunnelMgr │ │ConfigMgr│ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ ┌────┴─────────────┴────────────┴─────────────┴────┐ │
│ │ CryptoManager │ │
│ │ AES-256-GCM (CryptoKit) │ │
│ └──────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ NetworkExtension Layer │
│ ┌──────────────────────────────────────────────────┐ │
│ │ PacketTunnelProvider │ │
│ │ (WireGuard tunnel via wireguard-apple) │ │
│ └──────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│ Storage Layer │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Keychain │ │ UserDefaults│ │ CoreData│ │ FileMgr │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 3.2 Task Architecture
```
iOS Threads/Tasks
├── Main Thread # UI updates
├── AgentManager # Orchestration (async/await)
├── HeartbeatTask # HTTP heartbeat (30s interval)
├── TunnelManager # WireGuard tunnel management
└── BackgroundTask # BGAppRefreshTask (background refresh)
```
### 3.3 State Machine
```
┌─────────────┐
│ BOOT │
└──────┬──────┘
┌─────────────┐
│ APP_INIT │ ◄──────────────────────────────┐
└──────┬──────┘ │
│ loaded │
▼ │
┌─────────────┐ fail ┌─────────────┐│
│ PROVISION │───────────────►│ APP_RETRY ││
└──────┬──────┘ └──────┬──────┘│
│ success │ │
▼ └───────┘
┌─────────────┐
│ TUNNEL_UP │
└──────┬──────┘
┌─────────────┐
│ HEARTBEAT │ ◄─── 30s interval
└──────┬──────┘
│ config_changed
┌─────────────┐
│ TUNNEL_REBUILD │
└──────┬──────┘
└──► HEARTBEAT
```
## 4. Module Design
### 4.1 AgentManager
**Responsibilities**:
- Orchestrate all agent components
- Manage agent lifecycle (start/stop)
- Handle state transitions
**Key Properties**:
```swift
class AgentManager: ObservableObject {
@Published var state: AgentState = .disconnected
@Published var tunnelInfo: TunnelInfo?
@Published var logs: [LogEntry] = []
private let provisioning: ProvisioningService
private let heartbeat: HeartbeatService
private let tunnel: TunnelManager
private let config: KeychainStorage
}
```
**Key Methods**:
```swift
func startAgent() async
func stopAgent()
func rebuildTunnel(with config: WireGuardConfig) async
```
### 4.2 PacketTunnelProvider
**Responsibilities**:
- Handle NetworkExtension tunnel lifecycle
- Start/stop WireGuard tunnel
- Report tunnel status
**Key Methods**:
```swift
override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void)
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void)
override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)?)
```
### 4.3 TunnelManager
**Responsibilities**:
- Build WireGuard config from stored keys
- Start/stop tunnel via PacketTunnelProvider
- Monitor tunnel state
- Track last handshake time
**Key Methods**:
```swift
func startTunnel(config: WireGuardConfig) async throws
func stopTunnel()
func isTunnelUp() -> Bool
func getLastHandshake() -> Date?
```
### 4.4 HeartbeatService
**Responsibilities**:
- HTTP POST to `/api/v1/heartbeat` every 30s
- Send device status + tunnel state
- Receive config sync (detect changes)
- Trigger tunnel rebuild if config changed
**Key Methods**:
```swift
func startHeartbeatLoop()
func stopHeartbeat()
func sendHeartbeat(config: WireGuardConfig, tunnelUp: Bool, lastHandshake: Date?) async -> WireGuardConfig?
```
### 4.5 ProvisioningService
**Responsibilities**:
- HTTP POST to `/api/v1/provision`
- Parse encrypted config response
- Decrypt config via CryptoManager
- Store config in Keychain
**Key Methods**:
```swift
func provision(serverUrl: String, token: String, hwid: String) async -> WireGuardConfig?
func decryptConfig(encrypted: String, hwid: String, salt: String) -> WireGuardConfig?
```
### 4.6 CryptoManager
**Responsibilities**:
- Key derivation: SHA-256(hwid + salt)
- AES-256-GCM decryption
- Secure memory handling
**Key Methods**:
```swift
func deriveKey(token: String, hwid: String) -> SymmetricKey
func decrypt(encoded: String, key: SymmetricKey) -> Data?
```
### 4.7 KeychainStorage
**Responsibilities**:
- Store WireGuard keys in Keychain
- Store agent config in UserDefaults
- Secure key access
**Key Methods**:
```swift
func saveConfig(_ config: AgentConfig)
func loadConfig() -> AgentConfig?
func saveWireGuardConfig(_ config: WireGuardConfig)
func loadWireGuardConfig() -> WireGuardConfig?
func clearAll()
```
### 4.8 LogStore
**Responsibilities**:
- Buffer log entries in memory
- Persist to CoreData (optional)
- Provide log stream for UI
**Key Methods**:
```swift
func add(level: LogLevel, tag: String, message: String)
func clear()
func getLogs() -> [LogEntry]
```
## 5. Data Structures
### 5.1 WireGuardConfig
```swift
struct WireGuardConfig: Codable {
let deviceId: String
let privateKey: String
let presharedKey: String
let internalIp: String
let serverPub: String
let endpoint: String
let dns: String
let allowedIps: String
let serverWgIp: String
let configHash: String
let forwardsHash: String
}
```
### 5.2 AgentConfig
```swift
struct AgentConfig: Codable {
let serverUrl: String
let registrationToken: String
let isProvisioned: Bool
}
```
### 5.3 AgentState
```swift
enum AgentState: String {
case disconnected = "disconnected"
case connecting = "connecting"
case connected = "connected"
case failed = "failed"
}
```
### 5.4 LogEntry
```swift
struct LogEntry: Identifiable {
let id = UUID()
let timestamp: Date
let level: LogLevel
let tag: String
let message: String
}
```
## 6. Error Handling
### 6.1 Error Types
```swift
enum AgentError: Error {
case wifiNotConnected
case provisioningFailed(String)
case heartbeatFailed(String)
case cryptoDecryptionFailed
case configInvalid
case tunnelInitFailed
case keychainError(OSStatus)
}
```
### 6.2 Recovery Strategy
| Error | Recovery |
|-------|----------|
| WiFi not connected | Retry 3x, then pause heartbeat |
| HTTP provision fail | Retry 3x, then notify user |
| HTTP heartbeat fail | Log only, continue |
| Crypto decrypt fail | Clear config, re-provision |
| Tunnel init fail | Retry 3x, then notify user |
| Keychain error | Use UserDefaults fallback |
## 7. Background Tasks
### 7.1 BGAppRefreshTask
iOS doesn't have boot receiver like Android. Use Background Tasks:
```swift
// Register task
BGAppRefreshTaskRequest.register(forTaskWithIdentifier: "com.nexusguard.agent.refresh", using: nil) {
task in
self.handleBackgroundRefresh(task: task as! BGAppRefreshTask)
}
// Schedule task
let request = BGAppRefreshTaskRequest(identifier: "com.nexusguard.agent.refresh")
request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) // 30 minutes
try BGTaskScheduler.shared.submit(request)
```
### 7.2 Background Modes
Add to `Info.plist`:
```xml
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
<string>vpn-api</string>
</array>
```
## 8. Security Considerations
### 8.1 Key Storage
- WireGuard private keys stored in iOS Keychain (hardware-backed)
- Non-sensitive config in UserDefaults
- Never log keys or tokens
### 8.2 TLS
- Server communication over HTTPS (TLS 1.2+)
- Use `URLSession` with default TLS settings
- Certificate pinning (optional, future)
### 8.3 Hardware ID
- Use `identifierForVendor` for device identification
- Persists across app reinstalls until device factory reset
- Used for key derivation (HWID + salt)
## 9. Testing Strategy
### 9.1 Unit Tests
- Crypto: Decrypt known ciphertext with known key
- Config: Keychain read/write cycle
- Heartbeat: Parse server response
### 9.2 Integration Tests
- Provision → Tunnel → Heartbeat cycle
- Config change detection
- Tunnel rebuild on config change
### 9.3 UI Tests
- VPN toggle interaction
- Tab switching
- Settings save/load
### 9.4 Device Tests
- iPhone 12+ running iOS 16+
- NetworkExtension background operation
- Keychain access in background
## 10. Future Enhancements
### 10.1 Phase 2
- iPadOS support
- Widget for quick status
- Shortcuts integration
- Siri commands
### 10.2 Phase 3
- macOS Catalyst support
- Apple Watch companion
- CarPlay integration
## 11. References
- [Apple NetworkExtension Documentation](https://developer.apple.com/documentation/networkextension)
- [WireGuard for iOS](https://github.com/WireGuard/wireguard-apple)
- [CryptoKit Documentation](https://developer.apple.com/cryptokit/)
- [Background Tasks](https://developer.apple.com/documentation/backgroundtasks)
- [NexusGuard Server API](../../server-core/docs/)
+506
View File
@@ -0,0 +1,506 @@
# iOS Agent — UI Design Specs
**Status**: DESIGN ONLY
**Mirror**: Android Agent (`apps/android-agent/`)
## Overview
This document defines the UI design for the iOS WireGuard agent. It mirrors the Android agent's design, colors, layout, and user experience.
## Color Palette
### Status Colors
| Name | Hex | RGB | Usage |
|------|-----|-----|-------|
| `status_disconnected` | `#FFB0B0B0` | (176, 176, 176) | Disconnected state |
| `status_connecting` | `#FFFFD93D` | (255, 217, 61) | Connecting state |
| `status_connected` | `#FF4CAF50` | (76, 175, 80) | Connected state |
| `status_failed` | `#FFFF6B6B` | (255, 107, 107) | Failed state |
### UI Colors
| Name | Hex | RGB | Usage |
|------|-----|-----|-------|
| `card_bg` | `#FF1A1A2E` | (26, 26, 46) | Card background |
| `card_bg_dark` | `#FF0F0F1A` | (15, 15, 26) | Badge background |
| `text_primary` | `#FFFFFFFF` | (255, 255, 255) | Primary text |
| `text_secondary` | `#FFB0B0B0` | (176, 176, 176) | Secondary text |
| `accent_green` | `#FF4CAF50` | (76, 175, 80) | Accent/highlight |
| `accent_blue` | `#FF2196F3` | (33, 150, 243) | Links/actions |
| `border_color` | `#FF2A2A3E` | (42, 42, 62) | Card borders |
### Log Colors
| Level | Hex | RGB |
|-------|-----|-----|
| `DEBUG` | `#FF6B6B6B` | (107, 107, 107) |
| `INFO` | `#FFB0B0B0` | (176, 176, 176) |
| `WARN` | `#FFFFD93D` | (255, 217, 61) |
| `ERROR` | `#FFFF6B6B` | (255, 107, 107) |
## Color Definitions (Swift)
```swift
import SwiftUI
extension Color {
// Status colors
static let statusDisconnected = Color(red: 0.69, green: 0.69, blue: 0.69) // #B0B0B0
static let statusConnecting = Color(red: 1.0, green: 0.85, blue: 0.24) // #FFD93D
static let statusConnected = Color(red: 0.30, green: 0.69, blue: 0.31) // #4CAF50
static let statusFailed = Color(red: 1.0, green: 0.42, blue: 0.42) // #FF6B6B
// UI colors
static let cardBackground = Color(red: 0.10, green: 0.10, blue: 0.18) // #1A1A2E
static let cardBackgroundDark = Color(red: 0.06, green: 0.06, blue: 0.10) // #0F0F1A
static let textPrimary = Color.white // #FFFFFF
static let textSecondary = Color(red: 0.69, green: 0.69, blue: 0.69) // #B0B0B0
static let accentGreen = Color(red: 0.30, green: 0.69, blue: 0.31) // #4CAF50
static let accentBlue = Color(red: 0.13, green: 0.59, blue: 0.95) // #2196F3
static let borderColor = Color(red: 0.16, green: 0.16, blue: 0.24) // #2A2A3E
}
```
## Layout Structure
### Main Screen (ContentView)
```
┌─────────────────────────────────────┐
│ NexusGuard [⚙️ Settings]│
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ ● STATUS: Connected │ │
│ │ │ │
│ │ IP Address 10.172.21.2 │ │
│ │ Admin Web https://... │ │
│ │ Device ID abc-123-... │ │
│ │ Handshake 14:32:05 │ │
│ │ Allowed IPs 0.0.0.0/0 │ │
│ │ Transport HTTPS │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ [Status] [Ports] [Log] │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ Port Forwards: 2 │ │
│ │ TCP :8080 → 10.172.21.2:80│ │
│ │ UDP :53 → 10.172.21.2:53│ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ [Copy Log] [Clear Log] │ │
│ │ │ │
│ │ 14:32:05 [INFO] Agent... │ │
│ │ 14:32:05 [INFO] Tunnel... │ │
│ │ 14:32:05 [WARN] Retry... │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ ══════════════════ │ │
│ │ VPN Toggle │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
```
### Settings Screen (SettingsView)
```
┌─────────────────────────────────────┐
│ ← Settings │
├─────────────────────────────────────┤
│ │
│ Server URL │
│ ┌─────────────────────────────┐ │
│ │ https://api-nexus.datadunia.com│ │
│ └─────────────────────────────┘ │
│ │
│ Registration Token │
│ ┌─────────────────────────────┐ │
│ │ •••••••••••••••• │ │
│ └─────────────────────────────┘ │
│ │
│ Auto-Start on Boot │
│ ┌─────────────────────────────┐ │
│ │ [Toggle] │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ Save Settings │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ Export Config │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ Import Config │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ Paste Config │ │
│ └─────────────────────────────┘ │
│ │
└─────────────────────────────────────┘
```
## Component Specs
### Status Card
```swift
struct StatusCard: View {
let state: AgentState
let config: WireGuardConfig?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Status indicator
HStack {
Circle()
.fill(statusColor)
.frame(width: 12, height: 12)
Text(statusText)
.font(.headline)
.foregroundColor(statusColor)
}
// Info fields
if state == .connected {
InfoRow(label: "IP Address", value: config?.internalIp ?? "-")
InfoRow(label: "Admin Web", value: "https://api-nexus.datadunia.com")
InfoRow(label: "Device ID", value: config?.deviceId ?? "-")
InfoRow(label: "Handshake", value: lastHandshake ?? "-")
InfoRow(label: "Allowed IPs", value: config?.allowedIps ?? "-")
InfoRow(label: "Transport", value: transport)
}
}
.padding()
.background(Color.cardBackground)
.cornerRadius(12)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.borderColor, lineWidth: 1)
)
}
private var statusColor: Color {
switch state {
case .disconnected: return .statusDisconnected
case .connecting: return .statusConnecting
case .connected: return .statusConnected
case .failed: return .statusFailed
}
}
}
```
### Info Row
```swift
struct InfoRow: View {
let label: String
let value: String
var body: some View {
HStack {
Text(label)
.font(.subheadline)
.foregroundColor(.textSecondary)
Spacer()
Text(value)
.font(.subheadline)
.foregroundColor(.textPrimary)
.lineLimit(1)
}
}
}
```
### Port Forward Row
```swift
struct PortForwardRow: View {
let forward: PortForward
var body: some View {
HStack {
Text(forward.protocol.uppercased())
.font(.caption)
.fontWeight(.bold)
.foregroundColor(.cardBackgroundDark)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.accentGreen)
.cornerRadius(4)
Text(":\(forward.publicPort)")
.font(.subheadline)
.foregroundColor(.textPrimary)
.font(.system(.body, design: .monospaced))
Spacer()
Text("\(forward.targetIp):\(forward.targetPort)")
.font(.subheadline)
.foregroundColor(.textSecondary)
.font(.system(.body, design: .monospaced))
}
}
}
```
### Log Entry
```swift
struct LogEntryView: View {
let entry: LogEntry
var body: some View {
HStack(alignment: .top) {
Text(entry.timestamp, style: .time)
.font(.system(.caption, design: .monospaced))
.foregroundColor(entry.level.color)
Text("[\(entry.level.rawValue)]")
.font(.system(.caption, design: .monospaced))
.foregroundColor(entry.level.color)
Text("\(entry.tag): \(entry.message)")
.font(.system(.caption, design: .monospaced))
.foregroundColor(entry.level.color)
}
}
}
extension LogLevel {
var color: Color {
switch self {
case .debug: return Color(red: 0.42, green: 0.42, blue: 0.42) // #6B6B6B
case .info: return Color(red: 0.69, green: 0.69, blue: 0.69) // #B0B0B0
case .warn: return Color(red: 1.0, green: 0.85, blue: 0.24) // #FFD93D
case .error: return Color(red: 1.0, green: 0.42, blue: 0.42) // #FF6B6B
}
}
}
```
### VPN Toggle
```swift
struct VPNToggle: View {
@Binding var isOn: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
RoundedRectangle(cornerRadius: 25)
.fill(isOn ? Color.accentGreen : Color.statusDisconnected)
.frame(height: 50)
.overlay(
HStack {
Circle()
.fill(Color.white)
.frame(width: 40, height: 40)
.offset(x: isOn ? 40 : -40)
Spacer()
}
)
}
.buttonStyle(PlainButtonStyle())
}
}
```
## Tab Layout
```swift
struct TabBar: View {
@Binding var selectedTab: Int
var body: some View {
HStack(spacing: 0) {
TabButton(title: "Status", isSelected: selectedTab == 0) {
selectedTab = 0
}
TabButton(title: "Ports", isSelected: selectedTab == 1) {
selectedTab = 1
}
TabButton(title: "Log", isSelected: selectedTab == 2) {
selectedTab = 2
}
}
.background(Color.cardBackground)
.cornerRadius(8)
}
}
struct TabButton: View {
let title: String
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.subheadline)
.foregroundColor(isSelected ? .textPrimary : .textSecondary)
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
.background(isSelected ? Color.accentGreen : Color.clear)
.cornerRadius(8)
}
}
}
```
## Typography
### Font Sizes
| Element | Size | Weight | Design |
|---------|------|--------|--------|
| Status text | 18pt | Semibold | Default |
| Info label | 14pt | Regular | Default |
| Info value | 14pt | Regular | Monospaced |
| Tab text | 14pt | Medium | Default |
| Log text | 12pt | Regular | Monospaced |
| Badge text | 10pt | Bold | Default |
### Font Definitions
```swift
extension Font {
static let statusText = Font.headline
static let infoLabel = Font.subheadline
static let infoValue = Font.subheadline.monospaced()
static let tabText = Font.subheadline.weight(.medium)
static let logText = Font.caption.monospaced()
static let badgeText = Font.caption2.bold()
}
```
## Spacing
| Element | Value |
|---------|-------|
| Card padding | 16pt |
| Card corner radius | 12pt |
| Card border width | 1pt |
| Row spacing | 12pt |
| Badge padding | 8pt horizontal, 4pt vertical |
| Badge corner radius | 4pt |
| Button height | 50pt |
| Button corner radius | 25pt |
## Animation
### Status Change
```swift
withAnimation(.easeInOut(duration: 0.3)) {
state = .connected
}
```
### Tab Switch
```swift
withAnimation(.easeInOut(duration: 0.2)) {
selectedTab = 1
}
```
### VPN Toggle
```swift
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
isVPNOn.toggle()
}
```
## Haptic Feedback
```swift
// Success
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
// Error
UINotificationFeedbackGenerator().notificationOccurred(.error)
// Selection
UISelectionFeedbackGenerator().selectionChanged()
```
## Dark Mode
iOS agent uses dark mode only (matches Android design). Force dark mode:
```swift
@main
struct NexusGuardApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.preferredColorScheme(.dark)
}
}
}
```
## Responsive Design
### iPhone SE
- Compact layout
- Smaller font sizes
- Reduced padding
### iPhone 14/15
- Standard layout
- Default font sizes
- Standard padding
### iPhone 14/15 Pro Max
- Expanded layout
- Larger font sizes
- Increased padding
## Accessibility
### VoiceOver
```swift
Text("Connected")
.accessibilityLabel("VPN Status: Connected")
.accessibilityHint("Double tap to toggle VPN")
InfoRow(label: "IP Address", value: "10.172.21.2")
.accessibilityElement(children: .combine)
.accessibilityLabel("IP Address: 10.172.21.2")
```
### Dynamic Type
```swift
Text("Status")
.font(.headline)
.dynamicTypeSize(...DynamicTypeSize.accessibility2)
```
### Reduce Motion
```swift
@Environment(\.accessibilityReduceMotion) var reduceMotion
withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.3)) {
state = .connected
}
```