feat(T-2.9): APNs NotificationDelegate + DeviceTokenRegistrar
This commit is contained in:
parent
6b953008ce
commit
a5c937ad75
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue