Import MJCloudKitUserDefaultsSync 9dca4718726025350c480f1fb56dc928fdd29a71 to provide synchronization of Flycut data via iCloud. This would be a great opportunity to use Git submodules, but is currently imported to match with the existing style of the code base (ShortcutRecorder, SGHotKeysLib, and UKPrefsPanel).

This commit is contained in:
Mark Jerde 2017-10-04 09:41:12 -05:00
parent 28bfca83f4
commit 8b9cdcce52
3 changed files with 1114 additions and 0 deletions

View file

@ -0,0 +1,62 @@
//
// MJCloudKitUserDefaultsSync.m
//
// Created by Mark Jerde (http://github.com/MarkJerde)
// Copyright (C) 2017 by Mark Jerde
//
// Based on MKiCloudSync by Mugunth Kumar (@mugunthkumar)
// Portions Copyright (C) 2011-2020 by Steinlogic
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// As a side note, you might also consider
// 1) tweeting about this mentioning @mark_a_jerde
// 2) A paypal donation to mark.a.jerde@gmail.com
// 3) tweeting about this mentioning @mugunthkumar for his original contributions
// 4) A paypal donation to mugunth.kumar@gmail.com
#ifdef DEBUG
# define DLog(fmt, ...) NSLog((@"%s [Line %d] " fmt), __PRETTY_FUNCTION__, __LINE__, ##__VA_ARGS__);
#else
# define DLog(...)
#endif
// ALog always displays output regardless of the DEBUG setting
#define ALog(fmt, ...) NSLog((@"%s [Line %d] " fmt), __PRETTY_FUNCTION__, __LINE__, ##__VA_ARGS__);
#import <Foundation/Foundation.h>
typedef NS_ENUM(NSUInteger, MJSyncNotificationType) {
MJSyncNotificationChanges = 0,
MJSyncNotificationConflicts,
MJSyncNotificationSaveSuccess
};
static inline MJSyncNotificationType MJSyncNotificationTypeFirst() { return MJSyncNotificationChanges; }
static inline MJSyncNotificationType MJSyncNotificationTypeLast() { return MJSyncNotificationSaveSuccess; }
@interface MJCloudKitUserDefaultsSync : NSObject
+(void) setRemoteNotificationsEnabled:(bool) enabled;
+(void) startWithPrefix:(NSString*) prefixToSync withContainerIdentifier:(NSString*) containerIdentifier;
+(void) startWithKeyMatchList:(NSArray*) keyMatchList withContainerIdentifier:(NSString*) containerIdentifier;
+(void) stopForKeyMatchList:(NSArray*) keyMatchList;
+(void) addNotificationFor:(MJSyncNotificationType)type withSelector:(SEL)aSelector withTarget:(nullable id)aTarget;
+(void) removeNotificationsFor:(MJSyncNotificationType)type forTarget:(nullable id) aTargetadd;
+(void) checkCloudKitUpdates;
@end

View file

@ -0,0 +1,943 @@
//
// MJCloudKitUserDefaultsSync.m
//
// Created by Mark Jerde (http://github.com/MarkJerde)
// Copyright (C) 2017 by Mark Jerde
//
// Based on MKiCloudSync by Mugunth Kumar (@mugunthkumar)
// Portions Copyright (C) 2011-2020 by Steinlogic
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// As a side note, you might also consider
// 1) tweeting about this mentioning @mark_a_jerde
// 2) A paypal donation to mark.a.jerde@gmail.com
// 3) tweeting about this mentioning @mugunthkumar for his original contributions
// 4) A paypal donation to mugunth.kumar@gmail.com
#import "MJCloudKitUserDefaultsSync.h"
#import <CloudKit/CloudKit.h>
// Things we retain and better release.
static NSString *prefix = nil;
static NSArray *matchList = nil;
static NSTimer *pollCloudKitTimer = nil;
static NSString *databaseContainerIdentifier = nil;
static CKRecordZone *recordZone = nil;
static CKRecordZoneID *recordZoneID = nil;
static CKRecordID *recordID = nil;
static NSMutableArray *changeNotificationHandlers[] = {nil,nil,nil};
static CKServerChangeToken *previousChangeToken = nil;
static NSString *lastUpdateRecordChangeTagReceived = nil;
// Things we don't retain.
static CKDatabase *publicDB;
static CKDatabase *privateDB;
// Status flags.
static BOOL observingIdentityChanges = NO;
static BOOL observingActivity = NO;
static BOOL remoteNotificationsEnabled = YES;
// Strings we use.
static NSString *recordZoneName = @"MJCloudKitUserDefaultsSync";
static NSString *subscriptionID = @"UserDefaultSubscription";
static NSString *recordType = @"UserDefault";
static NSString *recordName = @"UserDefaults";
// Flow controls.
static BOOL refuseUpdateToICloudUntilAfterUpdateFromICloud = NO;
static BOOL oneTimeDeleteZoneFromICloud = NO; // To clear the user's sync data from iCloud for testing first-time scenario.
static dispatch_queue_t syncQueue = nil;
static dispatch_queue_t pollQueue = nil;
@implementation MJCloudKitUserDefaultsSync
+(void) updateToiCloud:(NSNotification*) notificationObject {
dispatch_async(syncQueue, ^{
DLog(@"Update to iCloud?");
if ( refuseUpdateToICloudUntilAfterUpdateFromICloud )
{
DLog(@"NO. Waiting until after update from iCloud");
}
else
{
if ( nil == privateDB )
{
DLog(@"Database has been unset. Not updating to iCloud");
return;
}
DLog(@"YES. Updating to iCloud");
dispatch_suspend(syncQueue);
[privateDB fetchRecordWithID:recordID completionHandler:^(CKRecord *record, NSError *error) {
if (error
&& !( nil != [error.userInfo objectForKey:@"ServerErrorDescription" ]
&& [(NSString*)[error.userInfo objectForKey:@"ServerErrorDescription" ] isEqualToString:@"Record not found" ] ) ) {
// Error handling for failed fetch from public database
DLog(@"CloudKit Fetch failure: %@", error.localizedDescription);
dispatch_resume(syncQueue);
}
else if ( ![[record recordChangeTag] isEqualToString:lastUpdateRecordChangeTagReceived] ) {
// We won't push our content if there is something we haven't received yet.
// Pull from iCloud now, pushing afterward.
[self updateFromiCloud:nil];
[self updateToiCloud:nil];
dispatch_resume(syncQueue);
}
else {
DLog(@"Updating to iCloud completion");
// Modify the record and save it to the database
BOOL needToReleaseRecord = NO;
if (error
&& nil != [error.userInfo objectForKey:@"ServerErrorDescription" ]
&& [(NSString*)[error.userInfo objectForKey:@"ServerErrorDescription" ] isEqualToString:@"Record not found" ] )
{
DLog(@"Updating to iCloud completion creation");
record = [[CKRecord alloc] initWithRecordType:recordType recordID:recordID];
needToReleaseRecord = YES;
}
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
NSDictionary *dict = [defaults dictionaryRepresentation];
__block int additions = 0, modifications = 0;
__block NSMutableDictionary *changes = nil;
// Maybe we could compare record and dict, creating an array of only the items which are not identical in both.
[dict enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
if ( ( nil != prefix && [key hasPrefix:prefix] )
|| ( nil != matchList && [matchList containsObject:key] ) ) {
Boolean skip = NO;
if ( nil != obj )
{
obj = [self serialize:obj forKey:key];
if ( nil == obj )
skip = YES;
}
if ( skip )
{
}
else if ( nil == [record objectForKey: key] )
{
DLog(@"Adding %@.", key);
additions++;
}
else if ( ( [obj isKindOfClass:[NSNumber class]] && [(NSNumber*)record[key] intValue] != [(NSNumber*)obj intValue] )
|| ( [obj isKindOfClass:[NSString class]] && ![(NSString*)obj isEqualToString:(NSString*)record[key]] )
|| ( [obj isKindOfClass:[NSData class]] && ![(NSData*)obj isEqualToData:(NSData*)record[key]] ) )
{
DLog(@"Changing %@.", key);
modifications++;
}
else
{
DLog(@"Skipping %@.", key);
skip = YES;
}
if ( !skip )
{
if ( !changes )
changes = [[NSMutableDictionary alloc] init];
NSMutableArray *fromToTheirs = [[NSMutableArray alloc] init];
[fromToTheirs addObject:record[key]];
[fromToTheirs addObject:obj];
[changes setObject:fromToTheirs forKey:key];
record[key] = obj;
}
}
}];
DLog(@"To iCloud: Adding %i keys. Modifying %i keys.", additions, modifications);
if ( additions + modifications > 0 )
{
[privateDB saveRecord:record completionHandler:^(CKRecord *savedRecord, NSError *saveError) {
DLog(@"Saving to iCloud.");
if ( saveError )
{
// Error handling for failed save to public database
DLog(@"CloudKit Save failure: %@", saveError.localizedDescription);
[privateDB fetchRecordWithID:recordID completionHandler:^(CKRecord *newRecord, NSError *error) {
if (error) {
// Error handling for failed fetch from public database
DLog(@"CloudKit Fetch failure: %@", error.localizedDescription);
[self completeUpdateToiCloudWithChanges:changes];
}
else {
DLog(@"Updating to iCloud completion");
[changes enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
// Add the new remote value and ensure the to, from, and theirs values are all deserialized if necessary.
NSObject *originalObj = [[NSUserDefaults standardUserDefaults] objectForKey: key];
id fromObj = [obj firstObject];
if ( nil != fromObj )
{
fromObj = [self deserialize:fromObj forKey:key similarTo:originalObj];
if ( nil == fromObj )
{
// Failed to deserialize. Put our value in.
fromObj = originalObj;
}
}
id remoteObj = [newRecord objectForKey:key];
if ( nil != remoteObj )
{
remoteObj = [self deserialize:remoteObj forKey:key similarTo:originalObj];
if ( nil == remoteObj )
{
// Failed to deserialize. Put our value in.
remoteObj = [obj lastObject];
}
}
obj[0] = fromObj;
obj[1] = originalObj;
[obj addObject:remoteObj];
}];
NSDictionary *corrections = [self sendNotificationsFor:MJSyncNotificationConflicts onKeys:changes];
if ( corrections && [corrections count] )
{
[corrections enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
Boolean skip = NO;
if ( nil != obj )
{
obj = [self serialize:obj forKey:key];
if ( nil == obj )
skip = YES;
}
if ( !skip )
newRecord[key] = obj;
}];
[corrections release];
[privateDB saveRecord:newRecord completionHandler:^(CKRecord *savedRecord, NSError *saveError) {
DLog(@"Saving to iCloud.");
if ( saveError )
{
// If we had a conflict on the conflict resolution, just give up for now.
DLog(@"CloudKit conflict-resolution Save failure: %@", saveError.localizedDescription);
}
[self completeUpdateToiCloudWithChanges:changes];
}];
}
else
[self completeUpdateToiCloudWithChanges:changes];
}
}];
}
else
{
[self sendNotificationsFor:MJSyncNotificationSaveSuccess onKeys:changes];
[self completeUpdateToiCloudWithChanges:changes];
}
}];
}
else
[self completeUpdateToiCloudWithChanges:changes];
// If the record wasn't found, so we had to create it, then we own it and better release it.
if ( needToReleaseRecord )
[record release];
}
}];
}
});
}
+(void) completeUpdateToiCloudWithChanges:(NSMutableDictionary*) changes
{
// Resume before releasing memory, since there's nothing shared about the memory.
dispatch_resume(syncQueue);
if ( changes )
{
[changes enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
[obj release];
}];
[changes release];
}
}
+(id) serialize:(id)obj forKey:(NSString*)key
{
// Only serialize types that need to be.
if ( [obj isKindOfClass:[NSDictionary class]] )
{
NSError *error;
NSData *data = [NSPropertyListSerialization dataWithPropertyList:obj format:NSPropertyListBinaryFormat_v1_0 options:0 error:&error];
if ( data )
obj = data;
else
{
DLog( @"Error serializing %@ to binary: %@", key, error );
obj = nil;
}
}
return obj;
}
+(id) deserialize:(id)remoteObj forKey:(NSString*)key similarTo:(id)originalObj
{
if ( [remoteObj isKindOfClass:[NSData class]]
&& !(originalObj && [originalObj isKindOfClass:[NSData class]]) )
{
NSError *error;
id deserialized = [NSPropertyListSerialization propertyListWithData:(NSData*)remoteObj options:NSPropertyListImmutable format:nil error:&error];
if ( deserialized )
remoteObj = deserialized;
else if ( originalObj )
{
DLog( @"Error deserializing %@ from binary: %@", key, error );
remoteObj = nil;
}
else
{
// Keep input remoteObj the same and return as output.
DLog( @"Error deserializing %@ from binary, but we didn't have a local copy so we assume it wasn't supposed to be deserialized. We assume this is okay in order to handle storing NSData that doesn't represent a serialized property list. %@", key, error );
}
}
return remoteObj;
}
+(void) updateFromiCloud:(NSNotification*) notificationObject {
dispatch_async(syncQueue, ^{
if ( nil == privateDB )
{
DLog(@"Database has been unset. Not updating from iCloud");
return;
}
DLog(@"Updating from iCloud");
dispatch_suspend(syncQueue);
[privateDB fetchRecordWithID:recordID completionHandler:^(CKRecord *record, NSError *error) {
if (error) {
// Error handling for failed fetch from public database
DLog(@"CloudKit Fetch failure: %@", error.localizedDescription);
}
else {
DLog(@"Updating from iCloud completion");
// prevent NSUserDefaultsDidChangeNotification from being posted while we update from iCloud
[[NSNotificationCenter defaultCenter] removeObserver:self
name:NSUserDefaultsDidChangeNotification
object:nil];
lastUpdateRecordChangeTagReceived = [[record recordChangeTag] retain];
DLog(@"Got record -%@-_-%@-_-%@-_-%@-",[[[record recordID] zoneID] zoneName],[[[record recordID] zoneID] ownerName],[[record recordID] recordName],[record recordChangeTag]);
__block int additions = 0, modifications = 0;
__block NSMutableDictionary *changes = nil;
[[record allKeys] enumerateObjectsUsingBlock:^(id key, NSUInteger idx, BOOL *stop) {
if ( ( nil != prefix && [key hasPrefix:prefix] )
|| ( nil != matchList && [matchList containsObject:key] ) ) {
BOOL skip = NO;
NSObject *obj = [[NSUserDefaults standardUserDefaults] objectForKey: key];
NSObject *originalObj = obj;
if ( nil != obj )
{
obj = [self serialize:obj forKey:key];
if ( nil == obj )
skip = YES;
}
if ( skip )
{
}
else if ( nil == obj )
{
DLog(@"Adding %@.", key);
additions++;
}
else if ( ( [obj isKindOfClass:[NSNumber class]] && [(NSNumber*)record[key] intValue] != [(NSNumber*)obj intValue] )
|| ( [obj isKindOfClass:[NSString class]] && ![(NSString*)obj isEqualToString:(NSString*)record[key]] )
|| ( [obj isKindOfClass:[NSData class]] && ![(NSData*)obj isEqualToData:(NSData*)record[key]] ) )
{
DLog(@"Changing %@.", key);
modifications++;
}
else
{
DLog(@"Skipping %@.", key);
skip = YES;
}
if ( !skip )
{
id remoteObj = [record objectForKey:key];
if ( nil != remoteObj )
{
remoteObj = [self deserialize:remoteObj forKey:key similarTo:originalObj];
if ( nil == remoteObj )
skip = YES;
}
if ( !skip )
{
[[NSUserDefaults standardUserDefaults] setObject:remoteObj forKey:key];
if ( !changes )
changes = [[NSMutableDictionary alloc] init];
[changes setObject:key forKey:key];
}
}
}
}];
DLog(@"From iCloud: Adding %i keys. Modifying %i keys.", additions, modifications);
if ( additions + modifications > 0 )
{
DLog(@"Synchronizing defaults.");
[[NSUserDefaults standardUserDefaults] synchronize];
[self sendNotificationsFor:MJSyncNotificationChanges onKeys:changes];
[changes release];
}
// enable NSUserDefaultsDidChangeNotification notifications again
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(updateToiCloud:)
name:NSUserDefaultsDidChangeNotification
object:nil];
refuseUpdateToICloudUntilAfterUpdateFromICloud = NO;
}
dispatch_resume(syncQueue);
}];
});
}
+(void) setRemoteNotificationsEnabled:(bool) enabled
{
if ( enabled != remoteNotificationsEnabled )
{
bool resume = observingActivity;
if ( observingActivity )
[self pause];
remoteNotificationsEnabled = enabled;
if ( resume )
[self resume];
}
}
+(void) startWithPrefix:(NSString*) prefixToSync withContainerIdentifier:(NSString*) containerIdentifier {
DLog(@"Starting with prefix");
// If we are already running and add criteria while updating to iCloud, we could push to iCloud before pulling the existing value from iCloud. Avoid this by dispatching into another thread that will wait pause existing activity and wait for it to stop before adding new criteria.
dispatch_async(dispatch_get_main_queue(), ^{
DLog(@"Actually starting with prefix");
[self commonStartInitialStepsOnContainerIdentifier:containerIdentifier];
if ( prefix )
[prefix release];
prefix = prefixToSync;
[prefix retain];
[self attemptToEnable];
});
}
+(void) startWithKeyMatchList:(NSArray*) keyMatchList withContainerIdentifier:(NSString*) containerIdentifier {
DLog(@"Starting with match list length %lu atop %lu", (unsigned long)[keyMatchList count], (unsigned long)[matchList count]);
// If we are already running and add criteria while updating to iCloud, we could push to iCloud before pulling the existing value from iCloud. Avoid this by dispatching into another thread that will wait pause existing activity and wait for it to stop before adding new criteria.
dispatch_async(dispatch_get_main_queue(), ^{
DLog(@"Actually starting with match list length %lu atop %lu", (unsigned long)[keyMatchList count], (unsigned long)[matchList count]);
[self commonStartInitialStepsOnContainerIdentifier:containerIdentifier];
if ( !matchList )
matchList = [[NSArray alloc] init];
NSArray *toRelease = matchList;
// Add to existing array.
matchList = [matchList arrayByAddingObjectsFromArray:keyMatchList];
// Remove duplicates.
matchList = [[NSSet setWithArray:matchList] allObjects];
[matchList retain];
[toRelease release];
DLog(@"Match list length is now %lu", (unsigned long)[matchList count]);
[self attemptToEnable];
});
}
+(void) commonStartInitialStepsOnContainerIdentifier:(NSString*) containerIdentifier {
[self pause];
DLog(@"Waiting for sync queue to clear before adding new criteria.");
if ( !syncQueue )
{
syncQueue = dispatch_queue_create("com.MJCloudKitUserDefaultsSync.queue", DISPATCH_QUEUE_SERIAL);
[syncQueue retain];
}
dispatch_sync(syncQueue, ^{
refuseUpdateToICloudUntilAfterUpdateFromICloud = YES;
DLog(@"Waited for sync queue to clear before adding new criteria.");
});
if ( databaseContainerIdentifier )
[databaseContainerIdentifier release];
databaseContainerIdentifier = containerIdentifier;
[databaseContainerIdentifier retain];
}
+(void) pause {
[self stopObservingActivity];
[self stopObservingIdentityChanges];
}
+(void) resume {
[self attemptToEnable];
}
+(void) stopForKeyMatchList:(NSArray*) keyMatchList {
DLog(@"Stopping match list length %lu from %lu", (unsigned long)[keyMatchList count], (unsigned long)[matchList count]);
if ( !matchList )
return;
NSArray *toRelease = matchList;
NSMutableArray *mutableList = [[NSMutableArray alloc] initWithArray:matchList];
[mutableList removeObjectsInArray:keyMatchList];
matchList = mutableList;
[toRelease release];
DLog(@"Match list length is now %lu", (unsigned long)[matchList count]);
if ( 0 == matchList.count )
[self stop];
}
+(void) stop {
DLog(@"Stopping.");
[self stopObservingActivity];
[self stopObservingIdentityChanges];
if ( matchList )
[matchList release];
if ( matchList )
{
[matchList release];
matchList = nil;
}
if ( prefix )
{
[prefix release];
prefix = nil;
}
if ( databaseContainerIdentifier )
{
[databaseContainerIdentifier release];
databaseContainerIdentifier = nil;
}
for ( int type = MJSyncNotificationTypeFirst(); type <= MJSyncNotificationTypeLast(); type++ )
{
if ( changeNotificationHandlers[type] )
{
[changeNotificationHandlers[type] release];
changeNotificationHandlers[type] = nil;
}
}
DLog(@"Stopped.");
}
+(void) addNotificationFor:(MJSyncNotificationType)type withSelector:(SEL)aSelector withTarget:(nullable id)aTarget {
DLog(@"Registering change notification selector.");
if ( !changeNotificationHandlers[type] )
changeNotificationHandlers[type] = [[NSMutableArray alloc] init];
[changeNotificationHandlers[type] addObject:aTarget];
[changeNotificationHandlers[type] addObject:[NSValue valueWithPointer:aSelector]];
}
+(void) removeNotificationsFor:(MJSyncNotificationType)type forTarget:(nullable id) aTarget {
DLog(@"Removing change notification selector(s).");
while ( changeNotificationHandlers[type] )
{
NSUInteger index = [changeNotificationHandlers[type] indexOfObjectIdenticalTo:aTarget];
if ( NSNotFound == index )
return;
DLog(@"Removing a change notification selector.");
[changeNotificationHandlers[type] removeObjectAtIndex:index]; // Target
[changeNotificationHandlers[type] removeObjectAtIndex:index]; // Selector
}
}
+(NSDictionary*) sendNotificationsFor:(MJSyncNotificationType)type onKeys:(NSDictionary*) changes {
DLog(@"Sending change notification selector(s).");
__block NSMutableDictionary *corrections = nil;
if (changeNotificationHandlers[type])
{
for ( int i = 0 ; i < [changeNotificationHandlers[type] count] ; i+=2 )
{
DLog(@"Sending a change notification selector.");
NSDictionary *currentCorrections = [changeNotificationHandlers[type][i] performSelector:[changeNotificationHandlers[type][i+1] pointerValue] withObject:changes];
if ( currentCorrections )
{
[currentCorrections enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
if ( !corrections )
corrections = [[NSMutableDictionary alloc] init];
[corrections setObject:obj forKey:key];
}];
[currentCorrections release];
}
}
}
return corrections;
}
+(void) identityDidChange:(NSNotification*) notificationObject {
DLog(@"iCloud Identity Change Detected");
[self attemptToEnable];
}
+(void) checkCloudKitUpdates {
DLog(@"Got checkCloudKitUpdates");
[self updateFromiCloud:nil];
}
+(void) attemptToEnable {
DLog(@"Attempting to enable");
[[CKContainer defaultContainer] accountStatusWithCompletionHandler: ^(CKAccountStatus accountStatus, NSError *error) {
switch ( accountStatus ) {
case CKAccountStatusAvailable: // is iCloud enabled
DLog(@"iCloud Available");
[self startObservingActivity];
break;
case CKAccountStatusNoAccount:
DLog(@"No iCloud account");
[self stopObservingActivity];
break;
case CKAccountStatusRestricted:
DLog(@"iCloud restricted");
[self stopObservingActivity];
break;
case CKAccountStatusCouldNotDetermine:
DLog(@"Unable to determine iCloud status");
[self stopObservingActivity];
break;
}
[self startObservingIdentityChanges];
}];
}
+(void) startObservingActivity {
DLog(@"Should start observing activity?");
if ( !observingActivity )
{
DLog(@"YES. Start observing activity.");
observingActivity = YES;
// Setup database connections.
CKContainer *container = [CKContainer containerWithIdentifier:databaseContainerIdentifier];
publicDB = [container publicCloudDatabase];
privateDB = [container privateCloudDatabase];
// Create a zone if needed.
if ( recordZoneID )
[recordZoneID release];
recordZoneID = [[CKRecordZoneID alloc] initWithZoneName:recordZoneName ownerName:CKOwnerDefaultName];
if ( recordID )
[recordID release];
recordID = [[CKRecordID alloc] initWithRecordName:recordName zoneID:recordZoneID];
if ( recordZone )
[recordZone release];
recordZone = [[CKRecordZone alloc] initWithZoneID:recordZoneID];
DLog(@"Created CKRecordZone.zoneID %@:%@", recordZone.zoneID.zoneName, recordZone.zoneID.ownerName);
if ( oneTimeDeleteZoneFromICloud )
{
observingActivity = NO;
oneTimeDeleteZoneFromICloud = NO;
DLog(@"Deleting CKRecordZone one time.");
CKModifyRecordZonesOperation *deleteOperation = [[CKModifyRecordZonesOperation alloc] initWithRecordZonesToSave:@[] recordZoneIDsToDelete:@[recordZoneID]];
deleteOperation.modifyRecordZonesCompletionBlock = ^(NSArray *savedRecordZones, NSArray *deletedRecordZoneIDs, NSError *error) {
if ( nil != error )
{
DLog(@"CloudKit Delete Record Zones failure: %@", error.localizedDescription);
}
else
{
DLog(@"Deleted CKRecordZone.");
}
[self startObservingActivity];
};
[privateDB addOperation:deleteOperation];
[deleteOperation release];
return;
}
CKModifyRecordZonesOperation *operation = [[CKModifyRecordZonesOperation alloc] initWithRecordZonesToSave:@[recordZone] recordZoneIDsToDelete:@[]];
operation.modifyRecordZonesCompletionBlock = ^(NSArray *savedRecordZones, NSArray *deletedRecordZoneIDs, NSError *error) {
if ( nil != error )
{
DLog(@"CloudKit Modify Record Zones failure: %@", error.localizedDescription);
[self stopObservingActivity];
}
else
{
DLog(@"Recorded CKRecordZone.zoneID %@:%@", ((CKRecordZone*)savedRecordZones[0]).zoneID.zoneName, ((CKRecordZone*)savedRecordZones[0]).zoneID.ownerName);
// Find out when things change
[self subscribeToDatabase];
// Pull from iCloud now, pushing afterward.
// If we push first, we overwrite the sync.
// If we don't push after the pull, we won't push until something changes.
[self updateFromiCloud:nil];
[self updateToiCloud:nil];
}
};
[privateDB addOperation:operation];
[operation release];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(updateToiCloud:)
name:NSUserDefaultsDidChangeNotification
object:nil];
}
}
+(void) subscribeToDatabase {
DLog(@"Subscribing to database.");
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"TRUEPREDICATE" ];
CKQuerySubscription *subscription =
#if TARGET_IPHONE_SIMULATOR
nil; // Simulator doesn't support remote notifications.
#else // TARGET_IPHONE_SIMULATOR
remoteNotificationsEnabled ?
[[CKQuerySubscription alloc] initWithRecordType:recordType
predicate:predicate
subscriptionID:subscriptionID
options:CKQuerySubscriptionOptionsFiresOnRecordCreation | CKQuerySubscriptionOptionsFiresOnRecordUpdate | CKQuerySubscriptionOptionsFiresOnRecordDeletion]
: nil;
#endif // TARGET_IPHONE_SIMULATOR
if ( nil == subscription )
{
// CKQuerySubscription was added after the core CloudKit APIs, so on OS versions that don't support it we will poll instead as there appears to be no alternative subscription API.
DLog(@"Using polling instead.");
if ( !pollQueue )
{
pollQueue = dispatch_queue_create("com.MJCloudKitUserDefaultsSync.poll", DISPATCH_QUEUE_SERIAL);
[pollQueue retain];
}
// Timers attach to the run loop of the process, which isn't present on all processes, so we must dispatch to the main queue to ensure we have a run loop for the timer.
dispatch_async(dispatch_get_main_queue(), ^{
NSDate *oneSecondFromNow = [NSDate dateWithTimeIntervalSinceNow:1.0];
pollCloudKitTimer = [[NSTimer alloc] initWithFireDate:oneSecondFromNow
interval:(1.0)
target:self
selector:@selector(pollCloudKit:)
userInfo:nil
repeats:YES];
// Assign it to NSRunLoopCommonModes so that it will still poll while the menu is open. Using a simple NSTimer scheduledTimerWithTimeInterval: would result in polling that stops while the menu is active. In the past this was okay but with Universal Clipboard a new clipping an arrive while the user has the menu open.
[[NSRunLoop currentRunLoop] addTimer:pollCloudKitTimer forMode:NSRunLoopCommonModes];
});
}
else
{
CKNotificationInfo *notification = [[CKNotificationInfo alloc] init];
notification.shouldSendContentAvailable = YES;
subscription.notificationInfo = notification;
DLog(@"Fetching existing subscription.");
[privateDB fetchSubscriptionWithID:subscription.subscriptionID completionHandler:^(CKSubscription * _Nullable existingSubscription, NSError * _Nullable error) {
DLog(@"Fetched existing subscription.");
if ( nil == existingSubscription )
{
DLog(@"No existing subscription. Saving ours.");
// In the not-yet-subscribed-but-everything-working case, error will contain k/v @"ServerErrorDescription" : @"subscription not found"
[privateDB saveSubscription:subscription completionHandler:^(CKSubscription * _Nullable subscription, NSError * _Nullable error) {
DLog(@"Saved subscription.");
if ( nil != error )
{
DLog(@"CloudKit Subscription failure: %@", error.localizedDescription);
[self stopObservingActivity];
}
}];
}
[subscription release];
}];
}
}
+(void) stopObservingActivity {
DLog(@"Should stop observing activity?");
if ( observingActivity )
{
DLog(@"YES. Stop observing activity.");
observingActivity = NO;
if ( pollCloudKitTimer )
{
[pollCloudKitTimer invalidate];
pollCloudKitTimer = nil;
}
if ( previousChangeToken )
{
[previousChangeToken release];
previousChangeToken = nil;
}
[privateDB deleteSubscriptionWithID:subscriptionID completionHandler:^(NSString * _Nullable subscriptionID, NSError * _Nullable error) {
DLog(@"Stopped observing activity.");
// We check for an existing subscription before saving a new subscription so the result here doesn't matter."
}];
if ( recordZone )
{
[recordZone release];
recordZone = nil;
}
if ( recordZoneID )
{
[recordZoneID release];
recordZoneID = nil;
}
if ( recordID )
{
[recordID release];
recordID = nil;
}
// Clear database connections.
publicDB = privateDB = nil;
[[NSNotificationCenter defaultCenter] removeObserver:self
name:NSUserDefaultsDidChangeNotification
object:nil];
}
}
+(void) startObservingIdentityChanges {
DLog(@"Should start observing identity changes?");
if ( !observingIdentityChanges )
{
DLog(@"YES. Start observing identity changes.");
observingIdentityChanges = YES;
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(identityDidChange:)
name:NSUbiquityIdentityDidChangeNotification
object:nil];
}
}
+(void) stopObservingIdentityChanges {
DLog(@"Should stop observing identity changes?");
if ( observingIdentityChanges )
{
DLog(@"YES. Stop observing identity changes.");
observingIdentityChanges = NO;
[[NSNotificationCenter defaultCenter] removeObserver:self
name:NSUbiquityIdentityDidChangeNotification
object:nil];
}
}
static bool alreadyPolling = NO;
static CFAbsoluteTime lastPollPokeTime;
+(void)pollCloudKit:(NSTimer *)timer {
// The fetchRecordChangesCompletionBlock below doesn't get called until after the recordChangedBlock below completes. The former block provides the change token which the poll uses to identify if changes have happened. Prevent use of the old change token while processing changes, which would of course detect changes and cause excess evaluation, by setting / checking a flag in a serial queue until the completion block which occurs on a different queue causes it to be cleared in the original serial queue.
// This is preferable to using a dispatch_suspend / dispatch_resume because it prevents amassing a long queue of serial GCD operations in the event that the CloudKit CKFetchRecordChangesOperation takes more than the polling interval.
dispatch_async(pollQueue, ^{
if ( !alreadyPolling )
{
DLog(@"Polling");
alreadyPolling = YES;
lastPollPokeTime = CFAbsoluteTimeGetCurrent();
// CKFetchRecordChangesOperation is OS X 10.10 to 10.12, but CKQuerySubscription is 10.12+ so for code exclusive to our pre-CKQuerySubscription support we can use things that were deprecated when CKQuerySubscription was added.
// We will have to revisit this ^ since Push Notifications is only allowed if distributed through the App Store and CKQuerySubscription depends on Push Notifications.
CKFetchRecordChangesOperation *operation = [[CKFetchRecordChangesOperation alloc] initWithRecordZoneID:recordZone.zoneID previousServerChangeToken:previousChangeToken];
operation.recordChangedBlock = ^(CKRecord *record) {
DLog(@"Polling got record change");
[self checkCloudKitUpdates];
};
operation.fetchRecordChangesCompletionBlock = ^(CKServerChangeToken * _Nullable serverChangeToken, NSData * _Nullable clientChangeTokenData, NSError * _Nullable operationError) {
DLog(@"Polling completion");
if ( nil == operationError )
{
DLog(@"Polling completion GOOD");
if ( previousChangeToken )
[previousChangeToken release];
previousChangeToken = serverChangeToken;
[previousChangeToken retain];
if(clientChangeTokenData)
[clientChangeTokenData release];
}
// Back to the pollQueue to clear the flag since we are now on a different queue.
dispatch_async(pollQueue, ^{
alreadyPolling = NO;
});
};
[privateDB addOperation:operation];
[operation release];
}
else if ( CFAbsoluteTimeGetCurrent() - lastPollPokeTime > 600 )
{
// If it has been more than ten minutes without a poll response, send another one to try to wake up iCloud since it seems to be slow to realize when we come back online.
// Would be better if we didn't send this while offline.
lastPollPokeTime = CFAbsoluteTimeGetCurrent();
DLog(@"Poking");
CKFetchRecordChangesOperation *operation = [[CKFetchRecordChangesOperation alloc] initWithRecordZoneID:recordZone.zoneID previousServerChangeToken:previousChangeToken];
operation.recordChangedBlock = ^(CKRecord *record) { DLog(@"Poke got record change"); };
operation.fetchRecordChangesCompletionBlock = ^(CKServerChangeToken * _Nullable serverChangeToken, NSData * _Nullable clientChangeTokenData, NSError * _Nullable operationError) { DLog(@"Poke completion"); };
[privateDB addOperation:operation];
[operation release];
}
});
}
+ (void) dealloc {
DLog(@"Deallocating");
[self stop];
DLog(@"Deallocated");
}
@end

View file

@ -0,0 +1,109 @@
### What is this?
A library to sync NSUserDefaults to iCloud via CloudKit. Based on MKiCloudSync,
by Mugunth Kumar, a clean and simple class to sync NSUserDefaults to iCloud.
Use of CloudKit removes the "clean and simple" but allows use in apps
distributed outside of the App Store.
### How to use?
1. Drag the two classes and #import the header file in your Application Delegate
2. Call `[MJCloudKitUserDefaultsSync startWithPrefix:@"sync" withContainerIdentifier@"iCloud.com.yourCompany.yourProduct"];`
3. Register for Remote Notifications in your applicationDidFinishLaunching.
// macOS
- (void)applicationDidFinishLaunching:(NSNotification *)notification
{
// Enable notification from CloudKit
[NSApp registerForRemoteNotificationTypes:NSRemoteNotificationTypeNone];// silent push notification!
//Create our hot key
[self toggleMainHotKey:[NSNull null]];
}
4. Add a NotificationCenter receiver and optional registration handlers as follows
// macOS:
- (void)application:(NSApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo
{
[MJCloudKitUserDefaultsSync checkCloudKitUpdates];
}
// Remote Notifications (APN, aka Push Notifications) are only available on apps distributed via the App Store.
// To support building for both distribution channels, include the following two methods to detect if Remote Notifications are available and inform MJCloudKitUserDefaultsSync.
- (void)application:(NSApplication *)application
didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
// Forward the token to your provider, using a custom method.
NSLog(@"Registered for remote notifications.");
[MJCloudKitUserDefaultsSync setRemoteNotificationsEnabled:YES];
}
- (void)application:(NSApplication *)application
didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
NSLog(@"Remote notification support is unavailable due to error: %@", error);
[MJCloudKitUserDefaultsSync setRemoteNotificationsEnabled:NO];
}
// iOS:
// TBD
### To Do
* Create an xcproj and turn this into a framework so it can be used by both ARC and non-ARC projects. It is currently being used by an old non-ARC project, so it isn't ARC for now.
* Consider switching to ARC.
* Evaluate the current model, inherited from MKiCloudSync, of being built on class methods and static variables vs containing the variables in the class and using a singleton vs possibilities of multiple instances of the class.
* Evaluate capability to synchronize other dictionaries beyond just NSUserDefaults.
* updateFromiCloud could perhaps include the old and new values in the change list provided to sendChangeNotifications to provide to interested parties.
### Compatibility
Care has been taken to enable this library to use newer and better CloudKit capabilities introduced in macOS 10.12 (Sierra) while maintaining compatibility with OS X 10.10 (Yosemite) where CloudKit was first introduced. The initial project using this library targets Mac OS X 10.7 (Lion), though the CloudKit portions of that project won't do anything on that release.
### Notes
This is currently a beta release. This initial commit has been demonstrated to
work but warrants further evaluation and testing. It includes copious logging
statements, a bit of debug code, a bit of commented-out stuff that can be
removed, and a bit of commented-out stuff that should maybe be restored. It has
not yet been verified to be of consistent style.
See [Flycut](http://github.com/MarkJerde/Flycut) for an example of
MJCloudKitUserDefaultsSync in use.
MJCloudKitUserDefaultsSync is based on Mugunth Kumar's MKiCloudSync, though it
is massively different as a result of using CloudKit and having a few additional
features. It does, however, retain about half of Mugunth Kumar's code, owing to
the simplicity of his original solution. Given their differences, in use of
CloudKit vs iCloud key-value store, the two are named and maintained
independently to avoid confusion. If you are looking for a simple solution for
apps distributed through the App Store, MJCloudKitUserDefaultsSync will work but
consider MKiCloudSync.
Read the [blog post](http://blog.mugunthkumar.com/coding/ios-code-mkicloudsync-sync-your-nsuserdefaults-to-icloud-with-a-single-line-of-code) for more on Mugunth Kumar's original solution.
### License
Copyright (C) 2017 by Mark Jerde
Portions Copyright (C) 2011-2020 by Steinlogic
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
As a side note, you might also consider
1) tweeting about this mentioning @mark_a_jerde
2) A paypal donation to mark.a.jerde@gmail.com
3) tweeting about this mentioning @mugunthkumar for his original contributions
4) A paypal donation to mugunth.kumar@gmail.com