diff --git a/NucleicRemote/NucleicRemote/HeartbeatReporter.swift b/NucleicRemote/NucleicRemote/HeartbeatReporter.swift new file mode 100644 index 0000000..e7e1a44 --- /dev/null +++ b/NucleicRemote/NucleicRemote/HeartbeatReporter.swift @@ -0,0 +1,101 @@ +import Foundation +import Security +import NucleicProtocol + +/// Sends the iPhone client's anonymous DAU heartbeat at most once per day +/// (`docs/CLOUD_INFRA.md` §4). Opt-out (default on) via `HeartbeatSettings`; debug builds never +/// report. The install ID is a random UUID kept only in the Keychain, deliberately **separate** +/// from the device's Noise identity (`IdentityStore`) so telemetry can't be correlated with it. +enum HeartbeatReporter { + /// Fire-and-forget; safe to call on every foreground. Self-gates on the opt-out switch, the + /// build channel, and the once-per-day schedule, and swallows network errors. + static func reportIfDue(session: URLSession = .shared, defaults: UserDefaults = .standard) async { + guard HeartbeatSettings.isEnabled(defaults) else { return } + let channel = Self.channel + guard channel != "debug" else { return } // never count local debug builds + guard HeartbeatSchedule.isDue(defaults) else { return } + + let info = Bundle.main.infoDictionary + let os = ProcessInfo.processInfo.operatingSystemVersion + let beat = Heartbeat( + installId: installID(), + platform: "ios", + osVersion: "\(os.majorVersion).\(os.minorVersion)", + channel: channel, + appVersion: info?["CFBundleShortVersionString"] as? String ?? "unknown", + build: info?["CFBundleVersion"] as? String ?? "unknown", + arch: "arm64", + locale: Locale.current.language.languageCode?.identifier ?? "und") + + do { + try await post(beat, session: session) + HeartbeatSchedule.markSent(defaults) + } catch { + // Best-effort telemetry: drop it and try again next foreground. + } + } + + /// TestFlight builds carry a `sandboxReceipt`; App Store builds a `receipt`. Debug builds + /// (run from Xcode) report `debug` and are skipped before any send. + private static var channel: String { + #if DEBUG + return "debug" + #else + if Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" { + return "testflight" + } + return "appstore" + #endif + } + + // MARK: - Transport + + private static func post(_ beat: Heartbeat, session: URLSession) async throws { + var req = URLRequest(url: HeartbeatSchedule.endpoint) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(beat) + let (_, resp) = try await session.data(for: req) + guard let http = resp as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw URLError(.badServerResponse) + } + } + + // MARK: - Anonymous install id (Keychain; separate from the Noise identity) + + private static let installAccount = "com.nucleic.remote.installid" + + private static func installID() -> String { + if let data = keychainRead(installAccount), let id = String(data: data, encoding: .utf8) { + return id + } + let id = UUID().uuidString + keychainWrite(Data(id.utf8), account: installAccount) + return id + } + + private static func keychainRead(_ account: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess else { return nil } + return item as? Data + } + + private static func keychainWrite(_ data: Data, account: String) { + SecItemDelete([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + ] as CFDictionary) + SecItemAdd([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] as CFDictionary, nil) + } +} diff --git a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift index 9261ca3..f00e426 100644 --- a/NucleicRemote/NucleicRemote/Models/RemoteStore.swift +++ b/NucleicRemote/NucleicRemote/Models/RemoteStore.swift @@ -163,7 +163,7 @@ final class RemoteStore: ObservableObject { let client = SyncClient( channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey, mode: .pair(secret: payload.pairingSecret), deviceID: deviceID, - deviceLabel: UIDevice.current.name) + deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex) self.client = client consume(client, pairingPayload: payload) } @@ -181,7 +181,8 @@ final class RemoteStore: ObservableObject { let channel = makeChannel(endpoint) let client = SyncClient( channel: channel, identity: identity, hostStaticKey: host.hostStaticKey, - mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name) + mode: .reconnect, deviceID: host.deviceID, deviceLabel: UIDevice.current.name, + pushToken: PushRegistrar.shared.tokenHex) self.client = client consume(client, pairingPayload: nil) } diff --git a/NucleicRemote/NucleicRemote/NucleicRemote.entitlements b/NucleicRemote/NucleicRemote/NucleicRemote.entitlements new file mode 100644 index 0000000..fb15153 --- /dev/null +++ b/NucleicRemote/NucleicRemote/NucleicRemote.entitlements @@ -0,0 +1,12 @@ + + + + + + aps-environment + development + + diff --git a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift index 450a3a3..a41ede2 100644 --- a/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift +++ b/NucleicRemote/NucleicRemote/NucleicRemoteApp.swift @@ -3,12 +3,25 @@ import SwiftUI @main struct NucleicRemoteApp: App { @StateObject private var store = RemoteStore() + @Environment(\.scenePhase) private var scenePhase + @UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate var body: some Scene { WindowGroup { RootView() .environmentObject(store) - .onAppear { store.onAppear() } + .onAppear { + store.onAppear() + // Surface the notifications prompt + register for APNS. The token rides along + // in the sync Hello; the relay uses it to wake the phone for approvals (§3). + PushRegistrar.shared.requestAuthorizationAndRegister() + // Anonymous, opt-out, once-a-day DAU heartbeat (docs/CLOUD_INFRA.md §4). + Task { await HeartbeatReporter.reportIfDue() } + } + // Re-check on every foreground so a new active day is counted. + .onChange(of: scenePhase) { _, phase in + if phase == .active { Task { await HeartbeatReporter.reportIfDue() } } + } } } } diff --git a/NucleicRemote/NucleicRemote/PushRegistrar.swift b/NucleicRemote/NucleicRemote/PushRegistrar.swift new file mode 100644 index 0000000..e2b1b32 --- /dev/null +++ b/NucleicRemote/NucleicRemote/PushRegistrar.swift @@ -0,0 +1,48 @@ +import Foundation +import UIKit +import UserNotifications + +/// Captures this phone's APNS device token so it can ride along in the sync `Hello` (SYNC §6) and, +/// once the relay ships, let the host wake the phone for an approval (docs/CLOUD_INFRA.md §3). +/// +/// Best-effort: the token may be nil until APNS responds, in which case it's simply included on a +/// later (re)connect — the phone still works fully on LAN without it. +@MainActor +final class PushRegistrar { + static let shared = PushRegistrar() + private init() {} + + /// Hex-encoded APNS device token, or nil until registration completes. + private(set) var tokenHex: String? + + /// Surface the notifications prompt and, if granted, register for remote notifications. Safe to + /// call on every launch; the system de-dupes registration. + func requestAuthorizationAndRegister() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in + guard granted else { return } + Task { @MainActor in UIApplication.shared.registerForRemoteNotifications() } + } + } + + func didRegister(deviceToken: Data) { + tokenHex = deviceToken.map { String(format: "%02x", $0) }.joined() + } +} + +/// SwiftUI needs a UIKit app delegate to receive the remote-registration callbacks; wired via +/// `@UIApplicationDelegateAdaptor` in `NucleicRemoteApp`. +final class PushAppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + Task { @MainActor in PushRegistrar.shared.didRegister(deviceToken: deviceToken) } + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + // Best-effort: no token → the phone still works on LAN, just no remote wake. + } +} diff --git a/NucleicRemote/NucleicRemote/Views/SettingsView.swift b/NucleicRemote/NucleicRemote/Views/SettingsView.swift index bc53eaa..94d020f 100644 --- a/NucleicRemote/NucleicRemote/Views/SettingsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SettingsView.swift @@ -1,10 +1,12 @@ import SwiftUI +import NucleicProtocol struct SettingsView: View { @EnvironmentObject var store: RemoteStore @State private var showScanner = false @AppStorage("nucleic.showRawEvents") private var showRaw = false @AppStorage("nucleic.showLockEvents") private var showLockEvents = true + @AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true var body: some View { NavigationStack { @@ -33,6 +35,14 @@ struct SettingsView: View { Text("Raw events are unrecognized backend output, shown for debugging.") } + Section { + Toggle("Share anonymous usage", isOn: $shareAnonymousUsage) + } header: { + Text("Privacy") + } footer: { + Text("A once-a-day anonymous ping — a random install ID, app version, iOS version, and language — so we can count active installs. No account, no IP, no content.") + } + Section { Button { showScanner = true