BitChat Client Architecture: A Deep Dive Into the SwiftUI-Based Messaging Stack

The BitChat client is a layered SwiftUI application that separates concerns into a composition root (AppRuntime), domain models backed by a centralized ConversationStore, and transport abstractions for BLE mesh, Tor, and Nostr networks, all communicating via typed async events.

The BitChat client architecture in the permissionlesstech/bitchat repository implements a clean architecture pattern optimized for decentralized messaging. This Swift codebase organizes functionality into distinct layers: a central composition root that wires dependencies, feature-specific models that maintain UI state, and transport layers handling BLE mesh, Tor, and Nostr protocols. Understanding this architecture is essential for developers contributing to the permissionless messaging stack or building similar offline-first communication applications.

Composition Root and Dependency Injection

The architecture centers on AppRuntime, a @MainActor ObservableObject defined in bitchat/App/AppRuntime.swift. This class functions as the composition root, instantiating a single instance of every major component and storing them as let properties.

The AppRuntime Class

AppRuntime owns three critical responsibilities:

  • Lifecycle Management: The start() method kicks off networking services including NetworkActivationService and GeohashPresenceService
  • Dependency Wiring: Constructs the ConversationStore (the single source of truth for message state) and injects it into feature models
  • Global Event Bus: Maintains the AppEventStream, a typed async-await event bus that propagates app-wide events such as Nostr relay changes and Tor lifecycle notifications

According to the source comments in AppRuntime.swift (lines 17-20), this design provides "a single source of truth for conversation message state and selection."

The AppEventStream

Rather than using delegate patterns throughout the stack, the architecture publishes typed TransportEvent objects through the AppEventStream. Feature models subscribe to these events using Combine or async-await, decoupling the UI from transport implementation details.

Domain Layer and State Management

The domain layer consists of focused feature models that encapsulate state for specific UI areas. Each model reads from and writes to the central ConversationStore, eliminating duplicated state and synchronization bugs.

ConversationStore as Single Source of Truth

Located in bitchat/App/ConversationStore.swift, the ConversationStore maintains:

  • All message timelines for public channels, private inboxes, and location-based channels
  • Read state and unread counts
  • The currently selected conversation identifier

All domain models receive a reference to this store during initialization, enabling authoritative reads and writes without state duplication.

Feature-Specific Models

The codebase splits UI logic into focused model classes rather than using a monolithic ViewModel:

As noted in docs/ARCHITECTURE_V2.md (lines 21-24), this "new runtime/store layer gives us a safe place to move lifecycle, routing, and cross-cutting state before we split the UI into smaller feature models."

Transport Layer Abstraction

The client abstracts three network backends through a common interface, each emitting typed events rather than using traditional delegate callbacks.

BLE Mesh Implementation

The Bluetooth Low Energy stack centers on BLEService, implemented across bitchat/Services/BLEService+LinkLayerCentralRole.swift and related files. The architecture separates concerns into:

  • BLE Link Layer: Handles CoreBluetooth delegate methods and serial mesh engine operations
  • Feature Modules: Consume TransportEvent objects rather than accessing CoreBluetooth directly

The detailed design in docs/BLE-ARCHITECTURE-V3.md specifies that "transport code should emit typed events first, with delegate forwarding used only as a compatibility adapter during migration" (line 44).

Tor and Nostr Integrations

  • TorManager: Manages the Tor daemon lifecycle and provides TorURLSession for encrypted internet traffic
  • NostrRelayManager: Handles decentralized relay connections for public chat functionality, defined alongside NostrProtocol implementations

Both services publish lifecycle events to the AppEventStream, allowing domain models to react to connectivity changes without direct dependencies on transport implementation.

Typed Transport Events

All transports emit TransportEvent enums that traverse the architecture as follows:

  1. Transport layer detects state change (e.g., BLE link up, Nostr relay connected)
  2. Event posts to AppEventStream
  3. AppRuntime observes via Combine and forwards to relevant domain models
  4. Models update @Published properties, triggering SwiftUI view updates

Security Layer (Noise Protocol)

Message confidentiality and authenticity across all transports utilize the Noise Protocol implementation located in bitchat/Noise/:

These components are referenced by each transport implementation to ensure end-to-end encryption regardless of the underlying network (BLE, Tor, or Nostr).

Concurrency Contracts and Thread Safety

The architecture enforces strict concurrency boundaries to prevent data races, documented in docs/BLE-ARCHITECTURE-V3.md (lines 55-58):

  • Engine-confined: All mesh-engine state mutates on a single serial queue (onEngine)
  • BLE-queue-confined: CoreBluetooth-specific state lives on its own dedicated queue
  • Lock-backed stores: Reads crossing domain boundaries (e.g., peer registry) use locks preventing torn reads

Debug traps using dispatchPrecondition and unit tests in BLEQueueContractTests enforce these contracts during development.

Practical Implementation Examples

Boot the client from your SwiftUI App struct:

import SwiftUI

@main
struct BitChatApp: App {
    @StateObject private var runtime = AppRuntime()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(runtime)
        }
        .onAppear {
            runtime.start()  // Kicks off networking and services
        }
    }
}

Send a public chat message from a view:

struct PublicChatView: View {
    @ObservedObject var publicChat: PublicChatModel
    
    var body: some View {
        // UI implementation
    }
    
    func sendMessage(_ text: String) {
        publicChat.sendMessage(text)  // Writes to ConversationStore → mesh/Nostr
    }
}

React to BLE link-layer events in a feature module:

import Combine

class PeerConnectionHandler {
    let runtime: AppRuntime
    var cancellables = Set<AnyCancellable>()
    
    init(runtime: AppRuntime) {
        self.runtime = runtime
        
        runtime.events
            .filter { $0 is AppEvent.linkUp }
            .sink { event in
                // Handle new peer connection
            }
            .store(in: &cancellables)
    }
}

Access the canonical store for testing or debugging:

let store = runtime.conversations
let selectedID = store.selectedConversationID  // Read-only view of selection state

Summary

  • Composition Root: AppRuntime (bitchat/App/AppRuntime.swift) initializes all dependencies and manages application lifecycle through the start() method
  • State Management: ConversationStore (bitchat/App/ConversationStore.swift) serves as the single source of truth for all message timelines and selection state
  • Feature Models: Specialized models like PublicChatModel and PrivateInboxModel encapsulate UI-specific logic without duplicating state
  • Transport Abstraction: BLE mesh, Tor, and Nostr implementations emit typed TransportEvent objects through AppEventStream rather than using delegate callbacks
  • Security: NoiseSessionManager provides encryption across all transport layers
  • Concurrency: Strict contracts (Engine-confined, BLE-queue-confined, Lock-backed) enforce thread safety in the mesh networking layer

Frequently Asked Questions

What is the role of AppRuntime in BitChat?

AppRuntime serves as the composition root and global dependency container for the BitChat client. Annotated with @MainActor and conforming to ObservableObject, it creates single instances of all services, constructs the ConversationStore, and manages application lifecycle through its start() method. It also owns the AppEventStream, which decouples transport events from UI updates by acting as a typed async-await event bus.

How does BitChat handle state management across multiple transports?

All transport implementations (BLE, Tor, Nostr) emit typed TransportEvent objects that propagate through the AppEventStream. Domain models subscribe to these events and update the centralized ConversationStore, which functions as the single source of truth. When users send messages, feature models write to ConversationStore, which routes encrypted payloads through the appropriate transport layer via NoiseSessionManager, ensuring consistent state regardless of which network layer delivers the message.

What concurrency model does the BitChat BLE layer use?

The BLE architecture enforces three distinct concurrency contracts: Engine-confined state mutates on a single serial queue (onEngine), CoreBluetooth-specific state lives on a dedicated BLE queue, and cross-domain reads utilize lock-backed stores to prevent torn reads. These contracts are enforced through debug traps using dispatchPrecondition and verified by BLEQueueContractTests, ensuring thread safety in the mesh networking implementation documented in docs/BLE-ARCHITECTURE-V3.md.

How are transport events propagated to the UI layer?

Transport events flow through a unidirectional data stream: transport layers emit TransportEvent enums to the AppEventStream, which AppRuntime observes via Combine. The runtime forwards relevant events to feature models (like PublicChatModel or PeerListModel), which update their @Published properties. SwiftUI views observing these models via @ObservedObject or @StateObject automatically reflect state changes, maintaining a clear separation between transport implementation and UI presentation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →