From 7eb60fe6721bb775d789830c9c939ee6e87be54d Mon Sep 17 00:00:00 2001 From: Nucleic Date: Fri, 26 Jun 2026 15:07:50 -0700 Subject: [PATCH] Cloud Infrastructure Setup Nucleic-Session: 5AC77C62-A729-42E5-A368-1142C3B9E695 --- .../NucleicRemote/HeartbeatReporter.swift | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 NucleicRemote/NucleicRemote/HeartbeatReporter.swift 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) + } +}