Check the CloudKit schema when packaging and show broken iCloud sync

This commit is contained in:
Ian Gulliver
2026-10-20 08:55:37 -07:00
parent e7724977c8
commit 8f3226350e
14 changed files with 502 additions and 288 deletions
+10
View File
@@ -5,6 +5,7 @@ import OpenAPIRuntime
typealias ClientRequest = Components.Schemas.ClientRequest
typealias ClientReply = Components.Schemas.ClientReply
typealias ClientSync = Components.Schemas.ClientSync
private let retryAfter = Duration.seconds(2)
private let checkEvery = Duration.seconds(10)
@@ -48,6 +49,15 @@ final class Remote {
heard = Date()
task.resume()
receive(task)
if let sync = Servers.shared.sync {
Task {
do {
_ = try await api.client.setClientSync(body: .json(sync)).noContent
} catch {
log.error("report icloud sync: \(errorText(error), privacy: .public)")
}
}
}
if checker == nil {
checker = Task { [weak self] in
while !Task.isCancelled {
+22
View File
@@ -49,6 +49,8 @@ struct SettingsView: View {
try await store.api.client.updateReviewModels(body: .json(.init(reviewModel: me.reviewModel, secondReviewModel: m))).ok.body.json
}
}
heading("iCloud sync")
SyncState()
heading("This Mac's permissions")
Text("The macOS privacy grants byre's app needs for agents to see and control this Mac. Checking asks macOS for any that are missing.")
.foregroundStyle(Palette.muted)
@@ -97,6 +99,26 @@ private struct ServerColorPicker: View {
}
}
private struct SyncState: View {
var body: some View {
TimelineView(.periodic(from: .now, by: 30)) { _ in
if let sync = Servers.shared.sync {
if sync.ok {
Text("This Mac shares its servers, window colours, drafts and task visits through iCloud. Last synced \(ago(sync.at)).")
.fixedSize(horizontal: false, vertical: true)
} else {
Text("iCloud sync is failing on this Mac, so changes made here aren't reaching your other Macs. Failed \(ago(sync.at)): \(sync.error ?? "")")
.foregroundStyle(Palette.danger)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
} else {
Text("No iCloud sync has finished since byre started.").foregroundStyle(Palette.muted)
}
}
}
}
private struct DeviceNotifications: View {
@ViewState private var status: UNAuthorizationStatus?
@ViewState private var asking = false
+39 -1
View File
@@ -1,3 +1,5 @@
import ByreAPI
import CloudKit
import CoreData
import Foundation
import Observation
@@ -11,6 +13,7 @@ final class Servers {
private(set) var entries: [(origin: String, record: ServerRecord)] = []
private(set) var connections: [String: Connection] = [:]
private var syncs: [NSPersistentCloudKitContainer.EventType: ClientSync] = [:]
var current: String?
@ObservationIgnored var openWindow: (() -> Void)?
@@ -19,7 +22,11 @@ final class Servers {
current = origins.first
Task {
for await note in NotificationCenter.default.notifications(named: NSPersistentCloudKitContainer.eventChangedNotification) {
if let event = note.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event, event.type == .import, event.endDate != nil {
guard let event = note.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey] as? NSPersistentCloudKitContainer.Event, let end = event.endDate else {
continue
}
synced(event, end)
if event.type == .import {
for connection in connections.values {
connection.store?.drafts?.reload()
}
@@ -29,6 +36,26 @@ final class Servers {
}
}
private func synced(_ event: NSPersistentCloudKitContainer.Event, _ end: Date) {
let error = event.succeeded ? nil : syncError(event.error)
if let error {
log.error("icloud \(String(describing: event.type), privacy: .public) failed: \(error, privacy: .public)")
}
syncs[event.type] = ClientSync(ok: event.succeeded, error: error, at: end)
guard let sync else {
return
}
for connection in connections.values {
connection.store?.reportSync(sync)
}
}
// A failed export stays the state until an export succeeds, whatever imports do meanwhile.
var sync: ClientSync? {
let failed = syncs.values.filter { !$0.ok }
return (failed.isEmpty ? Array(syncs.values) : failed).max { $0.at < $1.at }
}
var origins: [String] {
entries.map(\.origin)
}
@@ -103,6 +130,17 @@ final class Servers {
}
}
private func syncError(_ error: (any Error)?) -> String {
guard let error else {
return "failed with no error"
}
let ns = error as NSError
if let partial = ns.userInfo[CKPartialErrorsByItemIDKey] as? [AnyHashable: any Error], let first = partial.values.first {
return "\(first.localizedDescription) (\(partial.count) records)"
}
return "\(ns.localizedDescription) (\(ns.domain) \(ns.code))"
}
func serverName(_ origin: String) -> String {
let host = URL(string: origin)?.host() ?? origin
return host.split(separator: ".").first.map(String.init) ?? host
+20 -1
View File
@@ -382,7 +382,7 @@ private struct ClientTable: View {
} else {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 2) {
GridRow {
ForEach(["Mac", "User", "Build", "Connected", "Last action"], id: \.self) { h in
ForEach(["Mac", "User", "Build", "Connected", "Last action", "iCloud"], id: \.self) { h in
Text(h).font(Fonts.font(11)).foregroundStyle(Palette.muted)
}
}
@@ -394,6 +394,7 @@ private struct ClientTable: View {
Text(c.commit.isEmpty ? "—" : String(c.commit.prefix(8))).help(c.commit)
Text(since(c.connectedAt))
Text(c.lastActionAt.map(since) ?? "—")
SyncCell(sync: c.sync)
}
.textSelection(.enabled)
Divider().overlay(Palette.border)
@@ -403,6 +404,24 @@ private struct ClientTable: View {
}
}
private struct SyncCell: View {
let sync: ClientSync?
var body: some View {
if let sync {
if sync.ok {
Text("ok").help("Last synced \(since(sync.at))")
} else {
Text("failing")
.foregroundStyle(Palette.danger)
.help("Failed \(since(sync.at)): \(sync.error ?? "")")
}
} else {
Text("—")
}
}
}
private struct ServerRows: View {
let status: ServerStatus
+6
View File
@@ -531,6 +531,12 @@ final class Store {
}
}
func reportSync(_ sync: ClientSync) {
spawn("report icloud sync") {
_ = try await self.api.client.setClientSync(body: .json(sync)).noContent
}
}
func selectTask(_ taskId: String?) {
markVisited(selectedTaskId)
markVisited(taskId)