Peer‑to‑peer (P2P) ephemeral chat over WebRTC DataChannels for iOS (Swift / SwiftUI). Signaling + lightweight REST backend + in‑app session management with privacy protections.
Inviso is a small, focused example of building an end‑to‑end encrypted (transport level via DTLS/SRTP) peer chat using:
- SwiftUI for UI and state binding
- A WebSocket signaling channel (
SignalingClient) - WebRTC DataChannel transport (
PeerConnectionManager) - A coordinating state & domain layer (
ChatManager) - A thin REST API for creating and accepting “rooms” by short join codes
- Optional ephemeral mode (no local message history retention)
- A privacy overlay that masks UI when the app backgrounds
Messages never transit the REST API after the P2P connection is established; only signaling metadata (SDP + ICE) flows through the WebSocket server. TURN/STUN servers assist NAT traversal.
- Join‑code based session creation + acceptance workflow (pending → accepted → closed)
- WebRTC DataChannel (ordered, reliable) for text messages
- Ephemeral device IDs per session (no persistent device tracking)
- Automatic server purge when sessions are deleted
- Biometric authentication (Face ID / Touch ID) + passphrase protection
- Automatic ICE gathering + TURN fallback
- Heartbeat & reconnection strategy for signaling
- Session persistence in
UserDefaults(frontend only; no message storage server‑side) - Privacy overlay to hide content when app resigns active
- Graceful leave + reconnection suppression to avoid thrash
- Session name synchronization across UI components
- Lightweight modular code (single responsibility objects)
SwiftUI Views (ChatView, SessionsView, etc.)
│
▼ (Published properties / Combine)
ChatManager <——> SignalingClient (WebSocket) <——> Signaling Server
│ │
│ └─ JSON messages: connected, join_room, offer/answer, ice_candidate ...
▼
PeerConnectionManager (WebRTC PeerConnection + DataChannel)
│
└─ Direct P2P DataChannel (encrypted, DTLS/SRTP stack handled by WebRTC)
| Component | Responsibility |
|---|---|
ChatManager |
Orchestrates session lifecycle, REST calls, signaling messages, P2P state, UI‑facing published state. |
SignalingClient |
Manages WebSocket connection, heartbeat ping, reconnect logic, JSON encode/decode. |
PeerConnectionManager |
Creates & manages RTCPeerConnection and RTCDataChannel, offers/answers, ICE candidates. |
DeviceIDManager |
Manages ephemeral device IDs per session, handles server purge requests on deletion. |
AppSecurityManager |
Observes app lifecycle and toggles privacy overlay. |
AuthenticationSettingsStore |
Manages biometric and passphrase authentication settings. |
PassphraseManager |
Secure passphrase storage and validation using Keychain. |
| SwiftUI Views | Render real‑time state and dispatch user intents (create session, send message, leave room). |
Server-Side Calculation: The app sends duration in seconds (expiresInSeconds) instead of calculating expiry dates locally. The server calculates the exact expiry timestamp based on its own clock, eliminating clock skew issues between devices.
Duration Values:
- 1 minute =
60seconds - 5 minutes =
300seconds - 1 hour =
3600seconds - 12 hours =
43200seconds - 24 hours =
86400seconds
Benefits:
- ✅ No clock skew between devices
- ✅ Server is source of truth for expiry
- ✅ Automatic cleanup at database level
- ✅ Expired sessions cannot be joined
- User creates a session →
ChatSession(status: .pending)stored locally and POST/api/roomswith join code +expiresInSeconds. - Remote peer enters the join code → backend pairs clients → returns
roomidto second peer; first peer polls (checkPendingOnServer) until accepted. - When both sides have the
roomId, the UI triggersjoinRoom(roomId:)→ signaling server orchestrates who is initiator. - Initiator creates
RTCPeerConnection+ DataChannel → creates SDP offer → sent over WebSocket. - Responder sets remote offer, creates answer → answer sent back.
- ICE candidates exchanged until connectivity established (STUN/TURN).
PeerConnectionManagerreports connection viapcmIceStateChanged(connected: true)→ UI shows green status; messages flow P2P.- Leave: user taps Leave →
ChatManager.leave(userInitiated:)closes P2P first, sendsleave_room, clears room state; if no ack, a timed soft reconnect of signaling occurs.
disconnected → (connect) → connecting → (WS 'connected') → connected
connected + no room → (join_room) → room_joined → room_ready → WebRTC negotiation → P2P connected
P2P connected → (leave / peer_left) → no room (messages kept unless ephemeral)
Send:
ChatView → ChatManager.sendMessage() → PeerConnectionManager.send() → WebRTC DataChannel → Remote peer delegate → ChatManager.pcmDidReceiveMessage on other device.
Receive:
Remote DataChannel → PeerConnectionManager delegate → ChatManager appends ChatMessage (Published) → SwiftUI auto‑renders.
No message content traverses the signaling server after P2P establishment (only initial negotiation metadata does).
| Concern | Strategy |
|---|---|
| WebSocket liveness | Heartbeat ping every 25s + 10s timeout → triggers failAndReconnect() |
| Accidental leave thrash | One‑shot suppressReconnectOnce prevents immediate auto reconnect after user leave |
| ICE candidate ordering | Candidates buffered until remote description set (pending array) |
| Race on leaving room | P2P closed before signaling leave to avoid renegotiation events mid‑teardown |
| Session polling | pollPendingAndValidateRooms() validates pending acceptance + still‑existing rooms |
| Area | Current Approach | Notes |
|---|---|---|
| Transport | WebRTC DTLS + TURN relays | Dynamic TURN creds recommended for production |
| Message storage | In‑memory only (optionally ephemeral) | No message history stored server-side |
| Background privacy | Fullscreen black overlay (PrivacyOverlayView) |
Biometric re-unlock available via settings |
| Device identity | Ephemeral IDs per session | Each session gets unique UUID, cannot be correlated |
| Server cleanup | Automatic purge on deletion | Ephemeral IDs purged from server when sessions deleted |
| Authentication | Optional biometric + passphrase | Face ID / Touch ID + custom passphrase support |
| Error handling | Console prints with [Component] tags |
Structured logging for debugging |
Privacy-First Design: No persistent device ID is stored or transmitted. Each chat session generates a unique ephemeral ID that:
- Cannot be linked back to the device
- Is automatically purged from the server when the session is deleted
- Is cleared during "Erase All Data" operation
- Enables complete session unlinkability
Server Purge API: When ephemeral IDs are deleted locally, they are automatically purged from the backend via batch API (POST /api/user/purge with {"deviceIds": [...]}).
Session Name Sync: When you rename a session, the name is synchronized to the ephemeral ID record, ensuring Settings → Privacy → Session Identities always shows current names.
TURN credentials in the source are for development/testing only—rotate and secure in production.
Only ChatSession array is serialized to UserDefaults (chat.sessions.v1). Messages are intentionally transient. Ephemeral mode (isEphemeral) clears messages upon join, ensuring zero local history.
Inviso/
ChatManager.swift # Orchestration & state
Models/ChatModels.swift # Connection + session models
Signaling/SignalingClient.swift
Networking/PeerConnectionManager.swift
Security/ # Privacy overlay & lifecycle security
Services/
Authentication/ # Biometric + passphrase auth
Storage/ # DeviceIDManager, AppDataReset
Views/ # SwiftUI interface (ChatView, SessionsView, etc.)
Settings/ # Settings UI including EphemeralIDsView
Utilities/ # Helpers & security notifications
Prerequisites: Xcode 15+, iOS 15+ target.
- Open
Inviso.xcodeproj(or the workspace if integrating into a larger app). - Ensure Swift Package dependency resolves (
WebRTCvia SPM). If needed: File > Packages > Resolve Package Versions. - Run on a physical device or two Simulators (TURN usage may require real networking for NAT scenarios).
- Create a session on Device A → share the 6‑digit code → accept on Device B → wait for P2P indicator (green dot).
Add this repo as a remote Swift Package and depend on target Inviso. Instantiate ChatManager() and inject into your SwiftUI environment.
The app dynamically configures server endpoints via ServerConfig.shared.host:
// Signaling WebSocket
let signaling = SignalingClient(serverURL: "wss://\(ServerConfig.shared.host)")
// REST API
let apiBase = URL(string: "https://\(ServerConfig.shared.host)")!Change server host at runtime via Settings → Server Configuration.
The app communicates with the backend REST API for session coordination:
Create Pending Session (Client1):
// POST /api/rooms
{
"joinid": "123456",
"expiresInSeconds": 300, // Duration in seconds
"client1": "ephemeral-device-id"
}Accept Session (Client2):
// POST /api/rooms/accept
{
"joinid": "123456",
"client2": "ephemeral-device-id"
}
// Returns: { "roomid": "abc123..." }Check Status (Client1 polling):
// POST /api/rooms/check
{
"joinid": "123456",
"client1": "ephemeral-device-id"
}
// Returns: 204 (pending) or 200 { "roomid": "..." } (accepted)Change STUN/TURN servers in PeerConnectionManager.createPeerConnection:
let iceServers = [
RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"]),
RTCIceServer(urlStrings: ["turn:your-turn-server:3478"],
username: "user",
credential: "pass")
]Environment‑driven config can be introduced by wrapping these in a struct injected at init.
- Multi‑party rooms (SFU / Mesh) – adapt signaling semantics.
- File or image transfer (DataChannel chunking + metadata messages).
- Application‑level E2E encryption (Olm/DoubleRatchet libs) over DataChannel payload.
- Push notifications for pending session acceptance (APNs + backend trigger).
- Analytics & telemetry (with privacy controls and user consent).
- Offline queue (buffer sends until P2P established).
- Theming + accessibility improvements (Dynamic Type, VoiceOver labels for system messages).
- On‑device LLM assistant (iOS 26+) — local inference chat tab (prototype added).
- Message expiration timer beyond 24h default.
- Export/import session configuration (without message history).
| Symptom | Possible Cause | Action |
|---|---|---|
| Stuck on “Waiting for P2P…” | ICE blocked / TURN unreachable | Verify TURN ports, inspect network logs. |
| WebSocket reconnect loop | Server down / heartbeat failing | Check server logs; confirm ping interval alignment. |
| Messages not delivered | DataChannel not open | Confirm isP2PConnected and renegotiate if needed. |
| Session never becomes accepted | Peer didn’t enter code or expired | Poll checkPendingOnServer or recreate session. |
| Privacy overlay stuck | App lifecycle notification missed | Reproduce; ensure notifications on main thread. |
Enable verbose WebRTC logs by configuring RTCSetMinDebugLogLevel(.verbose) early (not included by default here).
Currently minimal. Recommended next steps:
- Unit test
ChatManagersession persistence & lifecycle edges. - Mock
SignalingClientto simulate offer/answer + error conditions. - UI Tests for Leave confirmation + privacy overlay appearance.
- Add structured logging + log viewer.
- Introduce optional message encryption layer.
- Support attachments (binary DataChannel frames + MIME labeling).
- Replace polling with server push for session acceptance.
- Add automated test suite + CI workflow.
- Dynamic TURN credentials via ephemeral auth service.
Pull requests welcome. Please:
- Open an issue describing the change.
- Keep components small & testable.
- Avoid introducing 3rd‑party dependencies unless necessary.
Specify your license (e.g., MIT) here. Example:
MIT License © 2025 YOUR NAME
Replace with actual license text and author details as appropriate.
This is a development / demonstration project. Do not ship to production without securing credentials, adding robust error handling, and completing a security review.
| Task | Entry Point |
|---|---|
| Create session | ChatManager.createSession |
| Accept by code | ChatManager.acceptJoinCode |
| Join room | ChatManager.joinRoom |
| Send message | ChatManager.sendMessage |
| Leave room | ChatManager.leave |
| Rename session | ChatManager.renameSession |
| Delete session | ChatManager.removeSession |
| P2P event | PeerConnectionManagerDelegate methods |
| Ephemeral ID management | DeviceIDManager.shared |
| Server purge | DeviceIDManager.purgeFromServer |
| Authentication settings | AuthenticationSettingsStore.shared |
| On‑device LLM chat | OnDeviceLLMManager via LLMView |
An experimental tab (LLM) showcases a local assistant UI. Implementation notes:
- Manager:
OnDeviceLLMManager(observable) reuses existingChatMessagemodel. - Availability: Gated by
#available(iOS 26, *)– gracefully degrades with a system message if unsupported. - Current Logic: Placeholder echo response with artificial delay; wired for future streaming token integration once full FoundationModels API surface is finalized.
- UI: Minimal scrollable transcript + input bar; shows a progress indicator while generating.
- Next Steps: Replace placeholder with real model session initialization, add configurable system prompt, add stop button, implement per‑message streaming updates.
Safety: No external network calls are performed for LLM replies; placeholder text is generated locally.
For questions or clarifications, open an issue or extend this README with additional diagrams.