| // Copyright 2016 The Vanadium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| import Foundation |
| import SyncbaseCore |
| |
| /// Syncbase is a storage system for developers that makes it easy to synchronize app data between |
| /// devices. It works even when devices are not connected to the Internet. |
| public enum Syncbase { |
| // Constants |
| static let DbName = "db" |
| public static let UserdataSyncgroupName = "userdata__", |
| UserdataCollectionPrefix = "__collections/" |
| // Initialization state |
| static var isUnitTest = false |
| static var didInit = false |
| static var didPostLogin = false |
| static var didStartShutdown = false |
| // Main database. |
| static var db: Database? |
| // The userdata collection, created post-login. |
| static var userdataCollection: Collection? |
| // The handler for auto-joining invites that match our user's blessings. We keep it in a var |
| // in order to remove it on `shutdown`. |
| static var autojoinInviteHandler: SyncbaseCore.SyncgroupInvitesScanHandler? |
| // Options for opening a database. |
| static var cloudName: String? |
| static var cloudBlessing: String? |
| static var disableSyncgroupPublishing = false |
| static var disableUserdataSyncgroup = false |
| static var mountPoints: [String] = [] |
| /// Queue used to dispatch all asynchronous callbacks. Defaults to main. |
| public static var queue: dispatch_queue_t { |
| // Map directly to SyncbaseCore. |
| get { |
| return SyncbaseCore.Syncbase.queue |
| } |
| set(queue) { |
| SyncbaseCore.Syncbase.queue = queue |
| } |
| } |
| |
| static public var defaultRootDir: String { |
| return NSFileManager.defaultManager() |
| .URLsForDirectory(.ApplicationSupportDirectory, inDomains: .UserDomainMask)[0] |
| .URLByAppendingPathComponent("Syncbase") |
| .path! |
| } |
| |
| static var publishSyncbaseName: String? { |
| if disableSyncgroupPublishing { |
| return nil |
| } |
| if usesCloud { |
| return cloudName |
| } |
| return nil |
| } |
| |
| static var usesCloud: Bool { |
| return cloudName != nil && cloudBlessing != nil |
| } |
| |
| /// Starts Syncbase if needed; creates default database if needed; performs create-or-join for |
| /// "userdata" syncgroup if needed. |
| /// |
| /// The "userdata" collection is a per-user collection (and associated syncgroup) for data that |
| /// should automatically get synced across a given user's devices. It has the following schema: |
| /// - `/syncgroups/{encodedSyncgroupId}` -> `nil` |
| /// - `/ignoredInvites/{encodedSyncgroupId}` -> `nil` |
| /// |
| /// Cloud usage is optional. It can be used for initial bootstrapping and increased data |
| /// availability. Apps that use a cloud will automatically synchronize data across all of the same |
| /// user's devices. To allocate a cloud instance of Syncbase, visit https://sb-allocator.v.io/home |
| /// to create your instance. There you'll be able to see the parameters for `cloudName` and |
| /// `cloudBlessing` parameters. After that, please complete the following steps (until we the |
| /// webpage includes similar functionality, see https://github.com/vanadium/issues/issues/1414): |
| |
| /// 1. Install Vanadium from https://vanadium.github.io/installation/ |
| /// |
| /// 2. Install the principal & sb binaries |
| /// `jiri go install v.io/...` |
| /// |
| /// 3. Create your principal |
| /// `$JIRI_ROOT/release/go/bin/principal create $JIRI_ROOT/.v23creds` |
| /// |
| /// 4. Bless it via OAuth. On the Vanadium blessings page, just click bless. |
| /// `$JIRI_ROOT/release/go/bin/principal -v23.credentials=$JIRI_ROOT/.v23creds seekblessings` |
| /// |
| /// 5. Create the shared database using the app client id blessing. Notice the , in the id... |
| /// it's _blessing_,db |
| /// `$JIRI_ROOT/release/go/bin/sb --v23.credentials=$JIRI_ROOT/.v23creds \ |
| /// -service=/ns.dev.v.io:8101/sb/syncbased-<SYNCBASEID> -create-if-absent=true \ |
| /// --db dev.v.io:o:<GOOGLE SIGNIN CLIENTID>,db` |
| /// |
| /// 6. At this point you can go to sb-allocator.v.io, click debug on your instance, |
| /// click Syncbase in the upper right, then verify the database was created. You can't currently |
| /// view the collections or syncgroups as the website is not authenticated to show them. |
| /// |
| /// - parameter rootDir: Where data should be persisted. |
| /// - parameter cloudName: Name of the cloud. See https://sb-allocator.v.io/home |
| /// - parameter cloudBlessing: The cloud's blessing pattern. See https://sb-allocator.v.io/home |
| /// - parameter mountPoints: // TODO(zinman): Get appropriate documentation on mountPoints |
| /// - parameter disableSyncgroupPublishing: **FOR ADVANCED USERS**. If true, syncgroups will not be published to the cloud peer. |
| /// - parameter disableUserdataSyncgroup: **FOR ADVANCED USERS**. If true, the user's data will not be synced across their devices. |
| /// - parameter callback: Called on `Syncbase.queue` with either `Database` if successful, or an error if unsuccessful. |
| public static func configure( |
| // Default to Application Support/Syncbase. |
| rootDir rootDir: String = defaultRootDir, |
| cloudName: String? = nil, |
| cloudBlessing: String? = nil, |
| mountPoints: [String], |
| disableSyncgroupPublishing: Bool = false, |
| disableUserdataSyncgroup: Bool = false, |
| queue: dispatch_queue_t = dispatch_get_main_queue()) throws { |
| if Syncbase.didInit { |
| throw SyncbaseError.AlreadyConfigured |
| } |
| Syncbase.cloudName = cloudName |
| Syncbase.cloudBlessing = cloudBlessing |
| Syncbase.mountPoints = mountPoints |
| Syncbase.disableSyncgroupPublishing = disableSyncgroupPublishing |
| Syncbase.disableUserdataSyncgroup = disableUserdataSyncgroup |
| Syncbase.didPostLogin = false |
| Syncbase.didStartShutdown = false |
| // We don't need to set Syncbase.queue as it is a proxy for SyncbaseCore's queue, which is |
| // set in the configure below. |
| try SyncbaseError.wrap { |
| try SyncbaseCore.Syncbase.configure(rootDir: rootDir, queue: queue) |
| } |
| // We use SyncbaseCore's isLoggedIn because this frameworks would fail as didInit hasn't |
| // been set to true yet. |
| if (SyncbaseCore.Syncbase.isLoggedIn) { |
| do { |
| try Syncbase.postLoginCreateDefaults() |
| } catch { |
| // If we get an exception after configuring the low-level API, make sure we shutdown |
| // Syncbase so that any subsequent call to this configure method doesn't get a |
| // SyncbaseError.AlreadyConfigured exception from SyncbaseCore.Syncbase.configure. |
| Syncbase.shutdown() |
| throw error |
| } |
| } |
| Syncbase.didInit = true |
| } |
| |
| /// Shuts down the Syncbase service. You must call configure again before any calls will work. |
| public static func shutdown() { |
| // We don't need to log/worry about the catch {} -- the exception is if we can't get a database |
| // handle, which would happen if we haven't logged in, etc. The actual remove handler does not |
| // throw any errors. |
| do { try database().removeAllSyncgroupInviteHandlers() } catch { } |
| do { try database().removeAllWatchChangeHandlers() } catch { } |
| do { try database().removeAllInternalWatchChangeHandlers() } catch { } |
| if let handler = autojoinInviteHandler { |
| do { try database().coreDatabase.stopSyncgroupInvitesScan(handler) } catch { } |
| } |
| Syncbase.didStartShutdown = true |
| SyncbaseCore.Syncbase.shutdown() |
| Syncbase.didInit = false |
| Syncbase.didPostLogin = false |
| } |
| |
| private static func postLoginCreateDefaults() throws { |
| let coreDb = try SyncbaseCore.Syncbase.database(Syncbase.DbName) |
| let database = Database(coreDatabase: coreDb) |
| try database.createIfMissing() |
| userdataCollection = try database.createCollection( |
| name: Syncbase.UserdataSyncgroupName, withoutSyncgroup: true) |
| // We only create a userdata syncgroup if we use the cloud (or we're in a unit test). |
| if (usesCloud || isUnitTest) && !Syncbase.disableUserdataSyncgroup { |
| let syncgroup = try userdataCollection!.syncgroup() |
| do { |
| // TODO(zinman): Return (via throwing) if this fails because the cloud isn't accessible, |
| // instead of it failing because a join isn't possible. |
| try syncgroup.join() |
| } catch { |
| // The above join() will fail the first time the user logs in and we create the syncgroup |
| // UNLESS another device of the same user is available to discovery (nearby or connected |
| // via some network path). If so, then the join will work. In subsequent boots join() will |
| // not throw and we won't re-attempt creation. |
| try syncgroup.createIfMissing([userdataCollection!]) |
| } |
| // TODO(zinman): Figure out when/how this can throw and if we should handle it better. |
| try database.addInternalUserdataWatchChangeHandler( |
| handler: WatchChangeHandler( |
| onInitialState: onUserdataWatchChange, |
| onChangeBatch: onUserdataWatchChange, |
| onError: { err in |
| if !Syncbase.didStartShutdown { |
| NSLog("Syncbase - Error watching userdata syncgroups: %@", "\(err)") |
| } |
| })) |
| // Auto-join syncgroups with the same blessings. |
| autojoinInviteHandler = SyncbaseCore.SyncgroupInvitesScanHandler(onInvite: onSyncgroupInvite) |
| try coreDb.scanForSyncgroupInvites( |
| try database.databaseId.encode(), |
| handler: autojoinInviteHandler!) |
| } |
| |
| Syncbase.db = database |
| Syncbase.didPostLogin = true |
| } |
| |
| private static func onSyncgroupInvite(invite: SyncbaseCore.SyncgroupInvite) { |
| // Auto-accept groups that have blessings that match our own. |
| let syncgroupId = Identifier(coreId: invite.syncgroupId) |
| guard invite.syncgroupId.blessing == (try? Principal.userBlessing()) && |
| // Ignore userdata syncgroup which is auto-joined if the blessings are correct in Go. |
| invite.syncgroupId.name != Syncbase.UserdataSyncgroupName && |
| // Ignore syncgroups that have already been joined. |
| !((try? Syncbase.syncgroupInUserdata(syncgroupId)) ?? false) else { |
| return |
| } |
| do { |
| let invite = SyncgroupInvite(coreInvite: invite) |
| try database().acceptSyncgroupInvite(invite, callback: { (sg, err) in |
| if err != nil { |
| print("Unable to auto-join syncgroup \(sg) with user blessings: \(err)") |
| } |
| }) |
| } catch { |
| print("Unable to auto-join syncgroup \(invite.syncgroupId) with user blessings: \(error)") |
| } |
| } |
| |
| private static func onUserdataWatchChange(changes: [WatchChange]) { |
| for change in changes { |
| guard let row = change.row where change.entityType == .Row && change.changeType == .Put else { |
| continue |
| } |
| guard let syncgroupId = try? Identifier.decode( |
| row.stringByReplacingOccurrencesOfString(Syncbase.UserdataCollectionPrefix, withString: "")) else { |
| print("Syncbase - Unable to decode userdata key: (row)") |
| continue |
| } |
| do { |
| let syncgroup = try Syncbase.database().syncgroup(syncgroupId) |
| try syncgroup.join() |
| } catch { |
| NSLog("Syncbase - Error joining syncgroup \(syncgroupId): %@", "\(error)") |
| } |
| } |
| } |
| |
| static func addSyncgroupToUserdata(syncgroupId: Identifier) throws { |
| if !Syncbase.didInit { |
| throw SyncbaseError.NotConfigured |
| } |
| if !Syncbase.didPostLogin { |
| throw SyncbaseError.NotLoggedIn |
| } |
| guard let userdataCollection = Syncbase.userdataCollection else { |
| throw SyncbaseError.IllegalArgument(detail: "No user data collection") |
| } |
| try userdataCollection.put(try Syncbase.UserdataCollectionPrefix + syncgroupId.encode(), value: NSData()) |
| } |
| |
| static func syncgroupInUserdata(syncgroupId: Identifier) throws -> Bool { |
| if !Syncbase.didInit { |
| throw SyncbaseError.NotConfigured |
| } |
| if !Syncbase.didPostLogin { |
| throw SyncbaseError.NotLoggedIn |
| } |
| guard let userdataCollection = Syncbase.userdataCollection else { |
| throw SyncbaseError.IllegalArgument(detail: "No user data collection") |
| } |
| return try userdataCollection.exists(try Syncbase.UserdataCollectionPrefix + syncgroupId.encode()) |
| } |
| |
| /// Returns the shared database handle. Must have already called `configure` and be logged in, |
| /// otherwise this will throw a `SyncbaseError.NotConfigured` or `SyncbaseError.NotLoggedIn` |
| /// error. |
| public static func database() throws -> Database { |
| if !Syncbase.didInit { |
| throw SyncbaseError.NotConfigured |
| } |
| guard let db = Syncbase.db where Syncbase.didPostLogin else { |
| throw SyncbaseError.NotLoggedIn |
| } |
| return db |
| } |
| |
| public typealias LoginCallback = (err: SyncbaseError?) -> Void |
| |
| /// Authorize using an oauth token. Right now only Google OAuth token is supported |
| /// (you should use the Google Sign In SDK to get this). |
| /// |
| /// You must login and have valid credentials before you can call `database()` to perform any |
| /// operation. |
| /// |
| /// Calls `callback` on `Syncbase.queue` with any error that occured, or nil on success. |
| public static func login(credentials: GoogleOAuthCredentials, callback: LoginCallback) { |
| SyncbaseCore.Syncbase.login( |
| SyncbaseCore.GoogleOAuthCredentials(token: credentials.token), |
| callback: { err in |
| guard err == nil else { |
| callback(err: SyncbaseError(coreError: err!)) |
| return |
| } |
| // postLoginCreateDefaults can be blocking when performing create-or-join. Run on |
| // a background queue to prevent blocking from the Go callback. |
| dispatch_async(dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0)) { |
| var callbackErr: SyncbaseError? |
| do { |
| try postLoginCreateDefaults() |
| } catch let e as SyncbaseError { |
| callbackErr = e |
| } catch { |
| preconditionFailure("Unsupported ErrorType: \(error)") |
| } |
| dispatch_async(Syncbase.queue) { |
| callback(err: callbackErr) |
| } |
| } |
| }) |
| } |
| |
| public static func isLoggedIn() throws -> Bool { |
| if !Syncbase.didInit { |
| throw SyncbaseError.NotConfigured |
| } |
| return SyncbaseCore.Syncbase.isLoggedIn |
| } |
| |
| public static func loggedInUser() -> User? { |
| if let bp = try? personalBlessingString(), |
| alias = aliasFromBlessingPattern(bp) { |
| return User(alias: alias) |
| } |
| return nil |
| } |
| |
| static var neighborhoodScans: [ScanNeighborhoodForUsersHandler: SyncbaseCore.NeighborhoodScanHandler] = [:] |
| static let neighborhoodScansMu = NSLock() |
| |
| /// Scans the neighborhood for nearby users. |
| /// |
| /// - parameter handler: The handler for to call when a User is found or lost. |
| /// Callbacks are called on `Syncbase.queue`. |
| public static func startScanForUsersInNeighborhood(handler: ScanNeighborhoodForUsersHandler) throws { |
| let coreHandler = NeighborhoodScanHandler(onPeer: { peer in |
| guard let alias = aliasFromBlessingPattern(peer.blessings) else { |
| NSLog("Syncbase - Could not get blessings from pattern %@", "\(peer.blessings)") |
| return |
| } |
| let user = User(alias: alias) |
| dispatch_async(Syncbase.queue) { |
| if peer.isLost { |
| handler.onLost(user) |
| } else { |
| handler.onFound(user) |
| } |
| } |
| }) |
| try SyncbaseError.wrap { |
| try Neighborhood.startScan(coreHandler) |
| } |
| neighborhoodScansMu.lock() |
| neighborhoodScans[handler] = coreHandler |
| neighborhoodScansMu.unlock() |
| } |
| |
| /// Stops the handler from receiving new neighborhood scan updates. |
| /// |
| /// - parameter handler: The original handler passed to a started scan. |
| public static func stopScanForUsersInNeighborhood(handler: ScanNeighborhoodForUsersHandler) { |
| neighborhoodScansMu.lock() |
| if let coreHandler = neighborhoodScans[handler] { |
| Neighborhood.stopScan(coreHandler) |
| neighborhoodScans.removeValueForKey(handler) |
| } |
| neighborhoodScansMu.unlock() |
| } |
| |
| /// Stops all existing scanning handlers from receiving new neighborhood scan updates. |
| public static func stopAllScansForUsersInNeighborhood() { |
| neighborhoodScansMu.lock() |
| for coreHandler in neighborhoodScans.values { |
| Neighborhood.stopScan(coreHandler) |
| } |
| neighborhoodScans.removeAll() |
| neighborhoodScansMu.unlock() |
| } |
| |
| /// Advertises the logged in user's presence to the target set of users who must be around them. |
| /// |
| /// - parameter usersWhoCanSee: The set of users who are allowed to find this user. If empty |
| /// then everyone can see the advertisement. |
| public static func startAdvertisingPresenceInNeighborhood(usersWhoCanSee: [User] = []) throws { |
| let visibility = try usersWhoCanSee.map { return try blessingPatternFromAlias($0.alias) } |
| try SyncbaseError.wrap { |
| try Neighborhood.startAdvertising(visibility) |
| } |
| } |
| |
| /// Stops advertising the presence of the logged in user so that they can no longer be found. |
| public static func stopAdvertisingPresenceInNeighborhood() throws { |
| try SyncbaseError.wrap { |
| try Neighborhood.stopAdvertising() |
| } |
| } |
| |
| /// Returns true iff this person appears in the neighborhood. |
| public static func isAdvertisingPresenceInNeighborhood() -> Bool { |
| return Neighborhood.isAdvertising() |
| } |
| } |
| |
| public struct ScanNeighborhoodForUsersHandler: Hashable { |
| public let onFound: User -> Void |
| public let onLost: User -> Void |
| // This internal-only variable allows us to test ScanNeighborhoodForUsersHandler structs for equality. |
| // This cannot be done otherwise as function calls cannot be tested for equality. |
| // Equality/hashValue is used to keep the set of all handlers in use. |
| let uniqueId = OSAtomicIncrement32(&uniqueIdCounter) |
| |
| public init(onFound: User -> Void, onLost: User -> Void) { |
| self.onFound = onFound |
| self.onLost = onLost |
| } |
| |
| public var hashValue: Int { |
| return uniqueId.hashValue |
| } |
| } |
| |
| public func == (lhs: ScanNeighborhoodForUsersHandler, rhs: ScanNeighborhoodForUsersHandler) -> Bool { |
| return lhs.uniqueId == rhs.uniqueId |
| } |