From 13ceb8d7d0a8421a4014aa60cbf39e9993ecf702 Mon Sep 17 00:00:00 2001 From: Mark Jerde Date: Sat, 2 Sep 2017 22:13:52 -0500 Subject: [PATCH] Add support for iCloud Sync. --- AppController.h | 7 +- AppController.m | 218 +++++++++++-- English.lproj/MainMenu.nib/designable.nib | 137 +++++--- English.lproj/MainMenu.nib/keyedobjects.nib | Bin 35329 -> 36858 bytes Flycut-iOS/AppDelegate.swift | 33 +- Flycut-iOS/Info.plist | 4 + Flycut-iOS/ViewController.swift | 151 ++++++--- Flycut.entitlements | 2 - Flycut.xcodeproj/project.pbxproj | 7 +- FlycutEngine/FlycutClipping.m | 6 +- FlycutEngine/FlycutStore.h | 19 ++ FlycutEngine/FlycutStore.m | 114 ++++++- FlycutOperator.h | 10 + FlycutOperator.m | 333 ++++++++++++++++---- 14 files changed, 854 insertions(+), 187 deletions(-) diff --git a/AppController.h b/AppController.h index cc3bd26..abbc90d 100755 --- a/AppController.h +++ b/AppController.h @@ -22,7 +22,7 @@ @class SGHotKey; -@interface AppController : NSObject { +@interface AppController : NSObject { BezelWindow *bezel; SGHotKey *mainHotKey; IBOutlet SRRecorderControl *mainRecorder; @@ -35,6 +35,8 @@ NSString *currentKeycodeCharacter; NSDateFormatter* dateFormat; + NSArray *settingsSyncList; + FlycutOperator *flycutOperator; // Status item -- the little icon in the menu bar @@ -105,6 +107,9 @@ -(IBAction) switchMenuIcon:(id)sender; -(IBAction) toggleLoadOnStartup:(id)sender; -(IBAction) toggleMainHotKey:(id)sender; +-(IBAction) toggleICloudSyncSettings:(id)sender; +-(IBAction) toggleICloudSyncClippings:(id)sender; +-(IBAction) setSavePreference:(id)sender; -(void) setHotKeyPreferenceForRecorder:(SRRecorderControl *)aRecorder; @end diff --git a/AppController.m b/AppController.m index 2e8cb3d..db748fc 100755 --- a/AppController.m +++ b/AppController.m @@ -19,6 +19,7 @@ #import "UKLoginItemRegistry.h" #import "NSWindow+TrueCenter.h" #import "NSWindow+ULIZoomEffect.h" +#import "MJCloudKitUserDefaultsSync.h" @implementation AppController @@ -53,9 +54,49 @@ [NSNumber numberWithBool:YES], @"displayClippingSource", nil]]; + + /* For testing, the ability to force initial values of the sync settings: + [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithBool:NO] + forKey:@"syncSettingsViaICloud"]; + [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithBool:NO] + forKey:@"syncClippingsViaICloud"];*/ + + settingsSyncList = @[@"displayNum", + @"displayLen", + @"menuIcon", + @"bezelAlpha", + @"stickyBezel", + @"wraparoundBezel", + @"loadOnStartup", + @"menuSelectionPastes", + @"bezelWidth", + @"bezelHeight", + @"popUpAnimation", + @"displayClippingSource"]; + [settingsSyncList retain]; + return [super init]; } +- (void)registerOrDeregisterICloudSync +{ + if ( [[NSUserDefaults standardUserDefaults] boolForKey:@"syncSettingsViaICloud"] ) { + [MJCloudKitUserDefaultsSync removeNotificationsFor:MJSyncNotificationChanges forTarget:self]; + [MJCloudKitUserDefaultsSync addNotificationFor:MJSyncNotificationChanges withSelector:@selector(checkPreferencesChanges:) withTarget: self]; + // Not registering for conflict notifications, since we just sync settings, and if the settings are conflictingly adjusted simultaneously on two systems there is nothing to say which setting is better. + + [MJCloudKitUserDefaultsSync startWithKeyMatchList:settingsSyncList + withContainerIdentifier:@"iCloud.com.mark-a-jerde.Flycut"]; + } + else { + [MJCloudKitUserDefaultsSync stopForKeyMatchList:settingsSyncList]; + + [MJCloudKitUserDefaultsSync removeNotificationsFor:MJSyncNotificationChanges forTarget:self]; + } + + [flycutOperator registerOrDeregisterICloudSync]; +} + - (void)awakeFromNib { [self buildAppearancesPreferencePanel]; @@ -130,6 +171,8 @@ } }); + [self registerOrDeregisterICloudSync]; + [NSApp activateIgnoringOtherApps: YES]; } @@ -312,52 +355,79 @@ } } +-(NSDictionary*) checkPreferencesChanges:(NSDictionary*)changes +{ + if ( [changes valueForKey:@"rememberNum"] ) + [self checkRememberNumPref:[[NSUserDefaults standardUserDefaults] integerForKey:@"rememberNum"] + forPrimaryStore:YES]; + if ( [changes valueForKey:@"favoritesRememberNum"] ) + [self checkFavoritesRememberNumPref:[[NSUserDefaults standardUserDefaults] integerForKey:@"favoritesRememberNum"]]; + return nil; +} + -(IBAction) setRememberNumPref:(id)sender { - int choice; - int newRemember = [sender intValue]; + [self checkRememberNumPref:[sender intValue] forPrimaryStore:YES]; +} + +-(void) checkRememberNumPref:(int)newRemember forPrimaryStore:(BOOL) isPrimaryStore +{ + int oldRemeber = [flycutOperator rememberNum]; if ( newRemember < [flycutOperator jcListCount] && ! issuedRememberResizeWarning && ! [[NSUserDefaults standardUserDefaults] boolForKey:@"stifleRememberResizeWarning"] ) { - choice = NSRunAlertPanel(@"Resize Stack", + int choice = NSRunAlertPanel(@"Resize Stack", @"Resizing the stack to a value below its present size will cause clippings to be lost.", @"Resize", @"Cancel", @"Don't Warn Me Again"); if ( choice == NSAlertAlternateReturn ) { - // User selected Cancel. This appears to set the user default to - // the current clipping count while not updating the clippingStore, - // resulting in truncation in the future. This condition dates back - // to snarkout's original creation of setRememberNumPref on May 17, - // 2006. - [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithInt:[flycutOperator jcListCount]] - forKey:@"rememberNum"]; - [self updateMenu]; - return; + // Cancel - Change to prior setting. + newRemember = oldRemeber; + if ( isPrimaryStore ) { + [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithInt:newRemember] + forKey:@"rememberNum"]; + [self updateMenu]; + } else { + [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithInt:newRemember] + forKey:@"favoritesRememberNum"]; + } } else if ( choice == NSAlertOtherReturn ) { + // Don't Warn Me Again [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithBool:YES] forKey:@"stifleRememberResizeWarning"]; } else { + // Resize issuedRememberResizeWarning = YES; } } - // Trim down the number displayed in the menu if it is greater than the new - // number to remember. - if ( newRemember < [[NSUserDefaults standardUserDefaults] integerForKey:@"displayNum"] ) { - [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithInt:newRemember] - forKey:@"displayNum"]; + if ( newRemember < oldRemeber ) + { + // Trim down the number displayed in the menu if it is greater than the new + // number to remember. + if ( isPrimaryStore ) { + if ( newRemember < [[NSUserDefaults standardUserDefaults] integerForKey:@"displayNum"] ) { + [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithInt:newRemember] + forKey:@"displayNum"]; + [self updateMenu]; + } + } } // Set the value. [flycutOperator setRememberNum: newRemember]; - [self updateMenu]; } -(IBAction) setFavoritesRememberNumPref:(id)sender { - [flycutOperator switchToFavoritesStore]; - [self setRememberNumPref: sender]; - [flycutOperator restoreStashedStore]; + [self checkFavoritesRememberNumPref:[sender intValue]]; +} + +-(void) checkFavoritesRememberNumPref:(int)newRemember +{ + [flycutOperator switchToFavoritesStore]; + [self checkRememberNumPref:newRemember forPrimaryStore:NO]; + [flycutOperator restoreStashedStore]; } -(IBAction) setDisplayNumPref:(id)sender @@ -847,10 +917,33 @@ - (void)applicationDidFinishLaunching:(NSNotification *)notification { + // Enable notification from CloudKit + [NSApp registerForRemoteNotificationTypes:NSRemoteNotificationTypeNone];// silent push notification! + //Create our hot key [self toggleMainHotKey:[NSNull null]]; } +// 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]; +} + +- (void)application:(NSApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo +{ + [flycutOperator checkCloudKitUpdates]; +} + - (void) updateBezel { [flycutOperator adjustStackPositionIfOutOfBounds]; @@ -937,6 +1030,89 @@ [[SGHotKeyCenter sharedCenter] registerHotKey:mainHotKey]; } +- (IBAction)toggleICloudSyncSettings:(id)sender +{ + if ( [[NSUserDefaults standardUserDefaults] boolForKey:@"syncSettingsViaICloud"] ) { + NSAlert *alert = [[NSAlert alloc] init]; + [alert setMessageText:@"Warning"]; + [alert addButtonWithTitle:@"Ok"]; + [alert addButtonWithTitle:@"Cancel"]; + [alert setInformativeText:@"Enabling iCloud Settings Sync will overwrite local settings if your iCloud account already has Flycut settings. If you have never enabled this in Flycut on any computer, your current settings will be retained and loaded into iCloud."]; + if ( [alert runModal] != NSAlertFirstButtonReturn ) + { + [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithBool:NO] + forKey:@"syncSettingsViaICloud"]; + } + [alert release]; + // Add option to overwrite iCloud. + } + + [self registerOrDeregisterICloudSync]; +} + +- (IBAction)toggleICloudSyncClippings:(id)sender +{ + if ( [[NSUserDefaults standardUserDefaults] boolForKey:@"syncClippingsViaICloud"] ) { + if ( [[NSUserDefaults standardUserDefaults] integerForKey:@"savePreference"] < 2 ) { + // Must set syncClippingsViaICloud = 2 + NSAlert *alert = [[NSAlert alloc] init]; + [alert setMessageText:@"Settings Change"]; + [alert addButtonWithTitle:@"Ok"]; + [alert addButtonWithTitle:@"Cancel"]; + [alert setInformativeText:@"iCloud Clippings Sync will set 'Save: After each clip'."]; + if ( [alert runModal] == NSAlertFirstButtonReturn ) + { + [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithInt:2] + forKey:@"savePreference"]; + } else { + [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithBool:NO] + forKey:@"syncClippingsViaICloud"]; + } + [alert release]; + } + } + + if ( [[NSUserDefaults standardUserDefaults] boolForKey:@"syncClippingsViaICloud"] ) { + NSAlert *alert = [[NSAlert alloc] init]; + [alert setMessageText:@"Warning"]; + [alert addButtonWithTitle:@"Ok"]; + [alert addButtonWithTitle:@"Cancel"]; + [alert setInformativeText:@"Enabling iCloud Clippings Sync will overwrite local clippings if your iCloud account already has Flycut clippings. If you have never enabled this in Flycut on any computer, your current clippings will be retained and loaded into iCloud."]; + if ( [alert runModal] != NSAlertFirstButtonReturn ) + { + [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithBool:NO] + forKey:@"syncClippingsViaICloud"]; + } + // Add option to overwrite iCloud. + } + + [self registerOrDeregisterICloudSync]; +} + +- (IBAction)setSavePreference:(id)sender +{ + if ( [[NSUserDefaults standardUserDefaults] integerForKey:@"savePreference"] < 2 ) { + if ( [[NSUserDefaults standardUserDefaults] boolForKey:@"syncClippingsViaICloud"] ) { + // Must disable syncClippingsViaICloud + NSAlert *alert = [[NSAlert alloc] init]; + [alert setMessageText:@"Settings Change"]; + [alert addButtonWithTitle:@"Ok"]; + [alert addButtonWithTitle:@"Cancel"]; + [alert setInformativeText:@"Disabling 'Save: After each clip' will disable iCloud Clippings Sync."]; + + if ( [alert runModal] == NSAlertFirstButtonReturn ) + { + [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithBool:NO]]; + } + else + { + [[NSUserDefaults standardUserDefaults] setValue:[NSNumber numberWithInt:2]]; + } + [alert release]; + } + } +} + -(IBAction)clearClippingList:(id)sender { int choice; diff --git a/English.lproj/MainMenu.nib/designable.nib b/English.lproj/MainMenu.nib/designable.nib index 4f7cb6b..d7ebd08 100644 --- a/English.lproj/MainMenu.nib/designable.nib +++ b/English.lproj/MainMenu.nib/designable.nib @@ -1,5 +1,5 @@ - - + + @@ -142,8 +142,8 @@ - - + + @@ -157,11 +157,12 @@ + - - + - - - + + @@ -205,8 +218,17 @@ - - + + + + + + + + + + + @@ -216,8 +238,8 @@ - - + + @@ -227,8 +249,8 @@ - - + + @@ -236,8 +258,8 @@ - - + + @@ -255,8 +277,8 @@ - - + + @@ -274,8 +296,8 @@ - - + + @@ -283,8 +305,8 @@ - - + + @@ -315,8 +337,8 @@ - - - - + + @@ -346,8 +368,8 @@ - - + + @@ -365,8 +387,8 @@ - - + + @@ -374,8 +396,8 @@ - - - - - + + @@ -419,8 +441,8 @@ - - + + @@ -431,8 +453,8 @@ - - + + @@ -440,8 +462,8 @@ - + @@ -536,7 +570,7 @@ - + @@ -613,6 +647,7 @@ Thanks to Clare Bates Congdon, Joshua Davis, Brad Graham, David Jacobs, John Ken + diff --git a/English.lproj/MainMenu.nib/keyedobjects.nib b/English.lproj/MainMenu.nib/keyedobjects.nib index 63ac20acfa554b8a86be49540e407ee1cdf3f54a..76a1ad3e43ceb89212f7722f1f417ddc1347348f 100644 GIT binary patch literal 36858 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

)0U@$y9@S0 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 fUDSxj^P0`;G4J(fW Void) { + // Notification was generated by CloudKit changes, so check for updates. + MJCloudKitUserDefaultsSync.checkCloudKitUpdates() + // Signal that we have completed our response to notification, with + // parameter assuming that we received new data. + completionHandler(.newData) + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + + let deviceTokenString = deviceToken.reduce("", {$0 + String(format: "%02X", $1)}) + print(deviceTokenString) + + MJCloudKitUserDefaultsSync.setRemoteNotificationsEnabled(true) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + + print("i am not available in simulator \(error)") + MJCloudKitUserDefaultsSync.setRemoteNotificationsEnabled(false) + + } + func applicationWillResignActive(_ application: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. diff --git a/Flycut-iOS/Info.plist b/Flycut-iOS/Info.plist index 0ba7fbf..d59256a 100644 --- a/Flycut-iOS/Info.plist +++ b/Flycut-iOS/Info.plist @@ -22,6 +22,10 @@ 1 LSRequiresIPhoneOS + UIBackgroundModes + + remote-notification + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/Flycut-iOS/ViewController.swift b/Flycut-iOS/ViewController.swift index e3e3ef4..203a1bf 100644 --- a/Flycut-iOS/ViewController.swift +++ b/Flycut-iOS/ViewController.swift @@ -8,11 +8,15 @@ import UIKit -class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource { +class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, FlycutStoreDelegate { let flycut:FlycutOperator = FlycutOperator() - var adjustQuantity:Int = 0 + var activeUpdates:Int = 0 var tableView:UITableView! + var currentAnimation = UITableViewRowAnimation.none + var pbCount:Int = -1 + + let pasteboardInteractionQueue = DispatchQueue(label: "com.Flycut.pasteboardInteractionQueue") // Some buttons we will reuse. var deleteButton:MGSwipeButton? = nil @@ -31,11 +35,11 @@ class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSour deleteButton = MGSwipeButton(title: "Delete", backgroundColor: .red, callback: { (cell) -> Bool in let indexPath = self.tableView.indexPath(for: cell) if ( nil != indexPath ) { + let previousAnimation = self.currentAnimation + self.currentAnimation = UITableViewRowAnimation.left // Use .left to look better with swiping left to delete. self.flycut.setStackPositionTo( Int32((indexPath?.row)! )) self.flycut.clearItemAtStackPosition() - self.tableView.beginUpdates() - self.tableView.deleteRows(at: [indexPath!], with: .left) // Use .left to look better with swiping left to delete. - self.tableView.endUpdates() + self.currentAnimation = previousAnimation } return true; @@ -52,6 +56,12 @@ class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSour return true; }) + // Enable sync by default on iOS until we have a mechanism to adjust preferences on-device. + UserDefaults.standard.set(NSNumber(value: true), forKey: "syncSettingsViaICloud") + UserDefaults.standard.set(NSNumber(value: true), forKey: "syncClippingsViaICloud") + + flycut.setClippingsStoreDelegate(self); + flycut.awake(fromNibDisplaying: 10, withDisplayLength: 140, withSave: #selector(savePreferences(toDict:)), forTarget: self) // The 10 isn't used in iOS right now and 140 characters seems to be enough to cover the width of the largest screen. NotificationCenter.default.addObserver(self, selector: #selector(self.checkForClippingAddedToClipboard), name: .UIPasteboardChanged, object: nil) @@ -63,39 +73,91 @@ class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSour { } + func beginUpdates() + { + if ( !Thread.isMainThread ) + { + DispatchQueue.main.sync { beginUpdates() } + return + } + + print("Begin updates") + print("Num rows: \(tableView.dataSource?.tableView(tableView, numberOfRowsInSection: 0))") + if ( 0 == activeUpdates ) + { + tableView.beginUpdates() + } + activeUpdates += 1 + } + + func endUpdates() + { + if ( !Thread.isMainThread ) + { + DispatchQueue.main.sync { endUpdates() } + return + } + + print("End updates"); + activeUpdates -= 1; + if ( 0 == activeUpdates ) + { + tableView.endUpdates() + } + } + + func insertClipping(at index: Int32) { + if ( !Thread.isMainThread ) + { + DispatchQueue.main.sync { insertClipping(at: index) } + return + } + print("Insert row \(index)") + tableView.insertRows(at: [IndexPath(row: Int(index), section: 0)], with: currentAnimation) // We will override the animation for now, because we are the ViewController and should guide the UX. + } + + func deleteClipping(at index: Int32) { + if ( !Thread.isMainThread ) + { + DispatchQueue.main.sync { deleteClipping(at: index) } + return + } + print("Delete row \(index)") + tableView.deleteRows(at: [IndexPath(row: Int(index), section: 0)], with: currentAnimation) // We will override the animation for now, because we are the ViewController and should guide the UX. + } + + func reloadClipping(at index: Int32) { + if ( !Thread.isMainThread ) + { + DispatchQueue.main.sync { reloadClipping(at: index) } + return + } + print("Reloading row \(index)") + tableView.reloadRows(at: [IndexPath(row: Int(index), section: 0)], with: currentAnimation) // We will override the animation for now, because we are the ViewController and should guide the UX. + } + + func moveClipping(at index: Int32, to newIndex: Int32) { + if ( !Thread.isMainThread ) + { + DispatchQueue.main.sync { moveClipping(at: index, to: newIndex) } + return + } + print("Moving row \(index) to \(newIndex)") + tableView.moveRow(at: IndexPath(row: Int(index), section: 0), to: IndexPath(row: Int(newIndex), section: 0)) + } + func checkForClippingAddedToClipboard() { - let pasteboard = UIPasteboard.general.string - if ( nil != pasteboard ) - { - - let startCount = Int(flycut.jcListCount()) - let previousIndex = flycut.index(ofClipping: pasteboard, ofType: "public.utf8-plain-text", fromApp: "iOS", withAppBundleURL: "iOS") - let added = flycut.addClipping(pasteboard, ofType: "public.utf8-plain-text", fromApp: "iOS", withAppBundleURL: "iOS", target: nil, clippingAddedSelector: nil) - - if ( added ) + pasteboardInteractionQueue.async { + if ( UIPasteboard.general.changeCount != self.pbCount ) { - var reAdjustQuantity = 0 - var deleteIndex = -1 - if( -1 < previousIndex ) - { - deleteIndex = Int(previousIndex) - } - else if(startCount == Int(flycut.jcListCount())) - { - deleteIndex = startCount - 1 - } + self.pbCount = UIPasteboard.general.changeCount; - tableView.beginUpdates() - if ( deleteIndex >= 0 ) + if ( UIPasteboard.general.types.contains("public.utf8-plain-text") ) { - adjustQuantity -= 1 - reAdjustQuantity = 1 - tableView.deleteRows(at: [IndexPath(row: deleteIndex, section: 0)], with: .none) + let pasteboard = UIPasteboard.general.value(forPasteboardType: "public.utf8-plain-text") + self.flycut.addClipping(pasteboard as! String!, ofType: "public.utf8-plain-text", fromApp: "iOS", withAppBundleURL: "iOS", target: nil, clippingAddedSelector: nil) } - adjustQuantity += reAdjustQuantity - tableView.insertRows(at: [IndexPath(row: 0, section: 0)], with: .none) - tableView.endUpdates() } } } @@ -121,7 +183,7 @@ class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSour } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return Int(flycut.jcListCount()) + adjustQuantity + return Int(flycut.jcListCount()) } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { @@ -162,10 +224,27 @@ class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSour func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { if ( MGSwipeState.none == (tableView.cellForRow(at: indexPath) as! MGSwipeTableCell).swipeState ) { - let content = flycut.clippingString(withCount: Int32(indexPath.row) ) + tableView.deselectRow(at: indexPath, animated: true) // deselect before getPaste since getPaste may reorder the list + let content = flycut.getPasteFrom(Int32(indexPath.row)) print("Select: \(indexPath.row) \(content) OK") - tableView.deselectRow(at: indexPath, animated: true) - UIPasteboard.general.string = content + + pasteboardInteractionQueue.async { + // Capture value before setting the pastboard for reasons noted below. + self.pbCount = UIPasteboard.general.changeCount + + // This call will clear all other content types and appears to immediately increment the changeCount. + UIPasteboard.general.setValue(content as Any, forPasteboardType: "public.utf8-plain-text") + + // Apple documents that "UIPasteboard waits until the end of the current event loop before incrementing the change count", but this doesn't seem to be the case for the above call. Handle both scenarios by doing a simple increment if unchanged and an update-to-match if changed. + if ( UIPasteboard.general.changeCount == self.pbCount ) + { + self.pbCount += 1 + } + else + { + self.pbCount = UIPasteboard.general.changeCount + } + } } } } diff --git a/Flycut.entitlements b/Flycut.entitlements index 4cec977..349c7b6 100644 --- a/Flycut.entitlements +++ b/Flycut.entitlements @@ -2,8 +2,6 @@ - com.apple.developer.aps-environment - development com.apple.developer.icloud-container-identifiers iCloud.com.mark-a-jerde.Flycut diff --git a/Flycut.xcodeproj/project.pbxproj b/Flycut.xcodeproj/project.pbxproj index 3cf510e..0a0e31d 100755 --- a/Flycut.xcodeproj/project.pbxproj +++ b/Flycut.xcodeproj/project.pbxproj @@ -224,6 +224,7 @@ 8D2E28831B0669F500AE62C8 /* com.generalarcade.flycut.black.32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = com.generalarcade.flycut.black.32.png; path = Resources/com.generalarcade.flycut.black.32.png; sourceTree = ""; }; 8D2E28841B0669F500AE62C8 /* com.generalarcade.flycut.xout.16.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = com.generalarcade.flycut.xout.16.png; path = Resources/com.generalarcade.flycut.xout.16.png; sourceTree = ""; }; 8D2E28851B0669F500AE62C8 /* com.generalarcade.flycut.xout.32.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = com.generalarcade.flycut.xout.32.png; path = Resources/com.generalarcade.flycut.xout.32.png; sourceTree = ""; }; + 8D61FD581F7961D200BD4B3D /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS10.2.sdk/System/Library/Frameworks/UserNotifications.framework; sourceTree = DEVELOPER_DIR; }; 8D84B6361F72FBA900DB58F9 /* Flycut.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Flycut.entitlements; sourceTree = ""; }; 8D84B63A1F7412E600DB58F9 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS10.2.sdk/System/Library/Frameworks/CloudKit.framework; sourceTree = DEVELOPER_DIR; }; 8DC9C99B1F742115003BE8B5 /* CloudKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CloudKit.framework; path = System/Library/Frameworks/CloudKit.framework; sourceTree = SDKROOT; }; @@ -378,6 +379,7 @@ 29B97323FDCFA39411CA2CEA /* Frameworks */ = { isa = PBXGroup; children = ( + 8D61FD581F7961D200BD4B3D /* UserNotifications.framework */, 8DC9C99B1F742115003BE8B5 /* CloudKit.framework */, 8D84B63A1F7412E600DB58F9 /* CloudKit.framework */, 1058C7A0FEA54F0111CA2CBB /* Linked Frameworks */, @@ -652,6 +654,9 @@ DevelopmentTeam = 66X95R6W2D; ProvisioningStyle = Automatic; SystemCapabilities = { + com.apple.BackgroundModes = { + enabled = 1; + }; com.apple.Push = { enabled = 1; }; @@ -675,7 +680,7 @@ ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.Push = { - enabled = 1; + enabled = 0; }; com.apple.Sandbox = { enabled = 0; diff --git a/FlycutEngine/FlycutClipping.m b/FlycutEngine/FlycutClipping.m index 8bb4798..5997530 100755 --- a/FlycutEngine/FlycutClipping.m +++ b/FlycutEngine/FlycutClipping.m @@ -102,7 +102,7 @@ -(void) setDisplayLength:(int)newDisplayLength { - if ( newDisplayLength > 0 ) { + if ( newDisplayLength > 0 && clipDisplayLength != newDisplayLength ) { clipDisplayLength = newDisplayLength; [self resetDisplayString]; } @@ -222,9 +222,7 @@ if (!other || ![other isKindOfClass:[self class]]) return NO; FlycutClipping * otherClip = (FlycutClipping *)other; - return ([self.type isEqualToString:otherClip.type] && - [self.displayString isEqualToString:otherClip.displayString] && - (self.displayLength == otherClip.displayLength) && + return (/*[self.type isEqualToString:otherClip.type] &&*/ // Type is under-utilized a this time and will mismatch on cross-device (macOS <-> iOS) usage. This should be revisited once we have support for more than just raw text clippings. [self.contents isEqualToString:otherClip.contents]); } diff --git a/FlycutEngine/FlycutStore.h b/FlycutEngine/FlycutStore.h index 2f1c36f..9a886c2 100755 --- a/FlycutEngine/FlycutStore.h +++ b/FlycutEngine/FlycutStore.h @@ -13,6 +13,18 @@ #import #import "FlycutClipping.h" +@protocol FlycutStoreDelegate +@optional +- (void)beginUpdates; // allow multiple insert/delete of rows and sections to be animated simultaneously. Nestable + +- (void)endUpdates; // only call insert/delete/reload calls or change the editing state inside an update block. otherwise things like row count, etc. may be invalid. + +- (void)insertClippingAtIndex:(int)index; +- (void)deleteClippingAtIndex:(int)index; +- (void)reloadClippingAtIndex:(int)index; +- (void)moveClippingAtIndex:(int)index toIndex:(int)newIndex; +@end + @interface FlycutStore : NSObject { // Our various listener-related preferences @@ -50,11 +62,14 @@ -(NSArray *) previousDisplayStrings:(int)howMany; -(NSArray *) previousDisplayStrings:(int)howMany containing:(NSString*)search; -(NSArray *) previousIndexes:(int)howMany containing:(NSString*)search; // This method is in newest-first order. +-(int) indexOfClipping:(FlycutClipping*) clipping; -(int) indexOfClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(int) timestamp; +-(bool) removeDuplicates; // Add a clipping -(bool) addClipping:(NSString *)clipping ofType:(NSString *)type fromAppLocalizedName:(NSString *)appLocalizedName fromAppBundleURL:(NSString *)bundleURL atTimestamp:(NSInteger) timestamp; -(void) addClipping:(FlycutClipping*) clipping; +-(void) insertClipping:(FlycutClipping*) clipping atIndex:(int) index; // Delete a clipping -(void) clearItem:(int)index; @@ -67,6 +82,10 @@ // Move the clipping at index to the top -(void) clippingMoveToTop:(int)index; +-(void) clippingMoveFrom:(int)index To:(int)toIndex; + +/** optional delegate (not retained) */ +@property (nonatomic, nullable, assign) id delegate; // Delete all named clippings @end diff --git a/FlycutEngine/FlycutStore.m b/FlycutEngine/FlycutStore.m index e248011..8d5c9e3 100755 --- a/FlycutEngine/FlycutStore.m +++ b/FlycutEngine/FlycutStore.m @@ -56,10 +56,16 @@ } -(int) indexOfClipping:(FlycutClipping*) clipping{ - if (![jcList containsObject:clipping]) { + return [self indexOfClipping:clipping afterIndex:-1]; +} + +-(int) indexOfClipping:(FlycutClipping*) clipping afterIndex:(int) after{ + NSUInteger index = [jcList indexOfObject:clipping + inRange:NSMakeRange(after + 1, [jcList count] - (after + 1) )]; + if ( NSNotFound == index ) { return -1; } - return (int)[jcList indexOfObject:clipping]; + return (int)index; } // Add a clipping @@ -83,16 +89,45 @@ return YES; } +-(bool) removeDuplicates{ + return [[[NSUserDefaults standardUserDefaults] valueForKey:@"removeDuplicates"] boolValue]; +} + -(void) addClipping:(FlycutClipping*) clipping{ - if ([jcList containsObject:clipping] && [[[NSUserDefaults standardUserDefaults] valueForKey:@"removeDuplicates"] boolValue]) { + [self insertClipping:clipping atIndex:0]; +} + +-(void) insertClipping:(FlycutClipping*) clipping atIndex:(int) index{ + [self delegateBeginUpdates]; + + int moveFromIndex = -1; + if ([jcList containsObject:clipping] && [self removeDuplicates]) { + moveFromIndex = (int)[jcList indexOfObject:clipping]; [jcList removeObject:clipping]; } + // Push it onto our recent clippings stack - [jcList insertObject:clipping atIndex:0]; + if ( index < [jcList count] ) { + [jcList insertObject:clipping atIndex:index]; + } + else { + // If the index is beyond the current count then just append it and disregard requested index. + // This doesn't alter the remember number and the jcList is self-growing so it is fine to append. + index = [jcList count]; + [jcList addObject:clipping]; + } + if ( moveFromIndex >= 0 ) + [self delegateMoveClippingAtIndex:moveFromIndex toIndex:index]; + else + [self delegateInsertClippingAtIndex:index]; + // Delete clippings older than jcRememberNum while ( [jcList count] > jcRememberNum ) { [jcList removeObjectAtIndex:jcRememberNum]; + [self delegateDeleteClippingAtIndex:(jcRememberNum-1)]; // -1 for before-add indexing } + + [self delegateEndUpdates]; } -(void) addClipping:(NSString *)clipping ofType:(NSString *)type withPBCount:(int *)pbCount @@ -102,10 +137,17 @@ // Clear remembered and listed -(void) clearList { + [self delegateBeginUpdates]; + + for ( int i = (int)[jcList count] ; i > 0 ; i-- ) + [self delegateDeleteClippingAtIndex:(i-1)]; + NSMutableArray *emptyJCList; emptyJCList = [[NSMutableArray alloc] init]; [jcList release]; jcList = emptyJCList; + + [self delegateEndUpdates]; } -(void) mergeList { @@ -115,14 +157,29 @@ -(void) clearItem:(int)index { + [self delegateBeginUpdates]; + [jcList removeObjectAtIndex:index]; + [self delegateDeleteClippingAtIndex:index]; + + [self delegateEndUpdates]; } -(void) clippingMoveToTop:(int)index { + [self clippingMoveFrom:index To:0]; +} + +-(void) clippingMoveFrom:(int)index To:(int)toIndex +{ + [self delegateBeginUpdates]; + FlycutClipping *clipping = [jcList objectAtIndex:index]; - [jcList insertObject:clipping atIndex:0]; + [jcList insertObject:clipping atIndex:toIndex]; [jcList removeObjectAtIndex:index+1]; + [self delegateMoveClippingAtIndex:index toIndex:toIndex]; + + [self delegateEndUpdates]; } // Set various values @@ -130,8 +187,15 @@ { if ( nowRemembering > 0 ) { jcRememberNum = nowRemembering; - while ( [jcList count] > jcRememberNum ) { - [jcList removeObjectAtIndex:jcRememberNum]; + + if ( [jcList count] > jcRememberNum ) { + [self delegateBeginUpdates]; + + while ( [jcList count] > jcRememberNum ) { + [jcList removeObjectAtIndex:jcRememberNum]; + [self delegateDeleteClippingAtIndex:jcRememberNum]; + } + [self delegateEndUpdates]; } } } @@ -295,6 +359,42 @@ return returnArray; } +-(void) delegateBeginUpdates +{ + if ( self.delegate && [self.delegate respondsToSelector:@selector(beginUpdates)] ) + [self.delegate beginUpdates]; +} + +-(void) delegateEndUpdates +{ + if ( self.delegate && [self.delegate respondsToSelector:@selector(endUpdates)] ) + [self.delegate endUpdates]; +} + +-(void) delegateInsertClippingAtIndex:(int)index +{ + if ( self.delegate && [self.delegate respondsToSelector:@selector(insertClippingAtIndex:)] ) + [self.delegate insertClippingAtIndex:index]; +} + +-(void) delegateDeleteClippingAtIndex:(int)index +{ + if ( self.delegate && [self.delegate respondsToSelector:@selector(deleteClippingAtIndex:)] ) + [self.delegate deleteClippingAtIndex:index]; +} + +-(void) delegateReloadClippingAtIndex:(int)index +{ + if ( self.delegate && [self.delegate respondsToSelector:@selector(reloadClippingAtIndex:)] ) + [self.delegate reloadClippingAtIndex:index]; +} + +-(void) delegateMoveClippingAtIndex:(int)index toIndex:(int)newIndex +{ + if ( self.delegate && [self.delegate respondsToSelector:@selector(moveClippingAtIndex:toIndex:)] ) + [self.delegate moveClippingAtIndex:index toIndex:newIndex]; +} + -(void) dealloc { // Free preferences diff --git a/FlycutOperator.h b/FlycutOperator.h index dfbe8fc..f8180f4 100644 --- a/FlycutOperator.h +++ b/FlycutOperator.h @@ -28,8 +28,13 @@ SEL saveSelector; NSObject* saveTarget; + int displayNum; + int displayLength; + + NSArray *settingsSyncList; BOOL disableStore; + BOOL inhibitSaveEngineAfterListModification; } // Basic functionality @@ -61,6 +66,8 @@ // Save and load -(void) saveEngine; -(bool) loadEngineFromPList; +-(void) registerOrDeregisterICloudSync; +-(void) checkCloudKitUpdates; // Preference related -(void) setRememberNum:(int)newRemember; @@ -78,11 +85,14 @@ // Clippings Store related -(int)jcListCount; +-(int)rememberNum; -(FlycutClipping*)clippingAtStackPosition; -(NSArray *) previousDisplayStrings:(int)howMany containing:(NSString*)search; -(NSArray *) previousIndexes:(int)howMany containing:(NSString*)search; // This method is in newest-first order. -(void)setDisableStoreTo:(bool) value; -(bool)storeDisabled; +-(void)setClippingsStoreDelegate:(id) delegate; +-(void)setFavoritesStoreDelegate:(id) delegate; @end diff --git a/FlycutOperator.m b/FlycutOperator.m index 392632e..595588d 100644 --- a/FlycutOperator.m +++ b/FlycutOperator.m @@ -14,6 +14,7 @@ #import #import "FlycutOperator.h" +#import "MJCloudKitUserDefaultsSync.h" @implementation FlycutOperator @@ -48,31 +49,83 @@ @"saveForgottenFavorites", [NSNumber numberWithBool:YES], // do not commit with YES. Use NO @"pasteMovesToTop", + [NSNumber numberWithBool:NO], + @"syncSettingsViaICloud", + [NSNumber numberWithBool:NO], + @"syncClippingsViaICloud", nil]]; + + settingsSyncList = @[@"rememberNum", + @"favoritesRememberNum", + @"savePreference", + @"skipPasswordFields", + @"skipPboardTypes", + @"skipPboardTypesList", + @"skipPasswordLengths", + @"skipPasswordLengthsList", + @"removeDuplicates", + @"saveForgottenClippings", + @"saveForgottenFavorites", + @"pasteMovesToTop"]; + [settingsSyncList retain]; + return self; } -- (void)awakeFromNibDisplaying:(int) displayNum withDisplayLength:(int) displayLength withSaveSelector:(SEL) selector forTarget:(NSObject*) target +- (void)awakeFromNibDisplaying:(int) dispNum withDisplayLength:(int) dispLength withSaveSelector:(SEL) selector forTarget:(NSObject*) target { - // Initialize the FlycutStore - clippingStore = [[FlycutStore alloc] initRemembering:[[NSUserDefaults standardUserDefaults] integerForKey:@"rememberNum"] - displaying:displayNum - withDisplayLength:displayLength]; - favoritesStore = [[FlycutStore alloc] initRemembering:[[NSUserDefaults standardUserDefaults] integerForKey:@"favoritesRememberNum"] - displaying:displayNum - withDisplayLength:displayLength]; + displayNum = dispNum; + displayLength = dispLength; saveSelector = selector; saveTarget = target; - stashedStore = NULL; - // If our preferences indicate that we are saving, load the dictionary from the saved plist - // and use it to get everything set up. - if ( [[NSUserDefaults standardUserDefaults] integerForKey:@"savePreference"] >= 1 ) { - [self loadEngineFromPList]; - } + // Initialize the FlycutStore + [self initializeStoresAndLoadContents]; // Stack position starts @ 0 by default stackPosition = favoritesStackPosition = stashedStackPosition = 0; + + [self registerOrDeregisterICloudSync]; +} + +-(FlycutStore*) allocInitFlycutStoreRemembering:(int) remembering +{ + return [[FlycutStore alloc] initRemembering:remembering + displaying:displayNum + withDisplayLength:displayLength]; +} + +-(void) initializeStores +{ + // Fixme - These stores are not released anywhere. + if ( !clippingStore ) + clippingStore = [self allocInitFlycutStoreRemembering:[[NSUserDefaults standardUserDefaults] integerForKey:@"rememberNum"]]; + else + { + [clippingStore setDisplayNum:displayNum]; + [clippingStore setDisplayLen:displayLength]; + } + + if ( ! favoritesStore ) + favoritesStore = [self allocInitFlycutStoreRemembering:[[NSUserDefaults standardUserDefaults] integerForKey:@"favoritesRememberNum"]]; + else + { + [favoritesStore setDisplayNum:displayNum]; + [favoritesStore setDisplayLen:displayLength]; + } + + stashedStore = NULL; +} + +-(void) initializeStoresAndLoadContents +{ + [self initializeStores]; + + // If our preferences indicate that we are saving, load the dictionary from the saved plist + // and use it to get everything set up. + if ( [[NSUserDefaults standardUserDefaults] integerForKey:@"savePreference"] >= 1 ) { + [self loadEngineFromPList]; + } } -(void) setRememberNum:(int) newRemember @@ -178,7 +231,7 @@ } // Get text from clipping store. [favoritesStore addClipping:[clippingStore clippingAtPosition:stackPosition] ]; - [clippingStore clearItem:stackPosition]; + [self clearItemAtStackPosition]; return YES; } return NO; @@ -190,6 +243,8 @@ if ( [[NSUserDefaults standardUserDefaults] boolForKey:@"pasteMovesToTop"] ) { [clippingStore clippingMoveToTop:position]; stackPosition = 0; + + [self actionAfterListModification]; } return clipping; } @@ -215,6 +270,7 @@ // Check to see if they want a little help figuring out what types to enter. if ( [[NSUserDefaults standardUserDefaults] boolForKey:@"revealPasteboardTypes"] ) [clippingStore addClipping:type ofType:type fromAppLocalizedName:@"Flycut" fromAppBundleURL:nil atTimestamp:0]; + [self actionAfterListModification]; __block bool skipClipping = NO; @@ -283,6 +339,20 @@ return disableStore; } +-(void)setClippingsStoreDelegate:(id) delegate +{ + if ( !clippingStore ) + [self initializeStores]; + clippingStore.delegate = delegate; +} + +-(void)setFavoritesStoreDelegate:(id) delegate +{ + if ( !favoritesStore ) + [self initializeStores]; + favoritesStore.delegate = delegate; +} + -(int)indexOfClipping:(NSString*)contents ofType:(NSString*)type fromApp:(NSString *)appName withAppBundleURL:(NSString *)bundleURL { return [clippingStore indexOfClipping:contents @@ -301,7 +371,8 @@ // clippingStore is full, so save the last entry before it gets lost. // Set to last item, save, and restore position. int savePosition = stackPosition; - stackPosition = [clippingStore rememberNum]-1; + stackPosition = [clippingStore rememberNum]-1; + [self saveFromStackWithPrefix:@"Autosave "]; stackPosition = savePosition; } @@ -311,23 +382,35 @@ fromAppLocalizedName:appName fromAppBundleURL:bundleURL atTimestamp:[[NSDate date] timeIntervalSince1970]]; + // The below tracks our position down down down... Maybe as an option? // if ( [clippingStore jcListCount] > 1 ) stackPosition++; stackPosition = 0; [selectorTarget performSelector:clippingAddedSelector]; - if ( [[NSUserDefaults standardUserDefaults] integerForKey:@"savePreference"] >= 2 ) - [self saveEngine]; + [self actionAfterListModification]; return success; } return NO; } +-(void)actionAfterListModification +{ + if ( !inhibitSaveEngineAfterListModification + && [[NSUserDefaults standardUserDefaults] integerForKey:@"savePreference"] >= 2 ) + [self saveEngine]; +} + -(int)jcListCount { return [clippingStore jcListCount]; } +-(int)rememberNum +{ + return [clippingStore rememberNum]; +} + -(int)stackPosition { return stackPosition; @@ -374,7 +457,9 @@ if ([clippingStore jcListCount] == 0) return NO; - [clippingStore clearItem:stackPosition]; + [clippingStore clearItem:stackPosition]; + [self actionAfterListModification]; + return YES; } @@ -404,11 +489,12 @@ -(void)clearList { [clippingStore clearList]; + [self actionAfterListModification]; } -(void)mergeList { - [clippingStore mergeList]; + [clippingStore mergeList]; } -(BOOL) isValidClippingNumber:(NSNumber *)number { @@ -423,52 +509,173 @@ } } +-(void) registerOrDeregisterICloudSync +{ + if ( [[NSUserDefaults standardUserDefaults] boolForKey:@"syncSettingsViaICloud"] ) { + [MJCloudKitUserDefaultsSync startWithKeyMatchList:settingsSyncList + withContainerIdentifier:@"iCloud.com.mark-a-jerde.Flycut"]; + } + else { + [MJCloudKitUserDefaultsSync stopForKeyMatchList:settingsSyncList]; + } + + if ( [[NSUserDefaults standardUserDefaults] boolForKey:@"syncClippingsViaICloud"] ) { + [MJCloudKitUserDefaultsSync removeNotificationsFor:MJSyncNotificationChanges forTarget:self]; + [MJCloudKitUserDefaultsSync addNotificationFor:MJSyncNotificationChanges withSelector:@selector(checkPreferencesChanges:) withTarget: self]; + + [MJCloudKitUserDefaultsSync removeNotificationsFor:MJSyncNotificationConflicts forTarget:self]; + [MJCloudKitUserDefaultsSync addNotificationFor:MJSyncNotificationConflicts withSelector:@selector(checkPreferencesConflicts:) withTarget: self]; + + [MJCloudKitUserDefaultsSync startWithKeyMatchList:@[@"store"] + withContainerIdentifier:@"iCloud.com.mark-a-jerde.Flycut"]; + } + else { + [MJCloudKitUserDefaultsSync removeNotificationsFor:MJSyncNotificationChanges forTarget:self]; + + [MJCloudKitUserDefaultsSync stopForKeyMatchList:@[@"store"]]; + } +} + +-(NSDictionary*) checkPreferencesChanges:(NSDictionary*)changes +{ + if ( [changes valueForKey:@"store"] ) + { + inhibitSaveEngineAfterListModification = YES; + + [self integrateStoreAtKey:@"jcList" into:clippingStore]; + [self integrateStoreAtKey:@"favoritesList" into:favoritesStore]; + + inhibitSaveEngineAfterListModification = NO; + [self actionAfterListModification]; + } + return nil; +} + +-(void) integrateStoreAtKey:(NSString*)listKey into:(FlycutStore*)store +{ + FlycutStore *newContent = [self allocInitFlycutStoreRemembering:[clippingStore rememberNum]]; + NSDictionary *loadDict = [[[NSUserDefaults standardUserDefaults] dictionaryForKey:@"store"] copy]; + + if ( loadDict && [self loadEngineFrom:loadDict key:listKey into:newContent] ) + { + int newCount = [newContent jcListCount]; + for ( int i = 0 ; i < newCount ; i++ ) + { + FlycutClipping *newClipping = [newContent clippingAtPosition:i]; + if ( i >= [store jcListCount] ) + { + // Clipping was beyond the end of the store, so just add it. + [newClipping setDisplayLength:displayLength]; + [store insertClipping:newClipping atIndex:i]; + } + else if ( ![newClipping isEqual:[store clippingAtPosition:i]] ) + { + int firstIndex = [store indexOfClipping:newClipping]; + if ( firstIndex < 0 ) + { + // Clipping wasn't previously in the store, so just add it. + [newClipping setDisplayLength:displayLength]; + [store insertClipping:newClipping atIndex:i]; + } + else if ( [newContent indexOfClipping:[store clippingAtPosition:i]] < 0 ) + { + // Contents in the store at this position didn't exist in the newContent. Handle deletion. + [store clearItem:i]; + i--; + } + else if ( [store removeDuplicates] ) + { + if ( i < firstIndex ) + [store clippingMoveFrom:firstIndex To:i]; + else + { + // This can only happen if the remote store allowed duplicates and we do not. Just delete from the new content and move on. + [newContent clearItem:i]; + i--; + newCount--; + } + } + else + { + [newClipping setDisplayLength:displayLength]; + [store insertClipping:newClipping atIndex:i]; + } + } + } + while ( [store jcListCount] > newCount ) + [store clearItem:newCount]; + +#ifdef DEBUG + [newContent release]; + newContent = [self allocInitFlycutStoreRemembering:[clippingStore rememberNum]]; + [self loadEngineFrom:loadDict key:listKey into:newContent]; + newCount = [newContent jcListCount]; + if ( newCount != [store jcListCount] ) + NSLog(@"Error in integrateStoreAtKey with mismatching after counts!"); + else + { + for ( int i = 0 ; i < newCount ; i++ ) + { + if ( ![[store clippingAtPosition:i] isEqual:[newContent clippingAtPosition:i]] ) + NSLog(@"Error in integrateStoreAtKey with mismatching clippings at index %i!", i); + } + } +#endif + } + + [newContent release]; +} + +-(NSDictionary*) checkPreferencesConflicts:(NSDictionary*)changes +{ + NSMutableDictionary *corrections = nil; + if ( [changes valueForKey:@"store"] ) + { + // Just clobber back, for now. They already finished their save, so they won't notice and will just think we made a calculated change. + NSLog(@"Oh. My changes were clobbered. I will clobber back."); + if ( !corrections ) + corrections = [[NSMutableDictionary alloc] init]; + corrections[@"store"] = [changes valueForKey:@"store"][1]; // We win. + } + return corrections; +} + +-(void) checkCloudKitUpdates +{ + [MJCloudKitUserDefaultsSync checkCloudKitUpdates]; +} + +-(bool) loadEngineFrom:(NSDictionary*)loadDict key:(NSString*)listKey into:(FlycutStore*)store +{ + NSArray *savedJCList = [loadDict objectForKey:listKey]; + if ( [savedJCList isKindOfClass:[NSArray class]] ) { + // There's probably a nicer way to prevent the range from going out of bounds, but this works. + int rangeCap = [savedJCList count] < [store rememberNum] ? [savedJCList count] : [store rememberNum]; + NSRange loadRange = NSMakeRange(0, rangeCap); + NSArray *toBeRestoredClips = [[[savedJCList subarrayWithRange:loadRange] reverseObjectEnumerator] allObjects]; + for( NSDictionary *aSavedClipping in toBeRestoredClips) + [store addClipping:[aSavedClipping objectForKey:@"Contents"] + ofType:[aSavedClipping objectForKey:@"Type"] + fromAppLocalizedName:[aSavedClipping objectForKey:@"AppLocalizedName"] + fromAppBundleURL:[aSavedClipping objectForKey:@"AppBundleURL"] + atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue]]; + return YES; + } else DLog(@"Not array"); + return NO; +} + -(bool) loadEngineFromPList { - NSDictionary *loadDict = [[[NSUserDefaults standardUserDefaults] dictionaryForKey:@"store"] copy]; - NSArray *savedJCList; - NSRange loadRange; + NSDictionary *loadDict = [[[NSUserDefaults standardUserDefaults] dictionaryForKey:@"store"] copy]; - int rangeCap; - - if ( loadDict != nil ) { - - savedJCList = [loadDict objectForKey:@"jcList"]; - - if ( [savedJCList isKindOfClass:[NSArray class]] ) { - int rememberNumPref = [[NSUserDefaults standardUserDefaults] - integerForKey:@"rememberNum"]; - // There's probably a nicer way to prevent the range from going out of bounds, but this works. - rangeCap = [savedJCList count] < rememberNumPref ? [savedJCList count] : rememberNumPref; - loadRange = NSMakeRange(0, rangeCap); - NSArray *toBeRestoredClips = [[[savedJCList subarrayWithRange:loadRange] reverseObjectEnumerator] allObjects]; - for( NSDictionary *aSavedClipping in toBeRestoredClips) - [clippingStore addClipping:[aSavedClipping objectForKey:@"Contents"] - ofType:[aSavedClipping objectForKey:@"Type"] - fromAppLocalizedName:[aSavedClipping objectForKey:@"AppLocalizedName"] - fromAppBundleURL:[aSavedClipping objectForKey:@"AppBundleURL"] - atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue]]; - - // Now for the favorites, same thing. - savedJCList =[loadDict objectForKey:@"favoritesList"]; - if ( [savedJCList isKindOfClass:[NSArray class]] ) { - rememberNumPref = [[NSUserDefaults standardUserDefaults] - integerForKey:@"favoritesRememberNum"]; - rangeCap = [savedJCList count] < rememberNumPref ? [savedJCList count] : rememberNumPref; - loadRange = NSMakeRange(0, rangeCap); - toBeRestoredClips = [[[savedJCList subarrayWithRange:loadRange] reverseObjectEnumerator] allObjects]; - for( NSDictionary *aSavedClipping in toBeRestoredClips) - [favoritesStore addClipping:[aSavedClipping objectForKey:@"Contents"] - ofType:[aSavedClipping objectForKey:@"Type"] - fromAppLocalizedName:[aSavedClipping objectForKey:@"AppLocalizedName"] - fromAppBundleURL:[aSavedClipping objectForKey:@"AppBundleURL"] - atTimestamp:[[aSavedClipping objectForKey:@"Timestamp"] integerValue]]; - } - } else DLog(@"Not array"); - [loadDict release]; - return YES; - } - return NO; + if ( loadDict != nil ) { + bool success = NO; + success |= [self loadEngineFrom:loadDict key:@"jcList" into:clippingStore]; + success |= [self loadEngineFrom:loadDict key:@"favoritesList" into:favoritesStore]; + [loadDict release]; + return success; + } + return NO; } -(bool)setStackPositionToOneLessRecent