diff --git a/Sources/App/piRemoteApp.swift b/Sources/App/piRemoteApp.swift index fb49c1c..ffb23cc 100644 --- a/Sources/App/piRemoteApp.swift +++ b/Sources/App/piRemoteApp.swift @@ -2,9 +2,15 @@ import SwiftUI @main struct piRemoteApp: App { + @StateObject private var notificationDelegate = NotificationDelegate.shared + var body: some Scene { WindowGroup { ContentView() + .onAppear { + notificationDelegate.setup() + UIApplication.shared.registerForRemoteNotifications() + } } } } diff --git a/Sources/Core/Persistence/ScrollbackCache.swift b/Sources/Core/Persistence/ScrollbackCache.swift new file mode 100644 index 0000000..47e800f --- /dev/null +++ b/Sources/Core/Persistence/ScrollbackCache.swift @@ -0,0 +1,133 @@ +// ScrollbackCache.swift +// Rolling on-disk cache of raw ANSI bytes per session. +// +// Design: +// • Storage: /pi-remote/scrollback/.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 + /// `/pi-remote/scrollback/.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 + } + } +} diff --git a/Sources/Core/Push/DeviceTokenRegistrar.swift b/Sources/Core/Push/DeviceTokenRegistrar.swift new file mode 100644 index 0000000..cff9c3a --- /dev/null +++ b/Sources/Core/Push/DeviceTokenRegistrar.swift @@ -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 `://:/device-token` + // with JSON body: + // { "deviceToken": tokenHex, "environment": DeviceTokenRegistrar.environment } + // Headers: Authorization: Bearer + // 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 +} diff --git a/Sources/Core/Push/NotificationDelegate.swift b/Sources/Core/Push/NotificationDelegate.swift new file mode 100644 index 0000000..2aa80ee --- /dev/null +++ b/Sources/Core/Push/NotificationDelegate.swift @@ -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() + } + } +} diff --git a/Sources/Core/Sessions/SessionConnection.swift b/Sources/Core/Sessions/SessionConnection.swift new file mode 100644 index 0000000..e504f31 --- /dev/null +++ b/Sources/Core/Sessions/SessionConnection.swift @@ -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://:/sessions//stream?token= +// (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() + + /// Emits every JSON frame received from the server. + public let stateEvents = PassthroughSubject() + + /// 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() + + // 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://:/sessions//stream?token=`. + /// + /// 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 + } +}