Easy file syncing between iOS, MacOS and tvOS, using CloudKit.
import FileSinki
Adopt the FileSyncable protocol to make your data work with FileSinki.
The most basic function is shouldOverwrite
, which decides what to do if a local copy and a remote (cloud) copy of the data conflicts.
struct SaveGame: FileSyncable {
let score: Double
func shouldOverwrite(other: Self) -> Bool {
return score > other.score
}
}
If your struct / class already conforms to Comparable, shouldOverwrite by default overwrites if self > other
// load a SaveGame from a file with path: "SaveGames/player1.save"
FileSinki.load(SaveGame.self,
fromPath: "SaveGames/player1.save") { saveGame, wasRemote in
// closure *may* be called multiple times,
// if the cloud has a better version of saveGame
}
// save a saveGame to a file with path: "SaveGames/player1.save"
FileSinki.save(saveGame,
toPath: "SaveGames/player1.save") { finalVersion in
// closure *may* be called with finalVersion
// if the saveGame changed as a result of a merge
// or a better available version
}
// delete the saveGame
FileSinki.delete(saveGame, at: "SaveGames/player1.save")
Adopt the FileMergable protocol and implement merge(with:)
to merge FileSyncables between devices.
Return the new merged object / struct which will be used.
struct SaveGame: FileSyncable, FileMergable {
let trophies: [Trophy]
func merge(with other: Self) -> Self? {
let combinedTrophies = (trophies + other.trophies).sorted()
return SaveGame(trophies: combinedTrophies)
}
}
If you return nil from merge(with:)
then FileSinki falls back to shouldOverwrite(other:)
If your decisions whether to overwrite / how to merge are more involved and require either user intervention or asynchromous work, implement one of the following functions:
extension SaveGame: FileSyncable {
func shouldOverwriteAsync(other: SaveGame,
keep: @escaping ShouldOverwriteClosure) {
// Do any kind of async decision making necessary.
// You just have to call keep() with the version you want to keep
SomeUserPrompt.chooseBetween(self, other) { userSelection in
keep(userSelection)
}
}
}
extension SaveGame: FileMergable, FileSyncable {
func mergeAsync(with other: SaveGame,
merged: @escaping MergedClosure) {
// Do any kind of async merging necessary.
// You just have to call merged() with the
// final merged version you want to keep
SomeSaveGameMergerThing.merge(self, other) { mergedSaveGame in
merged(mergedSaveGame)
}
}
}
Inside you can do any work asynchronously or in different threads, you just have to call keep
or merged
once the work is complete with the final item to use.
Similar to adding observers to the NotificationCenter
, you can watch for changes to items that happen on other devices:
FileSinki.addObserver(self,
for: SaveGame.self,
path: "SaveGames/player1.save") { changed in
// any time a SaveGame in the file player1.save changes remotely, this closure will be called.
let changedSaveGame = changed.item
print("Observed FileSinki change in \(changedSaveGame) with local URL \(changed.localURL) and path: \(changed.path)")
}
If the path provided ends in a trailing slash /
, then any files in that folder will be recursively checked for changes:
FileSinki.addObserver(self,
for: SaveGame.self,
path: "SaveGames/") { changed in
// any time a SaveGame anywhere in SaveGames/ changes remotely, this closure will be called.
let changedSaveGame = changed.item
print("Observed change in \(changedSaveGame) with local URL \(changed.localURL) and path: \(changed.path)")
}
If you are dealing with raw Data files or non Codable objects/structs you can use FileSinki at the raw data level.
// load a PDF from a file with path: "test.pdf"
FileSinki.loadBinaryFile(fromPath: "test.pdf",
mergeAsync: { left, right, merged in
let leftPDF = PDF(data: left)
let rightPDF = PDF(data: right)
SomePDFMerger.merge(leftPDF, rightPDF) { finalMergedPDF in {
merged(finalMergedPDF.data)
}
}) { data, wasRemote in
// closure *may* be called multiple times,
// if the cloud has a better version of your data
let loadedPDF = PDF(data: data) // the final data object which has been merged across devices
}
FileSinki.saveBinaryFile(pdf.data,
toPath: "test.pdf",
mergeAsync: { left, right, merged in
let leftPDF = PDF(data: left)
let rightPDF = PDF(data: right)
SomePDFMerger.merge(leftPDF, rightPDF) { finalMergedPDF in {
merged(finalMergedPDF.data)
}
}) { finalData in
// closure *may* be called with finalData
// if the data changed as a result of a merge
// or a better available version
let loadedPDF = PDF(data: finalData) // the final data object which has been merged across devices
}
FileSinki.deleteBinaryFile(pdf.data, at: "test.pdf")
Observing remote changes with binary files is more limited than with FileSyncables. You will only be notified of which paths / local urls which have changed. It is your responsibility to then load the binary files yourself.
FileSinki.addObserver(self,
path: "test.pdf") { changed in
// any time test.pdf changes remotely, this closure will be called.
print("Observed a binary file change with path: \(changed.path)")
// You'll probably want to actually do something now that you know a binary file has changed remotely.
FileSinki.loadBinaryFile(...
}
By default FileSinki puts files in .applicationSupportDirectory + bundle name
. You can specify a different location using the optional root
parameter.
// load a SaveGame from a file with path: "SaveGames/player1.save" inside the Documents directory
FileSinki.load(SaveGame.self,
fromPath: "SaveGames/player1.save",
root: .documentDirectory) { saveGame, wasRemote in
}
You can also pass in a full path from a local url:
let saveGameURL: URL = ... // some local file URL
FileSinki.load(SaveGame.self,
fromPath: saveGameURL.path) { saveGame, wasRemote in
}
Note that tvOS only supports writing to the .caches
folder. FileSinki automatically uses this folder instead of .applicationSupportDirectory
so you don't have to worry about it.
Internally FileSinki always stores compressed versions of your data in the cloud. It can also be advantageous to store compressed versions locally. Compression and decompression is often much faster than disk access, and Codable files generally compress extremely well.
There are compressed versions of all of the above FileSinki operations. For example:
// load a compressed SaveGame from a file with path: "SaveGames/player1.save"
FileSinki.loadCompressed(SaveGame.self,
fromPath: "SaveGames/player1.save") { saveGame, wasRemote in
}
// save a compressed saveGame to a file with path: "SaveGames/player1.save"
FileSinki.saveCompressed(saveGame,
toPath: "SaveGames/player1.save") { finalVersion in
// closure *may* be called with finalVersion
// if the saveGame changed as a result of a merge
// or a better available version
}
// delete the compressed saveGame
FileSinki.deleteCompressed(saveGame, at: "SaveGames/player1.save")
The compression used is Apple's LZFSE
There are also a few handy compression functions in Data+Compression.swift
and Codable+Compression.swift
which don't involve file syncing
FileSinki works with Objective-C, but functionality is limited to saving and loading NSData
. Here are some Objective-C equivalents of the above features:
@import FileSinki;
[FileSinki setupWithCloudKitContainer:@"Blaa"];
[FileSinki receivedNotification:notificationInfo];
[FileSinki loadBinaryFileFromPath:@"test.pdf"
root:NSApplicationSupportDirectory
mergeAsync:^(NSData *left, NSData *right, void (^merged)(NSData *data)) {
// decode left and right data, merge and then pass on the final mergedData to merged()
NSData *mergedData = [mergedPDF data];
merged(mergedData);
} loaded:^(NSData *finalData, BOOL wasRemote) {
if (!finalData) {
return;
}
}];
[FileSinki saveBinaryFile:pdfData
toPath:@"test.pdf"
root:NSApplicationSupportDirectory
mergeAsync:^(NSData *left, NSData *right, void (^ merge)(NSData *mergedData)) {
// decode left and right data, merge and then pass on the final mergedData to merged()
NSData *mergedData = [mergedPDF data];
merged(mergedData);
} finalVersion:^(NSData *finalVersion) {
// do stuff with the final merged data
}];
[FileSinki deleteBinaryFile:pdfData
atPath:@"test.pdf"
root:NSApplicationSupportDirectory];
[FileSinki addObserver:self
path:@"SaveGames/"
root:NSApplicationSupportDirectory
itemsChanged:^(NSArray<ChangeItem *> * changedItems) {
for (ChangeItem *item in changedItems) {
printf("File changed at %s\n", item.localURL.absoluteString.UTF8String);
}
}];
FileSinki can be installed via the Swift Package Manager or Cocoapods:
pod 'FileSinki'
- Enable
CloudKit
in your app'sCapabilities
. Note your application's CloudKit container identifier for use later on.
- In the https://icloud.developer.apple.com go to your application's Development
Schema
, and add a newRecord Type
calledFileSinki
with the followingCustom Fields
:
path
(Type String)type
(Type String)asset
(Type Asset)data
(Type Bytes)deleted
(Type Int(64))
- In the FileSinki Record Schema, click
Edit Indexes
, add the following Indexes:
recordName
(QUERYABLE
)type
(QUERYABLE
)path
(SEARCHABLE
)
And save changes.
The final result should look like:
Note: Once you have verfied that FileSinki is working correctly in the development environment, don't forget to deploy the schema to Production
:
Add the following code to your AppDelegate (or equivalent MacOS delegate functions)
- Add
FileSinki.setup()
andregisterForRemoteNotifications()
todidFinishLaunchingWithOptions
with your CloudKit container identifier
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
FileSinki.setup(cloudKitContainer: "iCloud.com.MyCompanyName.MyCoolApp")
application.registerForRemoteNotifications() // required for live change observing
}
- Add
FileSinki.didBecomeActive()
toapplicationDidBecomeActive
func applicationDidBecomeActive(_ application: UIApplication) {
FileSinki.didBecomeActive()
}
- Add
FileSinki.receivedNotification(userInfo)
todidReceiveRemoteNotification
func application(_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
FileSinki.receivedNotification(userInfo)
completionHandler(.newData)
}
Note: In my experience application.registerForRemoteNotifications()
will do nothing and didReceiveRemoteNotification
nor it's didFail
equivalent will be called for at least 24 hours after the first call. At some point it will just start working once Apple Push Notification Service has finished doing it's thing.
- James Vanas (@jamesvanas)
FileSinki is released under the MIT license. See LICENSE for details.