Compare commits
2 Commits
6b953008ce
...
048036d6a7
| Author | SHA1 | Date |
|---|---|---|
|
|
048036d6a7 | |
|
|
a5c937ad75 |
|
|
@ -2,9 +2,15 @@ import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct piRemoteApp: App {
|
struct piRemoteApp: App {
|
||||||
|
@StateObject private var notificationDelegate = NotificationDelegate.shared
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
.onAppear {
|
||||||
|
notificationDelegate.setup()
|
||||||
|
UIApplication.shared.registerForRemoteNotifications()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
// ScrollbackCache.swift
|
||||||
|
// Rolling on-disk cache of raw ANSI bytes per session.
|
||||||
|
//
|
||||||
|
// Design:
|
||||||
|
// • Storage: <caches>/pi-remote/scrollback/<sessionId>.bin
|
||||||
|
// • Cap: 5 MB. When an append would exceed the cap the oldest bytes are
|
||||||
|
// dropped from the front (slice off the head) so the file stays below cap.
|
||||||
|
// • Thread safety: a serial DispatchQueue guards all reads and writes.
|
||||||
|
// No async/await — this is called from both main and background contexts.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Rolling on-disk ANSI scrollback cache for a single session.
|
||||||
|
///
|
||||||
|
/// - `append(_:)` is O(n) on the file size only when the 5 MB cap is hit
|
||||||
|
/// (the head-trim path). In the common case (data fits) it is a simple
|
||||||
|
/// `FileHandle.seekToEnd` + `write`.
|
||||||
|
/// - All public methods are safe to call from any thread or queue.
|
||||||
|
public final class ScrollbackCache: @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: - Constants
|
||||||
|
|
||||||
|
private static let maxBytes = 5 * 1024 * 1024 // 5 MB
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
private let fileURL: URL
|
||||||
|
private let queue = DispatchQueue(label: "pi.scrollback", qos: .utility)
|
||||||
|
|
||||||
|
// Cached file size tracked in memory to avoid repeated stat() calls.
|
||||||
|
private var _sizeBytes: Int = 0
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
/// Creates (or reopens) a cache for `sessionId` stored at
|
||||||
|
/// `<caches>/pi-remote/scrollback/<sessionId>.bin`.
|
||||||
|
public init(sessionId: String) {
|
||||||
|
let cachesDir = FileManager.default.urls(
|
||||||
|
for: .cachesDirectory, in: .userDomainMask
|
||||||
|
).first ?? URL(fileURLWithPath: NSTemporaryDirectory())
|
||||||
|
|
||||||
|
let dir = cachesDir
|
||||||
|
.appendingPathComponent("pi-remote", isDirectory: true)
|
||||||
|
.appendingPathComponent("scrollback", isDirectory: true)
|
||||||
|
|
||||||
|
// Best-effort directory creation — ignore errors, writes will surface
|
||||||
|
// any real problem.
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: dir, withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
fileURL = dir.appendingPathComponent("\(sessionId).bin")
|
||||||
|
|
||||||
|
// Seed the in-memory size counter from the existing file (if any).
|
||||||
|
_sizeBytes = (try? fileURL.resourceValues(forKeys: [.fileSizeKey]))
|
||||||
|
.flatMap { $0.fileSize } ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Appends `data` to the cache, trimming the head when the 5 MB cap
|
||||||
|
/// would be exceeded.
|
||||||
|
public func append(_ data: Data) {
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
queue.sync {
|
||||||
|
_append(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the full current cache contents (may be empty).
|
||||||
|
public func read() -> Data {
|
||||||
|
queue.sync {
|
||||||
|
(try? Data(contentsOf: fileURL)) ?? Data()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the cache file and resets the in-memory size counter.
|
||||||
|
public func clear() {
|
||||||
|
queue.sync {
|
||||||
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
|
_sizeBytes = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current cache size in bytes (in-memory approximation, always accurate
|
||||||
|
/// after any `append` / `clear` call).
|
||||||
|
public var sizeBytes: Int {
|
||||||
|
queue.sync { _sizeBytes }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private (always called on `queue`)
|
||||||
|
|
||||||
|
private func _append(_ data: Data) {
|
||||||
|
let newSize = _sizeBytes + data.count
|
||||||
|
|
||||||
|
if newSize <= Self.maxBytes {
|
||||||
|
// Fast path: just append.
|
||||||
|
_write(data)
|
||||||
|
} else {
|
||||||
|
// Slow path: we need to drop bytes from the head.
|
||||||
|
// Read the existing content, combine with new data, then keep
|
||||||
|
// only the last `maxBytes` bytes so the result fits within cap.
|
||||||
|
let existing = (try? Data(contentsOf: fileURL)) ?? Data()
|
||||||
|
var combined = existing
|
||||||
|
combined.append(data)
|
||||||
|
|
||||||
|
let excess = combined.count - Self.maxBytes
|
||||||
|
if excess > 0 {
|
||||||
|
combined = combined.dropFirst(excess).withUnsafeBytes { Data($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite the file with the trimmed data.
|
||||||
|
try? combined.write(to: fileURL, options: .atomic)
|
||||||
|
_sizeBytes = combined.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _write(_ data: Data) {
|
||||||
|
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||||
|
// Append to existing file via FileHandle.
|
||||||
|
if let handle = try? FileHandle(forWritingTo: fileURL) {
|
||||||
|
defer { try? handle.close() }
|
||||||
|
handle.seekToEndOfFile()
|
||||||
|
handle.write(data)
|
||||||
|
_sizeBytes += data.count
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new file.
|
||||||
|
try? data.write(to: fileURL, options: .atomic)
|
||||||
|
_sizeBytes = data.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,113 @@
|
||||||
|
// Sources/Core/Push/DeviceTokenRegistrar.swift
|
||||||
|
// T-2.9: APNs — device token storage + deferred sidecar registration
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Receives the APNs device token and registers it with the paired sidecar.
|
||||||
|
///
|
||||||
|
/// **Phase 2 note:** the sidecar does not yet expose a dedicated
|
||||||
|
/// `/device-token` endpoint. The token and a *pending* flag are stored in
|
||||||
|
/// `UserDefaults`; `registerWithSidecar` will be fully wired once the
|
||||||
|
/// endpoint lands in a follow-up task. Until then the method records the
|
||||||
|
/// intent and returns without making a network call.
|
||||||
|
actor DeviceTokenRegistrar {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
static let shared = DeviceTokenRegistrar()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - UserDefaults keys
|
||||||
|
|
||||||
|
private enum Keys {
|
||||||
|
static let tokenHex = "piremote.push.tokenHex"
|
||||||
|
static let registrationPending = "piremote.push.registrationPending"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
/// Hex-encoded 40-byte device token string (80 hex chars).
|
||||||
|
private(set) var tokenHex: String? = nil
|
||||||
|
|
||||||
|
// MARK: - Token ingestion
|
||||||
|
|
||||||
|
/// Store the raw token data received from `didRegisterForRemoteNotificationsWithDeviceToken`.
|
||||||
|
func didRegister(tokenData: Data) {
|
||||||
|
let hex = tokenData.map { String(format: "%02x", $0) }.joined()
|
||||||
|
tokenHex = hex
|
||||||
|
UserDefaults.standard.set(hex, forKey: Keys.tokenHex)
|
||||||
|
// Mark any previously-pending registration as still pending so it is
|
||||||
|
// retried when `registerWithSidecar` is next called.
|
||||||
|
if UserDefaults.standard.bool(forKey: Keys.registrationPending) == false {
|
||||||
|
UserDefaults.standard.set(true, forKey: Keys.registrationPending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Registration
|
||||||
|
|
||||||
|
/// Attempt to register the current device token with the paired sidecar.
|
||||||
|
///
|
||||||
|
/// **Current behaviour (stub):** the sidecar endpoint is not yet
|
||||||
|
/// available, so the method persists the token locally and marks the
|
||||||
|
/// registration as pending. The pending flag will be consumed and cleared
|
||||||
|
/// when the real endpoint is wired in the Phase 2 follow-up.
|
||||||
|
///
|
||||||
|
/// - Parameter credential: The active `SidecarCredential` obtained from
|
||||||
|
/// the Keychain after a successful pairing.
|
||||||
|
/// - Throws: `DeviceTokenRegistrarError.noTokenAvailable` if the APNs
|
||||||
|
/// token has not been received yet.
|
||||||
|
func registerWithSidecar(credential: SidecarCredential) async throws {
|
||||||
|
// Recover a previously-stored token if we don't have one in memory yet
|
||||||
|
// (e.g. after a cold launch where `didRegister` fires after we call
|
||||||
|
// `registerWithSidecar`).
|
||||||
|
if tokenHex == nil {
|
||||||
|
tokenHex = UserDefaults.standard.string(forKey: Keys.tokenHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard tokenHex != nil else {
|
||||||
|
throw DeviceTokenRegistrarError.noTokenAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stub ────────────────────────────────────────────────────────────
|
||||||
|
// TODO(Phase 2 follow-up): POST to `<scheme>://<host>:<port>/device-token`
|
||||||
|
// with JSON body:
|
||||||
|
// { "deviceToken": tokenHex, "environment": DeviceTokenRegistrar.environment }
|
||||||
|
// Headers: Authorization: Bearer <credential.bearerToken>
|
||||||
|
// On HTTP 200 → clear the pending flag.
|
||||||
|
//
|
||||||
|
// For now, just mark it as pending so it is retried when the
|
||||||
|
// endpoint becomes available.
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
UserDefaults.standard.set(true, forKey: Keys.registrationPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Environment
|
||||||
|
|
||||||
|
/// APNs environment derived from the build configuration.
|
||||||
|
///
|
||||||
|
/// Switch to `"production"` when distributing via TestFlight or the
|
||||||
|
/// App Store (the aps-environment entitlement controls the actual
|
||||||
|
/// environment; this string is sent to the sidecar for its records).
|
||||||
|
nonisolated static var environment: String {
|
||||||
|
#if DEBUG
|
||||||
|
return "sandbox"
|
||||||
|
#else
|
||||||
|
return "production"
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pending state helper
|
||||||
|
|
||||||
|
/// `true` when a token has been stored but not yet acknowledged by the sidecar.
|
||||||
|
nonisolated static var isRegistrationPending: Bool {
|
||||||
|
UserDefaults.standard.bool(forKey: Keys.registrationPending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum DeviceTokenRegistrarError: Error, Sendable {
|
||||||
|
/// `didRegisterForRemoteNotificationsWithDeviceToken` has not been called yet.
|
||||||
|
case noTokenAvailable
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
// Sources/Core/Push/NotificationDelegate.swift
|
||||||
|
// T-2.9: APNs — foreground presentation control + tap handling
|
||||||
|
|
||||||
|
import UserNotifications
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Centralised `UNUserNotificationCenterDelegate`.
|
||||||
|
///
|
||||||
|
/// Responsibilities:
|
||||||
|
/// - Suppresses the banner when the user is already viewing the relevant
|
||||||
|
/// terminal session (`visibleSessionId`).
|
||||||
|
/// - Posts `"piRemote.openSession"` when the user taps a notification,
|
||||||
|
/// carrying the session-id as the notification object, so any subscriber
|
||||||
|
/// (e.g. the root navigation stack) can navigate to the right session.
|
||||||
|
@MainActor
|
||||||
|
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
static let shared = NotificationDelegate()
|
||||||
|
|
||||||
|
private override init() {}
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
/// Set by the visible terminal view so foreground banners can be suppressed.
|
||||||
|
var visibleSessionId: String? = nil
|
||||||
|
|
||||||
|
// MARK: - Setup
|
||||||
|
|
||||||
|
/// Wire this delegate into `UNUserNotificationCenter`.
|
||||||
|
/// Call once from `piRemoteApp.onAppear`.
|
||||||
|
func setup() {
|
||||||
|
UNUserNotificationCenter.current().delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Permission
|
||||||
|
|
||||||
|
/// Request alert + sound + badge authorisation.
|
||||||
|
/// - Returns: `true` if the user granted permission.
|
||||||
|
func requestPermission() async -> Bool {
|
||||||
|
do {
|
||||||
|
return try await UNUserNotificationCenter.current()
|
||||||
|
.requestAuthorization(options: [.alert, .sound, .badge])
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - UNUserNotificationCenterDelegate
|
||||||
|
|
||||||
|
/// Foreground presentation: suppress the banner when the relevant session
|
||||||
|
/// is already on-screen.
|
||||||
|
nonisolated func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
willPresent notification: UNNotification,
|
||||||
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||||
|
) {
|
||||||
|
let sessionId = notification.request.content.userInfo["sessionId"] as? String
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
if let sessionId, sessionId == self.visibleSessionId {
|
||||||
|
// Session is already visible — suppress the banner entirely.
|
||||||
|
completionHandler([])
|
||||||
|
} else {
|
||||||
|
// Show banner + sound + badge update.
|
||||||
|
completionHandler([.banner, .sound, .badge])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tap handling: post `"piRemote.openSession"` so any subscriber can
|
||||||
|
/// navigate to the correct session without a tight coupling.
|
||||||
|
nonisolated func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter,
|
||||||
|
didReceive response: UNNotificationResponse,
|
||||||
|
withCompletionHandler completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
let sessionId = response.notification.request.content.userInfo["sessionId"] as? String
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
NotificationCenter.default.post(
|
||||||
|
name: Notification.Name("piRemote.openSession"),
|
||||||
|
object: sessionId
|
||||||
|
)
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
// SessionConnection.swift
|
||||||
|
// IC-2.1 — one WebSocket connection per session.
|
||||||
|
//
|
||||||
|
// Lifecycle:
|
||||||
|
// init → resume(from:) → [stream data] → suspend() → resume(from:) → …
|
||||||
|
//
|
||||||
|
// URL pattern: ws://<host>:<port>/sessions/<id>/stream?token=<bearerToken>
|
||||||
|
// (TLS pinning is wired in a follow-up task; plain `ws://` for now.)
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - SessionConnection
|
||||||
|
|
||||||
|
/// Manages a single IC-1 WebSocket session stream.
|
||||||
|
///
|
||||||
|
/// Conforms to `ObservableObject` so SwiftUI views can react to
|
||||||
|
/// `connectionState` changes without manually subscribing to Combine.
|
||||||
|
///
|
||||||
|
/// All mutable state is main-actor-isolated. Callers on background contexts
|
||||||
|
/// must dispatch accordingly (normal for `@MainActor` types).
|
||||||
|
@MainActor
|
||||||
|
public final class SessionConnection: ObservableObject {
|
||||||
|
|
||||||
|
// MARK: - Identity
|
||||||
|
|
||||||
|
/// The session identifier used in the URL path and scrollback file name.
|
||||||
|
public let id: String
|
||||||
|
|
||||||
|
// MARK: - Combine publishers
|
||||||
|
|
||||||
|
/// Emits raw ANSI bytes in arrival order (binary frames, header stripped).
|
||||||
|
public let stream = PassthroughSubject<Data, Never>()
|
||||||
|
|
||||||
|
/// Emits every JSON frame received from the server.
|
||||||
|
public let stateEvents = PassthroughSubject<ServerToClient, Never>()
|
||||||
|
|
||||||
|
/// Tracks the WebSocket lifecycle.
|
||||||
|
@Published public private(set) var connectionState: WebSocketClient.ConnectionState = .disconnected
|
||||||
|
|
||||||
|
// MARK: - Scrollback
|
||||||
|
|
||||||
|
/// Persistent rolling ANSI cache for this session.
|
||||||
|
public private(set) var scrollback: ScrollbackCache
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private let credential: SidecarCredential
|
||||||
|
private var client: WebSocketClient?
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
/// Creates a `SessionConnection` for `id` authenticated with `credential`.
|
||||||
|
///
|
||||||
|
/// Does **not** open a WebSocket. Call `resume(from:)` to connect.
|
||||||
|
public init(id: String, credential: SidecarCredential) {
|
||||||
|
self.id = id
|
||||||
|
self.credential = credential
|
||||||
|
self.scrollback = ScrollbackCache(sessionId: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Opens (or re-opens) the WebSocket and sends a `resume` frame.
|
||||||
|
///
|
||||||
|
/// - Parameter lastSeq: The last acknowledged sequence number, or `nil`
|
||||||
|
/// to request replay from the beginning.
|
||||||
|
public func resume(from lastSeq: UInt64?) async {
|
||||||
|
guard let url = streamURL else {
|
||||||
|
#if DEBUG
|
||||||
|
print("[SessionConnection] Could not construct stream URL for session \(id) — aborting resume.")
|
||||||
|
#endif
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tear down any existing connection cleanly before reconnecting.
|
||||||
|
await suspend()
|
||||||
|
|
||||||
|
let ws = WebSocketClient()
|
||||||
|
client = ws
|
||||||
|
|
||||||
|
// Mirror WebSocketClient's connection state into our @Published property.
|
||||||
|
ws.connectionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] state in
|
||||||
|
self?.connectionState = state
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Binary frames → scrollback + downstream `stream` subject.
|
||||||
|
ws.incomingBinary
|
||||||
|
.sink { [weak self] frame in
|
||||||
|
guard let self else { return }
|
||||||
|
self.scrollback.append(frame.data)
|
||||||
|
self.stream.send(frame.data)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// JSON frames → `stateEvents` subject.
|
||||||
|
ws.incomingJSON
|
||||||
|
.sink { [weak self] frame in
|
||||||
|
self?.stateEvents.send(frame)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// Once connected, send the resume frame.
|
||||||
|
ws.connectionState
|
||||||
|
.filter { $0 == .connected }
|
||||||
|
.first()
|
||||||
|
.sink { [weak self, weak ws, lastSeq] _ in
|
||||||
|
guard let self, let ws else { return }
|
||||||
|
Task { @MainActor [self, ws, lastSeq] in
|
||||||
|
try? await ws.send(.resume(lastSeq: lastSeq))
|
||||||
|
_ = self // silence unused-capture warning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
ws.connect(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a frame to the server.
|
||||||
|
///
|
||||||
|
/// - Throws: `WebSocketClientError.notConnected` if there is no active
|
||||||
|
/// socket, or `WebSocketClientError.encodingFailed` on serialisation
|
||||||
|
/// failure.
|
||||||
|
public func send(_ frame: ClientToServer) async throws {
|
||||||
|
guard let client else {
|
||||||
|
throw WebSocketClientError.notConnected
|
||||||
|
}
|
||||||
|
try await client.send(frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the WebSocket but keeps local state (scrollback + cursor).
|
||||||
|
public func suspend() async {
|
||||||
|
client?.disconnect()
|
||||||
|
client = nil
|
||||||
|
cancellables.removeAll()
|
||||||
|
connectionState = .disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - URL construction
|
||||||
|
|
||||||
|
/// Builds `ws://<host>:<port>/sessions/<id>/stream?token=<bearerToken>`.
|
||||||
|
///
|
||||||
|
/// Returns `nil` if `URLComponents` cannot produce a valid URL (should
|
||||||
|
/// never happen in practice with well-formed credentials).
|
||||||
|
///
|
||||||
|
/// Note: plain `ws://` is used for now; TLS + cert-pinning wired in
|
||||||
|
/// the T-2.5 follow-up task.
|
||||||
|
private var streamURL: URL? {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = "ws"
|
||||||
|
components.host = credential.host
|
||||||
|
components.port = credential.port
|
||||||
|
components.path = "/sessions/\(id)/stream"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "token", value: credential.bearerToken)
|
||||||
|
]
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue