Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions Sources/CodexBar/MobileSync/MobileSyncPublisher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import CloudKit
import CodexBarCore
import Foundation
import Network
import OSLog

/// Publishes the aggregated `WidgetSnapshot` to the CodexBar iPhone companion app.
///
/// Two serverless transports, both 100% user-managed:
/// - **LAN**: advertises `_codexbar-sync._tcp` over Bonjour and streams the latest snapshot to any
/// iPhone on the same network — instant, offline, zero configuration.
/// - **iCloud**: writes the snapshot to the user's *private* CloudKit database so widgets, the Lock
/// Screen, and Live Activities stay fresh when the phone is away from the Mac's network.
///
/// Opt-in: does nothing unless `UserDefaults.standard.bool(forKey: "mobileSyncEnabled")` is true, so
/// existing users are unaffected. LAN advertising requires the `com.apple.security.network.server`
/// entitlement; CloudKit requires the iCloud/CloudKit entitlement and container (see `ios/README.md`).
final class MobileSyncPublisher: @unchecked Sendable {
static let shared = MobileSyncPublisher()

// UserDefaults keys (read directly to avoid threading a new flag through the whole SettingsStore).
static let enabledKey = "mobileSyncEnabled"
static let lanEnabledKey = "mobileSyncLANEnabled"
static let cloudKitEnabledKey = "mobileSyncCloudKitEnabled"

private let log = Logger(subsystem: "com.steipete.codexbar", category: "MobileSync")
private let queue = DispatchQueue(label: "com.steipete.codexbar.mobilesync")
private let defaults: UserDefaults
private var listener: NWListener?
private var connections: [ObjectIdentifier: NWConnection] = [:]
private var latestFramed: Data?
private var cachedContainer: CKContainer?

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

private var isEnabled: Bool { self.defaults.bool(forKey: Self.enabledKey) }
private var lanEnabled: Bool { self.defaults.object(forKey: Self.lanEnabledKey) as? Bool ?? true }
private var cloudKitEnabled: Bool { self.defaults.object(forKey: Self.cloudKitEnabledKey) as? Bool ?? true }

private var senderDeviceName: String {
Host.current().localizedName ?? "Mac"
}

/// Entry point called from the widget-snapshot persist path.
func handleSnapshot(_ snapshot: WidgetSnapshot) {
guard self.isEnabled else {
self.queue.async { self.shutdownLAN() }
return
}
let envelope = MobileSyncEnvelope(snapshot: snapshot, senderDeviceName: self.senderDeviceName)
guard let payload = try? envelope.encoded() else { return }
let framed = MobileSyncWire.framed(payload)
self.queue.async {
self.latestFramed = framed
if self.lanEnabled {
self.startLANIfNeeded()
self.broadcast(framed)
} else {
self.shutdownLAN()
}
}
if self.cloudKitEnabled { self.publishToCloudKit(payload: payload, snapshot: snapshot) }
}

// MARK: - LAN

private func startLANIfNeeded() {
guard self.listener == nil else { return }
do {
let listener = try NWListener(using: .tcp)
listener.service = NWListener.Service(type: MobileSyncWire.bonjourServiceType)
listener.newConnectionHandler = { [weak self] connection in
self?.queue.async { self?.accept(connection) }
}
listener.stateUpdateHandler = { [weak self] state in
if case let .failed(error) = state {
self?.log.error("LAN listener failed: \(error.localizedDescription, privacy: .public)")
self?.queue.async { self?.shutdownLAN() }
}
}
self.listener = listener
listener.start(queue: self.queue)
self.log.info("LAN listener advertising \(MobileSyncWire.bonjourServiceType, privacy: .public)")
} catch {
self.log.error("failed to start LAN listener: \(error.localizedDescription, privacy: .public)")
}
}

private func accept(_ connection: NWConnection) {
let key = ObjectIdentifier(connection)
self.connections[key] = connection
connection.stateUpdateHandler = { [weak self] state in
self?.queue.async {
switch state {
case .ready:
if let framed = self?.latestFramed {
connection.send(content: framed, completion: .contentProcessed { _ in })
}
case .failed, .cancelled:
self?.connections[key] = nil
default:
break
}
}
}
connection.start(queue: self.queue)
}

private func broadcast(_ framed: Data) {
for connection in self.connections.values where connection.state == .ready {
connection.send(content: framed, completion: .contentProcessed { _ in })
}
}

private func shutdownLAN() {
self.listener?.cancel()
self.listener = nil
for connection in self.connections.values { connection.cancel() }
self.connections.removeAll()
}

// MARK: - CloudKit

/// Gated on an iCloud identity because `CKContainer(identifier:)` traps without the entitlement.
private var cloudKitAvailable: Bool { FileManager.default.ubiquityIdentityToken != nil }

private func container() -> CKContainer? {
guard self.cloudKitAvailable else { return nil }
if let cachedContainer { return cachedContainer }
let created = CKContainer(identifier: MobileSyncWire.cloudKitContainerIdentifier)
self.cachedContainer = created
return created
}

private func publishToCloudKit(payload: Data, snapshot: WidgetSnapshot) {
guard let database = self.container()?.privateCloudDatabase else { return }
let recordID = CKRecord.ID(recordName: MobileSyncWire.cloudKitRecordName)
let deviceName = self.senderDeviceName
Task {
do {
let record = (try? await database.record(for: recordID))
?? CKRecord(recordType: MobileSyncWire.cloudKitRecordType, recordID: recordID)
record[MobileSyncWire.CloudKitField.payload] = payload as CKRecordValue
record[MobileSyncWire.CloudKitField.senderDeviceName] = deviceName as CKRecordValue
record[MobileSyncWire.CloudKitField.generatedAt] = snapshot.generatedAt as CKRecordValue
record[MobileSyncWire.CloudKitField.schemaVersion] = MobileSyncWire.schemaVersion as CKRecordValue
_ = try await database.modifyRecords(saving: [record], deleting: [], savePolicy: .allKeys)
self.log.info("published snapshot to CloudKit")
} catch {
self.log.error("CloudKit publish failed: \(error.localizedDescription, privacy: .public)")
}
}
}
}
56 changes: 56 additions & 0 deletions Sources/CodexBar/MobileSync/MobileSyncWire.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import CodexBarCore
import Foundation

/// Wire contract for the iPhone companion app. Mirrors `ios/Shared/Transport/SyncEnvelope.swift`
/// on the iOS side — both encode/decode the same JSON, so keep the two in sync.
enum MobileSyncWire {
static let schemaVersion = 1

// LAN (Bonjour + Network.framework)
static let bonjourServiceType = "_codexbar-sync._tcp"
static let lengthPrefixByteCount = 4
static let maxMessageByteCount = 2 * 1024 * 1024

// CloudKit (user's private database — no server)
static let cloudKitContainerIdentifier = "iCloud.com.steipete.codexbar"
static let cloudKitRecordType = "WidgetSnapshot"
static let cloudKitRecordName = "latest-snapshot"

enum CloudKitField {
static let payload = "payload"
static let senderDeviceName = "senderDeviceName"
static let generatedAt = "generatedAt"
static let schemaVersion = "schemaVersion"
}

/// Prefixes a payload with a 4-byte big-endian length for stream framing.
static func framed(_ payload: Data) -> Data {
var length = UInt32(payload.count).bigEndian
var out = Data(bytes: &length, count: Self.lengthPrefixByteCount)
out.append(payload)
return out
}

static var encoder: JSONEncoder {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return encoder
}
}

/// The envelope sent to iPhones. Wraps the same `WidgetSnapshot` the macOS widget already uses.
struct MobileSyncEnvelope: Encodable {
let schemaVersion: Int
let senderDeviceName: String
let snapshot: WidgetSnapshot

init(snapshot: WidgetSnapshot, senderDeviceName: String) {
self.schemaVersion = MobileSyncWire.schemaVersion
self.senderDeviceName = senderDeviceName
self.snapshot = snapshot
}

func encoded() throws -> Data {
try MobileSyncWire.encoder.encode(self)
}
}
2 changes: 2 additions & 0 deletions Sources/CodexBar/UsageStore+WidgetSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ extension UsageStore {
#if canImport(WidgetKit)
WidgetCenter.shared.reloadAllTimelines()
#endif
// Mirror the snapshot to the iPhone companion app (opt-in, no-op unless enabled).
MobileSyncPublisher.shared.handleSnapshot(snapshot)
}
}

Expand Down
81 changes: 81 additions & 0 deletions Tests/CodexBarTests/MobileSyncEnvelopeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation
import Testing
@testable import CodexBar
@testable import CodexBarCore

/// Guards the JSON contract the CodexBar iPhone app decodes (`ios/Shared/Transport/SyncEnvelope.swift`
/// + `ios/Shared/Model/WidgetSnapshot.swift`). If the macOS encoder drifts, the phone silently stops
/// syncing — these tests fail first.
struct MobileSyncEnvelopeTests {
private func sampleSnapshot() -> WidgetSnapshot {
let entry = WidgetSnapshot.ProviderEntry(
provider: .codex,
updatedAt: Date(timeIntervalSince1970: 1_700_000_000),
primary: RateWindow(usedPercent: 32, windowMinutes: 300, resetsAt: nil, resetDescription: nil),
secondary: RateWindow(usedPercent: 66, windowMinutes: 10_080, resetsAt: nil, resetDescription: nil),
tertiary: nil,
usageRows: [
.init(id: "primary", title: "Session", percentLeft: 68),
.init(id: "secondary", title: "Weekly", percentLeft: 34),
],
creditsRemaining: 42,
codeReviewRemainingPercent: nil,
tokenUsage: nil,
dailyUsage: [])
return WidgetSnapshot(
entries: [entry],
enabledProviders: [.codex],
usageBarsShowUsed: false,
generatedAt: Date(timeIntervalSince1970: 1_700_000_500))
}

@Test
func `envelope encodes the fields the iPhone decoder requires`() throws {
let envelope = MobileSyncEnvelope(snapshot: self.sampleSnapshot(), senderDeviceName: "Test Mac")
let json = try JSONSerialization.jsonObject(with: envelope.encoded()) as? [String: Any]
let root = try #require(json)

#expect(root["schemaVersion"] as? Int == 1)
#expect(root["senderDeviceName"] as? String == "Test Mac")

let snapshot = try #require(root["snapshot"] as? [String: Any])
#expect(snapshot["usageBarsShowUsed"] as? Bool == false)
#expect(snapshot["enabledProviders"] as? [String] == ["codex"])
// ISO8601 date encoding is part of the contract.
#expect((snapshot["generatedAt"] as? String)?.contains("T") == true)

let entries = try #require(snapshot["entries"] as? [[String: Any]])
let first = try #require(entries.first)
#expect(first["provider"] as? String == "codex")
#expect(first["creditsRemaining"] as? Double == 42)

let rows = try #require(first["usageRows"] as? [[String: Any]])
#expect(rows.first?["id"] as? String == "primary")
#expect(rows.first?["title"] as? String == "Session")
#expect(rows.first?["percentLeft"] as? Double == 68)

let primary = try #require(first["primary"] as? [String: Any])
#expect(primary["usedPercent"] as? Double == 32)
}

@Test
func `envelope JSON round-trips back into a WidgetSnapshot`() throws {
let envelope = MobileSyncEnvelope(snapshot: self.sampleSnapshot(), senderDeviceName: "Test Mac")
let data = try envelope.encoded()

// Decode just the snapshot payload the way the iPhone would.
struct DecodeEnvelope: Decodable {
let schemaVersion: Int
let senderDeviceName: String
let snapshot: WidgetSnapshot
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let decoded = try decoder.decode(DecodeEnvelope.self, from: data)

#expect(decoded.schemaVersion == 1)
#expect(decoded.snapshot.entries.count == 1)
#expect(decoded.snapshot.entries.first?.provider == .codex)
#expect(decoded.snapshot.entries.first?.usageRows?.count == 2)
}
}
Loading