#if PHONE_LINK import AppKit import CloudKit // MARK: - iPhone link // // Starts and stops the iPhone sync (Settings → General → iPhone, off by // default): push registration and SessionPublisher. Nothing runs and nothing // is sent to iCloud while it is off. // // Step 1 spike, kept for testing: when the phoneLinkPing default is on, proves // that the Mac and the iPhone app share a private CloudKit database: // writes a `Ping` every 60 s, waits for the iPhone's `Pong`, and logs the // round trip to ~/Library/Logs/NotchBuddy/nb.log. Pongs arrive both through a // silent push (CKDatabaseSubscription) and a 5 s poll, so the log shows which // path is faster. Compiled only with PHONE_LINK; normal builds never see it. @MainActor final class CloudProbe { static let shared = CloudProbe() static let containerID = "iCloud.fr.louisraille.Coucou" static let zoneID = CKRecordZone.ID(zoneName: "Coucou", ownerName: CKCurrentUserDefaultName) private static let subscriptionID = "coucou-zone-mac" private let container = CKContainer(identifier: CloudProbe.containerID) private var database: CKDatabase { container.privateCloudDatabase } private let launchDate = Date() private var ready = false private var pingCount = 0 private var changeToken: CKServerChangeToken? private var seenPongs = Set() private var fetching = false private var lastPushAt: Date? private var started = false private var pingTask: Task? private var pollTask: Task? private var appLabel: String { #if APPSTORE "CoucouAppStore" #else "NotchBuddy" #endif } private var macName: String { Host.current().localizedName ?? ProcessInfo.processInfo.hostName } static let enabledKey = "iPhoneSyncEnabled" static var isEnabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) } /// Called at launch: starts only if the user turned the iPhone sync on. func startIfEnabled() { if Self.isEnabled { start() } } func setEnabled(_ on: Bool) { UserDefaults.standard.set(on, forKey: Self.enabledKey) if on { start() } else { stop() } } private func stop() { guard started else { return } started = false pingTask?.cancel(); pingTask = nil pollTask?.cancel(); pollTask = nil NSApplication.shared.unregisterForRemoteNotifications() SessionPublisher.shared.stop() ApprovalRelay.shared.stop() QuestionRelay.shared.stop() ServiceDetailRunner.shared.stop() ServicePublisher.shared.stop() TurnRecorder.shared.stop() #if !APPSTORE InstructionRunner.shared.stop() #endif LiveActivityRelay.shared.stop() log("iPhone sync off") } private func start() { guard !started else { return } started = true #if DEBUG let build = "debug" #else let build = "release" #endif log("starting (\(appLabel), \(build) build, container \(Self.containerID))") NSApplication.shared.registerForRemoteNotifications() SessionPublisher.shared.start() ApprovalRelay.shared.start() QuestionRelay.shared.start() ServiceDetailRunner.shared.start() ServicePublisher.shared.start() TurnRecorder.shared.start() #if !APPSTORE InstructionRunner.shared.startIfEnabled() #endif LiveActivityRelay.shared.startIfEnabled() // The silent database subscription, so the iPhone's requests (services) wake this Mac. Task { _ = await prepare() } // Step 1 Ping/Pong test: off unless asked for, so the Mac stays idle at rest // (defaults write fr.louisraille.NotchBuddy phoneLinkPing -bool YES). guard UserDefaults.standard.bool(forKey: "phoneLinkPing") else { log("ping test off (phoneLinkPing)") return } pingTask = Task { [weak self] in while !Task.isCancelled { await self?.pingTick() try? await Task.sleep(for: .seconds(60)) } } pollTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(5)) await self?.fetchChanges(source: "poll") } } } // MARK: Setup /// Checks the iCloud account, creates the zone and the subscription. /// Returns false (and logs why) when iCloud isn't usable yet; retried on the next tick. private func prepare() async -> Bool { if ready { return true } do { let status = try await container.accountStatus() guard status == .available else { log("iCloud account not available (status \(describe(status))), will retry in 60 s") return false } _ = try await database.modifyRecordZones(saving: [CKRecordZone(zoneID: Self.zoneID)], deleting: []) log("zone Coucou ready") let sub = CKDatabaseSubscription(subscriptionID: Self.subscriptionID) let info = CKSubscription.NotificationInfo() info.shouldSendContentAvailable = true // silent push sub.notificationInfo = info _ = try await database.modifySubscriptions(saving: [sub], deleting: []) log("database subscription saved") let subs = try await database.allSubscriptions() log("subscriptions on this iCloud account: \(subs.map(\.subscriptionID).sorted().joined(separator: ", "))") ready = true return true } catch { log("setup failed: \(error.localizedDescription)") return false } } // MARK: Ping private func pingTick() async { guard await prepare() else { return } pingCount += 1 let record = CKRecord(recordType: "Ping", recordID: CKRecord.ID(recordName: UUID().uuidString, zoneID: Self.zoneID)) let sentAt = Date() record["macName"] = macName record["app"] = appLabel record["sentAt"] = sentAt record.encryptedValues["message"] = "Ping #\(pingCount) from \(appLabel)" do { _ = try await database.save(record) log("Ping #\(pingCount) saved in \(String(format: "%.1f", Date().timeIntervalSince(sentAt))) s") } catch { log("Ping #\(pingCount) failed: \(error.localizedDescription)") } } // MARK: Pong func handleRemoteNotification(_ userInfo: [String: Any]) { guard CKNotification(fromRemoteNotificationDictionary: userInfo) != nil else { log("remote notification received, not from CloudKit (keys: \(userInfo.keys.sorted().joined(separator: ", ")))") return } lastPushAt = Date() // A request from the iPhone (a service to read, an action) may be waiting. Task { await ServiceDetailRunner.shared.checkNow() } guard pingTask != nil else { return } // the Pong fetch is only for the ping test log("push received") Task { await fetchChanges(source: "push") } } private func fetchChanges(source: String) async { guard ready, !fetching else { return } fetching = true defer { fetching = false } do { var more = true while more { let changes = try await database.recordZoneChanges(inZoneWith: Self.zoneID, since: changeToken) for (_, result) in changes.modificationResultsByID { if case .success(let mod) = result { handle(mod.record, source: source) } } changeToken = changes.changeToken more = changes.moreComing } } catch let error as CKError where error.code == .changeTokenExpired { changeToken = nil } catch let error as CKError where error.code == .zoneNotFound { log("zone Coucou missing, recreating") ready = false } catch { log("fetch (\(source)) failed: \(error.localizedDescription)") } } private func handle(_ record: CKRecord, source: String) { guard record.recordType == "Pong" else { return } let id = record.recordID.recordName guard !seenPongs.contains(id) else { return } seenPongs.insert(id) // Older pongs from previous runs: remember them, don't log them. guard let created = record.creationDate, created >= launchDate else { return } let message = record.encryptedValues["message"] as? String ?? "?" var line = "Pong from iPhone: \(message)" if let pingSentAt = record["pingSentAt"] as? Date { line += String(format: ", round trip %.1f s", Date().timeIntervalSince(pingSentAt)) } if let repliedAt = record["repliedAt"] as? Date { line += String(format: ", %.1f s after the tap", Date().timeIntervalSince(repliedAt)) } line += " (via \(source)" if source == "poll", let push = lastPushAt, Date().timeIntervalSince(push) < 10 { line += ", push came \(String(format: "%.1f", Date().timeIntervalSince(push))) s ago" } line += ")" log(line) } // MARK: Helpers private func describe(_ status: CKAccountStatus) -> String { switch status { case .available: "available" case .noAccount: "no account" case .restricted: "restricted" case .couldNotDetermine: "could not determine" case .temporarilyUnavailable: "temporarily unavailable" @unknown default: "unknown" } } /// Writes to nb.log and to the Xcode console (the sandboxed build's nb.log /// sits in its container, which Terminal can't read). func log(_ message: String) { appendAppLog("nb.log", "[PhoneLink] \(message)") print("[PhoneLink] \(message)") } } extension AppDelegate { @objc func application(_ application: NSApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let token = deviceToken.prefix(4).map { String(format: "%02x", $0) }.joined() CloudProbe.shared.log("registered for remote notifications (token \(token)…)") } @objc func application(_ application: NSApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { CloudProbe.shared.log("remote notification registration failed: \(error.localizedDescription)") } @objc func application(_ application: NSApplication, didReceiveRemoteNotification userInfo: [String: Any]) { CloudProbe.shared.handleRemoteNotification(userInfo) } } #endif