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/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() + } + } +}