blob: 2cd4fec7d1f9e665b83810871c563831b02ab284 [file] [log] [blame]
// 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__",
UserdataInternalPrefix = "internal__/",
UserdataInternalSyncgroupPrefix = UserdataInternalPrefix + "syncgroups/"
// 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:
/// - `internal__/syncgroups/{encodedSyncgroupId}` -> `nil`
/// - `internal__/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.UserdataInternalSyncgroupPrefix, 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.UserdataInternalSyncgroupPrefix + 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.UserdataInternalSyncgroupPrefix + 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
}