nvrsion: promote trunk to dev
Nucleic-Promote: 1
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -163,7 +163,7 @@ final class RemoteStore: ObservableObject {
|
|||||||
let client = SyncClient(
|
let client = SyncClient(
|
||||||
channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey,
|
channel: channel, identity: identity, hostStaticKey: payload.hostStaticKey,
|
||||||
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
|
mode: .pair(secret: payload.pairingSecret), deviceID: deviceID,
|
||||||
deviceLabel: UIDevice.current.name)
|
deviceLabel: UIDevice.current.name, pushToken: PushRegistrar.shared.tokenHex)
|
||||||
self.client = client
|
self.client = client
|
||||||
consume(client, pairingPayload: payload)
|
consume(client, pairingPayload: payload)
|
||||||
}
|
}
|
||||||
@@ -181,7 +181,8 @@ final class RemoteStore: ObservableObject {
|
|||||||
let channel = makeChannel(endpoint)
|
let channel = makeChannel(endpoint)
|
||||||
let client = SyncClient(
|
let client = SyncClient(
|
||||||
channel: channel, identity: identity, hostStaticKey: host.hostStaticKey,
|
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
|
self.client = client
|
||||||
consume(client, pairingPayload: nil)
|
consume(client, pairingPayload: nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- APNS approval push (docs/CLOUD_INFRA.md §3). `development` is the source value; a
|
||||||
|
distribution build signs as `production` automatically. Enable the Push Notifications
|
||||||
|
capability in Xcode (Signing & Capabilities) so the provisioning profile matches and
|
||||||
|
CODE_SIGN_ENTITLEMENTS points here. -->
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -3,12 +3,25 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct NucleicRemoteApp: App {
|
struct NucleicRemoteApp: App {
|
||||||
@StateObject private var store = RemoteStore()
|
@StateObject private var store = RemoteStore()
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@UIApplicationDelegateAdaptor(PushAppDelegate.self) private var pushDelegate
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView()
|
RootView()
|
||||||
.environmentObject(store)
|
.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() } }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import NucleicProtocol
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var store: RemoteStore
|
@EnvironmentObject var store: RemoteStore
|
||||||
@State private var showScanner = false
|
@State private var showScanner = false
|
||||||
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
@AppStorage("nucleic.showRawEvents") private var showRaw = false
|
||||||
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
@AppStorage("nucleic.showLockEvents") private var showLockEvents = true
|
||||||
|
@AppStorage(HeartbeatSettings.shareAnonymousUsageKey) private var shareAnonymousUsage = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -33,6 +35,14 @@ struct SettingsView: View {
|
|||||||
Text("Raw events are unrecognized backend output, shown for debugging.")
|
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 {
|
Section {
|
||||||
Button {
|
Button {
|
||||||
showScanner = true
|
showScanner = true
|
||||||
|
|||||||
Reference in New Issue
Block a user