TunnelVision

Spec — Inter-process communication (Shared/IPC)

Two channels between the extension (producer) and the app (consumer), both in the App Group container:

Plus a payload-less Darwin notification used only to wake the consumer. Rationale: ../decisions/0004-ipc-mmap-ringbuffer-plus-darwin.md.

And a third, low-frequency control channel that does not go through the container at all: the app calls NETunnelProviderSession.sendProviderMessage and the extension answers in handleAppMessage. Its Codable codec (ControlCommand/ControlResponse/TunnelStats, which pairs PipelineStats with RelayStats) lives in Shared/IPC/ControlChannel.swift because both processes must see the same types and the app cannot link an app-extension. It is specified in tunnel-provider.md (the receiving end) and app-services.md (the sending end). Per-packet data never travels here.

Shared identifiers (single source of truth)

public enum AppGroup {
    public static let identifier = "group.com.juanmmm21.tunnelvision"
}

public enum DarwinSignal {
    /// Posted by the extension when new records are available in the ring.
    /// Stored as `String` (Sendable). It is bridged to `CFString` (`name as CFString`) at the call
    /// site where `CFNotificationCenter` requires it — a global `CFString` is not concurrency-safe
    /// under Swift 6 strict concurrency.
    public static let liveDataAvailable = "com.juanmmm21.tunnelvision.live-data"
}

AppGroup.identifier is the one place this string is defined; persistence and IPC both read it from here.

Why mmap, not Darwin payloads

Darwin notifications carry no data — they are a bare cross-process signal. They cannot transport packets. The ring buffer (a file mmap‘d into both processes) is what actually moves bytes; the notification only tells the app “there is something new, come drain.” A common misconception is that Darwin notifications can pass packet data; they cannot.

Ring buffer layout

A single App Group file, mapped MAP_SHARED into both processes. Fixed-width records so the consumer computes slot offsets without parsing and reads are never torn.

The header is a C struct (CTVAtomics), fixed at a 64-byte reserved region (cache-line aligned) before the slots. magic is 0x54565231 ("TVR1"), version is 1.

┌────────────── header (64 B reserved, cache-line aligned) ──────────────┐
│ magic: UInt32            (0x54565231 "TVR1" — validate + versioning)    │
│ version: UInt32          (1)                                            │
│ slotSize: UInt32         (bytes per slot; ≥ PackedPacketMeta 64 B)      │
│ slotCount: UInt32        (power of two)                                 │
│ head: _Atomic UInt64     (producer writes release; next write index)   │
│ tail: _Atomic UInt64     (consumer writes release; next read index)    │
│ dropped: _Atomic UInt64  (producer increments on overflow)             │
└────────────────────────────────────────────────────────────────────────┘
┌──────── slots[slotCount] : fixed-size PackedPacketMeta (64 B) ──────────┐

head/tail are monotonic UInt64 indices (never wrapped modulo slotCount); the slot index is index & (slotCount - 1). Full = head - tail == slotCount.

/// Representación empaquetada de ancho fijo de `PacketMeta` para el ring (little-endian explícito).
/// `packedByteCount` = 64. El proto se guarda como código de 4 bits (0=tcp,1=udp,2=icmp,3=icmpv6,
/// 4=other); el valor de cable (17, 58…) no cabe en un nibble, pero `PacketMeta` ya porta el proto
/// colapsado, así que no se pierde nada. `ipVersion` (4/6) desambigua las direcciones de 16 bytes.
public struct PackedPacketMeta: Sendable, Hashable {
    public var timestamp: UInt64            // @0  (8)  ns monotónicos
    public var protoAndDirection: UInt8     // @8  (1)  código proto (nibble alto) + dirección (bajo)
    public var ipVersion: UInt8             // @9  (1)  4 o 6
    public var tcpFlags: UInt8              // @10 (1)
    // @11 (1) reservado (padding, siempre 0)
    public var portA: UInt16                // @12 (2)  endpoint canónico A (menor)
    public var portB: UInt16                // @14 (2)  endpoint canónico B (mayor)
    public var addrA: [UInt8]               // @16 (16) v4 usa los primeros 4, resto 0
    public var addrB: [UInt8]               // @32 (16)
    public var length: UInt32               // @48 (4)
    /// Dónde quedaron sus bytes: @52 (8) offset del registro, @60 (4) secuencia del fichero.
    /// Sobre el cable, `recordOffset == 0` es el centinela de "sin captura" — ningún registro
    /// puede vivir ahí, porque todo `.pcap` empieza por su cabecera global —, y entonces la
    /// secuencia se ignora en vez de fabricar una localización que apuntaría al fichero 0.
    public var capture: CaptureLocation?    // @52 (8) + @60 (4)
}

El estado TLS es por-flujo (vive en el store, no en este feed por-paquete), por eso no está aquí.

El registro lleva el fichero además del offset porque el writer rota por tamaño y cada fichero reinicia sus offsets: sin él, un offset guardado apunta a una posición de un fichero desconocido.

Protocol (single-producer / single-consumer)

Both sides also expose a init(fileURL:…) (used by the tests, over a temp-file-backed ring) that the App-Group initializer delegates to — mirroring FlowStore(databaseURL:). slotSize defaults to PackedPacketMeta.packedByteCount.

public final class RingBufferProducer: @unchecked Sendable {   // used in the extension
    public init(appGroupID: String, slotCount: Int, slotSize: Int = PackedPacketMeta.packedByteCount) throws
    public init(fileURL: URL, slotCount: Int, slotSize: Int = PackedPacketMeta.packedByteCount) throws
    /// Escribe un registro. Si el ring está lleno, incrementa `dropped` y descarta (back-pressure).
    public func push(_ meta: PackedPacketMeta)
    public func close()
}

public final class RingBufferConsumer: @unchecked Sendable {   // used in the app
    public init(appGroupID: String) throws
    public init(fileURL: URL) throws              // lee slotSize/slotCount de la cabecera existente
    /// Drena hasta `max` registros disponibles; avanza `tail`. No bloquea.
    public func drain(max: Int) -> [PackedPacketMeta]
    public func droppedCount() -> UInt64
    public func close()
}

public enum IPCError: Error, Sendable, Equatable { case appGroupUnavailable, mapFailed, badHeader }

@unchecked Sendable: cada instancia la usa un único hilo (contrato SPSC); se marca así solo para poder confinarla a una Task/Thread. La coordinación real es vía los atómicos de la memoria compartida, no vía el estado de la clase.

Memory ordering

Wakeup

After a batch of pushes, the extension posts DarwinSignal.liveDataAvailable. The app registers a CFNotificationCenter Darwin observer that schedules a drain on a background task and then hands parsed records to the @MainActor view models. The notification is coalesced — many pushes, one wakeup — so it never becomes a per-packet cost.

The consuming side of all this is the live-feed reader, specified in app-services.md: LiveFeedReader (an actor, so the SPSC contract holds by isolation), the LiveFeedWakeup seam over the observer above, and the pure core that turns a PackedPacketMeta into something a view can draw. Note that because the producer recreates the ring file on every session, a consumer mapped against the previous one stays silently alive: the reader exposes reattach() for that, and closing the hole properly (not truncating, or a generation counter in the header) belongs here, in Shared/IPC.

The durable contract: user settings (M9)

The ring carries what is happening and the control channel carries a gesture; AppSettings carries what the user decided. It is the third cross-process contract in Shared and the only durable one: the app writes it when a setting is touched and the extension reads it in startTunnel, so a choice survives the extension dying and the device being switched off. The control channel is the other half and does not replace it — it applies a change to a session already running, and a command lost to a race with stopTunnel must not leave the setting unsaved.

public enum CaptureDetail: String, Codable, Sendable, CaseIterable {
    case metadataOnly, fullPayload
    public var snaplen: UInt32          // 128 / 262 144
}

public struct AppSettings: Codable, Sendable, Equatable {
    public var tlsInspectionEnabled: Bool          // false: opt-in (ADR 0003)
    public var captureEnabled: Bool                // true
    public var captureDetail: CaptureDetail        // fullPayload
    public var retention: RetentionSettings        // app-services.md
    public static let `default`: AppSettings
}

public struct SettingsStore: Sendable {
    public init(appGroupID: String = AppGroup.identifier)
    public init(reading: @escaping @Sendable () throws -> Data?,
                writing: @escaping @Sendable (Data) throws -> Void)   // la costura
    public func load() throws -> AppSettings
    public func save(_ settings: AppSettings) throws
}

The backing store is the App Group’s UserDefaults, which is the mechanism Apple documents for exactly this. Four decisions:

Lifecycle and safety

Tests (M5)