mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-09-06 07:48:38 +09:00
432 lines
17 KiB
Swift
432 lines
17 KiB
Swift
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
import Foundation
|
|
import Shared
|
|
import Storage
|
|
import XCGLogger
|
|
import Deferred
|
|
import SwiftyJSON
|
|
|
|
private let log = Logger.syncLogger
|
|
let ClientsStorageVersion = 1
|
|
|
|
// TODO
|
|
public protocol Command {
|
|
static func fromName(_ command: String, args: [JSON]) -> Command?
|
|
func run(_ synchronizer: ClientsSynchronizer) -> Success
|
|
static func commandFromSyncCommand(_ syncCommand: SyncCommand) -> Command?
|
|
}
|
|
|
|
// Shit.
|
|
// We need a way to wipe or reset engines.
|
|
// We need a way to log out the account.
|
|
// So when we sync commands, we're gonna need a delegate of some kind.
|
|
open class WipeCommand: Command {
|
|
|
|
public init?(command: String, args: [JSON]) {
|
|
return nil
|
|
}
|
|
|
|
open class func fromName(_ command: String, args: [JSON]) -> Command? {
|
|
return WipeCommand(command: command, args: args)
|
|
}
|
|
|
|
open func run(_ synchronizer: ClientsSynchronizer) -> Success {
|
|
return succeed()
|
|
}
|
|
|
|
open static func commandFromSyncCommand(_ syncCommand: SyncCommand) -> Command? {
|
|
let json = JSON(parseJSON: syncCommand.value)
|
|
if let name = json["command"].string,
|
|
let args = json["args"].array {
|
|
return WipeCommand.fromName(name, args: args)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
open class DisplayURICommand: Command {
|
|
let uri: URL
|
|
let title: String
|
|
let sender: String
|
|
|
|
public init?(command: String, args: [JSON]) {
|
|
if let uri = args[0].string?.asURL,
|
|
let sender = args[1].string,
|
|
let title = args[2].string {
|
|
self.uri = uri
|
|
self.sender = sender
|
|
self.title = title
|
|
} else {
|
|
// Oh, Swift.
|
|
self.uri = "http://localhost/".asURL!
|
|
self.title = ""
|
|
return nil
|
|
}
|
|
}
|
|
|
|
open class func fromName(_ command: String, args: [JSON]) -> Command? {
|
|
return DisplayURICommand(command: command, args: args)
|
|
}
|
|
|
|
open func run(_ synchronizer: ClientsSynchronizer) -> Success {
|
|
func display(_ deviceName: String? = nil) -> Success {
|
|
synchronizer.delegate.displaySentTab(for: uri, title: title, from: deviceName)
|
|
return succeed()
|
|
}
|
|
|
|
guard let getClientWithId = synchronizer.localClients?.getClientWithId(sender) else {
|
|
return display()
|
|
}
|
|
|
|
return getClientWithId >>== { client in
|
|
return display(client?.name)
|
|
}
|
|
}
|
|
|
|
open static func commandFromSyncCommand(_ syncCommand: SyncCommand) -> Command? {
|
|
let json = JSON(parseJSON: syncCommand.value)
|
|
if let name = json["command"].string,
|
|
let args = json["args"].array {
|
|
return DisplayURICommand.fromName(name, args: args)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
open class RepairResponseCommand: Command {
|
|
let repairResponse: RepairResponse
|
|
|
|
public init(command: String, args: [JSON]) {
|
|
self.repairResponse = RepairResponse.fromJSON(args: args[0])
|
|
}
|
|
|
|
open class func fromName(_ command: String, args: [JSON]) -> Command? {
|
|
return RepairResponseCommand(command: command, args: args)
|
|
}
|
|
|
|
open func run(_ synchronizer: ClientsSynchronizer) -> Success {
|
|
let repairer = BookmarksRepairRequestor(scratchpad: synchronizer.scratchpad, basePrefs: synchronizer.basePrefs, remoteClients: synchronizer.localClients!)
|
|
return repairer.continueRepairs(response: self.repairResponse) >>> succeed
|
|
}
|
|
|
|
open static func commandFromSyncCommand(_ syncCommand: SyncCommand) -> Command? {
|
|
let json = JSON(parseJSON: syncCommand.value)
|
|
if let name = json["command"].string,
|
|
let args = json["args"].array {
|
|
return RepairResponseCommand.fromName(name, args: args)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
let Commands: [String: (String, [JSON]) -> Command?] = [
|
|
"wipeAll": WipeCommand.fromName,
|
|
"wipeEngine": WipeCommand.fromName,
|
|
// resetEngine
|
|
// resetAll
|
|
// logout
|
|
"displayURI": DisplayURICommand.fromName,
|
|
"repairResponse": RepairResponseCommand.fromName
|
|
]
|
|
|
|
open class ClientsSynchronizer: TimestampedSingleCollectionSynchronizer, Synchronizer {
|
|
public required init(scratchpad: Scratchpad, delegate: SyncDelegate, basePrefs: Prefs, why: SyncReason) {
|
|
super.init(scratchpad: scratchpad, delegate: delegate, basePrefs: basePrefs, why: why, collection: "clients")
|
|
}
|
|
|
|
var localClients: RemoteClientsAndTabs?
|
|
|
|
override var storageVersion: Int {
|
|
return ClientsStorageVersion
|
|
}
|
|
|
|
var clientRecordLastUpload: Timestamp {
|
|
set(value) {
|
|
self.prefs.setLong(value, forKey: "lastClientUpload")
|
|
}
|
|
|
|
get {
|
|
return self.prefs.unsignedLongForKey("lastClientUpload") ?? 0
|
|
}
|
|
}
|
|
|
|
// Sync Object Format (Version 1) for Form Factors: http://docs.services.mozilla.com/sync/objectformats.html#id2
|
|
fileprivate enum SyncFormFactorFormat: String {
|
|
case phone = "phone"
|
|
case tablet = "tablet"
|
|
}
|
|
|
|
open func getOurClientRecord() -> Record<ClientPayload> {
|
|
let guid = self.scratchpad.clientGUID
|
|
let formfactor = formFactorString()
|
|
|
|
let json = JSON(object: [
|
|
"id": guid,
|
|
"fxaDeviceId": self.scratchpad.fxaDeviceId,
|
|
"version": AppInfo.appVersion,
|
|
"protocols": ["1.5"],
|
|
"name": self.scratchpad.clientName,
|
|
"os": "iOS",
|
|
"commands": [JSON](),
|
|
"type": "mobile",
|
|
"appPackage": AppInfo.baseBundleIdentifier,
|
|
"application": AppInfo.displayName,
|
|
"device": DeviceInfo.deviceModel(),
|
|
"formfactor": formfactor])
|
|
|
|
let payload = ClientPayload(json)
|
|
return Record(id: guid, payload: payload, ttl: ThreeWeeksInSeconds)
|
|
}
|
|
|
|
fileprivate func formFactorString() -> String {
|
|
let userInterfaceIdiom = UIDevice.current.userInterfaceIdiom
|
|
var formfactor: String
|
|
|
|
switch userInterfaceIdiom {
|
|
case .phone:
|
|
formfactor = SyncFormFactorFormat.phone.rawValue
|
|
case .pad:
|
|
formfactor = SyncFormFactorFormat.tablet.rawValue
|
|
default:
|
|
formfactor = SyncFormFactorFormat.phone.rawValue
|
|
}
|
|
|
|
return formfactor
|
|
}
|
|
|
|
fileprivate func clientRecordToLocalClientEntry(_ record: Record<ClientPayload>) -> RemoteClient {
|
|
let modified = record.modified
|
|
let payload = record.payload
|
|
return RemoteClient(json: payload.json, modified: modified)
|
|
}
|
|
|
|
// If this is a fresh start, do a wipe.
|
|
// N.B., we don't wipe outgoing commands! (TODO: check this when we implement commands!)
|
|
// N.B., but perhaps we should discard outgoing wipe/reset commands!
|
|
fileprivate func wipeIfNecessary(_ localClients: RemoteClientsAndTabs) -> Success {
|
|
if self.lastFetched == 0 {
|
|
return localClients.wipeClients()
|
|
}
|
|
return succeed()
|
|
}
|
|
|
|
/**
|
|
* Returns whether any commands were found (and thus a replacement record
|
|
* needs to be uploaded). Also returns the commands: we run them after we
|
|
* upload a replacement record.
|
|
*/
|
|
fileprivate func processCommandsFromRecord(_ record: Record<ClientPayload>?, withServer storageClient: Sync15CollectionClient<ClientPayload>) -> Deferred<Maybe<(Bool, [Command])>> {
|
|
log.debug("Processing commands from downloaded record.")
|
|
|
|
// TODO: short-circuit based on the modified time of the record we uploaded, so we don't need to skip ahead.
|
|
if let record = record {
|
|
let commands = record.payload.commands
|
|
if !commands.isEmpty {
|
|
func parse(_ json: JSON) -> Command? {
|
|
if let name = json["command"].string,
|
|
let args = json["args"].array,
|
|
let constructor = Commands[name] {
|
|
return constructor(name, args)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TODO: can we do anything better if a command fails?
|
|
return deferMaybe((true, optFilter(commands.map(parse))))
|
|
}
|
|
}
|
|
|
|
return deferMaybe((false, []))
|
|
}
|
|
|
|
fileprivate func uploadClientCommands(toLocalClients localClients: RemoteClientsAndTabs, withServer storageClient: Sync15CollectionClient<ClientPayload>) -> Success {
|
|
return localClients.getCommands() >>== { clientCommands in
|
|
return clientCommands.map { (clientGUID, commands) -> Success in
|
|
self.syncClientCommands(clientGUID, commands: commands, clientsAndTabs: localClients, withServer: storageClient)
|
|
}.allSucceed()
|
|
}
|
|
}
|
|
|
|
fileprivate func syncClientCommands(_ clientGUID: GUID, commands: [SyncCommand], clientsAndTabs: RemoteClientsAndTabs, withServer storageClient: Sync15CollectionClient<ClientPayload>) -> Success {
|
|
|
|
let deleteCommands: () -> Success = {
|
|
return clientsAndTabs.deleteCommands(clientGUID).bind({ x in return succeed() })
|
|
}
|
|
|
|
log.debug("Fetching current client record for client \(clientGUID).")
|
|
let fetch = storageClient.get(clientGUID)
|
|
return fetch.bind() { result in
|
|
if let response = result.successValue, response.value.payload.isValid() {
|
|
let record = response.value
|
|
if var clientRecord = record.payload.json.dictionary {
|
|
clientRecord["commands"] = JSON(record.payload.commands + commands.map { JSON(parseJSON: $0.value) })
|
|
let uploadRecord = Record(id: clientGUID, payload: ClientPayload(JSON(clientRecord)), ttl: ThreeWeeksInSeconds)
|
|
return storageClient.put(uploadRecord, ifUnmodifiedSince: record.modified)
|
|
>>== { resp in
|
|
log.debug("Client \(clientGUID) commands upload succeeded.")
|
|
|
|
// Always succeed, even if we couldn't delete the commands.
|
|
return deleteCommands()
|
|
}
|
|
}
|
|
} else {
|
|
if let failure = result.failureValue {
|
|
log.warning("Failed to fetch record with GUID \(clientGUID).")
|
|
if failure is NotFound<HTTPURLResponse> {
|
|
log.debug("Not waiting to see if the client comes back.")
|
|
|
|
// TODO: keep these around and retry, expiring after a while.
|
|
// For now we just throw them away so we don't fail every time.
|
|
return deleteCommands()
|
|
}
|
|
|
|
if failure is BadRequestError<HTTPURLResponse> {
|
|
log.debug("We made a bad request. Throwing away queued commands.")
|
|
return deleteCommands()
|
|
}
|
|
}
|
|
}
|
|
|
|
log.error("Client \(clientGUID) commands upload failed: No remote client for GUID")
|
|
return deferMaybe(UnknownError())
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload our record if either (a) we know we should upload, or (b)
|
|
* our own notes tell us we're due to reupload.
|
|
*/
|
|
fileprivate func maybeUploadOurRecord(_ should: Bool, ifUnmodifiedSince: Timestamp?, toServer storageClient: Sync15CollectionClient<ClientPayload>) -> Success {
|
|
|
|
let lastUpload = self.clientRecordLastUpload
|
|
let expired = lastUpload < (Date.now() - (2 * OneDayInMilliseconds))
|
|
log.debug("Should we upload our client record? Caller = \(should), expired = \(expired).")
|
|
if !should && !expired {
|
|
return succeed()
|
|
}
|
|
|
|
let iUS: Timestamp? = ifUnmodifiedSince ?? ((lastUpload == 0) ? nil : lastUpload)
|
|
|
|
var uploadStats = SyncUploadStats()
|
|
return storageClient.put(getOurClientRecord(), ifUnmodifiedSince: iUS)
|
|
>>== { resp in
|
|
if let ts = resp.metadata.lastModifiedMilliseconds {
|
|
// Protocol says this should always be present for success responses.
|
|
log.debug("Client record upload succeeded. New timestamp: \(ts).")
|
|
self.clientRecordLastUpload = ts
|
|
uploadStats.sent += 1
|
|
} else {
|
|
uploadStats.sentFailed += 1
|
|
}
|
|
self.statsSession.recordUpload(stats: uploadStats)
|
|
return succeed()
|
|
}
|
|
}
|
|
|
|
fileprivate func applyStorageResponse(_ response: StorageResponse<[Record<ClientPayload>]>, toLocalClients localClients: RemoteClientsAndTabs, withServer storageClient: Sync15CollectionClient<ClientPayload>, notifier: CollectionChangedNotifier?) -> Success {
|
|
log.debug("Applying clients response.")
|
|
|
|
var downloadStats = SyncDownloadStats()
|
|
|
|
let records = response.value
|
|
let responseTimestamp = response.metadata.lastModifiedMilliseconds
|
|
|
|
log.debug("Got \(records.count) client records.")
|
|
|
|
let ourGUID = self.scratchpad.clientGUID
|
|
var toInsert = [RemoteClient]()
|
|
var ours: Record<ClientPayload>? = nil
|
|
|
|
for (rec) in records {
|
|
guard rec.payload.isValid() else {
|
|
log.warning("Client record \(rec.id) is invalid. Skipping.")
|
|
continue
|
|
}
|
|
|
|
if rec.id == ourGUID {
|
|
if rec.modified == self.clientRecordLastUpload {
|
|
log.debug("Skipping our own unmodified record.")
|
|
} else {
|
|
log.debug("Saw our own record in response.")
|
|
ours = rec
|
|
}
|
|
} else {
|
|
toInsert.append(self.clientRecordToLocalClientEntry(rec))
|
|
}
|
|
}
|
|
|
|
downloadStats.applied += toInsert.count
|
|
|
|
// Apply remote changes.
|
|
// Collect commands from our own record and reupload if necessary.
|
|
// Then run the commands and return.
|
|
return localClients.insertOrUpdateClients(toInsert)
|
|
>>== { succeeded in
|
|
downloadStats.succeeded += succeeded
|
|
downloadStats.failed += (toInsert.count - succeeded)
|
|
self.statsSession.recordDownload(stats: downloadStats)
|
|
return succeed()
|
|
}
|
|
>>== { self.processCommandsFromRecord(ours, withServer: storageClient) }
|
|
>>== { (shouldUpload, commands) in
|
|
let isFirstSync = self.lastFetched == 0
|
|
return self.maybeUploadOurRecord(shouldUpload || self.why == .didLogin, ifUnmodifiedSince: ours?.modified, toServer: storageClient)
|
|
>>> { self.uploadClientCommands(toLocalClients: localClients, withServer: storageClient) }
|
|
>>> {
|
|
log.debug("Running \(commands.count) commands.")
|
|
for command in commands {
|
|
_ = command.run(self)
|
|
}
|
|
self.lastFetched = responseTimestamp!
|
|
if isFirstSync,
|
|
let notifier = notifier {
|
|
DispatchQueue.global(qos: DispatchQoS.background.qosClass).async { _ = notifier.notifyAll(collectionsChanged: ["clients"], reason: "firstsync") }
|
|
}
|
|
return succeed()
|
|
}
|
|
}
|
|
}
|
|
|
|
open func synchronizeLocalClients(_ localClients: RemoteClientsAndTabs, withServer storageClient: Sync15StorageClient, info: InfoCollections, notifier: CollectionChangedNotifier?) -> SyncResult {
|
|
log.debug("Synchronizing clients.")
|
|
self.localClients = localClients // Store for later when we process a repairResponse command
|
|
|
|
if let reason = self.reasonToNotSync(storageClient) {
|
|
switch reason {
|
|
case .engineRemotelyNotEnabled:
|
|
// This is a hard error for us.
|
|
return deferMaybe(FatalError(message: "clients not mentioned in meta/global. Server wiped?"))
|
|
default:
|
|
return deferMaybe(SyncStatus.notStarted(reason))
|
|
}
|
|
}
|
|
|
|
let keys = self.scratchpad.keys?.value
|
|
let encoder = RecordEncoder<ClientPayload>(decode: { ClientPayload($0) }, encode: { $0.json })
|
|
let encrypter = keys?.encrypter(self.collection, encoder: encoder)
|
|
if encrypter == nil {
|
|
log.error("Couldn't make clients encrypter.")
|
|
return deferMaybe(FatalError(message: "Couldn't make clients encrypter."))
|
|
}
|
|
|
|
let clientsClient = storageClient.clientForCollection(self.collection, encrypter: encrypter!)
|
|
|
|
// TODO: some of the commands we process might involve wiping collections or the
|
|
// entire profile. We should model this as an explicit status, and return it here
|
|
// instead of .completed.
|
|
statsSession.start()
|
|
// XXX: This is terrible. We always force a re-sync of the clients to work around
|
|
// the fact that `fxaDeviceId` may not have been populated if the list of clients
|
|
// hadn't changed since before the update to v8.0. To force a re-sync, we get all
|
|
// clients since the beginning of time instead of looking at `self.lastFetched`.
|
|
return clientsClient.getSince(0)
|
|
>>== { response in
|
|
return self.wipeIfNecessary(localClients)
|
|
>>> { self.applyStorageResponse(response, toLocalClients: localClients, withServer: clientsClient, notifier: notifier) }
|
|
}
|
|
>>> { deferMaybe(self.completedWithStats) }
|
|
}
|
|
}
|