Skip to content

Commit 217e4c9

Browse files
Customizable logout behavior (pointfreeco#261)
* Introduce SyncEngineDelegate to customize logout behavior. * Basic docs * Docs * wip * main actor fixes * docs * send signIn event to delegate too * fixed default implementation --------- Co-authored-by: Stephen Celis <stephen@stephencelis.com>
1 parent 833b762 commit 217e4c9

14 files changed

Lines changed: 496 additions & 58 deletions

File tree

‎Examples/Reminders/RemindersApp.swift‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,15 @@ import UIKit
1010
struct RemindersApp: App {
1111
@UIApplicationDelegateAdaptor var delegate: AppDelegate
1212
@Dependency(\.context) var context
13+
@Dependency(\.defaultSyncEngine) var syncEngine
1314
static let model = RemindersListsModel()
1415

16+
@State var syncEngineDelegate = RemindersSyncEngineDelegate()
17+
1518
init() {
1619
if context == .live {
1720
try! prepareDependencies {
18-
try $0.bootstrapDatabase()
21+
try $0.bootstrapDatabase(syncEngineDelegate: syncEngineDelegate)
1922
}
2023
}
2124
}
@@ -26,11 +29,47 @@ struct RemindersApp: App {
2629
NavigationStack {
2730
RemindersListsView(model: Self.model)
2831
}
32+
.alert(
33+
"Reset local data?",
34+
isPresented: $syncEngineDelegate.isDeleteLocalDataAlertPresented
35+
) {
36+
Button("Reset", role: .destructive) {
37+
Task {
38+
try await syncEngine.deleteLocalData()
39+
}
40+
}
41+
} message: {
42+
Text(
43+
"""
44+
You are no longer logged into iCloud. Would you like to reset your local data to the \
45+
defaults? This will not affect your data in iCloud.
46+
"""
47+
)
48+
}
2949
}
3050
}
3151
}
3252
}
3353

54+
@MainActor
55+
@Observable
56+
class RemindersSyncEngineDelegate: SyncEngineDelegate {
57+
var isDeleteLocalDataAlertPresented = false
58+
func syncEngine(
59+
_ syncEngine: SQLiteData.SyncEngine,
60+
accountChanged changeType: CKSyncEngine.Event.AccountChange.ChangeType
61+
) async {
62+
switch changeType {
63+
case .signIn:
64+
break
65+
case .signOut, .switchAccounts:
66+
isDeleteLocalDataAlertPresented = true
67+
@unknown default:
68+
break
69+
}
70+
}
71+
}
72+
3473
class AppDelegate: UIResponder, UIApplicationDelegate, ObservableObject {
3574
func application(
3675
_ application: UIApplication,

‎Examples/Reminders/Schema.swift‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,16 @@ struct ReminderText: FTS5 {
116116
}
117117

118118
extension DependencyValues {
119-
mutating func bootstrapDatabase() throws {
119+
mutating func bootstrapDatabase(syncEngineDelegate: (any SyncEngineDelegate)? = nil) throws {
120120
defaultDatabase = try Reminders.appDatabase()
121121
defaultSyncEngine = try SyncEngine(
122122
for: defaultDatabase,
123123
tables: RemindersList.self,
124124
RemindersListAsset.self,
125125
Reminder.self,
126126
Tag.self,
127-
ReminderTag.self
127+
ReminderTag.self,
128+
delegate: syncEngineDelegate
128129
)
129130
}
130131
}
@@ -135,6 +136,7 @@ func appDatabase() throws -> any DatabaseWriter {
135136
configuration.foreignKeysEnabled = true
136137
configuration.prepareDatabase { db in
137138
try db.attachMetadatabase()
139+
db.add(function: $createDefaultRemindersList)
138140
db.add(function: $handleReminderStatusUpdate)
139141
#if DEBUG
140142
db.trace(options: .profile) {
@@ -251,12 +253,7 @@ func appDatabase() throws -> any DatabaseWriter {
251253

252254
try RemindersList.createTemporaryTrigger(
253255
after: .delete { _ in
254-
RemindersList.insert {
255-
RemindersList.Draft(
256-
color: RemindersList.defaultColor,
257-
title: RemindersList.defaultTitle
258-
)
259-
}
256+
Values($createDefaultRemindersList())
260257
} when: { _ in
261258
!RemindersList.exists()
262259
}
@@ -367,6 +364,22 @@ nonisolated func handleReminderStatusUpdate() {
367364
}
368365
}
369366

367+
@DatabaseFunction
368+
nonisolated func createDefaultRemindersList() {
369+
Task {
370+
@Dependency(\.defaultDatabase) var database
371+
try await database.write { db in
372+
try RemindersList.insert {
373+
RemindersList.Draft(
374+
color: RemindersList.defaultColor,
375+
title: RemindersList.defaultTitle
376+
)
377+
}
378+
.execute(db)
379+
}
380+
}
381+
}
382+
370383
nonisolated private let logger = Logger(subsystem: "Reminders", category: "Database")
371384

372385
#if DEBUG

‎Examples/Reminders/SearchReminders.swift‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ class SearchRemindersModel {
164164
}
165165
}
166166

167-
struct Token: Hashable, Identifiable {
167+
nonisolated struct Token: Hashable, Identifiable {
168168
enum Kind {
169169
case near
170170
case tag
@@ -264,7 +264,7 @@ struct SearchRemindersView: View {
264264
}
265265
}
266266

267-
fileprivate func baseQuery(
267+
nonisolated fileprivate func baseQuery(
268268
searchText: String,
269269
searchTokens: [SearchRemindersModel.Token]
270270
) -> SelectOf<ReminderText, Reminder> {
@@ -291,7 +291,7 @@ fileprivate func baseQuery(
291291
}
292292

293293
extension String {
294-
fileprivate func quoted() -> String {
294+
nonisolated fileprivate func quoted() -> String {
295295
split(separator: " ")
296296
.map { #""\#($0.replacingOccurrences(of: #"""#, with: #""""#))""# }
297297
.joined(separator: " ")

‎Sources/SQLiteData/CloudKit/CloudKitSharing.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
/// * The table the `record` belongs to is not synchronized to CloudKit.
5757
/// * The `record` has any foreign keys. Only root records are shareable in CloudKit.
5858
/// * The table the `record` belongs to is a "private" table as determined by the
59-
/// [`SyncEngine` initializer](<doc:SyncEngine/init(for:tables:privateTables:containerIdentifier:defaultZone:startImmediately:logger:)>).
59+
/// [`SyncEngine` initializer](<doc:SyncEngine/init(for:tables:privateTables:containerIdentifier:defaultZone:startImmediately:delegate:logger:)>).
6060
/// * The `record` is being shared before it has been synchronized to CloudKit.
6161
/// * Any of the CloudKit APIs invoked throw an error.
6262
///

‎Sources/SQLiteData/CloudKit/Internal/Logging.swift‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@
4747
dataFrame.sort(on: ColumnID("action", String.self))
4848
}
4949
var formattingOptions = FormattingOptions(
50-
maximumLineWidth: 120,
50+
maximumLineWidth: 300,
5151
maximumCellWidth: 80,
52-
maximumRowCount: 50,
52+
maximumRowCount: 1000,
5353
includesColumnTypes: false
5454
)
5555
formattingOptions.includesRowAndColumnCounts = false

‎Sources/SQLiteData/CloudKit/Internal/SyncEngineProtocol.swift‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,6 @@
11
#if canImport(CloudKit)
22
import CloudKit
33

4-
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
5-
package protocol SyncEngineDelegate: AnyObject, Sendable {
6-
func handleEvent(_ event: SyncEngine.Event, syncEngine: any SyncEngineProtocol) async
7-
func nextRecordZoneChangeBatch(
8-
reason: CKSyncEngine.SyncReason,
9-
options: CKSyncEngine.SendChangesOptions,
10-
syncEngine: any SyncEngineProtocol
11-
) async -> CKSyncEngine.RecordZoneChangeBatch?
12-
}
13-
144
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
155
package protocol SyncEngineProtocol<Database, State>: AnyObject, Sendable {
166
associatedtype State: CKSyncEngineStateProtocol

‎Sources/SQLiteData/CloudKit/SyncEngine.swift‎

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
let foreignKeysByTableName: [String: [ForeignKey]]
2929
package let syncEngines = LockIsolated<SyncEngines>(SyncEngines())
3030
package let defaultZone: CKRecordZone
31+
let delegate: (any SyncEngineDelegate)?
3132
let defaultSyncEngines:
3233
@Sendable (any DatabaseReader, SyncEngine)
3334
-> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol)
@@ -37,8 +38,8 @@
3738
private let notificationsObserver = LockIsolated<(any NSObjectProtocol)?>(nil)
3839
private let activityCounts = LockIsolated(ActivityCounts())
3940

40-
/// The error message used when a write occurs to a record for which the current user
41-
/// does not have permission.
41+
/// The error message used when a write occurs to a record for which the current user does not
42+
/// have permission.
4243
///
4344
/// This error is thrown from any database write to a row for which the current user does
4445
/// not have permissions to write, as determined by its `CKShare` (if applicable). To catch
@@ -65,16 +66,18 @@
6566
/// - Parameters:
6667
/// - database: The database to synchronize to CloudKit.
6768
/// - tables: A list of tables that you want to synchronize _and_ that you want to be
68-
/// shareable with other users on CloudKit.
69+
/// shareable with other users on CloudKit.
6970
/// - privateTables: A list of tables that you want to synchronize to CloudKit but that
70-
/// you do not want to be shareable with other users.
71+
/// you do not want to be shareable with other users.
7172
/// - containerIdentifier: The container identifier in CloudKit to synchronize to. If omitted
72-
/// the container will be determined from the entitlements of your app.
73+
/// the container will be determined from the entitlements of your app.
7374
/// - defaultZone: The zone for all records to be stored in.
7475
/// - startImmediately: Determines if the sync engine starts right away or requires an
75-
/// explicit call to ``start()``. By default this argument is `true`.
76+
/// explicit call to ``start()``. By default this argument is `true`.
77+
/// - delegate: A delegate object that can be notified of events and override default sync
78+
/// engine behavior.
7679
/// - logger: The logger used to log events in the sync engine. By default a `.disabled`
77-
/// logger is used, which means logs are not printed.
80+
/// logger is used, which means logs are not printed.
7881
public convenience init<
7982
each T1: PrimaryKeyedTable & _SendableMetatype,
8083
each T2: PrimaryKeyedTable & _SendableMetatype
@@ -85,6 +88,7 @@
8588
containerIdentifier: String? = nil,
8689
defaultZone: CKRecordZone = CKRecordZone(zoneName: "co.pointfree.SQLiteData.defaultZone"),
8790
startImmediately: Bool = DependencyValues._current.context == .live,
91+
delegate: (any SyncEngineDelegate)? = nil,
8892
logger: Logger = isTesting
8993
? Logger(.disabled) : Logger(subsystem: "SQLiteData", category: "CloudKit")
9094
) throws
@@ -136,6 +140,7 @@
136140
},
137141
userDatabase: userDatabase,
138142
logger: logger,
143+
delegate: delegate,
139144
tables: allTables,
140145
privateTables: allPrivateTables
141146
)
@@ -184,6 +189,7 @@
184189
},
185190
userDatabase: userDatabase,
186191
logger: logger,
192+
delegate: delegate,
187193
tables: allTables,
188194
privateTables: allPrivateTables
189195
)
@@ -203,13 +209,15 @@
203209
) -> (private: any SyncEngineProtocol, shared: any SyncEngineProtocol),
204210
userDatabase: UserDatabase,
205211
logger: Logger,
212+
delegate: (any SyncEngineDelegate)?,
206213
tables: [any SynchronizableTable],
207214
privateTables: [any SynchronizableTable] = []
208215
) throws {
209216
let allTables = Set((tables + privateTables).map(HashableSynchronizedTable.init))
210217
.map(\.type)
211218
self.tables = allTables
212219
self.privateTables = privateTables
220+
self.delegate = delegate
213221

214222
let foreignKeysByTableName = Dictionary(
215223
uniqueKeysWithValues: try userDatabase.read { db in
@@ -620,7 +628,16 @@
620628
try migrate(metadatabase: metadatabase)
621629
}
622630

623-
func deleteLocalData() async throws {
631+
/// Deletes synchronized data locally on device and restarts the sync engine.
632+
///
633+
/// This method is called automatically by the sync engine when it detects the device's iCloud
634+
/// account has logged out or changed. To customize this behavior, provide a
635+
/// ``SyncEngineDelegate`` to the sync engine and implement
636+
/// ``SyncEngineDelegate/syncEngine(_:accountChanged:)``.
637+
///
638+
/// > Important: It is only appropriate to call this method when the device's iCloud account
639+
/// > logs out or changes.
640+
public func deleteLocalData() async throws {
624641
stop()
625642
try tearDownSyncEngine()
626643
await withErrorReporting(.sqliteDataCloudKitFailure) {
@@ -823,7 +840,7 @@
823840
}
824841

825842
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
826-
extension SyncEngine: CKSyncEngineDelegate, SyncEngineDelegate {
843+
extension SyncEngine: CKSyncEngineDelegate {
827844
public func handleEvent(_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine) async {
828845
guard let event = Event(event)
829846
else {
@@ -1187,10 +1204,17 @@
11871204
await withErrorReporting {
11881205
try await enqueueUnknownRecordsForCloudKit()
11891206
}
1207+
await delegate?.syncEngine(self, accountChanged: changeType)
11901208
case .signOut, .switchAccounts:
1191-
await withErrorReporting(.sqliteDataCloudKitFailure) {
1192-
try await deleteLocalData()
1209+
guard let delegate
1210+
else {
1211+
await withErrorReporting(.sqliteDataCloudKitFailure) {
1212+
try await deleteLocalData()
1213+
}
1214+
return
11931215
}
1216+
await delegate.syncEngine(self, accountChanged: changeType)
1217+
11941218
@unknown default:
11951219
break
11961220
}

0 commit comments

Comments
 (0)