p2p-swift / Sources / IrdP2P / P2PApp.swift
891 words
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
import Foundation
import IrdP2PCrypto
import IrdP2PSignaling

/// Everything needed to talk to one p2p app: its hostname, the salt that
/// namespaces its rooms, the `Origin` its allowlist admits, and the injectable
/// transports that make the SDK testable offline.
public struct P2PAppConfiguration: Sendable {
    /// The app hostname (`<name>.ap2p.ro` or a custom domain).
    public var host: String
    /// Namespaces room ids and keys; never reuse another app's salt.
    public var appSalt: String
    /// Sent as the `Origin` header on the WebSocket upgrade and credential mints.
    /// Required when the app's origin allowlist is non-empty.
    public var origin: String?
    public var descriptorTimeout: TimeInterval = 3
    /// Use only TURN-relayed ICE candidates (tests the fallback path).
    public var forceRelay = false
    /// Reconnect rooms when the app returns to the foreground.
    public var observeAppLifecycle = true
    /// Explicit endpoint overrides (local development against the node servers).
    public var endpoints: AppEndpoints?
    public var http: any HTTPTransport = URLSessionHTTPTransport()
    public var webSocket: any WebSocketTransport = URLSessionWebSocketTransport()
    public var clock: any P2PClock = SystemClock()
    /// Defaults to the WebRTC-backed factory.
    public var peerConnections: (any PeerConnectionFactory)?
    /// Mailbox tokens from your own backend; defaults to the node's open mint.
    public var mailboxTokens: (any MailboxTokenProvider)?

    public init(host: String, appSalt: String, origin: String? = nil) {
        self.host = host
        self.appSalt = appSalt
        self.origin = origin
    }
}

/// The entry point: discovers the app's descriptor, caches TURN credentials and
/// joins rooms.
public actor P2PApp {
    public let configuration: P2PAppConfiguration
    private let endpoints: AppEndpoints
    private var descriptorCache: (descriptor: AppDescriptor, fetchedAt: Duration)?
    private var descriptorFetch: Task<AppDescriptor, Never>?
    private var iceCache: IceServerCache?
    /// The self-minted token provider, shared by every mailbox of this app so its
    /// app-scoped token is minted once, not once per room.
    private var mailboxProvider: (mint: URL, provider: any MailboxTokenProvider)?

    public init(host: String, appSalt: String, origin: String? = nil) {
        self.init(configuration: P2PAppConfiguration(host: host, appSalt: appSalt, origin: origin))
    }

    public init(configuration: P2PAppConfiguration) {
        self.configuration = configuration
        endpoints = configuration.endpoints ?? .standard(host: configuration.host)
    }

    // MARK: descriptor

    /// The app's `/.well-known/ap2p` descriptor; synthesized (`isFallback`) when unreachable.
    /// A fallback is retried after 30 s, a real descriptor after an hour.
    public func descriptor(refresh: Bool = false) async -> AppDescriptor {
        if !refresh, let cached = descriptorCache {
            let age = configuration.clock.now - cached.fetchedAt
            if age < (cached.descriptor.isFallback ? .seconds(30) : .seconds(3600)) { return cached.descriptor }
        }
        if let running = descriptorFetch { return await running.value }
        let task = Task { await self.fetchDescriptor() }
        descriptorFetch = task
        let d = await task.value
        descriptorCache = (d, configuration.clock.now)
        descriptorFetch = nil
        return d
    }

    private func fetchDescriptor() async -> AppDescriptor {
        do {
            let response = try await configuration.http.send(.p2p(endpoints.descriptor, origin: configuration.origin, timeout: configuration.descriptorTimeout))
            guard response.isSuccess else { throw P2PError.http(status: response.status, code: nil, body: response.body) }
            return try AppDescriptor.decode(response.body)
        } catch {
            return AppDescriptor.fallback(host: configuration.host)
        }
    }

    /// Resolved URLs for this app (overrides win over the descriptor).
    public func resolvedEndpoints() async -> AppEndpoints.Resolved {
        endpoints.resolved(with: await descriptor(), host: configuration.host)
    }

    // MARK: ICE

    /// TURN credentials for `RTCPeerConnection`, cached and renewed; empty on failure.
    public func iceConfiguration() async -> IceConfiguration {
        if iceCache == nil {
            let r = await resolvedEndpoints()
            iceCache = IceServerCache(url: r.turnCredentials, origin: configuration.origin, http: configuration.http, clock: configuration.clock, relayOnly: configuration.forceRelay)
        }
        return await iceCache!.configuration()
    }

    // MARK: rooms

    /// Joins the room derived from `secret`. Throws when the relay refuses the upgrade
    /// (403 wrong or missing Origin, 404 unknown host) or the room is full.
    public func join(secret: RoomSecret, identity: Identity? = nil, peerId: PeerId = .random(), options: RoomOptions = RoomOptions()) async throws -> Room {
        let d = await descriptor()
        let r = endpoints.resolved(with: d, host: configuration.host)
        let crypto = RoomCrypto(secret: secret, appSalt: configuration.appSalt, peerId: peerId)
        var relayConfig = RelaySession.Configuration(url: r.ws, origin: configuration.origin, room: crypto.roomId, peer: peerId, limits: d.limits)
        relayConfig.keepaliveInterval = options.keepaliveInterval
        relayConfig.pacing = options.pacing
        let relay = RelaySession(configuration: relayConfig, transport: configuration.webSocket, clock: configuration.clock)
        let factory: any PeerConnectionFactory = configuration.peerConnections ?? WebRTCPeerConnectionFactory()
        let room = Room(crypto: crypto, relay: relay, identity: identity, appSalt: configuration.appSalt, options: options,
                        clock: configuration.clock, peerConnections: factory, ice: { [weak self] in await self?.iceConfiguration() ?? .none },
                        maxFrameBytes: d.limits.maxFrameBytes, observeLifecycle: configuration.observeAppLifecycle)
        try await room.start(observeLifecycle: configuration.observeAppLifecycle)
        return room
    }

    // MARK: mailbox

    /// The raw mailbox client (opaque bytes). Throws `config` when the app has no mailbox,
    /// or when its open mint is off and no token provider was injected.
    public func rawMailbox(tokens: (any MailboxTokenProvider)? = nil) async throws -> MailboxClient {
        let d = await descriptor()
        let r = endpoints.resolved(with: d, host: configuration.host)
        guard let endpoint = r.mailbox else { throw P2PError.config("the app has no mailbox enabled (no mailbox block in its descriptor)") }
        let provider: any MailboxTokenProvider
        if let tokens = tokens ?? configuration.mailboxTokens {
            provider = tokens
        } else if let mint = r.mailboxMint {
            // injected providers above are the caller's to share; only our own is cached (per mint URL)
            if mailboxProvider?.mint != mint {
                mailboxProvider = (mint, OpenMintTokenProvider(mintURL: mint, origin: configuration.origin, http: configuration.http))
            }
            provider = mailboxProvider!.provider
        } else {
            throw P2PError.config("the app's mailbox open mint is off: inject a MailboxTokenProvider backed by your own backend")
        }
        return MailboxClient(endpoint: endpoint, tokens: provider, http: configuration.http, origin: configuration.origin)
    }

    /// The room-bound, end-to-end encrypted mailbox for `secret`.
    public func mailbox(secret: RoomSecret, tokens: (any MailboxTokenProvider)? = nil) async throws -> SealedMailbox {
        let client = try await rawMailbox(tokens: tokens)
        return SealedMailbox(secret: secret, appSalt: configuration.appSalt, client: client, clock: configuration.clock)
    }
}

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/p2p-swift.git