102 lines
4.2 KiB
Swift
102 lines
4.2 KiB
Swift
import Foundation
|
|
import Security
|
|
import NucleicProtocol
|
|
|
|
/// Sends the iPhone client's anonymous DAI (daily active installs) 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 = "xyz.blakeslee.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)
|
|
}
|
|
}
|