Check the CloudKit schema when packaging and show broken iCloud sync
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user