Dactyloidae iOS initial commit

This commit is contained in:
wuggy 2026-06-26 21:04:09 -07:00
commit 7154a0497e
2123 changed files with 197052 additions and 0 deletions

View file

@ -0,0 +1,529 @@
/* 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 UIKit
import Shared
import Deferred
import SwiftyJSON
private let log = Logger.syncLogger
public protocol SearchableBookmarks: class {
func bookmarksByURL(_ url: URL) -> Deferred<Maybe<Cursor<BookmarkItem>>>
}
public protocol SyncableBookmarks: class, ResettableSyncStorage, AccountRemovalDelegate {
// TODO
func isUnchanged() -> Deferred<Maybe<Bool>>
func getLocalBookmarksModifications(limit: Int) -> Deferred<Maybe<(deletions: [GUID], additions: [BookmarkMirrorItem])>>
func getLocalDeletions() -> Deferred<Maybe<[(GUID, Timestamp)]>>
func treesForEdges() -> Deferred<Maybe<(local: BookmarkTree, buffer: BookmarkTree)>>
func treeForMirror() -> Deferred<Maybe<BookmarkTree>>
func applyLocalOverrideCompletionOp(_ op: LocalOverrideCompletionOp, itemSources: ItemSources) -> Success
func applyBufferUpdatedCompletionOp(_ op: BufferUpdatedCompletionOp) -> Success
}
public let NotificationBookmarkBufferValidated = Notification.Name("NotificationBookmarkBufferValidated")
public protocol BookmarkBufferStorage: class {
func isEmpty() -> Deferred<Maybe<Bool>>
func applyRecords(_ records: [BookmarkMirrorItem]) -> Success
func doneApplyingRecordsAfterDownload() -> Success
func validate() -> Success
func getBufferedDeletions() -> Deferred<Maybe<[(GUID, Timestamp)]>>
func applyBufferCompletionOp(_ op: BufferCompletionOp, itemSources: ItemSources) -> Success
// Only use for diagnostics.
func synchronousBufferCount() -> Int?
func getUpstreamRecordCount() -> Deferred<Int?>
}
public protocol MirrorItemSource: class {
func getMirrorItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>>
func getMirrorItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID
func prefetchMirrorItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID
}
public protocol BufferItemSource: class {
func getBufferItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>>
func getBufferItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID
func getBufferChildrenGUIDsForParent(_ guid: GUID) -> Deferred<Maybe<[GUID]>>
func prefetchBufferItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID
}
public protocol LocalItemSource: class {
func getLocalItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>>
func getLocalItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID
func prefetchLocalItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID
}
open class ItemSources {
open let local: LocalItemSource
open let mirror: MirrorItemSource
open let buffer: BufferItemSource
public init(local: LocalItemSource, mirror: MirrorItemSource, buffer: BufferItemSource) {
self.local = local
self.mirror = mirror
self.buffer = buffer
}
open func prefetchWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID {
return self.local.prefetchLocalItemsWithGUIDs(guids)
>>> { self.mirror.prefetchMirrorItemsWithGUIDs(guids) }
>>> { self.buffer.prefetchBufferItemsWithGUIDs(guids) }
}
}
public struct BookmarkRoots {
// These match Places on desktop.
public static let RootGUID = "root________"
public static let MobileFolderGUID = "mobile______"
public static let MenuFolderGUID = "menu________"
public static let ToolbarFolderGUID = "toolbar_____"
public static let UnfiledFolderGUID = "unfiled_____"
public static let FakeDesktopFolderGUID = "desktop_____" // Pseudo. Never mentioned in a real record.
// This is the order we use.
public static let RootChildren: [GUID] = [
BookmarkRoots.MenuFolderGUID,
BookmarkRoots.ToolbarFolderGUID,
BookmarkRoots.UnfiledFolderGUID,
BookmarkRoots.MobileFolderGUID,
]
public static let DesktopRoots: [GUID] = [
BookmarkRoots.MenuFolderGUID,
BookmarkRoots.ToolbarFolderGUID,
BookmarkRoots.UnfiledFolderGUID,
]
public static let Real = Set<GUID>([
BookmarkRoots.RootGUID,
BookmarkRoots.MobileFolderGUID,
BookmarkRoots.MenuFolderGUID,
BookmarkRoots.ToolbarFolderGUID,
BookmarkRoots.UnfiledFolderGUID,
])
public static let All = Set<GUID>([
BookmarkRoots.RootGUID,
BookmarkRoots.MobileFolderGUID,
BookmarkRoots.MenuFolderGUID,
BookmarkRoots.ToolbarFolderGUID,
BookmarkRoots.UnfiledFolderGUID,
BookmarkRoots.FakeDesktopFolderGUID,
])
/**
* Sync records are a horrible mess of Places-native GUIDs and Sync-native IDs.
* For example:
* {"id":"places",
* "type":"folder",
* "title":"",
* "description":null,
* "children":["menu________","toolbar_____",
* "tags________","unfiled_____",
* "jKnyPDrBQSDg","T6XK5oJMU8ih"],
* "parentid":"2hYxKgBwvkEH"}"
*
* We thus normalize on the extended Places IDs (with underscores) for
* local storage, and translate to the Sync IDs when creating an outbound
* record.
* We translate the record's ID and also its parent. Evidence suggests that
* we don't need to translate children IDs.
*
* TODO: We don't create outbound records yet, so that's why there's no
* translation in that direction yet!
*/
public static func translateIncomingRootGUID(_ guid: GUID) -> GUID {
return [
"places": RootGUID,
"root": RootGUID,
"mobile": MobileFolderGUID,
"menu": MenuFolderGUID,
"toolbar": ToolbarFolderGUID,
"unfiled": UnfiledFolderGUID
][guid] ?? guid
}
public static func translateOutgoingRootGUID(_ guid: GUID) -> GUID {
return [
RootGUID: "places",
MobileFolderGUID: "mobile",
MenuFolderGUID: "menu",
ToolbarFolderGUID: "toolbar",
UnfiledFolderGUID: "unfiled"
][guid] ?? guid
}
/*
public static let TagsFolderGUID = "tags________"
public static let PinnedFolderGUID = "pinned______"
*/
static let RootID = 0
static let MobileID = 1
static let MenuID = 2
static let ToolbarID = 3
static let UnfiledID = 4
}
/**
* This partly matches Places's nsINavBookmarksService, just for sanity.
*
* It is further extended to support the types that exist in Sync, so we can use
* this to store mirrored rows.
*
* These are only used at the DB layer.
*/
public enum BookmarkNodeType: Int {
case bookmark = 1
case folder = 2
case separator = 3
case dynamicContainer = 4
case livemark = 5
case query = 6
// No microsummary: those turn into bookmarks.
}
public func == (lhs: BookmarkMirrorItem, rhs: BookmarkMirrorItem) -> Bool {
if lhs.type != rhs.type ||
lhs.guid != rhs.guid ||
lhs.dateAdded != rhs.dateAdded ||
lhs.serverModified != rhs.serverModified ||
lhs.isDeleted != rhs.isDeleted ||
lhs.hasDupe != rhs.hasDupe ||
lhs.pos != rhs.pos ||
lhs.faviconID != rhs.faviconID ||
lhs.localModified != rhs.localModified ||
lhs.parentID != rhs.parentID ||
lhs.parentName != rhs.parentName ||
lhs.feedURI != rhs.feedURI ||
lhs.siteURI != rhs.siteURI ||
lhs.title != rhs.title ||
lhs.description != rhs.description ||
lhs.bookmarkURI != rhs.bookmarkURI ||
lhs.tags != rhs.tags ||
lhs.keyword != rhs.keyword ||
lhs.folderName != rhs.folderName ||
lhs.queryID != rhs.queryID {
return false
}
if let lhsChildren = lhs.children, let rhsChildren = rhs.children {
return lhsChildren == rhsChildren
}
return lhs.children == nil && rhs.children == nil
}
public struct BookmarkMirrorItem: Equatable {
public let guid: GUID
public let type: BookmarkNodeType
public let dateAdded: Timestamp?
public var serverModified: Timestamp
public let isDeleted: Bool
public let hasDupe: Bool
public let parentID: GUID?
public let parentName: String?
// Livemarks.
public let feedURI: String?
public let siteURI: String?
// Separators.
let pos: Int?
// Folders, livemarks, bookmarks and queries.
public let title: String?
let description: String?
// Bookmarks and queries.
let bookmarkURI: String?
let tags: String?
let keyword: String?
// Queries.
let folderName: String?
let queryID: String?
// Folders.
public let children: [GUID]?
// Internal stuff.
let faviconID: Int?
public let localModified: Timestamp?
let syncStatus: SyncStatus?
public func copyWithDateAdded(_ dateAdded: Timestamp) -> BookmarkMirrorItem {
return BookmarkMirrorItem(
guid: self.guid,
type: self.type,
dateAdded: dateAdded,
serverModified: self.serverModified,
isDeleted: self.isDeleted,
hasDupe: self.hasDupe,
parentID: self.parentID,
parentName: self.parentName,
feedURI: self.feedURI,
siteURI: self.siteURI,
pos: self.pos,
title: self.title,
description: self.description,
bookmarkURI: self.bookmarkURI,
tags: self.tags,
keyword: self.keyword,
folderName: self.folderName,
queryID: self.queryID,
children: self.children,
faviconID: self.faviconID,
localModified: self.localModified,
syncStatus: self.syncStatus)
}
public func copyWithParentID(_ parentID: GUID, parentName: String?) -> BookmarkMirrorItem {
return BookmarkMirrorItem(
guid: self.guid,
type: self.type,
dateAdded: self.dateAdded,
serverModified: self.serverModified,
isDeleted: self.isDeleted,
hasDupe: self.hasDupe,
parentID: parentID,
parentName: parentName,
feedURI: self.feedURI,
siteURI: self.siteURI,
pos: self.pos,
title: self.title,
description: self.description,
bookmarkURI: self.bookmarkURI,
tags: self.tags,
keyword: self.keyword,
folderName: self.folderName,
queryID: self.queryID,
children: self.children,
faviconID: self.faviconID,
localModified: self.localModified,
syncStatus: self.syncStatus)
}
// Ignores internal metadata and GUID; a pure value comparison.
// Does compare child GUIDs!
public func sameAs(_ rhs: BookmarkMirrorItem) -> Bool {
if self.type != rhs.type ||
self.dateAdded != rhs.dateAdded ||
self.isDeleted != rhs.isDeleted ||
self.pos != rhs.pos ||
self.parentID != rhs.parentID ||
self.parentName != rhs.parentName ||
self.feedURI != rhs.feedURI ||
self.siteURI != rhs.siteURI ||
self.title != rhs.title ||
(self.description ?? "") != (rhs.description ?? "") ||
self.bookmarkURI != rhs.bookmarkURI ||
self.tags != rhs.tags ||
self.keyword != rhs.keyword ||
self.folderName != rhs.folderName ||
self.queryID != rhs.queryID {
return false
}
if let lhsChildren = self.children, let rhsChildren = rhs.children {
return lhsChildren == rhsChildren
}
return self.children == nil && rhs.children == nil
}
public func asJSON() -> JSON {
return self.asJSONWithChildren(self.children)
}
public func asJSONWithChildren(_ children: [GUID]?) -> JSON {
var out: [String: Any] = [:]
out["id"] = BookmarkRoots.translateOutgoingRootGUID(self.guid)
func take(_ key: String, _ val: String?) {
guard let val = val else {
return
}
out[key] = val
}
if self.isDeleted {
out["deleted"] = true
return JSON(out)
}
out["dateAdded"] = self.dateAdded
out["hasDupe"] = self.hasDupe
// TODO: this should never be nil!
if let parentID = self.parentID {
out["parentid"] = BookmarkRoots.translateOutgoingRootGUID(parentID)
take("parentName", titleForSpecialGUID(parentID) ?? self.parentName ?? "")
}
func takeBookmarkFields() {
take("title", self.title)
take("bmkUri", self.bookmarkURI)
take("description", self.description)
if let tags = self.tags {
let tagsJSON = JSON(parseJSON: tags)
if let tagsArray = tagsJSON.array, tagsArray.every({ $0.type == SwiftyJSON.Type.string }) {
out["tags"] = tagsArray
} else {
out["tags"] = []
}
} else {
out["tags"] = []
}
take("keyword", self.keyword)
}
func takeFolderFields() {
take("title", titleForSpecialGUID(self.guid) ?? self.title)
take("description", self.description)
if let children = children {
if BookmarkRoots.RootGUID == self.guid {
// Only the root contains roots, and so only its children
// need to be translated.
out["children"] = children.map(BookmarkRoots.translateOutgoingRootGUID)
} else {
out["children"] = children
}
}
}
switch self.type {
case .query:
out["type"] = "query"
take("folderName", self.folderName)
take("queryId", self.queryID)
takeBookmarkFields()
case .bookmark:
out["type"] = "bookmark"
takeBookmarkFields()
case .livemark:
out["type"] = "livemark"
take("siteUri", self.siteURI)
take("feedUri", self.feedURI)
takeFolderFields()
case .folder:
out["type"] = "folder"
takeFolderFields()
case .separator:
out["type"] = "separator"
if let pos = self.pos {
out["pos"] = pos
}
case .dynamicContainer:
// Sigh.
preconditionFailure("DynamicContainer not supported.")
}
return JSON(out)
}
// The places root is a folder but has no parentName.
public static func folder(_ guid: GUID, dateAdded: Timestamp?, modified: Timestamp, hasDupe: Bool, parentID: GUID, parentName: String?, title: String, description: String?, children: [GUID]) -> BookmarkMirrorItem {
let id = BookmarkRoots.translateIncomingRootGUID(guid)
let parent = BookmarkRoots.translateIncomingRootGUID(parentID)
return BookmarkMirrorItem(guid: id, type: .folder, dateAdded: dateAdded, serverModified: modified,
isDeleted: false, hasDupe: hasDupe, parentID: parent, parentName: parentName,
feedURI: nil, siteURI: nil,
pos: nil,
title: title, description: description,
bookmarkURI: nil, tags: nil, keyword: nil,
folderName: nil, queryID: nil,
children: children,
faviconID: nil, localModified: nil, syncStatus: nil)
}
public static func livemark(_ guid: GUID, dateAdded: Timestamp?, modified: Timestamp, hasDupe: Bool, parentID: GUID, parentName: String?, title: String?, description: String?, feedURI: String, siteURI: String) -> BookmarkMirrorItem {
let id = BookmarkRoots.translateIncomingRootGUID(guid)
let parent = BookmarkRoots.translateIncomingRootGUID(parentID)
return BookmarkMirrorItem(guid: id, type: .livemark, dateAdded: dateAdded, serverModified: modified,
isDeleted: false, hasDupe: hasDupe, parentID: parent, parentName: parentName,
feedURI: feedURI, siteURI: siteURI,
pos: nil,
title: title, description: description,
bookmarkURI: nil, tags: nil, keyword: nil,
folderName: nil, queryID: nil,
children: nil,
faviconID: nil, localModified: nil, syncStatus: nil)
}
public static func separator(_ guid: GUID, dateAdded: Timestamp?, modified: Timestamp, hasDupe: Bool, parentID: GUID, parentName: String?, pos: Int) -> BookmarkMirrorItem {
let id = BookmarkRoots.translateIncomingRootGUID(guid)
let parent = BookmarkRoots.translateIncomingRootGUID(parentID)
return BookmarkMirrorItem(guid: id, type: .separator, dateAdded: dateAdded, serverModified: modified,
isDeleted: false, hasDupe: hasDupe, parentID: parent, parentName: parentName,
feedURI: nil, siteURI: nil,
pos: pos,
title: nil, description: nil,
bookmarkURI: nil, tags: nil, keyword: nil,
folderName: nil, queryID: nil,
children: nil,
faviconID: nil, localModified: nil, syncStatus: nil)
}
public static func bookmark(_ guid: GUID, dateAdded: Timestamp?, modified: Timestamp, hasDupe: Bool, parentID: GUID, parentName: String?, title: String, description: String?, URI: String, tags: String, keyword: String?) -> BookmarkMirrorItem {
let id = BookmarkRoots.translateIncomingRootGUID(guid)
let parent = BookmarkRoots.translateIncomingRootGUID(parentID)
return BookmarkMirrorItem(guid: id, type: .bookmark, dateAdded: dateAdded, serverModified: modified,
isDeleted: false, hasDupe: hasDupe, parentID: parent, parentName: parentName,
feedURI: nil, siteURI: nil,
pos: nil,
title: title, description: description,
bookmarkURI: URI, tags: tags, keyword: keyword,
folderName: nil, queryID: nil,
children: nil,
faviconID: nil, localModified: nil, syncStatus: nil)
}
public static func query(_ guid: GUID, dateAdded: Timestamp?, modified: Timestamp, hasDupe: Bool, parentID: GUID, parentName: String?, title: String, description: String?, URI: String, tags: String, keyword: String?, folderName: String?, queryID: String?) -> BookmarkMirrorItem {
let id = BookmarkRoots.translateIncomingRootGUID(guid)
let parent = BookmarkRoots.translateIncomingRootGUID(parentID)
return BookmarkMirrorItem(guid: id, type: .query, dateAdded: dateAdded, serverModified: modified,
isDeleted: false, hasDupe: hasDupe, parentID: parent, parentName: parentName,
feedURI: nil, siteURI: nil,
pos: nil,
title: title, description: description,
bookmarkURI: URI, tags: tags, keyword: keyword,
folderName: folderName, queryID: queryID,
children: nil,
faviconID: nil, localModified: nil, syncStatus: nil)
}
public static func deleted(_ type: BookmarkNodeType, guid: GUID, modified: Timestamp) -> BookmarkMirrorItem {
let id = BookmarkRoots.translateIncomingRootGUID(guid)
return BookmarkMirrorItem(guid: id, type: type, dateAdded: nil, serverModified: modified,
isDeleted: true, hasDupe: false, parentID: nil, parentName: nil,
feedURI: nil, siteURI: nil,
pos: nil,
title: nil, description: nil,
bookmarkURI: nil, tags: nil, keyword: nil,
folderName: nil, queryID: nil,
children: nil,
faviconID: nil, localModified: nil, syncStatus: nil)
}
}

View file

@ -0,0 +1,417 @@
/* 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 Deferred
import Foundation
import Shared
private let log = Logger.syncLogger
/**
* The kinda-immutable base interface for bookmarks and folders.
*/
open class BookmarkNode {
open var id: Int?
open let guid: GUID
open let title: String
open let isEditable: Bool
open var favicon: Favicon?
init(guid: GUID, title: String, isEditable: Bool=false) {
self.guid = guid
self.title = title
self.isEditable = isEditable
}
open var canDelete: Bool {
return self.isEditable
}
}
open class BookmarkSeparator: BookmarkNode {
init(guid: GUID) {
super.init(guid: guid, title: "")
}
}
/**
* An immutable item representing a bookmark.
*
* To modify this, issue changes against the backing store and get an updated model.
*/
open class BookmarkItem: BookmarkNode {
open let url: String!
public init(guid: String, title: String, url: String, isEditable: Bool=false) {
self.url = url
super.init(guid: guid, title: title, isEditable: isEditable)
}
}
/**
* A folder is an immutable abstraction over a named
* thing that can return its child nodes by index.
*/
open class BookmarkFolder: BookmarkNode {
open var count: Int { return 0 }
open subscript(index: Int) -> BookmarkNode? { return nil }
open func itemIsEditableAtIndex(_ index: Int) -> Bool {
return self[index]?.canDelete ?? false
}
override open var canDelete: Bool {
return false
}
open func removeItemWithGUID(_ guid: GUID) -> BookmarkFolder? {
return nil
}
}
/**
* A model is a snapshot of the bookmarks store, suitable for backing a table view.
*
* Navigation through the folder hierarchy produces a sequence of models.
*
* Changes to the backing store implicitly invalidates a subset of models.
*
* 'Refresh' means requesting a new model from the store.
*/
open class BookmarksModel: BookmarksModelFactorySource {
fileprivate let factory: BookmarksModelFactory
open let modelFactory: Deferred<Maybe<BookmarksModelFactory>>
open let current: BookmarkFolder
public init(modelFactory: BookmarksModelFactory, root: BookmarkFolder) {
self.factory = modelFactory
self.modelFactory = deferMaybe(modelFactory)
self.current = root
}
/**
* Produce a new model rooted at the appropriate folder. Fails if the folder doesn't exist.
*/
open func selectFolder(_ folder: BookmarkFolder) -> Deferred<Maybe<BookmarksModel>> {
return self.factory.modelForFolder(folder)
}
/**
* Produce a new model rooted at the appropriate folder. Fails if the folder doesn't exist.
*/
open func selectFolder(_ guid: String) -> Deferred<Maybe<BookmarksModel>> {
return self.factory.modelForFolder(guid)
}
/**
* Produce a new model rooted at the base of the hierarchy. Should never fail.
*/
open func selectRoot() -> Deferred<Maybe<BookmarksModel>> {
return self.factory.modelForRoot()
}
/**
* Produce a new model with a memory-backed root with the given GUID removed from the current folder
*/
open func removeGUIDFromCurrent(_ guid: GUID) -> BookmarksModel {
if let removedRoot = self.current.removeItemWithGUID(guid) {
return BookmarksModel(modelFactory: self.factory, root: removedRoot)
}
log.warning("BookmarksModel.removeGUIDFromCurrent did not remove anything. Check to make sure you're not using the abstract BookmarkFolder class.")
return self
}
/**
* Produce a new model rooted at the same place as this model. Can fail if
* the folder has been deleted from the backing store.
*/
open func reloadData() -> Deferred<Maybe<BookmarksModel>> {
return self.factory.modelForFolder(current)
}
open var canDelete: Bool {
return false
}
}
public protocol BookmarksModelFactorySource {
var modelFactory: Deferred<Maybe<BookmarksModelFactory>> { get }
}
public protocol BookmarksModelFactory {
func factoryForIndex(_ index: Int, inFolder folder: BookmarkFolder) -> BookmarksModelFactory
func modelForFolder(_ folder: BookmarkFolder) -> Deferred<Maybe<BookmarksModel>>
func modelForFolder(_ guid: GUID) -> Deferred<Maybe<BookmarksModel>>
func modelForFolder(_ guid: GUID, title: String) -> Deferred<Maybe<BookmarksModel>>
func modelForRoot() -> Deferred<Maybe<BookmarksModel>>
// Whenever async construction is necessary, we fall into a pattern of needing
// a placeholder that behaves correctly for the period between kickoff and set.
var nullModel: BookmarksModel { get }
func isBookmarked(_ url: String) -> Deferred<Maybe<Bool>>
func removeByGUID(_ guid: GUID) -> Success
@discardableResult func removeByURL(_ url: String) -> Success
}
/*
* A folder that contains an array of children.
*/
open class MemoryBookmarkFolder: BookmarkFolder, Sequence {
let children: [BookmarkNode]
public init(guid: GUID, title: String, children: [BookmarkNode]) {
self.children = children
super.init(guid: guid, title: title)
}
public struct BookmarkNodeGenerator: IteratorProtocol {
public typealias Element = BookmarkNode
let children: [BookmarkNode]
var index: Int = 0
init(children: [BookmarkNode]) {
self.children = children
}
public mutating func next() -> BookmarkNode? {
if index < children.count {
defer { index += 1 }
return children[index]
}
return nil
}
}
override open var favicon: Favicon? {
get {
if let path = Bundle.main.path(forResource: "bookmarkFolder", ofType: "png") {
let url = URL(fileURLWithPath: path)
return Favicon(url: url.absoluteString, date: Date(), type: IconType.local)
}
return nil
}
set {
}
}
override open var count: Int {
return children.count
}
override open subscript(index: Int) -> BookmarkNode {
get {
return children[index]
}
}
override open func itemIsEditableAtIndex(_ index: Int) -> Bool {
return true
}
override open func removeItemWithGUID(_ guid: GUID) -> BookmarkFolder? {
let without = children.filter { $0.guid != guid }
return MemoryBookmarkFolder(guid: self.guid, title: self.title, children: without)
}
open func makeIterator() -> BookmarkNodeGenerator {
return BookmarkNodeGenerator(children: self.children)
}
/**
* Return a new immutable folder that's just like this one,
* but also contains the new items.
*/
func append(_ items: [BookmarkNode]) -> MemoryBookmarkFolder {
if items.isEmpty {
return self
}
return MemoryBookmarkFolder(guid: self.guid, title: self.title, children: self.children + items)
}
}
open class MemoryBookmarksSink: ShareToDestination {
var queue: [BookmarkNode] = []
public init() { }
open func shareItem(_ item: ShareItem) -> Success {
let title = item.title == nil ? "Untitled" : item.title!
func exists(_ e: BookmarkNode) -> Bool {
if let bookmark = e as? BookmarkItem {
return bookmark.url == item.url
}
return false
}
// Don't create duplicates.
if !queue.contains(where: exists) {
queue.append(BookmarkItem(guid: Bytes.generateGUID(), title: title, url: item.url))
}
return succeed()
}
}
private extension SuggestedSite {
func asBookmark() -> BookmarkNode {
let b = BookmarkItem(guid: self.guid ?? Bytes.generateGUID(), title: self.title, url: self.url)
b.favicon = self.icon
return b
}
}
open class PrependedBookmarkFolder: BookmarkFolder {
let main: BookmarkFolder
fileprivate let prepend: BookmarkNode
init(main: BookmarkFolder, prepend: BookmarkNode) {
self.main = main
self.prepend = prepend
super.init(guid: main.guid, title: main.guid)
}
override open var count: Int {
return self.main.count + 1
}
override open subscript(index: Int) -> BookmarkNode? {
if index == 0 {
return self.prepend
}
return self.main[index - 1]
}
override open func itemIsEditableAtIndex(_ index: Int) -> Bool {
return index > 0 && self.main.itemIsEditableAtIndex(index - 1)
}
override open func removeItemWithGUID(_ guid: GUID) -> BookmarkFolder? {
guard let removedFolder = main.removeItemWithGUID(guid) else {
log.warning("Failed to remove child item from prepended folder. Check that main folder overrides removeItemWithGUID.")
return nil
}
return PrependedBookmarkFolder(main: removedFolder, prepend: prepend)
}
}
open class ConcatenatedBookmarkFolder: BookmarkFolder {
fileprivate let main: BookmarkFolder
fileprivate let append: BookmarkFolder
init(main: BookmarkFolder, append: BookmarkFolder) {
self.main = main
self.append = append
super.init(guid: main.guid, title: main.title)
}
var pivot: Int {
return main.count
}
override open var count: Int {
return main.count + append.count
}
override open subscript(index: Int) -> BookmarkNode? {
return index < main.count ? main[index] : append[index - main.count]
}
override open func itemIsEditableAtIndex(_ index: Int) -> Bool {
return index < main.count ? main.itemIsEditableAtIndex(index) : append.itemIsEditableAtIndex(index - main.count)
}
override open func removeItemWithGUID(_ guid: GUID) -> BookmarkFolder? {
let newMain = main.removeItemWithGUID(guid) ?? main
let newAppend = append.removeItemWithGUID(guid) ?? append
return ConcatenatedBookmarkFolder(main: newMain, append: newAppend)
}
}
/**
* A trivial offline model factory that represents a simple hierarchy.
*/
open class MockMemoryBookmarksStore: BookmarksModelFactory, ShareToDestination {
let mobile: MemoryBookmarkFolder
let root: MemoryBookmarkFolder
var unsorted: MemoryBookmarkFolder
let sink: MemoryBookmarksSink
public init() {
let res = [BookmarkItem]()
mobile = MemoryBookmarkFolder(guid: BookmarkRoots.MobileFolderGUID, title: "Mobile Bookmarks", children: res)
unsorted = MemoryBookmarkFolder(guid: BookmarkRoots.UnfiledFolderGUID, title: "Unsorted Bookmarks", children: [])
sink = MemoryBookmarksSink()
root = MemoryBookmarkFolder(guid: BookmarkRoots.RootGUID, title: "Root", children: [mobile, unsorted])
}
public func factoryForIndex(_ index: Int, inFolder folder: BookmarkFolder) -> BookmarksModelFactory {
return self
}
open func modelForFolder(_ folder: BookmarkFolder) -> Deferred<Maybe<BookmarksModel>> {
return self.modelForFolder(folder.guid, title: folder.title)
}
open func modelForFolder(_ guid: GUID) -> Deferred<Maybe<BookmarksModel>> {
return self.modelForFolder(guid, title: "")
}
open func modelForFolder(_ guid: GUID, title: String) -> Deferred<Maybe<BookmarksModel>> {
var m: BookmarkFolder
switch guid {
case BookmarkRoots.MobileFolderGUID:
// Transparently merges in any queued items.
m = self.mobile.append(self.sink.queue)
break
case BookmarkRoots.RootGUID:
m = self.root
break
case BookmarkRoots.UnfiledFolderGUID:
m = self.unsorted
break
default:
return deferMaybe(DatabaseError(description: "No such folder \(guid)."))
}
return deferMaybe(BookmarksModel(modelFactory: self, root: m))
}
open func modelForRoot() -> Deferred<Maybe<BookmarksModel>> {
return deferMaybe(BookmarksModel(modelFactory: self, root: self.root))
}
/**
* This class could return the full data immediately. We don't, because real DB-backed code won't.
*/
open var nullModel: BookmarksModel {
let f = MemoryBookmarkFolder(guid: BookmarkRoots.RootGUID, title: "Root", children: [])
return BookmarksModel(modelFactory: self, root: f)
}
open func shareItem(_ item: ShareItem) -> Success {
return self.sink.shareItem(item)
}
open func isBookmarked(_ url: String) -> Deferred<Maybe<Bool>> {
return deferMaybe(DatabaseError(description: "Not implemented"))
}
open func removeByGUID(_ guid: GUID) -> Success {
return deferMaybe(DatabaseError(description: "Not implemented"))
}
open func removeByURL(_ url: String) -> Success {
return deferMaybe(DatabaseError(description: "Not implemented"))
}
open func clearBookmarks() -> Success {
return succeed()
}
}

View file

@ -0,0 +1,192 @@
/* 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 Deferred
import Foundation
import Shared
private let log = Logger.syncLogger
private class CachedSource {
// We track not just mappings between values and non-nil items, but also whether we've tried
// to look up a value at all. This allows us to distinguish between a cache miss and a
// cache hit that didn't find an item in the backing store.
// We expect, given our prefetching, that cache misses will be rare.
fileprivate var cache: [GUID: BookmarkMirrorItem] = [:]
fileprivate var seen: Set<GUID> = Set()
subscript(guid: GUID) -> BookmarkMirrorItem? {
get {
return self.cache[guid]
}
set(value) {
self.cache[guid] = value
}
}
func lookup(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>>? {
guard self.seen.contains(guid) else {
log.warning("Cache miss for \(guid).")
return nil
}
guard let found = self.cache[guid] else {
log.verbose("Cache hit, but no record found for \(guid).")
return deferMaybe(NoSuchRecordError(guid: guid))
}
log.verbose("Cache hit for \(guid).")
return deferMaybe(found)
}
var isEmpty: Bool {
return self.cache.isEmpty
}
// fill and seen are separate: we won't find every item in the DB.
func fill(_ items: [GUID: BookmarkMirrorItem]) -> Success {
for (x, y) in items {
self.cache[x] = y
}
return succeed()
}
func markSeen(_ guid: GUID) {
self.seen.insert(guid)
}
func markSeen<T: Sequence>(_ guids: T) where T.Iterator.Element == GUID {
self.seen.formUnion(guids)
}
func takingGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID {
var out: [GUID: BookmarkMirrorItem] = [:]
guids.forEach {
if let v = self.cache[$0] {
out[$0] = v
}
}
return deferMaybe(out)
}
}
// Sorry about the boilerplate.
// These are separate protocols so that the method names don't collide when implemented
// by the same class, but that means extracting more base implementation is more trouble than
// it's worth.
open class CachingLocalItemSource: LocalItemSource {
fileprivate let cache: CachedSource
fileprivate let source: LocalItemSource
public init(source: LocalItemSource) {
self.cache = CachedSource()
self.source = source
}
open func getLocalItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>> {
if let found = self.cache.lookup(guid) {
return found
}
return self.source.getLocalItemWithGUID(guid) >>== effect {
self.cache.markSeen(guid)
self.cache[guid] = $0
}
}
open func getLocalItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID {
return self.prefetchLocalItemsWithGUIDs(guids) >>> { self.cache.takingGUIDs(guids) }
}
open func prefetchLocalItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID {
log.debug("Prefetching \(guids.count) local items: \(guids.prefix(10))….")
if guids.isEmpty {
return succeed()
}
return self.source.getLocalItemsWithGUIDs(guids) >>== {
self.cache.markSeen(guids)
return self.cache.fill($0)
}
}
}
open class CachingMirrorItemSource: MirrorItemSource {
fileprivate let cache: CachedSource
fileprivate let source: MirrorItemSource
public init(source: MirrorItemSource) {
self.cache = CachedSource()
self.source = source
}
open func getMirrorItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>> {
if let found = self.cache.lookup(guid) {
return found
}
return self.source.getMirrorItemWithGUID(guid) >>== effect {
self.cache.markSeen(guid)
self.cache[guid] = $0
}
}
open func getMirrorItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID {
return self.prefetchMirrorItemsWithGUIDs(guids) >>> { self.cache.takingGUIDs(guids) }
}
open func prefetchMirrorItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID {
log.debug("Prefetching \(guids.count) mirror items: \(guids.prefix(10))….")
if guids.isEmpty {
return succeed()
}
return self.source.getMirrorItemsWithGUIDs(guids) >>== {
self.cache.markSeen(guids)
return self.cache.fill($0)
}
}
}
open class CachingBufferItemSource: BufferItemSource {
fileprivate let cache: CachedSource
fileprivate let source: BufferItemSource
public init(source: BufferItemSource) {
self.cache = CachedSource()
self.source = source
}
open func getBufferItemWithGUID(_ guid: GUID) -> Deferred<Maybe<BookmarkMirrorItem>> {
if let found = self.cache.lookup(guid) {
return found
}
return self.source.getBufferItemWithGUID(guid) >>== effect {
self.cache.markSeen(guid)
self.cache[guid] = $0
}
}
open func getBufferItemsWithGUIDs<T: Collection>(_ guids: T) -> Deferred<Maybe<[GUID: BookmarkMirrorItem]>> where T.Iterator.Element == GUID {
return self.prefetchBufferItemsWithGUIDs(guids) >>> { self.cache.takingGUIDs(guids) }
}
public func getBufferChildrenGUIDsForParent(_ guid: GUID) -> Deferred<Maybe<[GUID]>> {
return self.source.getBufferChildrenGUIDsForParent(guid)
}
open func prefetchBufferItemsWithGUIDs<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID {
log.debug("Prefetching \(guids.count) buffer items: \(guids.prefix(10))….")
if guids.isEmpty {
return succeed()
}
return self.source.getBufferItemsWithGUIDs(guids) >>== {
self.cache.markSeen(guids)
return self.cache.fill($0)
}
}
}

View file

@ -0,0 +1,319 @@
/* 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
private let log = Logger.syncLogger
// MARK: - Defining a tree structure for syncability.
public enum BookmarkTreeNode: Comparable {
indirect case folder(guid: GUID, children: [BookmarkTreeNode])
case nonFolder(guid: GUID)
case unknown(guid: GUID)
// Because shared associated values between enum cases aren't possible.
public var recordGUID: GUID {
switch self {
case let .folder(guid, _):
return guid
case let .nonFolder(guid):
return guid
case let .unknown(guid):
return guid
}
}
public var isRoot: Bool {
return BookmarkRoots.All.contains(self.recordGUID)
}
public var isUnknown: Bool {
if case .unknown = self {
return true
}
return false
}
public var children: [BookmarkTreeNode]? {
if case let .folder(_, children) = self {
return children
}
return nil
}
public func hasChildList(_ nodes: [BookmarkTreeNode]) -> Bool {
if case let .folder(_, ours) = self {
return ours.elementsEqual(nodes, by: { $0.recordGUID == $1.recordGUID })
}
return false
}
public func hasSameChildListAs(_ other: BookmarkTreeNode) -> Bool {
if case let .folder(_, ours) = self {
if case let .folder(_, theirs) = other {
return ours.elementsEqual(theirs, by: { $0.recordGUID == $1.recordGUID })
}
}
return false
}
// Returns false for unknowns.
public func isSameTypeAs(_ other: BookmarkTreeNode) -> Bool {
switch self {
case .folder:
if case .folder = other {
return true
}
case .nonFolder:
if case .nonFolder = other {
return true
}
default:
return false
}
return false
}
}
public func == (lhs: BookmarkTreeNode, rhs: BookmarkTreeNode) -> Bool {
switch lhs {
case let .folder(guid, children):
if case let .folder(rguid, rchildren) = rhs {
return guid == rguid && children == rchildren
}
return false
case let .nonFolder(guid):
if case let .nonFolder(rguid) = rhs {
return guid == rguid
}
return false
case let .unknown(guid):
if case let .unknown(rguid) = rhs {
return guid == rguid
}
return false
}
}
public func < (lhs: BookmarkTreeNode, rhs: BookmarkTreeNode) -> Bool {
return lhs.recordGUID < rhs.recordGUID
}
typealias StructureRow = (parent: GUID, child: GUID, type: BookmarkNodeType?)
// This is really a forest, not a tree: it can have multiple 'subtrees'
// and carries a collection of associated values.
public struct BookmarkTree {
// Records with no parents.
public let subtrees: [BookmarkTreeNode]
// Record GUID -> record.
public let lookup: [GUID: BookmarkTreeNode]
// Child GUID -> parent GUID.
public let parents: [GUID: GUID]
// Records that appear in 'lookup' because they're modified, but aren't present
// in 'subtrees' because their parent didn't change.
public let orphans: Set<GUID>
// Records that have been deleted.
public let deleted: Set<GUID>
// Every record that's changed but not deleted.
public let modified: Set<GUID>
// Nodes that are present in this tree but aren't present in the source.
// In practical terms, this will be roots that we pretend exist in
// the mirror for purposes of three-way merging.
public let virtual: Set<GUID>
// Accessor for all top-level folders' GUIDs.
public var subtreeGUIDs: Set<GUID> {
return Set(self.subtrees.map { $0.recordGUID })
}
public var isEmpty: Bool {
return self.subtrees.isEmpty && self.deleted.isEmpty
}
public static func emptyTree() -> BookmarkTree {
return BookmarkTree(subtrees: [], lookup: [:], parents: [:], orphans: Set<GUID>(), deleted: Set<GUID>(), modified: Set<GUID>(), virtual: Set<GUID>())
}
public static func emptyMirrorTree() -> BookmarkTree {
return mappingsToTreeForStructureRows([], withNonFoldersAndEmptyFolders: [], withDeletedRecords: Set(), modifiedRecords: Set(), alwaysIncludeRoots: true)
}
public func includesOrDeletesNode(_ node: BookmarkTreeNode) -> Bool {
return self.includesOrDeletesGUID(node.recordGUID)
}
public func includesNode(_ node: BookmarkTreeNode) -> Bool {
return self.includesGUID(node.recordGUID)
}
public func includesOrDeletesGUID(_ guid: GUID) -> Bool {
return self.includesGUID(guid) || self.deleted.contains(guid)
}
public func includesGUID(_ guid: GUID) -> Bool {
return self.lookup[guid] != nil
}
public func find(_ guid: GUID) -> BookmarkTreeNode? {
return self.lookup[guid]
}
public func find(_ node: BookmarkTreeNode) -> BookmarkTreeNode? {
return self.find(node.recordGUID)
}
/**
* True if there is one subtree, and it's the Root, when overlayed.
* We assume that the mirror will always be consistent, so what
* this really means is that every subtree in this tree is *present*
* in the comparison tree, or is itself rooted in a known root.
*
* In a fully rooted tree there can be no orphans; if our partial tree
* includes orphans, they must be known by the comparison tree.
*/
public func isFullyRootedIn(_ tree: BookmarkTree) -> Bool {
// We don't compare against tree.deleted, because you can't *undelete*.
return self.orphans.every(tree.includesGUID) &&
self.subtrees.every { subtree in
tree.includesNode(subtree) || subtree.isRoot
}
}
// If this tree contains the root, return it.
public var root: BookmarkTreeNode? {
return self.find(BookmarkRoots.RootGUID)
}
// Recursively process an input set of structure pairs to yield complete subtrees,
// assembling those subtrees to make a minimal set of trees.
static func mappingsToTreeForStructureRows(_ mappings: [StructureRow], withNonFoldersAndEmptyFolders nonFoldersAndEmptyFolders: [BookmarkTreeNode], withDeletedRecords deleted: Set<GUID>, modifiedRecords modified: Set<GUID>, alwaysIncludeRoots: Bool) -> BookmarkTree {
// Accumulate.
var nodes: [GUID: BookmarkTreeNode] = [:]
var parents: [GUID: GUID] = [:]
var remainingFolders = Set<GUID>()
// `tops` is the collection of things that we think are the roots of subtrees (until
// we're proved wrong). We add GUIDs here when we don't know their parents; if we get to
// the end and they're still here, they're roots.
var tops = Set<GUID>()
var notTops = Set<GUID>()
var orphans = Set<GUID>()
var virtual = Set<GUID>()
// We can't immediately build the final tree, because we need to do it bottom-up!
// So store structure, which we can figure out flat.
var pseudoTree: [GUID: [GUID]] = mappings.groupBy({ $0.parent }, transformer: { $0.child })
// Deal with the ones that are non-structural first.
nonFoldersAndEmptyFolders.forEach { node in
let guid = node.recordGUID
nodes[guid] = node
switch node {
case .folder:
// If we end up here, it's because this folder is empty, and it won't
// appear in structure. Assert to make sure that's true!
assert(pseudoTree[guid] == nil)
pseudoTree[guid] = []
// It'll be a top unless we find it as a child in the structure somehow.
tops.insert(guid)
default:
orphans.insert(guid)
}
}
// Precompute every leaf node.
mappings.forEach { row in
parents[row.child] = row.parent
remainingFolders.insert(row.parent)
tops.insert(row.parent)
// None of the children we've seen can be top, so remove them.
notTops.insert(row.child)
if let type = row.type {
switch type {
case .folder:
// The child is itself a folder.
remainingFolders.insert(row.child)
default:
nodes[row.child] = BookmarkTreeNode.nonFolder(guid: row.child)
}
} else {
// This will be the case if we've shadowed a folder; we indirectly reference the original rows.
nodes[row.child] = BookmarkTreeNode.unknown(guid: row.child)
}
}
// When we build the mirror, we always want to pretend it has our stock roots.
// This gives us our shared basis from which to merge.
// Doing it here means we don't need to protect the mirror database table.
if alwaysIncludeRoots {
func setVirtual(_ guid: GUID) {
if !remainingFolders.contains(guid) && nodes[guid] == nil {
virtual.insert(guid)
}
}
// Note that we don't check whether the input already contained the roots; we
// never change them, so it's safe to do this unconditionally.
setVirtual(BookmarkRoots.RootGUID)
BookmarkRoots.RootChildren.forEach {
setVirtual($0)
}
pseudoTree[BookmarkRoots.RootGUID] = BookmarkRoots.RootChildren
tops.insert(BookmarkRoots.RootGUID)
notTops.formUnion(Set(BookmarkRoots.RootChildren))
remainingFolders.formUnion(BookmarkRoots.All)
BookmarkRoots.RootChildren.forEach {
parents[$0] = BookmarkRoots.RootGUID
}
}
tops.subtract(notTops)
orphans.subtract(notTops)
// Recursive. (Not tail recursive, but trees shouldn't be deep enough to blow the stack.)
@discardableResult func nodeForGUID(_ guid: GUID) -> BookmarkTreeNode {
if let already = nodes[guid] {
return already
}
if !remainingFolders.contains(guid) {
let node = BookmarkTreeNode.unknown(guid: guid)
nodes[guid] = node
return node
}
// Removing these eagerly prevents infinite recursion in the case of a cycle.
let childGUIDs = pseudoTree[guid] ?? []
pseudoTree.removeValue(forKey: guid)
remainingFolders.remove(guid)
let node = BookmarkTreeNode.folder(guid: guid, children: childGUIDs.map(nodeForGUID))
nodes[guid] = node
return node
}
// Process every record.
// Do the not-tops first: shallower recursion.
notTops.forEach({ nodeForGUID($0) })
let subtrees = tops.map(nodeForGUID) // These will all be folders.
// Whatever we're left with in `tops` is the set of records for which we
// didn't process a parent.
return BookmarkTree(subtrees: subtrees, lookup: nodes, parents: parents, orphans: orphans, deleted: deleted, modified: modified, virtual: virtual)
}
}

View file

@ -0,0 +1,31 @@
/* 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 UIKit
import Deferred
/// In-memory certificate store.
open class CertStore {
fileprivate var keys = Set<String>()
public init() {}
open func addCertificate(_ cert: SecCertificate, forOrigin origin: String) {
let data: Data = SecCertificateCopyData(cert) as Data
let key = keyForData(data, origin: origin)
keys.insert(key)
}
open func containsCertificate(_ cert: SecCertificate, forOrigin origin: String) -> Bool {
let data: Data = SecCertificateCopyData(cert) as Data
let key = keyForData(data, origin: origin)
return keys.contains(key)
}
fileprivate func keyForData(_ data: Data, origin: String) -> String {
return "\(origin)/\(data.sha256.hexEncodedString)"
}
}

View file

@ -0,0 +1,75 @@
/* 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 Shared
import SwiftyJSON
public struct RemoteClient: Equatable {
public let guid: GUID?
public let modified: Timestamp
public let name: String
public let type: String?
public let os: String?
public let version: String?
public let fxaDeviceId: String?
let protocols: [String]?
let appPackage: String?
let application: String?
let formfactor: String?
let device: String?
// Requires a valid ClientPayload (: CleartextPayloadJSON: JSON).
public init(json: JSON, modified: Timestamp) {
self.guid = json["id"].string
self.modified = modified
self.name = json["name"].stringValue
self.type = json["type"].string
self.version = json["version"].string
self.protocols = jsonsToStrings(json["protocols"].array)
self.os = json["os"].string
self.appPackage = json["appPackage"].string
self.application = json["application"].string
self.formfactor = json["formfactor"].string
self.device = json["device"].string
self.fxaDeviceId = json["fxaDeviceId"].string
}
public init(guid: GUID?, name: String, modified: Timestamp, type: String?, formfactor: String?, os: String?, version: String?, fxaDeviceId: String?) {
self.guid = guid
self.name = name
self.modified = modified
self.type = type
self.formfactor = formfactor
self.os = os
self.version = version
self.fxaDeviceId = fxaDeviceId
self.device = nil
self.appPackage = nil
self.application = nil
self.protocols = nil
}
}
// TODO: should this really compare tabs?
public func ==(lhs: RemoteClient, rhs: RemoteClient) -> Bool {
return lhs.guid == rhs.guid &&
lhs.name == rhs.name &&
lhs.modified == rhs.modified &&
lhs.type == rhs.type &&
lhs.formfactor == rhs.formfactor &&
lhs.os == rhs.os &&
lhs.version == rhs.version &&
lhs.fxaDeviceId == rhs.fxaDeviceId
}
extension RemoteClient: CustomStringConvertible {
public var description: String {
return "<RemoteClient GUID: \(guid ?? "nil"), name: \(name), modified: \(modified), type: \(type ?? "nil"), formfactor: \(formfactor ?? "nil"), OS: \(os ?? "nil"), version: \(version ?? "nil"), fxaDeviceId: \(fxaDeviceId ?? "nil")>"
}
}

View file

@ -0,0 +1,89 @@
/* 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 XCGLogger
private let log = Logger.syncLogger
public protocol PerhapsNoOp {
var isNoOp: Bool { get }
}
open class LocalOverrideCompletionOp: PerhapsNoOp {
open var processedLocalChanges: Set<GUID> = Set() // These can be deleted when we're run. Mark mirror as non-overridden, too.
open var mirrorItemsToDelete: Set<GUID> = Set() // These were locally or remotely deleted.
open var mirrorItemsToInsert: [GUID: BookmarkMirrorItem] = [:] // These were locally or remotely added.
open var mirrorItemsToUpdate: [GUID: BookmarkMirrorItem] = [:] // These were already in the mirror, but changed.
open var mirrorStructures: [GUID: [GUID]] = [:] // New or changed structure.
open var mirrorValuesToCopyFromBuffer: Set<GUID> = Set() // No need to synthesize BookmarkMirrorItem instances in memory.
open var mirrorValuesToCopyFromLocal: Set<GUID> = Set()
open var modifiedTimes: [Timestamp: [GUID]] = [:] // Only for copy.
open var isNoOp: Bool {
return processedLocalChanges.isEmpty &&
mirrorValuesToCopyFromBuffer.isEmpty &&
mirrorValuesToCopyFromLocal.isEmpty &&
mirrorItemsToDelete.isEmpty &&
mirrorItemsToInsert.isEmpty &&
mirrorItemsToUpdate.isEmpty &&
mirrorStructures.isEmpty
}
open func setModifiedTime(_ time: Timestamp, guids: [GUID]) {
var forCopy: [GUID] = self.modifiedTimes[time] ?? []
for guid in guids {
// This saves us doing an UPDATE on these items.
if var item = self.mirrorItemsToInsert[guid] {
item.serverModified = time
} else if var item = self.mirrorItemsToUpdate[guid] {
item.serverModified = time
} else {
forCopy.append(guid)
}
}
if !forCopy.isEmpty {
modifiedTimes[time] = forCopy
}
}
public init() {
}
}
open class BufferCompletionOp: PerhapsNoOp {
open var processedBufferChanges: Set<GUID> = Set() // These can be deleted when we're run.
open var isNoOp: Bool {
return self.processedBufferChanges.isEmpty
}
public init() {
}
}
// This supports the "simple" bookmark syncing scenario where 3-way-merging is disabled:
// After upload, we first remove from the buffer the deleted records,
// then we move new local records to the buffer.
open class BufferUpdatedCompletionOp: PerhapsNoOp {
internal let bufferValuesToMoveFromLocal: Set<GUID>
internal let deletedValues: Set<GUID>
internal let mobileRoot: BookmarkMirrorItem
internal let modifiedTime: Timestamp
open var isNoOp: Bool {
return false
}
public init(bufferValuesToMoveFromLocal: Set<GUID>, deletedValues: Set<GUID>, mobileRoot: BookmarkMirrorItem, modifiedTime: Timestamp) {
self.bufferValuesToMoveFromLocal = bufferValuesToMoveFromLocal
self.deletedValues = deletedValues
self.mobileRoot = mobileRoot
self.modifiedTime = modifiedTime
}
}

View file

@ -0,0 +1,124 @@
/* 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
/**
* Status results for a Cursor
*/
public enum CursorStatus {
case success
case failure
case closed
}
public protocol TypedCursor: Sequence {
associatedtype T
var count: Int { get }
var status: CursorStatus { get }
var statusMessage: String { get }
subscript(index: Int) -> T? { get }
func asArray() -> [T]
}
/**
* Provides a generic method of returning some data and status information about a request.
*/
open class Cursor<T>: TypedCursor {
open var count: Int {
get { return 0 }
}
// Extra status information
open var status: CursorStatus
public var statusMessage: String
init(err: NSError) {
self.status = .failure
self.statusMessage = err.description
}
public init(status: CursorStatus = .success, msg: String = "") {
self.statusMessage = msg
self.status = status
}
// Collection iteration and access functions
open subscript(index: Int) -> T? {
get { return nil }
}
open func asArray() -> [T] {
var acc = [T]()
acc.reserveCapacity(self.count)
for row in self {
// Shouldn't ever be nil -- that's to allow the generator or subscript to be
// out of range.
if let row = row {
acc.append(row)
}
}
return acc
}
open func makeIterator() -> AnyIterator<T?> {
var nextIndex = 0
return AnyIterator() {
if nextIndex >= self.count || self.status != CursorStatus.success {
return nil
}
defer { nextIndex += 1 }
return self[nextIndex]
}
}
open func close() {
status = .closed
statusMessage = "Closed"
}
deinit {
if status != CursorStatus.closed {
close()
}
}
}
/*
* A cursor implementation that wraps an array.
*/
open class ArrayCursor<T> : Cursor<T> {
fileprivate var data: [T]
open override var count: Int {
if status != .success {
return 0
}
return data.count
}
public init(data: [T], status: CursorStatus, statusMessage: String) {
self.data = data
super.init(status: status, msg: statusMessage)
}
public convenience init(data: [T]) {
self.init(data: data, status: CursorStatus.success, statusMessage: "Success")
}
open override subscript(index: Int) -> T? {
get {
if index >= data.count || index < 0 || status != .success {
return nil
}
return data[index]
}
}
override open func close() {
data = [T]()
super.close()
}
}

View file

@ -0,0 +1,24 @@
/* 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 Shared
/**
* Used to bridge the NSErrors we get here into something that Result is happy with.
*/
open class DatabaseError: MaybeErrorType {
let err: NSError?
open var description: String {
return err?.localizedDescription ?? "Unknown database error."
}
public init(description: String) {
self.err = NSError(domain: "mozilla", code: 0, userInfo: [NSLocalizedDescriptionKey: description])
}
public init(err: NSError?) {
self.err = err
}
}

View file

@ -0,0 +1,130 @@
/* 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
open class DefaultSuggestedSites {
open static let urlMap = [
"https://www.amazon.com/": [
"as": "https://www.amazon.in",
"cy": "https://www.amazon.co.uk",
"da": "https://www.amazon.co.uk",
"de": "https://www.amazon.de",
"dsb": "https://www.amazon.de",
"en_GB": "https://www.amazon.co.uk",
"et": "https://www.amazon.co.uk",
"ff": "https://www.amazon.fr",
"ga_IE": "https://www.amazon.co.uk",
"gu_IN": "https://www.amazon.in",
"hi_IN": "https://www.amazon.in",
"hr": "https://www.amazon.co.uk",
"hsb": "https://www.amazon.de",
"ja": "https://www.amazon.co.jp",
"kn": "https://www.amazon.in",
"mr": "https://www.amazon.in",
"or": "https://www.amazon.in",
"sq": "https://www.amazon.co.uk",
"ta": "https://www.amazon.in",
"te": "https://www.amazon.in",
"ur": "https://www.amazon.in",
"en_CA": "https://www.amazon.ca",
"fr_CA": "https://www.amazon.ca"
]
]
open static let sites = [
"default": [
SuggestedSiteData(
url: "https://m.facebook.com/",
bgColor: "0x385185",
imageUrl: "asset://suggestedsites_facebook",
faviconUrl: "asset://defaultFavicon",
trackingId: 632,
title: NSLocalizedString("Facebook", comment: "Tile title for Facebook")
),
SuggestedSiteData(
url: "https://m.youtube.com/",
bgColor: "0xcd201f",
imageUrl: "asset://suggestedsites_youtube",
faviconUrl: "asset://defaultFavicon",
trackingId: 631,
title: NSLocalizedString("YouTube", comment: "Tile title for YouTube")
),
SuggestedSiteData(
url: "https://www.amazon.com/",
bgColor: "0x000000",
imageUrl: "asset://suggestedsites_amazon",
faviconUrl: "asset://defaultFavicon",
trackingId: 630,
title: NSLocalizedString("Amazon", comment: "Tile title for Amazon")
),
SuggestedSiteData(
url: "https://www.wikipedia.org/",
bgColor: "0x000000",
imageUrl: "asset://suggestedsites_wikipedia",
faviconUrl: "asset://defaultFavicon",
trackingId: 629,
title: NSLocalizedString("Wikipedia", comment: "Tile title for Wikipedia")
),
SuggestedSiteData(
url: "https://mobile.twitter.com/",
bgColor: "0x55acee",
imageUrl: "asset://suggestedsites_twitter",
faviconUrl: "asset://defaultFavicon",
trackingId: 628,
title: NSLocalizedString("Twitter", comment: "Tile title for Twitter")
)
],
"zh_CN": [
SuggestedSiteData(
url: "http://mozilla.com.cn",
bgColor: "0xbc3326",
imageUrl: "asset://suggestedsites_mozchina",
faviconUrl: "asset://mozChinaLogo",
trackingId: 700,
title: "火狐社区"
),
SuggestedSiteData(
url: "https://m.baidu.com/?from=1000969b",
bgColor: "0x00479d",
imageUrl: "asset://suggestedsites_baidu",
faviconUrl: "asset://baiduLogo",
trackingId: 701,
title: "百度"
),
SuggestedSiteData(
url: "http://sina.cn",
bgColor: "0xe60012",
imageUrl: "asset://suggestedsites_sina",
faviconUrl: "asset://sinaLogo",
trackingId: 702,
title: "新浪"
),
SuggestedSiteData(
url: "http://info.3g.qq.com/g/s?aid=index&g_f=23946&g_ut=3",
bgColor: "0x028cca",
imageUrl: "asset://suggestedsites_qq",
faviconUrl: "asset://qqLogo",
trackingId: 703,
title: "腾讯"
),
SuggestedSiteData(
url: "http://m.taobao.com",
bgColor: "0xee5900",
imageUrl: "asset://suggestedsites_taobao",
faviconUrl: "asset://taobaoLogo",
trackingId: 704,
title: "淘宝"
),
SuggestedSiteData(
url: "http://union.click.jd.com/jdc?e=0&p=AyIHVCtaJQMiQwpDBUoyS0IQWlALHE4YDk5ER1xONwdJKVxASgI%2BeDkWfGJ6HEAOUmkbcjUXVyUBEQZRG1IXARQ3VhhaEQETBVweayVkbzcedVolBxIEUBxdFAoQN1UeXRQLGwFXHlsUABs3UisnS0lKWghLWBQCFzdlK2s%3D&t=W1dCFBBFC14NXAAECUte",
bgColor: "0xc71622",
imageUrl: "asset://suggestedsites_jd",
faviconUrl: "asset://jdLogo",
trackingId: 705,
title: "京东"
)
]
]
}

View file

@ -0,0 +1,105 @@
/* 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 UIKit
import Deferred
import XCGLogger
private var log = XCGLogger.default
private class DiskImageStoreErrorType: MaybeErrorType {
let description: String
init(description: String) {
self.description = description
}
}
/**
* Disk-backed key-value image store.
*/
open class DiskImageStore {
fileprivate let files: FileAccessor
fileprivate let filesDir: String
fileprivate let queue = DispatchQueue(label: "DiskImageStore")
fileprivate let quality: CGFloat
fileprivate var keys: Set<String>
required public init(files: FileAccessor, namespace: String, quality: Float) {
self.files = files
self.filesDir = try! files.getAndEnsureDirectory(namespace)
self.quality = CGFloat(quality)
// Build an in-memory set of keys from the existing images on disk.
var keys = [String]()
if let fileEnumerator = FileManager.default.enumerator(atPath: filesDir) {
for file in fileEnumerator {
keys.append(file as! String)
}
}
self.keys = Set(keys)
}
/// Gets an image for the given key if it is in the store.
open func get(_ key: String) -> Deferred<Maybe<UIImage>> {
return deferDispatchAsync(queue) { () -> Deferred<Maybe<UIImage>> in
if !self.keys.contains(key) {
return deferMaybe(DiskImageStoreErrorType(description: "Image key not found"))
}
let imagePath = URL(fileURLWithPath: self.filesDir).appendingPathComponent(key)
if let data = try? Data(contentsOf: imagePath),
let image = UIImage.imageFromDataThreadSafe(data) {
return deferMaybe(image)
}
return deferMaybe(DiskImageStoreErrorType(description: "Invalid image data"))
}
}
/// Adds an image for the given key.
/// This put is asynchronous; the image is not recorded in the cache until the write completes.
/// Does nothing if this key already exists in the store.
@discardableResult open func put(_ key: String, image: UIImage) -> Success {
return deferDispatchAsync(queue) { () -> Success in
if self.keys.contains(key) {
return deferMaybe(DiskImageStoreErrorType(description: "Key already in store"))
}
let imageURL = URL(fileURLWithPath: self.filesDir).appendingPathComponent(key)
if let data = UIImageJPEGRepresentation(image, self.quality) {
do {
try data.write(to: imageURL, options: .noFileProtection)
self.keys.insert(key)
return succeed()
} catch {
log.error("Unable to write image to disk: \(error)")
}
}
return deferMaybe(DiskImageStoreErrorType(description: "Could not write image to file"))
}
}
/// Clears all images from the cache, excluding the given set of keys.
open func clearExcluding(_ keys: Set<String>) -> Success {
return deferDispatchAsync(queue) { () -> Success in
let keysToDelete = self.keys.subtracting(keys)
for key in keysToDelete {
let url = URL(fileURLWithPath: self.filesDir).appendingPathComponent(key)
do {
try FileManager.default.removeItem(at: url)
} catch {
log.warning("Failed to remove DiskImageStore item at \(url.absoluteString): \(error)")
}
}
self.keys = self.keys.intersection(keys)
return succeed()
}
}
}

View file

@ -0,0 +1,47 @@
/* 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 UIKit
import MobileCoreServices
public struct ExtensionUtils {
/// Look through the extensionContext for a url and title. Walks over all inputItems and then over all the attachments.
/// Has a completionHandler because ultimately an XPC call to the sharing application is done.
/// We can always extract a URL and sometimes a title. The favicon is currently just a placeholder, but
/// future code can possibly interact with a web page to find a proper icon.
public static func extractSharedItemFromExtensionContext(_ extensionContext: NSExtensionContext?, completionHandler: @escaping (ShareItem?, NSError?) -> Void) {
guard let extensionContext = extensionContext,
let inputItems = extensionContext.inputItems as? [NSExtensionItem] else {
completionHandler(nil, nil)
return
}
for inputItem in inputItems {
guard let attachments = inputItem.attachments as? [NSItemProvider] else { continue }
for attachment in attachments {
if attachment.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
attachment.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil) { obj, err in
guard err == nil else {
completionHandler(nil, err as NSError!)
return
}
guard let url = obj as? URL else {
completionHandler(nil, NSError(domain: "org.mozilla.fennec", code: 999, userInfo: ["Problem": "Non-URL result."]))
return
}
let title = inputItem.attributedContentText?.string
completionHandler(ShareItem(url: url.absoluteString, title: title, favicon: nil), nil)
}
return
}
}
}
completionHandler(nil, nil)
}
}

View file

@ -0,0 +1,22 @@
/* 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 Shared
import UIKit
import Deferred
/* The base favicons protocol */
public protocol Favicons {
func clearAllFavicons() -> Success
/**
* Returns the ID of the added favicon.
*/
func addFavicon(_ icon: Favicon) -> Deferred<Maybe<Int>>
/**
* Returns the ID of the added favicon.
*/
@discardableResult func addFavicon(_ icon: Favicon, forSite site: Site) -> Deferred<Maybe<Int>>
}

View file

@ -0,0 +1,117 @@
/* 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
/**
* A convenience class for file operations under a given root directory.
* Note that while this class is intended to be used to operate only on files
* under the root, this is not strictly enforced: clients can go outside
* the path using ".." or symlinks.
*/
open class FileAccessor {
open let rootPath: String
public init(rootPath: String) {
self.rootPath = rootPath
}
/**
* Gets the absolute directory path at the given relative path, creating it if it does not exist.
*/
open func getAndEnsureDirectory(_ relativeDir: String? = nil) throws -> String {
var absolutePath = rootPath
if let relativeDir = relativeDir {
absolutePath = URL(fileURLWithPath: absolutePath).appendingPathComponent(relativeDir).path
}
try createDir(absolutePath)
return absolutePath
}
/**
* Removes the file or directory at the given path, relative to the root.
*/
open func remove(_ relativePath: String) throws {
let path = URL(fileURLWithPath: rootPath).appendingPathComponent(relativePath).path
try FileManager.default.removeItem(atPath: path)
}
/**
* Removes the contents of the directory without removing the directory itself.
*/
open func removeFilesInDirectory(_ relativePath: String = "") throws {
let fileManager = FileManager.default
let path = URL(fileURLWithPath: rootPath).appendingPathComponent(relativePath).path
let files = try fileManager.contentsOfDirectory(atPath: path)
for file in files {
try remove(URL(fileURLWithPath: relativePath).appendingPathComponent(file).path)
}
return
}
/**
* Determines whether a file exists at the given path, relative to the root.
*/
open func exists(_ relativePath: String) -> Bool {
let path = URL(fileURLWithPath: rootPath).appendingPathComponent(relativePath).path
return FileManager.default.fileExists(atPath: path)
}
open func attributesForFileAt(relativePath: String) throws -> [FileAttributeKey: Any] {
return try FileManager.default.attributesOfItem(atPath: URL(fileURLWithPath: rootPath).appendingPathComponent(relativePath).path)
}
/**
* Moves the file or directory to the given destination, with both paths relative to the root.
* The destination directory is created if it does not exist.
*/
open func move(_ fromRelativePath: String, toRelativePath: String) throws {
let rootPathURL = URL(fileURLWithPath: rootPath)
let fromPath = rootPathURL.appendingPathComponent(fromRelativePath).path
let toPath = rootPathURL.appendingPathComponent(toRelativePath)
let toDir = toPath.deletingLastPathComponent()
let toDirPath = toDir.path
try createDir(toDirPath)
try FileManager.default.moveItem(atPath: fromPath, toPath: toPath.path)
}
open func copyMatching(fromRelativeDirectory relativePath: String, toAbsoluteDirectory absolutePath: String, matching: (String) -> Bool) throws {
let fileManager = FileManager.default
let pathURL = URL(fileURLWithPath: rootPath).appendingPathComponent(relativePath)
let path = pathURL.path
let destURL = URL(fileURLWithPath: absolutePath, isDirectory: true)
let files = try fileManager.contentsOfDirectory(atPath: path)
for file in files {
if !matching(file) {
continue
}
let from = pathURL.appendingPathComponent(file, isDirectory: false).path
let to = destURL.appendingPathComponent(file, isDirectory: false).path
do {
try fileManager.copyItem(atPath: from, toPath: to)
} catch {
}
}
}
open func copy(_ fromRelativePath: String, toAbsolutePath: String) throws -> Bool {
let fromPath = URL(fileURLWithPath: rootPath).appendingPathComponent(fromRelativePath).path
let dest = URL(fileURLWithPath: toAbsolutePath).deletingLastPathComponent().path
try createDir(dest)
try FileManager.default.copyItem(atPath: fromPath, toPath: toAbsolutePath)
return true
}
/**
* Creates a directory with the given path, including any intermediate directories.
* Does nothing if the directory already exists.
*/
fileprivate func createDir(_ absolutePath: String) throws {
try FileManager.default.createDirectory(atPath: absolutePath, withIntermediateDirectories: true, attributes: nil)
}
}

View file

@ -0,0 +1,108 @@
/* 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 Shared
import Deferred
open class IgnoredSiteError: MaybeErrorType {
open var description: String {
return "Ignored site."
}
}
/**
* The base history protocol for front-end code.
*
* Note that the implementation of these methods might be complicated if
* the implementing class also implements SyncableHistory -- for example,
* `clear` might or might not need to set a bunch of flags to upload deletions.
*/
public protocol BrowserHistory {
@discardableResult func addLocalVisit(_ visit: SiteVisit) -> Success
func clearHistory() -> Success
@discardableResult func removeHistoryForURL(_ url: String) -> Success
func removeSiteFromTopSites(_ site: Site) -> Success
func removeHostFromTopSites(_ host: String) -> Success
func getFrecentHistory() -> FrecentHistory
func getSitesByLastVisit(_ limit: Int) -> Deferred<Maybe<Cursor<Site>>>
func getTopSitesWithLimit(_ limit: Int) -> Deferred<Maybe<Cursor<Site>>>
func setTopSitesNeedsInvalidation()
func setTopSitesCacheSize(_ size: Int32)
func clearTopSitesCache() -> Success
// Pinning top sites
func removeFromPinnedTopSites(_ site: Site) -> Success
func addPinnedTopSite(_ site: Site) -> Success
func getPinnedTopSites() -> Deferred<Maybe<Cursor<Site>>>
}
/**
* An interface for fast repeated frecency queries.
*/
public protocol FrecentHistory {
func getSites(whereURLContains filter: String?, historyLimit limit: Int, bookmarksLimit: Int) -> Deferred<Maybe<Cursor<Site>>>
func updateTopSitesCacheQuery() -> (String, Args?)
}
/**
* An interface for accessing recommendation content from Storage
*/
public protocol HistoryRecommendations {
func getHighlights() -> Deferred<Maybe<Cursor<Site>>>
func getRecentBookmarks(_ limit: Int) -> Deferred<Maybe<Cursor<Site>>>
func removeHighlightForURL(_ url: String) -> Success
func repopulate(invalidateTopSites shouldInvalidateTopSites: Bool, invalidateHighlights shouldInvalidateHighlights: Bool) -> Success
}
/**
* The interface that history storage needs to provide in order to be
* synced by a `HistorySynchronizer`.
*/
public protocol SyncableHistory: AccountRemovalDelegate {
/**
* Make sure that the local place with the provided URL has the provided GUID.
* Succeeds if no place exists with that URL.
*/
func ensurePlaceWithURL(_ url: String, hasGUID guid: GUID) -> Success
/**
* Delete the place with the provided GUID, and all of its visits. Succeeds if the GUID is unknown.
*/
func deleteByGUID(_ guid: GUID, deletedAt: Timestamp) -> Success
func storeRemoteVisits(_ visits: [Visit], forGUID guid: GUID) -> Success
func insertOrUpdatePlace(_ place: Place, modified: Timestamp) -> Deferred<Maybe<GUID>>
func getModifiedHistoryToUpload() -> Deferred<Maybe<[(Place, [Visit])]>>
func getDeletedHistoryToUpload() -> Deferred<Maybe<[GUID]>>
/**
* Chains through the provided timestamp.
*/
func markAsSynchronized(_: [GUID], modified: Timestamp) -> Deferred<Maybe<Timestamp>>
func markAsDeleted(_ guids: [GUID]) -> Success
func doneApplyingRecordsAfterDownload() -> Success
func doneUpdatingMetadataAfterUpload() -> Success
/**
* For inspecting whether we're an active participant in history sync.
*/
func hasSyncedHistory() -> Deferred<Maybe<Bool>>
}
// TODO: integrate Site with this.
open class Place {
open let guid: GUID
open let url: String
open let title: String
public init(guid: GUID, url: String, title: String) {
self.guid = guid
self.url = url
self.title = title
}
}

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>10.6</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSPrincipalClass</key>
<string></string>
</dict>
</plist>

View file

@ -0,0 +1,621 @@
/* 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 WebKit
import Shared
import Deferred
import XCGLogger
private var log = Logger.syncLogger
enum SyncStatus: Int {
// Ordinarily not needed; synced items are removed from the overlay. But they start here when cloned.
case synced = 0
// A material change that we want to upload on next sync.
case changed = 1
// Created locally.
case new = 2
}
public enum CommutativeLoginField {
case timesUsed(increment: Int)
}
public protocol Indexable {
var index: Int { get }
}
public enum NonCommutativeLoginField: Indexable {
case hostname(to: String)
case password(to: String)
case username(to: String?)
case httpRealm(to: String?)
case formSubmitURL(to: String?)
case timeCreated(to: MicrosecondTimestamp) // Should be immutable.
case timeLastUsed(to: MicrosecondTimestamp)
case timePasswordChanged(to: MicrosecondTimestamp)
public var index: Int {
switch self {
case .hostname:
return 0
case .password:
return 1
case .username:
return 2
case .httpRealm:
return 3
case .formSubmitURL:
return 4
case .timeCreated:
return 5
case .timeLastUsed:
return 6
case .timePasswordChanged:
return 7
}
}
static let Entries: Int = 8
}
// We don't care about these, because they're slated for removal at some point --
// we don't really use them for form fill.
// We handle them in the same way as NonCommutative, just broken out to allow us
// flexibility in removing them or reconciling them differently.
public enum NonConflictingLoginField: Indexable {
case usernameField(to: String?)
case passwordField(to: String?)
public var index: Int {
switch self {
case .usernameField:
return 0
case .passwordField:
return 1
}
}
static let Entries: Int = 2
}
public typealias LoginDeltas = (
commutative: [CommutativeLoginField],
nonCommutative: [NonCommutativeLoginField],
nonConflicting: [NonConflictingLoginField]
)
public typealias TimestampedLoginDeltas = (at: Timestamp, changed: LoginDeltas)
/**
* LoginData is a wrapper around NSURLCredential and NSURLProtectionSpace to allow us to add extra fields where needed.
**/
public protocol LoginData: class {
var guid: String { get set } // It'd be nice if this were read-only.
var credentials: URLCredential { get }
var protectionSpace: URLProtectionSpace { get }
var hostname: String { get }
var username: String? { get }
var password: String { get }
var httpRealm: String? { get set }
var formSubmitURL: String? { get set }
var usernameField: String? { get set }
var passwordField: String? { get set }
var isValid: Maybe<()> { get }
// https://bugzilla.mozilla.org/show_bug.cgi?id=1238103
var hasMalformedHostname: Bool { get set }
func toDict() -> [String: String]
func isSignificantlyDifferentFrom(_ login: LoginData) -> Bool
}
public protocol LoginUsageData {
var timesUsed: Int { get set }
var timeCreated: MicrosecondTimestamp { get set }
var timeLastUsed: MicrosecondTimestamp { get set }
var timePasswordChanged: MicrosecondTimestamp { get set }
}
open class Login: CustomStringConvertible, LoginData, LoginUsageData, Equatable {
open var guid: String
open fileprivate(set) var credentials: URLCredential
open let protectionSpace: URLProtectionSpace
open var hostname: String {
if let _ = protectionSpace.`protocol` {
return protectionSpace.urlString()
}
return protectionSpace.host
}
open var hasMalformedHostname: Bool = false
open var username: String? { return credentials.user }
open var password: String { return credentials.password ?? "" }
open var usernameField: String?
open var passwordField: String?
fileprivate var _httpRealm: String?
open var httpRealm: String? {
get { return self._httpRealm ?? protectionSpace.realm }
set { self._httpRealm = newValue }
}
fileprivate var _formSubmitURL: String?
open var formSubmitURL: String? {
get {
return self._formSubmitURL
}
set(value) {
guard let value = value, !value.isEmpty else {
self._formSubmitURL = nil
return
}
let url2 = URL(string: self.hostname)
let url1 = URL(string: value)
if url1?.host != url2?.host {
log.warning("Form submit URL domain doesn't match login's domain.")
}
self._formSubmitURL = value
}
}
// LoginUsageData. These defaults only apply to locally created records.
open var timesUsed = 0
open var timeCreated = Date.nowMicroseconds()
open var timeLastUsed = Date.nowMicroseconds()
open var timePasswordChanged = Date.nowMicroseconds()
// Printable
open var description: String {
return "Login for \(hostname)"
}
open var isValid: Maybe<()> {
// Referenced from https://mxr.mozilla.org/mozilla-central/source/toolkit/components/passwordmgr/nsLoginManager.js?rev=f76692f0fcf8&mark=280-281#271
// Logins with empty hostnames are not valid.
if hostname.isEmpty {
return Maybe(failure: LoginDataError(description: "Can't add a login with an empty hostname."))
}
// Logins with empty passwords are not valid.
if password.isEmpty {
return Maybe(failure: LoginDataError(description: "Can't add a login with an empty password."))
}
// Logins with both a formSubmitURL and httpRealm are not valid.
if let _ = formSubmitURL, let _ = httpRealm {
return Maybe(failure: LoginDataError(description: "Can't add a login with both a httpRealm and formSubmitURL."))
}
// Login must have at least a formSubmitURL or httpRealm.
if (formSubmitURL == nil) && (httpRealm == nil) {
return Maybe(failure: LoginDataError(description: "Can't add a login without a httpRealm or formSubmitURL."))
}
// All good.
return Maybe(success: ())
}
open func update(password: String, username: String) {
self.credentials =
URLCredential(user: username, password: password, persistence: credentials.persistence)
}
// Essentially: should we sync a change?
// Desktop ignores usernameField and hostnameField.
open func isSignificantlyDifferentFrom(_ login: LoginData) -> Bool {
return login.password != self.password ||
login.hostname != self.hostname ||
login.username != self.username ||
login.formSubmitURL != self.formSubmitURL ||
login.httpRealm != self.httpRealm
}
/* Used for testing purposes since formSubmitURL should be given back to use from the Logins.js script */
open class func createWithHostname(_ hostname: String, username: String, password: String, formSubmitURL: String?) -> LoginData {
let loginData = Login(hostname: hostname, username: username, password: password) as LoginData
loginData.formSubmitURL = formSubmitURL
return loginData
}
open class func createWithHostname(_ hostname: String, username: String, password: String) -> LoginData {
return Login(hostname: hostname, username: username, password: password) as LoginData
}
open class func createWithCredential(_ credential: URLCredential, protectionSpace: URLProtectionSpace) -> LoginData {
return Login(credential: credential, protectionSpace: protectionSpace) as LoginData
}
public init(guid: String, hostname: String, username: String, password: String) {
self.guid = guid
self.credentials = URLCredential(user: username, password: password, persistence: URLCredential.Persistence.none)
// Break down the full url hostname into its scheme/protocol and host components
let hostnameURL = hostname.asURL
let host = hostnameURL?.host ?? hostname
let scheme = hostnameURL?.scheme ?? ""
// We should ignore any SSL or normal web ports in the URL.
var port = hostnameURL?.port ?? 0
if port == 443 || port == 80 {
port = 0
}
self.protectionSpace = URLProtectionSpace(host: host, port: port, protocol: scheme, realm: nil, authenticationMethod: nil)
}
convenience init(hostname: String, username: String, password: String) {
self.init(guid: Bytes.generateGUID(), hostname: hostname, username: username, password: password)
}
// Why do we need this initializer to be marked as required? Because otherwise we can't
// use this type in our factory for MirrorLogin and LocalLogin.
// SO: http://stackoverflow.com/questions/26280176/swift-generics-not-preserving-type
// Playground: https://gist.github.com/rnewman/3fb0c4dbd25e7fda7e3d
// Conversation: https://twitter.com/rnewman/status/611332618412359680
required public init(credential: URLCredential, protectionSpace: URLProtectionSpace) {
self.guid = Bytes.generateGUID()
self.credentials = credential
self.protectionSpace = protectionSpace
}
open func toDict() -> [String: String] {
return [
"hostname": hostname,
"formSubmitURL": formSubmitURL ?? "",
"httpRealm": httpRealm ?? "",
"username": username ?? "",
"password": password,
"usernameField": usernameField ?? "",
"passwordField": passwordField ?? ""
]
}
open class func fromScript(_ url: URL, script: [String: Any]) -> LoginData? {
guard let username = script["username"] as? String,
let password = script["password"] as? String else {
return nil
}
guard let origin = getPasswordOrigin(url.absoluteString) else {
return nil
}
let login = Login(hostname: origin, username: username, password: password)
if let formSubmit = script["formSubmitURL"] as? String {
login.formSubmitURL = formSubmit
}
if let passwordField = script["passwordField"] as? String {
login.passwordField = passwordField
}
if let userField = script["usernameField"] as? String {
login.usernameField = userField
}
return login as LoginData
}
fileprivate class func getPasswordOrigin(_ uriString: String, allowJS: Bool = false) -> String? {
var realm: String? = nil
if let uri = URL(string: uriString),
let scheme = uri.scheme, !scheme.isEmpty,
let host = uri.host {
if allowJS && scheme == "javascript" {
return "javascript:"
}
realm = "\(scheme)://\(host)"
// If the URI explicitly specified a port, only include it when
// it's not the default. (We never want "http://foo.com:80")
if let port = uri.port {
realm? += ":\(port)"
}
} else {
// bug 159484 - disallow url types that don't support a hostPort.
// (although we handle "javascript:..." as a special case above.)
log.debug("Couldn't parse origin for \(uriString)")
realm = nil
}
return realm
}
/**
* Produce a delta stream by comparing this record to a source.
* Note that the source might be missing the timestamp and counter fields
* introduced in Bug 555755, so we pay special attention to those, checking for
* and ignoring transitions to zero.
*
* TODO: it's possible that we'll have, say, two iOS clients working with a desktop.
* Each time the desktop changes the password fields, it'll upload a record without
* these extra timestamp fields. We need to make sure the right thing happens.
*
* There are three phases in this process:
* 1. Producing deltas. There is no intrinsic ordering here, but we yield ordered
* arrays for convenience and ease of debugging.
* 2. Comparing deltas. This is done through a kind of array-based Perlish Schwartzian
* transform, where each field has a known index in space to allow for trivial
* comparison; this, of course, is ordered.
* 3. Applying a merged delta stream to a record. Again, this is unordered, but we
* use arrays for convenience.
*/
open func deltas(from: Login) -> LoginDeltas {
let commutative: [CommutativeLoginField]
if self.timesUsed > 0 && self.timesUsed != from.timesUsed {
commutative = [CommutativeLoginField.timesUsed(increment: self.timesUsed - from.timesUsed)]
} else {
commutative = []
}
var nonCommutative = [NonCommutativeLoginField]()
if self.hostname != from.hostname {
nonCommutative.append(NonCommutativeLoginField.hostname(to: self.hostname))
}
if self.password != from.password {
nonCommutative.append(NonCommutativeLoginField.password(to: self.password))
}
if self.username != from.username {
nonCommutative.append(NonCommutativeLoginField.username(to: self.username))
}
if self.httpRealm != from.httpRealm {
nonCommutative.append(NonCommutativeLoginField.httpRealm(to: self.httpRealm))
}
if self.formSubmitURL != from.formSubmitURL {
nonCommutative.append(NonCommutativeLoginField.formSubmitURL(to: self.formSubmitURL))
}
if self.timeCreated > 0 && self.timeCreated != from.timeCreated {
nonCommutative.append(NonCommutativeLoginField.timeCreated(to: self.timeCreated))
}
if self.timeLastUsed > 0 && self.timeLastUsed != from.timeLastUsed {
nonCommutative.append(NonCommutativeLoginField.timeLastUsed(to: self.timeLastUsed))
}
if self.timeLastUsed > 0 && self.timePasswordChanged != from.timePasswordChanged {
nonCommutative.append(NonCommutativeLoginField.timePasswordChanged(to: self.timePasswordChanged))
}
var nonConflicting = [NonConflictingLoginField]()
if self.passwordField != from.passwordField {
nonConflicting.append(NonConflictingLoginField.passwordField(to: self.passwordField))
}
if self.usernameField != from.usernameField {
nonConflicting.append(NonConflictingLoginField.usernameField(to: self.usernameField))
}
return (commutative, nonCommutative, nonConflicting)
}
fileprivate class func mergeDeltaFields<T: Indexable>(_ count: Int, a: [T], b: [T], preferBToA: Bool) -> [T] {
var deltas = Array<T?>(repeating: nil, count: count)
// Let's start with the 'a's.
for f in a {
deltas[f.index] = f
}
// Then detect any conflicts and fill out the rest.
for f in b {
let index = f.index
if deltas[index] != nil {
log.warning("Collision in \(T.self) \(f.index). Using latest.")
if preferBToA {
deltas[index] = f
}
} else {
deltas[index] = f
}
}
return optFilter(deltas)
}
open class func mergeDeltas(a: TimestampedLoginDeltas, b: TimestampedLoginDeltas) -> LoginDeltas {
let (aAt, aChanged) = a
let (bAt, bChanged) = b
let (aCommutative, aNonCommutative, aNonConflicting) = aChanged
let (bCommutative, bNonCommutative, bNonConflicting) = bChanged
// If the timestamps are exactly the same -- an exceedingly rare occurrence -- we default
// to 'b', which is the remote record by convention.
let bLatest = aAt <= bAt
let commutative = aCommutative + bCommutative
let nonCommutative: [NonCommutativeLoginField]
let nonConflicting: [NonConflictingLoginField]
if aNonCommutative.isEmpty {
nonCommutative = bNonCommutative
} else if bNonCommutative.isEmpty {
nonCommutative = aNonCommutative
} else {
nonCommutative = mergeDeltaFields(NonCommutativeLoginField.Entries, a: aNonCommutative, b: bNonCommutative, preferBToA: bLatest)
}
if aNonConflicting.isEmpty {
nonConflicting = bNonConflicting
} else if bNonCommutative.isEmpty {
nonConflicting = aNonConflicting
} else {
nonConflicting = mergeDeltaFields(NonConflictingLoginField.Entries, a: aNonConflicting, b: bNonConflicting, preferBToA: bLatest)
}
return (
commutative: commutative,
nonCommutative: nonCommutative,
nonConflicting: nonConflicting
)
}
/**
* Apply the provided changes to yield a new login.
*/
open func applyDeltas(_ deltas: LoginDeltas) -> Login {
let guid = self.guid
var hostname = self.hostname
var username = self.username
var password = self.password
var usernameField = self.usernameField
var passwordField = self.passwordField
var timesUsed = self.timesUsed
var httpRealm = self.httpRealm
var formSubmitURL = self.formSubmitURL
var timeCreated = self.timeCreated
var timeLastUsed = self.timeLastUsed
var timePasswordChanged = self.timePasswordChanged
for delta in deltas.commutative {
switch delta {
case let .timesUsed(increment):
timesUsed += increment
}
}
for delta in deltas.nonCommutative {
switch delta {
case let .hostname(to):
hostname = to
break
case let .password(to):
password = to
break
case let .username(to):
username = to
break
case let .httpRealm(to):
httpRealm = to
break
case let .formSubmitURL(to):
formSubmitURL = to
break
case let .timeCreated(to):
timeCreated = to
break
case let .timeLastUsed(to):
timeLastUsed = to
break
case let .timePasswordChanged(to):
timePasswordChanged = to
break
}
}
for delta in deltas.nonConflicting {
switch delta {
case let .usernameField(to):
usernameField = to
break
case let .passwordField(to):
passwordField = to
break
}
}
let out = Login(guid: guid, hostname: hostname, username: username ?? "", password: password)
out.timesUsed = timesUsed
out.httpRealm = httpRealm
out.formSubmitURL = formSubmitURL
out.timeCreated = timeCreated
out.timeLastUsed = timeLastUsed
out.timePasswordChanged = timePasswordChanged
out.usernameField = usernameField
out.passwordField = passwordField
return out
}
}
public func ==(lhs: Login, rhs: Login) -> Bool {
return lhs.credentials == rhs.credentials && lhs.protectionSpace == rhs.protectionSpace
}
open class ServerLogin: Login {
var serverModified: Timestamp = 0
public init(guid: String, hostname: String, username: String, password: String, modified: Timestamp) {
self.serverModified = modified
super.init(guid: guid, hostname: hostname, username: username, password: password)
}
required public init(credential: URLCredential, protectionSpace: URLProtectionSpace) {
super.init(credential: credential, protectionSpace: protectionSpace)
}
}
class MirrorLogin: ServerLogin {
var isOverridden: Bool = false
}
class LocalLogin: Login {
var syncStatus: SyncStatus = .synced
var isDeleted: Bool = false
var localModified: Timestamp = 0
}
public protocol BrowserLogins {
func getUsageDataForLoginByGUID(_ guid: GUID) -> Deferred<Maybe<LoginUsageData>>
func getLoginDataForGUID(_ guid: GUID) -> Deferred<Maybe<Login>>
func getLoginsForProtectionSpace(_ protectionSpace: URLProtectionSpace) -> Deferred<Maybe<Cursor<LoginData>>>
func getLoginsForProtectionSpace(_ protectionSpace: URLProtectionSpace, withUsername username: String?) -> Deferred<Maybe<Cursor<LoginData>>>
func getAllLogins() -> Deferred<Maybe<Cursor<Login>>>
func searchLoginsWithQuery(_ query: String?) -> Deferred<Maybe<Cursor<Login>>>
// Add a new login regardless of whether other logins might match some fields. Callers
// are responsible for querying first if they care.
@discardableResult func addLogin(_ login: LoginData) -> Success
@discardableResult func updateLoginByGUID(_ guid: GUID, new: LoginData, significant: Bool) -> Success
// Add the use of a login by GUID.
@discardableResult func addUseOfLoginByGUID(_ guid: GUID) -> Success
func removeLoginByGUID(_ guid: GUID) -> Success
func removeLoginsWithGUIDs(_ guids: [GUID]) -> Success
func removeAll() -> Success
}
public protocol SyncableLogins: AccountRemovalDelegate {
/**
* Delete the login with the provided GUID. Succeeds if the GUID is unknown.
*/
func deleteByGUID(_ guid: GUID, deletedAt: Timestamp) -> Success
func applyChangedLogin(_ upstream: ServerLogin) -> Success
func getModifiedLoginsToUpload() -> Deferred<Maybe<[Login]>>
func getDeletedLoginsToUpload() -> Deferred<Maybe<[GUID]>>
/**
* Chains through the provided timestamp.
*/
func markAsSynchronized<T: Collection>(_: T, modified: Timestamp) -> Deferred<Maybe<Timestamp>> where T.Iterator.Element == GUID
func markAsDeleted<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID
/**
* For inspecting whether we're an active participant in login sync.
*/
func hasSyncedLogins() -> Deferred<Maybe<Bool>>
}
open class LoginDataError: MaybeErrorType {
open let description: String
public init(description: String) {
self.description = description
}
}

View file

@ -0,0 +1,13 @@
/* 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 Deferred
import Shared
/// Interface for saving and retrieving metadata web content
public protocol Metadata {
@discardableResult func storeMetadata(_ metadata: PageMetadata, forPageURL: URL, expireAt: UInt64) -> Success
func deleteExpiredMetadata() -> Success
}

View file

@ -0,0 +1,154 @@
/* 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 Deferred
open class MockLogins: BrowserLogins, SyncableLogins {
fileprivate var cache = [Login]()
public init(files: FileAccessor) {
}
open func getLoginsForProtectionSpace(_ protectionSpace: URLProtectionSpace) -> Deferred<Maybe<Cursor<LoginData>>> {
let cursor = ArrayCursor(data: cache.filter({ login in
return login.protectionSpace.host == protectionSpace.host
}).sorted(by: { (loginA, loginB) -> Bool in
return loginA.timeLastUsed > loginB.timeLastUsed
}).map({ login in
return login as LoginData
}))
return Deferred(value: Maybe(success: cursor))
}
open func getLoginsForProtectionSpace(_ protectionSpace: URLProtectionSpace, withUsername username: String?) -> Deferred<Maybe<Cursor<LoginData>>> {
let cursor = ArrayCursor(data: cache.filter({ login in
return login.protectionSpace.host == protectionSpace.host &&
login.username == username
}).sorted(by: { (loginA, loginB) -> Bool in
return loginA.timeLastUsed > loginB.timeLastUsed
}).map({ login in
return login as LoginData
}))
return Deferred(value: Maybe(success: cursor))
}
open func getLoginDataForGUID(_ guid: GUID) -> Deferred<Maybe<Login>> {
if let login = (cache.filter { $0.guid == guid }).first {
return deferMaybe(login)
} else {
return deferMaybe(LoginDataError(description: "Login for GUID \(guid) not found"))
}
}
open func getAllLogins() -> Deferred<Maybe<Cursor<Login>>> {
let cursor = ArrayCursor(data: cache.sorted(by: { (loginA, loginB) -> Bool in
return loginA.hostname > loginB.hostname
}))
return Deferred(value: Maybe(success: cursor))
}
open func searchLoginsWithQuery(_ query: String?) -> Deferred<Maybe<Cursor<Login>>> {
let cursor = ArrayCursor(data: cache.filter({ login in
var checks = [Bool]()
if let query = query {
checks.append(login.username?.contains(query) ?? false)
checks.append(login.password.contains(query))
checks.append(login.hostname.contains(query))
}
return checks.contains(true)
}).sorted(by: { (loginA, loginB) -> Bool in
return loginA.hostname > loginB.hostname
}))
return Deferred(value: Maybe(success: cursor))
}
// This method is only here for testing
open func getUsageDataForLoginByGUID(_ guid: GUID) -> Deferred<Maybe<LoginUsageData>> {
let res = cache.filter({ login in
return login.guid == guid
}).sorted(by: { (loginA, loginB) -> Bool in
return loginA.timeLastUsed > loginB.timeLastUsed
})[0] as LoginUsageData
return Deferred(value: Maybe(success: res))
}
open func addLogin(_ login: LoginData) -> Success {
if let _ = cache.index(of: login as! Login) {
return deferMaybe(LoginDataError(description: "Already in the cache"))
}
cache.append(login as! Login)
return succeed()
}
open func updateLoginByGUID(_ guid: GUID, new: LoginData, significant: Bool) -> Success {
// TODO
return succeed()
}
open func getModifiedLoginsToUpload() -> Deferred<Maybe<[Login]>> {
// TODO
return deferMaybe([])
}
open func getDeletedLoginsToUpload() -> Deferred<Maybe<[GUID]>> {
// TODO
return deferMaybe([])
}
open func updateLogin(_ login: LoginData) -> Success {
if let index = cache.index(of: login as! Login) {
cache[index].timePasswordChanged = Date.nowMicroseconds()
return succeed()
}
return deferMaybe(LoginDataError(description: "Password wasn't cached yet. Can't update"))
}
open func addUseOfLoginByGUID(_ guid: GUID) -> Success {
if let login = cache.filter({ $0.guid == guid }).first {
login.timeLastUsed = Date.nowMicroseconds()
return succeed()
}
return deferMaybe(LoginDataError(description: "Password wasn't cached yet. Can't update"))
}
open func removeLoginByGUID(_ guid: GUID) -> Success {
let filtered = cache.filter { $0.guid != guid }
if filtered.count == cache.count {
return deferMaybe(LoginDataError(description: "Can not remove a password that wasn't stored"))
}
cache = filtered
return succeed()
}
open func removeLoginsWithGUIDs(_ guids: [GUID]) -> Success {
return walk(guids) { guid in
self.removeLoginByGUID(guid)
}
}
open func removeAll() -> Success {
cache.removeAll(keepingCapacity: false)
return succeed()
}
open func hasSyncedLogins() -> Deferred<Maybe<Bool>> {
return deferMaybe(true)
}
// TODO
open func deleteByGUID(_ guid: GUID, deletedAt: Timestamp) -> Success { return succeed() }
open func applyChangedLogin(_ upstream: ServerLogin) -> Success { return succeed() }
open func markAsSynchronized<T: Collection>(_: T, modified: Timestamp) -> Deferred<Maybe<Timestamp>> where T.Iterator.Element == GUID { return deferMaybe(0) }
open func markAsDeleted<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID { return succeed() }
open func onRemovedAccount() -> Success { return succeed() }
}
extension MockLogins: ResettableSyncStorage {
public func resetClient() -> Success {
return succeed()
}
}

View file

@ -0,0 +1,106 @@
/* 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 UIKit
import SDWebImage
enum MetadataKeys: String {
case imageURL = "image"
case imageDataURI = "image_data_uri"
case pageURL = "url"
case title = "title"
case description = "description"
case type = "type"
case provider = "provider"
case favicon = "icon"
case keywords = "keywords"
}
/*
* Value types representing a page's metadata
*/
public struct PageMetadata {
public let id: Int?
public let siteURL: String
public let mediaURL: String?
public let title: String?
public let description: String?
public let type: String?
public let providerName: String?
public let faviconURL: String?
public let keywordsString: String?
public var keywords: Set<String> {
guard let string = keywordsString else {
return Set()
}
let strings = string.split(separator: ",", omittingEmptySubsequences: true).map(String.init)
return Set(strings)
}
public init(id: Int?, siteURL: String, mediaURL: String?, title: String?, description: String?, type: String?, providerName: String?, mediaDataURI: String?, faviconURL: String? = nil, keywords: String? = nil, cacheImages: Bool = true) {
self.id = id
self.siteURL = siteURL
self.mediaURL = mediaURL
self.title = title
self.description = description
self.type = type
self.providerName = providerName
self.faviconURL = faviconURL
self.keywordsString = keywords
if let urlString = mediaURL, let url = URL(string: urlString), cacheImages {
self.cacheImage(fromDataURI: mediaDataURI, forURL: url)
}
}
public static func fromDictionary(_ dict: [String: Any]) -> PageMetadata? {
guard let siteURL = dict[MetadataKeys.pageURL.rawValue] as? String else {
return nil
}
return PageMetadata(id: nil, siteURL: siteURL, mediaURL: dict[MetadataKeys.imageURL.rawValue] as? String,
title: dict[MetadataKeys.title.rawValue] as? String, description: dict[MetadataKeys.description.rawValue] as? String,
type: dict[MetadataKeys.type.rawValue] as? String, providerName: dict[MetadataKeys.provider.rawValue] as? String, mediaDataURI: dict[MetadataKeys.imageDataURI.rawValue] as? String, faviconURL: dict[MetadataKeys.favicon.rawValue] as? String, keywords: dict[MetadataKeys.keywords.rawValue] as? String)
}
fileprivate func cacheImage(fromDataURI dataURI: String?, forURL url: URL) {
let manager = SDWebImageManager.shared()
func cacheUsingURLOnly() {
manager.cachedImageExists(for: url) { exists in
if !exists {
self.downloadAndCache(fromURL: url)
}
}
}
guard let dataURI = dataURI, let dataURL = URL(string: dataURI) else {
cacheUsingURLOnly()
return
}
manager.cachedImageExists(for: dataURL) { exists in
if let data = try? Data(contentsOf: dataURL), let image = UIImage(data: data), !exists {
self.cache(image: image, forURL: url)
} else {
cacheUsingURLOnly()
}
}
}
fileprivate func downloadAndCache(fromURL webUrl: URL) {
let manager = SDWebImageManager.shared()
manager.loadImage(with: webUrl, options: .continueInBackground, progress: nil) { (image, _, _, _, _, _) in
if let image = image {
self.cache(image: image, forURL: webUrl)
}
}
}
fileprivate func cache(image: UIImage, forURL url: URL) {
SDWebImageManager.shared().saveImage(toCache: image, for: url)
}
}

View file

@ -0,0 +1,13 @@
/* 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 Deferred
public protocol TabQueue {
func addToQueue(_ tab: ShareItem) -> Success
func getQueuedTabs() -> Deferred<Maybe<Cursor<ShareItem>>>
@discardableResult func clearQueuedTabs() -> Success
}

View file

@ -0,0 +1,76 @@
/* 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
open class ClosedTabsStore {
let prefs: Prefs
lazy open var tabs: [ClosedTab] = {
guard let tabsArray: Data = self.prefs.objectForKey("recentlyClosedTabs") as Any? as? Data,
let unarchivedArray = NSKeyedUnarchiver.unarchiveObject(with: tabsArray) as? [ClosedTab] else {
return []
}
return unarchivedArray
}()
public init(prefs: Prefs) {
self.prefs = prefs
}
open func addTab(_ url: URL, title: String?, faviconURL: String?) {
let recentlyClosedTab = ClosedTab(url: url, title: title ?? "", faviconURL: faviconURL ?? "")
tabs.insert(recentlyClosedTab, at: 0)
if tabs.count > 5 {
tabs.removeLast()
}
let archivedTabsArray = NSKeyedArchiver.archivedData(withRootObject: tabs)
prefs.setObject(archivedTabsArray, forKey: "recentlyClosedTabs")
}
open func clearTabs() {
prefs.removeObjectForKey("recentlyClosedTabs")
tabs = []
}
}
open class ClosedTab: NSObject, NSCoding {
open let url: URL
open let title: String?
open let faviconURL: String?
var jsonDictionary: [String: Any] {
let title = (self.title ?? "")
let faviconURL = (self.faviconURL ?? "")
let json: [String: Any] = ["title": title, "url": url, "faviconURL": faviconURL]
return json
}
init(url: URL, title: String?, faviconURL: String?) {
assert(Thread.isMainThread)
self.title = title
self.url = url
self.faviconURL = faviconURL
super.init()
}
required convenience public init?(coder: NSCoder) {
guard let url = coder.decodeObject(forKey: "url") as? URL,
let faviconURL = coder.decodeObject(forKey: "faviconURL") as? String,
let title = coder.decodeObject(forKey: "title") as? String else { return nil }
self.init(
url: url,
title: title,
faviconURL: faviconURL
)
}
open func encode(with coder: NSCoder) {
coder.encode(url, forKey: "url")
coder.encode(faviconURL, forKey: "faviconURL")
coder.encode(title, forKey: "title")
}
}

View file

@ -0,0 +1,109 @@
/* 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 Deferred
public struct ClientAndTabs: Equatable, CustomStringConvertible {
public let client: RemoteClient
public let tabs: [RemoteTab]
public var description: String {
return "<Client guid: \(client.guid ?? "nil"), \(tabs.count) tabs.>"
}
// See notes in RemoteTabsPanel.swift.
public func approximateLastSyncTime() -> Timestamp {
if tabs.isEmpty {
return client.modified
}
return tabs.reduce(Timestamp(0), { m, tab in
return max(m, tab.lastUsed)
})
}
}
public func ==(lhs: ClientAndTabs, rhs: ClientAndTabs) -> Bool {
return (lhs.client == rhs.client) &&
(lhs.tabs == rhs.tabs)
}
public protocol RemoteClientsAndTabs: SyncCommands {
func wipeClients() -> Deferred<Maybe<()>>
func wipeRemoteTabs() -> Deferred<Maybe<()>>
func wipeTabs() -> Deferred<Maybe<()>>
func getClientGUIDs() -> Deferred<Maybe<Set<GUID>>>
func getClients() -> Deferred<Maybe<[RemoteClient]>>
func getClient(guid: GUID) -> Deferred<Maybe<RemoteClient?>>
func getClient(fxaDeviceId: String) -> Deferred<Maybe<RemoteClient?>>
@available(*, deprecated, message: "use getClient(guid:) instead")
func getClientWithId(_ clientID: GUID) -> Deferred<Maybe<RemoteClient?>>
func getClientsAndTabs() -> Deferred<Maybe<[ClientAndTabs]>>
func getTabsForClientWithGUID(_ guid: GUID?) -> Deferred<Maybe<[RemoteTab]>>
func insertOrUpdateClient(_ client: RemoteClient) -> Deferred<Maybe<Int>>
func insertOrUpdateClients(_ clients: [RemoteClient]) -> Deferred<Maybe<Int>>
// Returns number of tabs inserted.
func insertOrUpdateTabs(_ tabs: [RemoteTab]) -> Deferred<Maybe<Int>> // Insert into the local client.
func insertOrUpdateTabsForClientGUID(_ clientGUID: String?, tabs: [RemoteTab]) -> Deferred<Maybe<Int>>
func deleteClient(guid: GUID) -> Success
}
public struct RemoteTab: Equatable {
public let clientGUID: String?
public let URL: Foundation.URL
public let title: String
public let history: [Foundation.URL]
public let lastUsed: Timestamp
public let icon: Foundation.URL?
public static func shouldIncludeURL(_ url: Foundation.URL) -> Bool {
let scheme = url.scheme
if scheme == "about" {
return false
}
if scheme == "javascript" {
return false
}
if let hostname = url.host?.lowercased() {
if hostname == "localhost" {
return false
}
return true
}
return false
}
public init(clientGUID: String?, URL: Foundation.URL, title: String, history: [Foundation.URL], lastUsed: Timestamp, icon: Foundation.URL?) {
self.clientGUID = clientGUID
self.URL = URL
self.title = title
self.history = history
self.lastUsed = lastUsed
self.icon = icon
}
public func withClientGUID(_ clientGUID: String?) -> RemoteTab {
return RemoteTab(clientGUID: clientGUID, URL: URL, title: title, history: history, lastUsed: lastUsed, icon: icon)
}
}
public func ==(lhs: RemoteTab, rhs: RemoteTab) -> Bool {
return lhs.clientGUID == rhs.clientGUID &&
lhs.URL == rhs.URL &&
lhs.title == rhs.title &&
lhs.history == rhs.history &&
lhs.lastUsed == rhs.lastUsed &&
lhs.icon == rhs.icon
}
extension RemoteTab: CustomStringConvertible {
public var description: String {
return "<RemoteTab clientGUID: \(clientGUID ?? "nil"), URL: \(URL), title: \(title), lastUsed: \(lastUsed)>"
}
}

View file

@ -0,0 +1,226 @@
/* 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 XCGLogger
import Deferred
import Shared
public let NotificationDatabaseWasRecreated = Notification.Name("NotificationDatabaseWasRecreated")
private let log = Logger.syncLogger
public typealias Args = [Any?]
open class BrowserDB {
fileprivate let db: SwiftData
// SQLITE_MAX_VARIABLE_NUMBER = 999 by default. This controls how many ?s can
// appear in a query string.
open static let MaxVariableNumber = 999
public init(filename: String, secretKey: String? = nil, schema: Schema, files: FileAccessor) {
log.debug("Initializing BrowserDB: \(filename).")
let file = URL(fileURLWithPath: (try! files.getAndEnsureDirectory())).appendingPathComponent(filename).path
if AppConstants.BuildChannel == .developer && secretKey != nil {
log.debug("Will attempt to use encrypted DB: \(file) with secret = \(secretKey ?? "nil")")
}
self.db = SwiftData(filename: file, key: secretKey, prevKey: nil, schema: schema, files: files)
}
// Remove the DB op from the queue (by marking it cancelled), and if it is already running tell sqlite to cancel it.
// At any point the operation could complete on another thread, so it is held weakly.
// Swift compiler bug: failing to compile WeakRef<Cancellable> here.
public func cancel(databaseOperation: WeakRef<AnyObject>) {
weak var databaseOperation = databaseOperation.value as? Cancellable
db.suspendQueue()
defer {
db.resumeQueue()
}
databaseOperation?.cancel()
if databaseOperation?.running ?? false {
db.cancel()
}
}
// For testing purposes or other cases where we want to ensure that this `BrowserDB`
// instance has been initialized (schema is created/updated).
public func touch() -> Success {
return withConnection { connection -> Void in
guard let _ = connection as? ConcreteSQLiteDBConnection else {
throw DatabaseError(description: "Could not establish a database connection")
}
}
}
/*
* Opening a WAL-using database with a hot journal cannot complete in read-only mode.
* The supported mechanism for a read-only query against a WAL-using SQLite database is to use PRAGMA query_only,
* but this isn't all that useful for us, because we have a mixed read/write workload.
*/
@discardableResult func withConnection<T>(flags: SwiftData.Flags = .readWriteCreate, _ callback: @escaping (_ connection: SQLiteDBConnection) throws -> T) -> Deferred<Maybe<T>> {
return db.withConnection(flags, callback)
}
func transaction<T>(_ callback: @escaping (_ connection: SQLiteDBConnection) throws -> T) -> Deferred<Maybe<T>> {
return db.transaction(callback)
}
@discardableResult func vacuum() -> Success {
log.debug("Vacuuming a BrowserDB.")
return withConnection({ connection -> Void in
try connection.vacuum()
})
}
@discardableResult func checkpoint() -> Success {
log.debug("Checkpointing a BrowserDB.")
return transaction { connection in
connection.checkpoint()
}
}
public class func varlist(_ count: Int) -> String {
return "(" + Array(repeating: "?", count: count).joined(separator: ", ") + ")"
}
enum InsertOperation: String {
case Insert = "INSERT"
case Replace = "REPLACE"
case InsertOrIgnore = "INSERT OR IGNORE"
case InsertOrReplace = "INSERT OR REPLACE"
case InsertOrRollback = "INSERT OR ROLLBACK"
case InsertOrAbort = "INSERT OR ABORT"
case InsertOrFail = "INSERT OR FAIL"
}
/**
* Insert multiple sets of values into the given table.
*
* Assumptions:
* 1. The table exists and contains the provided columns.
* 2. Every item in `values` is the same length.
* 3. That length is the same as the length of `columns`.
* 4. Every value in each element of `values` is non-nil.
*
* If there are too many items to insert, multiple individual queries will run
* in sequence.
*
* A failure anywhere in the sequence will cause immediate return of failure, but
* will not roll back use a transaction if you need one.
*/
func bulkInsert(_ table: String, op: InsertOperation, columns: [String], values: [Args]) -> Success {
// Note that there's a limit to how many ?s can be in a single query!
// So here we execute 999 / (columns * rows) insertions per query.
// Note that we can't use variables for the column names, so those don't affect the count.
if values.isEmpty {
log.debug("No values to insert.")
return succeed()
}
let variablesPerRow = columns.count
// Sanity check.
assert(values[0].count == variablesPerRow)
let cols = columns.joined(separator: ", ")
let queryStart = "\(op.rawValue) INTO \(table) (\(cols)) VALUES "
let varString = BrowserDB.varlist(variablesPerRow)
let insertChunk: ([Args]) -> Success = { vals -> Success in
let valuesString = Array(repeating: varString, count: vals.count).joined(separator: ", ")
let args: Args = vals.flatMap { $0 }
return self.run(queryStart + valuesString, withArgs: args)
}
let rowCount = values.count
if (variablesPerRow * rowCount) < BrowserDB.MaxVariableNumber {
return insertChunk(values)
}
log.debug("Splitting bulk insert across multiple runs. I hope you started a transaction!")
let rowsPerInsert = (999 / variablesPerRow)
let chunks = chunk(values, by: rowsPerInsert)
log.debug("Inserting in \(chunks.count) chunks.")
// There's no real reason why we can't pass the ArraySlice here, except that I don't
// want to keep fighting Swift.
return walk(chunks, f: { insertChunk(Array($0)) })
}
func write(_ sql: String, withArgs args: Args? = nil) -> Deferred<Maybe<Int>> {
return withConnection { connection -> Int in
try connection.executeChange(sql, withArgs: args)
let modified = connection.numberOfRowsModified
log.debug("Modified rows: \(modified).")
return modified
}
}
public func forceClose() {
db.forceClose()
}
public func reopenIfClosed() {
db.reopenIfClosed()
}
func run(_ sql: String, withArgs args: Args? = nil) -> Success {
return run([(sql, args)])
}
func run(_ commands: [String]) -> Success {
return run(commands.map { (sql: $0, args: nil) })
}
/**
* Runs an array of SQL commands. Note: These will all run in order in a transaction and will block
* the caller's thread until they've finished. If any of them fail the operation will abort (no more
* commands will be run) and the transaction will roll back, returning a DatabaseError.
*/
func run(_ commands: [(sql: String, args: Args?)]) -> Success {
if commands.isEmpty {
return succeed()
}
return transaction { connection -> Void in
for (sql, args) in commands {
try connection.executeChange(sql, withArgs: args)
}
}
}
func runQuery<T>(_ sql: String, args: Args?, factory: @escaping (SDRow) -> T) -> Deferred<Maybe<Cursor<T>>> {
return withConnection { connection -> Cursor<T> in
connection.executeQuery(sql, factory: factory, withArgs: args)
}
}
func runQueryUnsafe<T, U>(_ sql: String, args: Args?, factory: @escaping (SDRow) -> T, block: @escaping (Cursor<T>) throws -> U) -> Deferred<Maybe<U>> {
return withConnection { connection -> U in
let cursor = connection.executeQueryUnsafe(sql, factory: factory, withArgs: args)
defer { cursor.close() }
return try block(cursor)
}
}
func queryReturnsResults(_ sql: String, args: Args? = nil) -> Deferred<Maybe<Bool>> {
return runQuery(sql, args: args, factory: { _ in true })
>>== { deferMaybe($0[0] ?? false) }
}
func queryReturnsNoResults(_ sql: String, args: Args? = nil) -> Deferred<Maybe<Bool>> {
return runQuery(sql, args: nil, factory: { _ in false })
>>== { deferMaybe($0[0] ?? true) }
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,123 @@
/* 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 XCGLogger
let TableLoginsMirror = "loginsM"
let TableLoginsLocal = "loginsL"
let IndexLoginsOverrideHostname = "idx_loginsM_is_overridden_hostname"
let IndexLoginsDeletedHostname = "idx_loginsL_is_deleted_hostname"
private let AllTables: [String] = [
TableLoginsMirror,
TableLoginsLocal
]
private let log = Logger.syncLogger
open class LoginsSchema: Schema {
static let DefaultVersion = 3
public var name: String { return "LOGINS" }
public var version: Int { return LoginsSchema.DefaultVersion }
public init() {}
func run(_ db: SQLiteDBConnection, sql: String, args: Args? = nil) -> Bool {
do {
try db.executeChange(sql, withArgs: args)
} catch let err as NSError {
log.error("Error running SQL in LoginsSchema: \(err.localizedDescription)")
log.error("SQL was \(sql)")
return false
}
return true
}
// TODO: transaction.
func run(_ db: SQLiteDBConnection, queries: [String]) -> Bool {
for sql in queries {
if !run(db, sql: sql, args: nil) {
return false
}
}
return true
}
let indexIsOverriddenHostname =
"CREATE INDEX IF NOT EXISTS \(IndexLoginsOverrideHostname) ON \(TableLoginsMirror) (is_overridden, hostname)"
let indexIsDeletedHostname =
"CREATE INDEX IF NOT EXISTS \(IndexLoginsDeletedHostname) ON \(TableLoginsLocal) (is_deleted, hostname)"
public func create(_ db: SQLiteDBConnection) -> Bool {
let common =
"id INTEGER PRIMARY KEY AUTOINCREMENT" +
", hostname TEXT NOT NULL" +
", httpRealm TEXT" +
", formSubmitURL TEXT" +
", usernameField TEXT" +
", passwordField TEXT" +
", timesUsed INTEGER NOT NULL DEFAULT 0" +
", timeCreated INTEGER NOT NULL" +
", timeLastUsed INTEGER" +
", timePasswordChanged INTEGER NOT NULL" +
", username TEXT" +
", password TEXT NOT NULL"
let mirror = "CREATE TABLE IF NOT EXISTS \(TableLoginsMirror) (" +
common +
", guid TEXT NOT NULL UNIQUE" +
", server_modified INTEGER NOT NULL" + // Integer milliseconds.
", is_overridden TINYINT NOT NULL DEFAULT 0" +
")"
let local = "CREATE TABLE IF NOT EXISTS \(TableLoginsLocal) (" +
common +
", guid TEXT NOT NULL UNIQUE " + // Typically overlaps one in the mirror unless locally new.
", local_modified INTEGER" + // Can be null. Client clock. In extremis only.
", is_deleted TINYINT NOT NULL DEFAULT 0" + // Boolean. Locally deleted.
", sync_status TINYINT " + // SyncStatus enum. Set when changed or created.
"NOT NULL DEFAULT \(SyncStatus.synced.rawValue)" +
")"
return self.run(db, queries: [mirror, local, indexIsOverriddenHostname, indexIsDeletedHostname])
}
public func update(_ db: SQLiteDBConnection, from: Int) -> Bool {
let to = self.version
if from == to {
log.debug("Skipping update from \(from) to \(to).")
return true
}
if from == 0 {
// This is likely an upgrade from before Bug 1160399.
log.debug("Updating logins tables from zero. Assuming drop and recreate.")
return drop(db) && create(db)
}
if from < 3 && to >= 3 {
log.debug("Updating logins tables to include version 3 indices")
return self.run(db, queries: [indexIsOverriddenHostname, indexIsDeletedHostname])
}
// TODO: real update!
log.debug("Updating logins table from \(from) to \(to).")
return drop(db) && create(db)
}
public func drop(_ db: SQLiteDBConnection) -> Bool {
log.debug("Dropping logins table.")
do {
try db.executeChange("DROP TABLE IF EXISTS \(name)")
} catch {
return false
}
return true
}
}

View file

@ -0,0 +1,65 @@
/* 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 Deferred
import Foundation
import Shared
private let log = Logger.syncLogger
class NoSuchSearchKeywordError: MaybeErrorType {
let keyword: String
init(keyword: String) {
self.keyword = keyword
}
var description: String {
return "No such search keyword: \(keyword)."
}
}
open class SQLiteBookmarks: BookmarksModelFactorySource, KeywordSearchSource {
let db: BrowserDB
let favicons: SQLiteFavicons
static let defaultFolderTitle: String = NSLocalizedString("Untitled", tableName: "Storage", comment: "The default name for bookmark folders without titles.")
static let defaultItemTitle: String = NSLocalizedString("Untitled", tableName: "Storage", comment: "The default name for bookmark nodes without titles.")
open lazy var modelFactory: Deferred<Maybe<BookmarksModelFactory>> =
deferMaybe(SQLiteBookmarksModelFactory(bookmarks: self, direction: .local))
public init(db: BrowserDB) {
self.db = db
self.favicons = SQLiteFavicons(db: self.db)
}
open func isBookmarked(_ url: String, direction: Direction) -> Deferred<Maybe<Bool>> {
let sql = "SELECT id FROM " +
"(SELECT id FROM \(direction.valueTable) WHERE " +
" bmkUri = ? AND is_deleted IS NOT 1" +
" UNION ALL " +
" SELECT id FROM \(TableBookmarksMirror) WHERE " +
" bmkUri = ? AND is_deleted IS NOT 1 AND is_overridden IS NOT 1" +
" LIMIT 1)"
let args: Args = [url, url]
return self.db.queryReturnsResults(sql, args: args)
}
open func getURLForKeywordSearch(_ keyword: String) -> Deferred<Maybe<String>> {
let sql = "SELECT bmkUri FROM \(ViewBookmarksBufferOnMirror) WHERE " +
" keyword = ?"
let args: Args = [keyword]
return self.db.runQuery(sql, args: args, factory: { $0["bmkUri"] as! String })
>>== { cursor in
if cursor.status == .success {
if let str = cursor[0] {
return deferMaybe(str)
}
}
return deferMaybe(NoSuchSearchKeywordError(keyword: keyword))
}
}
}

View file

@ -0,0 +1,75 @@
/* 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
public func titleForSpecialGUID(_ guid: GUID) -> String? {
switch guid {
case BookmarkRoots.RootGUID:
return "<Root>"
case BookmarkRoots.MobileFolderGUID:
return BookmarksFolderTitleMobile
case BookmarkRoots.ToolbarFolderGUID:
return BookmarksFolderTitleToolbar
case BookmarkRoots.MenuFolderGUID:
return BookmarksFolderTitleMenu
case BookmarkRoots.UnfiledFolderGUID:
return BookmarksFolderTitleUnsorted
default:
return nil
}
}
class BookmarkURLTooLargeError: MaybeErrorType {
init() {
}
var description: String {
return "URL too long to bookmark."
}
}
extension String {
public func truncateToUTF8ByteCount(_ keep: Int) -> String {
let byteCount = self.lengthOfBytes(using: String.Encoding.utf8)
if byteCount <= keep {
return self
}
let toDrop = keep - byteCount
// If we drop this many characters from the string, we will drop at least this many bytes.
// That's aggressive, but that's OK for our purposes.
guard let endpoint = self.index(self.endIndex, offsetBy: toDrop, limitedBy: self.startIndex) else {
return ""
}
return self.substring(to: endpoint)
}
}
extension SQLiteBookmarks: ShareToDestination {
public func addToMobileBookmarks(_ url: URL, title: String, favicon: Favicon?) -> Success {
if url.absoluteString.lengthOfBytes(using: String.Encoding.utf8) > AppConstants.DB_URL_LENGTH_MAX {
return deferMaybe(BookmarkURLTooLargeError())
}
let title = title.truncateToUTF8ByteCount(AppConstants.DB_TITLE_LENGTH_MAX)
return isBookmarked(String(describing: url), direction: Direction.local)
>>== { yes in
guard !yes else { return succeed() }
return self.insertBookmark(url, title: title, favicon: favicon,
intoFolder: BookmarkRoots.MobileFolderGUID,
withTitle: BookmarksFolderTitleMobile)
}
}
public func shareItem(_ item: ShareItem) -> Success {
// We parse here in anticipation of getting real URLs at some point.
if let url = item.url.asURL {
let title = item.title ?? url.absoluteString
return self.addToMobileBookmarks(url, title: title, favicon: item.favicon)
}
return succeed()
}
}

View file

@ -0,0 +1,948 @@
/* 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 Deferred
import Foundation
import Shared
private let log = Logger.syncLogger
private let desktopBookmarksLabel: String = NSLocalizedString("Desktop Bookmarks", tableName: "BookmarkPanel", comment: "The folder name for the virtual folder that contains all desktop bookmarks.")
public enum Direction {
case buffer
case local
var structureTable: String {
switch self {
case .local:
return TableBookmarksLocalStructure
case .buffer:
return TableBookmarksBufferStructure
}
}
var valueTable: String {
switch self {
case .local:
return TableBookmarksLocal
case .buffer:
return TableBookmarksBuffer
}
}
var valueView: String {
switch self {
case .local:
return ViewBookmarksLocalOnMirror
case .buffer:
return ViewBookmarksBufferWithDeletionsOnMirror
}
}
var structureView: String {
switch self {
case .local:
return ViewBookmarksLocalStructureOnMirror
case .buffer:
return ViewBookmarksBufferStructureOnMirror
}
}
}
public protocol KeywordSearchSource {
func getURLForKeywordSearch(_ keyword: String) -> Deferred<Maybe<String>>
}
open class SQLiteBookmarksModelFactory: BookmarksModelFactory {
fileprivate let bookmarks: SQLiteBookmarks
fileprivate let direction: Direction
public init(bookmarks: SQLiteBookmarks, direction: Direction) {
self.bookmarks = bookmarks
self.direction = direction
}
public func factoryForIndex(_ index: Int, inFolder folder: BookmarkFolder) -> BookmarksModelFactory {
return self
}
fileprivate func withDifferentDirection(_ direction: Direction) -> SQLiteBookmarksModelFactory {
if self.direction == direction {
return self
}
return SQLiteBookmarksModelFactory(bookmarks: self.bookmarks, direction: direction)
}
fileprivate func getChildrenWithParent(_ parentGUID: GUID, excludingGUIDs: [GUID]?=nil, includeIcon: Bool) -> Deferred<Maybe<Cursor<BookmarkNode>>> {
return self.bookmarks.getChildrenWithParent(parentGUID, direction: self.direction, excludingGUIDs: excludingGUIDs, includeIcon: includeIcon)
}
fileprivate func getRootChildren() -> Deferred<Maybe<Cursor<BookmarkNode>>> {
return self.getChildrenWithParent(BookmarkRoots.RootGUID, excludingGUIDs: [BookmarkRoots.RootGUID], includeIcon: true)
}
fileprivate func getChildren(_ guid: String) -> Deferred<Maybe<Cursor<BookmarkNode>>> {
return self.getChildrenWithParent(guid, includeIcon: true)
}
func folderForGUID(_ guid: GUID, title: String) -> Deferred<Maybe<BookmarkFolder>> {
return self.getChildren(guid)
>>== { cursor in
if cursor.status == .failure {
return deferMaybe(DatabaseError(description: "Couldn't get children: \(cursor.statusMessage)."))
}
return deferMaybe(SQLiteBookmarkFolder(guid: guid, title: title, children: cursor))
}
}
fileprivate func modelWithRoot(_ root: BookmarkFolder) -> Deferred<Maybe<BookmarksModel>> {
return deferMaybe(BookmarksModel(modelFactory: self, root: root))
}
open func modelForFolder(_ guid: String, title: String) -> Deferred<Maybe<BookmarksModel>> {
if guid == BookmarkRoots.MobileFolderGUID {
return self.modelForRoot()
}
if guid == BookmarkRoots.FakeDesktopFolderGUID {
return self.modelForDesktopBookmarks()
}
let outputTitle = titleForSpecialGUID(guid) ?? title
return self.folderForGUID(guid, title: outputTitle)
>>== self.modelWithRoot
}
open func modelForFolder(_ folder: BookmarkFolder) -> Deferred<Maybe<BookmarksModel>> {
return self.modelForFolder(folder.guid, title: folder.title)
}
open func modelForFolder(_ guid: String) -> Deferred<Maybe<BookmarksModel>> {
return self.modelForFolder(guid, title: "")
}
open func modelForRoot() -> Deferred<Maybe<BookmarksModel>> {
log.debug("Getting model for root.")
let getFolder = self.folderForGUID(BookmarkRoots.MobileFolderGUID, title: BookmarksFolderTitleMobile)
if self.direction == .buffer {
return getFolder >>== self.modelWithRoot
}
// Return a virtual model containing "Desktop bookmarks" prepended to the local mobile bookmarks.
return getFolder >>== { folder in
self.extendWithDesktopBookmarksFolder(folder, factory: self)
}
}
open var nullModel: BookmarksModel {
let children = Cursor<BookmarkNode>(status: .failure, msg: "Null model")
let folder = SQLiteBookmarkFolder(guid: "Null", title: "Null", children: children)
return BookmarksModel(modelFactory: self, root: folder)
}
open func isBookmarked(_ url: String) -> Deferred<Maybe<Bool>> {
return self.bookmarks.isBookmarked(url, direction: self.direction)
}
open func removeByURL(_ url: String) -> Success {
if self.direction == Direction.buffer {
return deferMaybe(DatabaseError(description: "Refusing to remove URL from buffer in model."))
}
// Find all of the records for the provided URL. Don't bother with
// any that are already deleted!
return self.bookmarks.nonDeletedGUIDsForURL(url)
>>== self.bookmarks.removeGUIDs
}
open func removeByGUID(_ guid: GUID) -> Success {
if self.direction == Direction.buffer {
return deferMaybe(DatabaseError(description: "Refusing to remove GUID from buffer in model."))
}
log.debug("removeByGUID: \(guid)")
return self.bookmarks.removeGUIDs([guid])
}
func hasDesktopBookmarks() -> Deferred<Maybe<Bool>> {
// This is very lazy, but it has the nice property of keeping Desktop Bookmarks visible
// for a while after you mark the last desktop child as deleted.
let parents: Args = [
// Local.
BookmarkRoots.MenuFolderGUID,
BookmarkRoots.ToolbarFolderGUID,
BookmarkRoots.UnfiledFolderGUID,
// Mirror.
BookmarkRoots.MenuFolderGUID,
BookmarkRoots.ToolbarFolderGUID,
BookmarkRoots.UnfiledFolderGUID,
]
let sql =
"SELECT 1 FROM \(self.direction.structureTable) WHERE parent IN (?, ?, ?)" +
" UNION ALL " +
"SELECT 1 FROM \(TableBookmarksMirrorStructure) WHERE parent IN (?, ?, ?)" +
" LIMIT 1"
return self.bookmarks.db.queryReturnsResults(sql, args: parents)
}
func getDesktopRoots() -> Deferred<Maybe<Cursor<BookmarkNode>>> {
if self.direction == .buffer {
// DesktopRoots excludes the Mobile folder, local and non-local mobile are aggregated
return self.bookmarks.getRecordsWithGUIDs(BookmarkRoots.DesktopRoots, direction: self.direction, includeIcon: false)
}
// We deliberately exclude the mobile folder, because we're inverting the containment
// relationship here.
let exclude = [BookmarkRoots.MobileFolderGUID, BookmarkRoots.RootGUID]
return self.getChildrenWithParent(BookmarkRoots.RootGUID, excludingGUIDs: exclude, includeIcon: false)
}
/**
* Prepend the provided mobile bookmarks folder with a single folder.
* The prepended folder is "Desktop Bookmarks". It contains mirrored folders.
*/
open func extendWithDesktopBookmarksFolder(_ mobile: BookmarkFolder, factory: BookmarksModelFactory) -> Deferred<Maybe<BookmarksModel>> {
func onlyMobile() -> Deferred<Maybe<BookmarksModel>> {
// No desktop bookmarks.
log.debug("No desktop bookmarks. Only showing mobile.")
return deferMaybe(BookmarksModel(modelFactory: factory, root: mobile))
}
return self.hasDesktopBookmarks() >>== { yes in
if !yes {
return onlyMobile()
}
return self.getDesktopRoots() >>== { cursor in
if cursor.count == 0 {
// This shouldn't occur.
return onlyMobile()
}
let desktop = self.folderForDesktopBookmarksCursor(cursor)
let prepended = PrependedBookmarkFolder(main: mobile, prepend: desktop)
return deferMaybe(BookmarksModel(modelFactory: factory, root: prepended))
}
}
}
fileprivate func modelForDesktopBookmarks() -> Deferred<Maybe<BookmarksModel>> {
return self.getDesktopRoots() >>== { cursor in
let desktop = self.folderForDesktopBookmarksCursor(cursor)
return deferMaybe(BookmarksModel(modelFactory: self, root: desktop))
}
}
fileprivate func folderForDesktopBookmarksCursor(_ cursor: Cursor<BookmarkNode>) -> SQLiteBookmarkFolder {
return SQLiteBookmarkFolder(guid: BookmarkRoots.FakeDesktopFolderGUID, title: desktopBookmarksLabel, children: cursor)
}
}
class EditableBufferBookmarksSQLiteBookmarksModelFactory: SQLiteBookmarksModelFactory {
override func getChildrenWithParent(_ parentGUID: GUID, excludingGUIDs: [GUID]?, includeIcon: Bool) -> Deferred<Maybe<Cursor<BookmarkNode>>> {
if parentGUID == BookmarkRoots.MobileFolderGUID {
return self.bookmarks.getChildrenWithParent(parentGUID, direction: self.direction, excludingGUIDs: excludingGUIDs, includeIcon: includeIcon, factory: BookmarkFactory.editableItemsFactory)
}
return super.getChildrenWithParent(parentGUID, excludingGUIDs: excludingGUIDs, includeIcon: includeIcon)
}
override func removeByGUID(_ guid: GUID) -> Success {
log.debug("Removing \(guid) from buffer.")
return self.bookmarks.markBufferBookmarkAsDeleted(guid)
}
}
private func isEditableExpression(_ direction: Direction) -> String {
if direction == .buffer {
return "0"
}
return "SELECT exists( " +
" SELECT exists(SELECT 1 FROM \(TableBookmarksBuffer)) AS hasBuffer, exists(SELECT 1 FROM \(TableBookmarksMirror)) AS hasMirror " +
" WHERE hasBuffer IS 0 OR hasMirror IS 0" +
")"
}
extension SQLiteBookmarks {
fileprivate func getRecordsWithGUIDs(_ guids: [GUID], direction: Direction, includeIcon: Bool) -> Deferred<Maybe<Cursor<BookmarkNode>>> {
let args: Args = guids
let varlist = BrowserDB.varlist(args.count)
let values =
"SELECT -1 AS id, guid, type, date_added, is_deleted, parentid, parentName, feedUri, pos, title, bmkUri, siteUri, folderName, faviconID, (\(isEditableExpression(direction))) AS isEditable " +
"FROM \(direction.valueView) WHERE guid IN \(varlist) AND NOT is_deleted"
let withIcon = [
"SELECT bookmarks.id AS id, bookmarks.guid AS guid, bookmarks.type AS type,",
" bookmarks.date_added AS date_added,",
" bookmarks.is_deleted AS is_deleted,",
" bookmarks.parentid AS parentid, bookmarks.parentName AS parentName,",
" bookmarks.feedUri AS feedUri, bookmarks.pos AS pos, title AS title,",
" bookmarks.bmkUri AS bmkUri, bookmarks.siteUri AS siteUri,",
" bookmarks.folderName AS folderName,",
" bookmarks.isEditable AS isEditable,",
" favicons.url AS iconURL, favicons.date AS iconDate, favicons.type AS iconType",
"FROM (", values, ") AS bookmarks",
"LEFT OUTER JOIN favicons ON bookmarks.faviconID = favicons.id",
"ORDER BY title ASC",
].joined(separator: " ")
let sql = (includeIcon ? withIcon : values) + " ORDER BY title ASC"
return self.db.runQuery(sql, args: args, factory: BookmarkFactory.factory)
}
/**
* Return the children of the provided parent.
* Rows are ordered by positional index.
* This method is aware of is_overridden and deletion, using local override structure by preference.
* Note that a folder can be empty locally; we thus use the flag rather than looking at the structure itself.
*/
func getChildrenWithParent(_ parentGUID: GUID, direction: Direction, excludingGUIDs: [GUID]?=nil, includeIcon: Bool, factory: @escaping (SDRow) -> BookmarkNode = BookmarkFactory.factory) -> Deferred<Maybe<Cursor<BookmarkNode>>> {
precondition((excludingGUIDs ?? []).count < 100, "Sanity bound for the number of GUIDs we can exclude.")
let valueView = direction.valueView
let structureView = direction.structureView
let structure =
"SELECT parent, child AS guid, idx FROM \(structureView) " +
"WHERE parent = ?"
let values =
"SELECT -1 AS id, guid, type, date_added, is_deleted, parentid, parentName, feedUri, pos, title, bmkUri, siteUri, folderName, faviconID, (\(isEditableExpression(direction))) AS isEditable " +
"FROM \(valueView)"
// We exclude queries and dynamic containers, because we can't
// usefully display them.
let typeQuery = BookmarkNodeType.query.rawValue
let typeDynamic = BookmarkNodeType.dynamicContainer.rawValue
let typeFilter = " vals.type NOT IN (\(typeQuery), \(typeDynamic))"
let args: Args
let exclusion: String
if let excludingGUIDs = excludingGUIDs {
args = ([parentGUID] + excludingGUIDs).map { $0 }
exclusion = "\(typeFilter) AND vals.guid NOT IN " + BrowserDB.varlist(excludingGUIDs.count)
} else {
args = [parentGUID]
exclusion = typeFilter
}
let fleshed =
"SELECT vals.id AS id, vals.guid AS guid, vals.type AS type, vals.date_added AS date_added, vals.is_deleted AS is_deleted, " +
" vals.parentid AS parentid, vals.parentName AS parentName, vals.feedUri AS feedUri, " +
" vals.siteUri AS siteUri," +
" vals.pos AS pos, vals.title AS title, vals.bmkUri AS bmkUri, vals.folderName AS folderName, " +
" vals.faviconID AS faviconID, " +
" vals.isEditable AS isEditable, " +
" structure.idx AS idx, " +
" structure.parent AS _parent " +
"FROM (\(structure)) AS structure JOIN (\(values)) AS vals " +
"ON vals.guid = structure.guid " +
"WHERE " + exclusion
let withIcon =
"SELECT bookmarks.id AS id, bookmarks.guid AS guid, bookmarks.type AS type, " +
" bookmarks.date_added AS date_added, " +
" bookmarks.is_deleted AS is_deleted, " +
" bookmarks.parentid AS parentid, bookmarks.parentName AS parentName, " +
" bookmarks.feedUri AS feedUri, bookmarks.siteUri AS siteUri, " +
" bookmarks.pos AS pos, title AS title, " +
" bookmarks.bmkUri AS bmkUri, bookmarks.folderName AS folderName, " +
" bookmarks.idx AS idx, bookmarks._parent AS _parent, " +
" bookmarks.isEditable AS isEditable, " +
" favicons.url AS iconURL, favicons.date AS iconDate, favicons.type AS iconType " +
"FROM (\(fleshed)) AS bookmarks " +
"LEFT OUTER JOIN favicons ON bookmarks.faviconID = favicons.id"
let sql = (includeIcon ? withIcon : fleshed) + " ORDER BY idx ASC"
return self.db.runQuery(sql, args: args, factory: factory)
}
// This is only used from tests.
func clearBookmarks() -> Success {
log.warning("CALLING clearBookmarks -- this should only be used from tests.")
return self.db.run([
("DELETE FROM \(TableBookmarksLocal) WHERE parentid IS NOT ?", [BookmarkRoots.RootGUID]),
self.favicons.getCleanupFaviconsQuery()
])
}
public func removeGUIDs(_ guids: [GUID]) -> Success {
log.debug("removeGUIDs: \(guids)")
// Override any parents that aren't already overridden. We're about to remove some
// of their children.
return self.overrideParentsOfGUIDs(guids)
// Find, recursively, any children of the provided GUIDs. This will only be the case
// if you specify folders. This is a special case because we're removing *all*
// children, so we don't need to do the reindexing dance.
>>> { self.deleteChildrenOfGUIDs(guids) }
// Override any records that aren't already overridden.
>>> { self.overrideGUIDs(guids) }
// Then delete the already-overridden records. We do this one at a time in order
// to get indices correct in edge cases. (We do bulk-delete their children
// one layer at a time, at least.)
>>> { walk(guids, f: self.removeLocalByGUID) }
}
fileprivate func nonDeletedGUIDsForURL(_ url: String) -> Deferred<Maybe<([GUID])>> {
let sql = "SELECT DISTINCT guid FROM \(ViewBookmarksLocalOnMirror) WHERE bmkUri = ? AND is_deleted = 0"
let args: Args = [url]
return self.db.runQuery(sql, args: args, factory: { $0[0] as! GUID }) >>== { guids in
return deferMaybe(guids.asArray())
}
}
fileprivate func overrideParentsOfGUIDs(_ guids: [GUID]) -> Success {
log.debug("Overriding parents of \(guids).")
// TODO: Yes, this can be done in one go.
let getParentsSQL =
"SELECT DISTINCT parent FROM \(ViewBookmarksLocalStructureOnMirror) " +
"WHERE child IN \(BrowserDB.varlist(guids.count)) AND is_overridden = 0"
let getParentsArgs: Args = guids
return self.db.runQuery(getParentsSQL, args: getParentsArgs, factory: { $0[0] as! GUID })
>>== { parentsCursor in
let parents = parentsCursor.asArray()
log.debug("Overriding parents: \(parents).")
let (sql, args) = self.getSQLToOverrideFolders(parents, atModifiedTime: Date.now())
return self.db.run(sql.map { ($0, args) })
}
}
fileprivate func overrideGUIDs(_ guids: [GUID]) -> Success {
log.debug("Overriding GUIDs: \(guids).")
let (sql, args) = self.getSQLToOverrideNonFolders(guids, atModifiedTime: Date.now())
return self.db.run(sql.map { ($0, args) })
}
// Recursive.
fileprivate func deleteChildrenOfGUIDs(_ guids: [GUID]) -> Success {
if guids.isEmpty {
return succeed()
}
precondition(BookmarkRoots.All.intersection(guids).isEmpty, "You can't even touch the roots for removal.")
log.debug("Deleting children of \(guids).")
let topArgs: Args = guids
let topVarlist = BrowserDB.varlist(topArgs.count)
let query =
"SELECT child FROM \(ViewBookmarksLocalStructureOnMirror) " +
"WHERE parent IN \(topVarlist)"
// We're deleting whole folders, so we don't need to worry about indices.
return self.db.runQuery(query, args: topArgs, factory: { $0[0] as! GUID })
>>== { children in
let childGUIDs = children.asArray()
log.debug("… children of \(guids) are \(childGUIDs).")
if childGUIDs.isEmpty {
log.debug("No children; nothing more to do.")
return succeed()
}
let childArgs: Args = childGUIDs
let childVarlist = BrowserDB.varlist(childArgs.count)
// Mirror the children if they're not already.
// We use the non-folder version of this query because we're recursively
// destroying structure right after this, so there's no point cloning the
// mirror structure.
// Then delete the children's children, so we don't leave orphans. This is
// recursive, so by the time this succeeds we know that all of these records
// have no remaining children.
let (overrideSQL, overrideArgs) = self.getSQLToOverrideNonFolders(childGUIDs, atModifiedTime: Date.now())
return self.deleteChildrenOfGUIDs(childGUIDs)
>>> { self.db.run(overrideSQL.map { ($0, overrideArgs) }) }
>>> {
// Delete the children themselves.
// Remove each child from structure. We use the top list to save effort.
let deleteStructure =
"DELETE FROM \(TableBookmarksLocalStructure) WHERE parent IN \(topVarlist)"
// If a bookmark is New, delete it outright.
let deleteNew =
"DELETE FROM \(TableBookmarksLocal) WHERE guid IN \(childVarlist) AND sync_status = \(SyncStatus.new.rawValue)"
// If a bookmark is Changed, mark it as deleted and bump its modified time.
let markChanged = self.getMarkDeletedSQLWithWhereFragment("guid IN \(childVarlist)")
return self.db.run([
(deleteStructure, topArgs),
(deleteNew, childArgs),
(markChanged, childArgs),
])
}
}
}
fileprivate func getMarkDeletedSQLWithWhereFragment(_ whereFragment: String) -> String {
let sql =
"UPDATE \(TableBookmarksLocal) SET" +
" date_added = NULL" +
", is_deleted = 1" +
", local_modified = \(Date.now())" +
", bmkUri = NULL" +
", feedUri = NULL" +
", siteUri = NULL" +
", pos = NULL" +
", title = NULL" +
", tags = NULL" +
", keyword = NULL" +
", description = NULL" +
", parentid = NULL" +
", parentName = NULL" +
", folderName = NULL" +
", queryId = NULL" +
" WHERE \(whereFragment) AND sync_status = \(SyncStatus.changed.rawValue)"
return sql
}
/**
* This depends on the record's parent already being overridden if necessary.
*/
fileprivate func removeLocalByGUID(_ guid: GUID) -> Success {
let args: Args = [guid]
// Find the index we're currently occupying.
let previousIndexSubquery = "SELECT idx FROM \(TableBookmarksLocalStructure) WHERE child = ?"
// Fix up the indices of subsequent siblings.
let updateIndices =
"UPDATE \(TableBookmarksLocalStructure) SET idx = (idx - 1) WHERE idx > (\(previousIndexSubquery))"
// If the bookmark is New, delete it outright.
let deleteNew =
"DELETE FROM \(TableBookmarksLocal) WHERE guid = ? AND sync_status = \(SyncStatus.new.rawValue)"
// If the bookmark is Changed, mark it as deleted and bump its modified time.
let markChanged = self.getMarkDeletedSQLWithWhereFragment("guid = ?")
// Its parent must be either New or Changed, so we don't need to re-mirror it.
// TODO: bump the parent's modified time, because the child list changed?
// Now delete from structure.
let deleteStructure =
"DELETE FROM \(TableBookmarksLocalStructure) WHERE child = ?"
return self.db.run([
(updateIndices, args),
(deleteNew, args),
(markChanged, args),
(deleteStructure, args),
])
}
fileprivate func markBufferBookmarkAsDeleted(_ guid: GUID) -> Success {
let insertInPendingDeletions =
"INSERT OR IGNORE INTO \(TablePendingBookmarksDeletions) " +
"(id) " +
"VALUES (?)"
let args: Args = [guid]
return self.db.run(insertInPendingDeletions, withArgs: args)
}
}
class SQLiteBookmarkFolder: BookmarkFolder {
fileprivate let cursor: Cursor<BookmarkNode>
override var count: Int {
return cursor.count
}
override subscript(index: Int) -> BookmarkNode {
let bookmark = cursor[index]
return bookmark! as BookmarkNode
}
init(guid: String, title: String, children: Cursor<BookmarkNode>) {
self.cursor = children
super.init(guid: guid, title: title)
}
override func removeItemWithGUID(_ guid: GUID) -> BookmarkFolder? {
let without = cursor.asArray().filter { $0.guid != guid }
return MemoryBookmarkFolder(guid: self.guid, title: self.title, children: without)
}
}
class BookmarkFactory {
fileprivate class func addIcon(_ bookmark: BookmarkNode, row: SDRow) {
// TODO: share this logic with SQLiteHistory.
if let faviconURL = row["iconURL"] as? String,
let date = row["iconDate"] as? Double,
let faviconType = row["iconType"] as? Int,
let type = IconType(rawValue: faviconType) {
bookmark.favicon = Favicon(url: faviconURL,
date: Date(timeIntervalSince1970: date),
type: type)
}
}
fileprivate class func livemarkFactory(_ row: SDRow) -> BookmarkItem {
let id = row["id"] as? Int
let guid = row["guid"] as! String
let url = row["siteUri"] as! String
let title = row["title"] as? String ?? "Livemark" // TODO
let isEditable = row.getBoolean("isEditable") // Defaults to false.
let bookmark = BookmarkItem(guid: guid, title: title, url: url, isEditable: isEditable)
bookmark.id = id
BookmarkFactory.addIcon(bookmark, row: row)
return bookmark
}
// We ignore queries altogether inside the model factory.
fileprivate class func queryFactory(_ row: SDRow) -> BookmarkItem {
log.warning("Creating a BookmarkItem from a query. This is almost certainly unexpected.")
let id = row["id"] as? Int
let guid = row["guid"] as! String
let title = row["title"] as? String ?? SQLiteBookmarks.defaultItemTitle
let isEditable = row.getBoolean("isEditable") // Defaults to false.
let bookmark = BookmarkItem(guid: guid, title: title, url: "about:blank", isEditable: isEditable)
bookmark.id = id
BookmarkFactory.addIcon(bookmark, row: row)
return bookmark
}
fileprivate class func separatorFactory(_ row: SDRow) -> BookmarkSeparator {
let id = row["id"] as? Int
let guid = row["guid"] as! String
let separator = BookmarkSeparator(guid: guid)
separator.id = id
return separator
}
fileprivate class func itemRowFactory(_ row: SDRow, forceEditable: Bool = false) -> BookmarkItem {
let id = row["id"] as? Int
let guid = row["guid"] as! String
let url = row["bmkUri"] as! String
let title = row["title"] as? String ?? url
let isEditable = forceEditable || row.getBoolean("isEditable") // Defaults to false.
let bookmark = BookmarkItem(guid: guid, title: title, url: url, isEditable: isEditable)
bookmark.id = id
BookmarkFactory.addIcon(bookmark, row: row)
return bookmark
}
fileprivate class func itemFactory(_ row: SDRow) -> BookmarkItem {
return BookmarkFactory.itemRowFactory(row, forceEditable: false)
}
fileprivate class func folderFactory(_ row: SDRow) -> BookmarkFolder {
let id = row["id"] as? Int
let guid = row["guid"] as! String
let isEditable = row.getBoolean("isEditable") // Defaults to false.
let title = titleForSpecialGUID(guid) ??
row["title"] as? String ??
SQLiteBookmarks.defaultFolderTitle
let folder = BookmarkFolder(guid: guid, title: title, isEditable: isEditable)
folder.id = id
BookmarkFactory.addIcon(folder, row: row)
return folder
}
class func factory(_ row: SDRow) -> BookmarkNode {
return BookmarkFactory.rowFactory(row, forceEditable: false)
}
class func editableItemsFactory(_ row: SDRow) -> BookmarkNode {
return BookmarkFactory.rowFactory(row, forceEditable: true)
}
class func rowFactory(_ row: SDRow, forceEditable: Bool = false) -> BookmarkNode {
if let typeCode = row["type"] as? Int, let type = BookmarkNodeType(rawValue: typeCode) {
switch type {
case .bookmark:
return itemRowFactory(row, forceEditable: forceEditable)
case .dynamicContainer:
// This should never be hit: we exclude dynamic containers from our models.
fallthrough
case .folder:
return folderFactory(row)
case .separator:
return separatorFactory(row)
case .livemark:
return livemarkFactory(row)
case .query:
// This should never be hit: we exclude queries from our models.
return queryFactory(row)
}
}
assert(false, "Invalid bookmark data.")
return itemFactory(row) // This will fail, but it keeps the compiler happy.
}
// N.B., doesn't include children!
class func mirrorItemFactory(_ row: SDRow) -> BookmarkMirrorItem {
// TODO
// let id = row["id"] as! Int
let guid = row["guid"] as! GUID
let typeCode = row["type"] as! Int
let is_deleted = row.getBoolean("is_deleted")
let parentid = row["parentid"] as? GUID
let parentName = row["parentName"] as? String
let feedUri = row["feedUri"] as? String
let siteUri = row["siteUri"] as? String
let pos = row["pos"] as? Int
let title = row["title"] as? String
let description = row["description"] as? String
let bmkUri = row["bmkUri"] as? String
let tags = row["tags"] as? String
let keyword = row["keyword"] as? String
let folderName = row["folderName"] as? String
let queryId = row["queryId"] as? String
let date_added = row.getTimestamp("date_added")
// Local and mirror only.
let faviconID = row["faviconID"] as? Int
// Local only.
let local_modified = row.getTimestamp("local_modified")
// Mirror and buffer.
let server_modified = row.getTimestamp("server_modified")
let hasDupe = row.getBoolean("hasDupe")
// Mirror only. TODO
//let is_overridden = row.getBoolean("is_overridden")
// Use the struct initializer directly. Yes, this doesn't validate as strongly as
// using the static constructors, but it'll be as valid as the contents of the DB.
let type = BookmarkNodeType(rawValue: typeCode)!
// This one might really be missing (it's local-only), so do this the hard way.
let syncStatus: SyncStatus?
if let s = row["sync_status"] as? Int {
syncStatus = SyncStatus(rawValue: s)
} else {
syncStatus = nil
}
let item = BookmarkMirrorItem(guid: guid, type: type, dateAdded: date_added, serverModified: server_modified ?? 0,
isDeleted: is_deleted, hasDupe: hasDupe, parentID: parentid, parentName: parentName,
feedURI: feedUri, siteURI: siteUri,
pos: pos,
title: title, description: description,
bookmarkURI: bmkUri, tags: tags, keyword: keyword,
folderName: folderName, queryID: queryId,
children: nil,
faviconID: faviconID, localModified: local_modified,
syncStatus: syncStatus)
return item
}
}
extension SQLiteBookmarks: SearchableBookmarks {
public func bookmarksByURL(_ url: URL) -> Deferred<Maybe<Cursor<BookmarkItem>>> {
let inner =
"SELECT id, type, date_added, guid, bmkUri, title, faviconID FROM \(TableBookmarksLocal) " +
"WHERE " +
"type = \(BookmarkNodeType.bookmark.rawValue) AND is_deleted IS NOT 1 AND bmkUri = ? " +
"UNION ALL " +
"SELECT id, type, date_added, guid, bmkUri, title, faviconID FROM \(TableBookmarksMirror) " +
"WHERE " +
"type = \(BookmarkNodeType.bookmark.rawValue) AND is_overridden IS NOT 1 AND is_deleted IS NOT 1 AND bmkUri = ? "
let sql =
"SELECT bookmarks.id AS id, bookmarks.type AS type, bookmarks.date_added AS date_added, guid, bookmarks.bmkUri AS bmkUri, title, " +
"favicons.url AS iconURL, favicons.date AS iconDate, favicons.type AS iconType " +
"FROM (\(inner)) AS bookmarks " +
"LEFT OUTER JOIN favicons ON bookmarks.faviconID = favicons.id"
let u = url.absoluteString
let args: Args = [u, u]
return db.runQuery(sql, args: args, factory: BookmarkFactory.itemFactory)
}
}
extension SQLiteBookmarks {
// We're in sync (even partially) if the mirror is non-empty.
// We can show fallback desktop bookmarks if the mirror is empty and the buffer contains
// children of the roots.
func hasOnlyUnmergedRemoteBookmarks() -> Deferred<Maybe<Bool>> {
let parents: Args = [
BookmarkRoots.MenuFolderGUID,
BookmarkRoots.ToolbarFolderGUID,
BookmarkRoots.UnfiledFolderGUID,
BookmarkRoots.MobileFolderGUID,
]
let sql = [
"SELECT",
"not exists(SELECT 1 FROM \(TableBookmarksMirror))",
"AND",
"exists(SELECT 1 FROM \(TableBookmarksBufferStructure) WHERE parent IN (?, ?, ?, ?))",
].joined(separator: " ")
return self.db.runQuery(sql, args: parents, factory: { $0[0] as! Int == 1 })
>>== { row in
guard row.status == .success,
let result = row[0] else {
// if the query did not succeed, we should return false so that we can use local bookmarks
return deferMaybe(false)
}
return deferMaybe(result)
}
}
}
// It's a factory where the root contains Desktop Bookmarks from the buffer, and
// mobile bookmarks from local.
open class UnsyncedBookmarksFallbackModelFactory: BookmarksModelFactory {
let localFactory: SQLiteBookmarksModelFactory
let bufferFactory: SQLiteBookmarksModelFactory
init(bookmarks: SQLiteBookmarks) {
// This relies on SQLiteBookmarks being the storage for both directions.
self.localFactory = SQLiteBookmarksModelFactory(bookmarks: bookmarks, direction: .local)
if AppConstants.MOZ_SIMPLE_BOOKMARKS_SYNCING {
self.bufferFactory = EditableBufferBookmarksSQLiteBookmarksModelFactory(bookmarks: bookmarks, direction: .buffer)
} else {
self.bufferFactory = SQLiteBookmarksModelFactory(bookmarks: bookmarks, direction: .buffer)
}
}
// This is a special-case class, so here's the special-case behavior to
// know how to handle a folder that contains items drawn from different
// parts of the database. We look for the special kinds of folders we
// nest at the top level, and then we pick a folder to match.
public func factoryForIndex(_ index: Int, inFolder folder: BookmarkFolder) -> BookmarksModelFactory {
let concatenated: ConcatenatedBookmarkFolder
let i: Int
// We have either just remote and local mobile bookmarks, or we have Desktop Bookmarks
// followed by remote and local mobile bookmarks. Handle either.
if let prepended = folder as? PrependedBookmarkFolder {
if index == 0 {
return self
}
guard let c = prepended.main as? ConcatenatedBookmarkFolder else {
return self
}
i = index - 1 // Drop the prepend.
concatenated = c
} else {
guard let c = folder as? ConcatenatedBookmarkFolder else {
return self
}
i = index
concatenated = c
}
if i < concatenated.pivot {
return self.bufferFactory // This comes first in our concatenation.
}
return self.localFactory
}
open func modelForFolder(_ folder: BookmarkFolder) -> Deferred<Maybe<BookmarksModel>> {
return self.modelForFolder(folder.guid, title: folder.title)
}
open func modelForFolder(_ guid: GUID) -> Deferred<Maybe<BookmarksModel>> {
return self.modelForFolder(guid, title: "")
}
open func modelForFolder(_ guid: GUID, title: String) -> Deferred<Maybe<BookmarksModel>> {
if guid == BookmarkRoots.MobileFolderGUID {
return self.modelForRoot()
}
if guid == BookmarkRoots.FakeDesktopFolderGUID {
return self.bufferFactory.modelForFolder(guid, title: title)
}
return self.localFactory.modelForFolder(guid, title: title)
}
open func modelForRoot() -> Deferred<Maybe<BookmarksModel>> {
log.debug("Getting model for fallback root.")
// Return a virtual model containing "Desktop bookmarks" prepended to the local mobile bookmarks.
return self.localFactory.folderForGUID(BookmarkRoots.MobileFolderGUID, title: BookmarksFolderTitleMobile)
>>== {
localMobileFolder in
self.bufferFactory.folderForGUID(BookmarkRoots.MobileFolderGUID, title: BookmarksFolderTitleMobile) >>== {
bufferMobileFolder in
let bufferAndLocalMobile = ConcatenatedBookmarkFolder(main: bufferMobileFolder, append: localMobileFolder)
return self.bufferFactory.hasDesktopBookmarks() >>== { yes in
guard yes else {
return deferMaybe(BookmarksModel(modelFactory: self, root: bufferAndLocalMobile))
}
return self.bufferFactory.getDesktopRoots() >>== { cursor in
let desktop = self.bufferFactory.folderForDesktopBookmarksCursor(cursor)
let withDesktopPrepended = PrependedBookmarkFolder(main: bufferAndLocalMobile, prepend: desktop)
return deferMaybe(BookmarksModel(modelFactory: self, root: withDesktopPrepended))
}
}
}
}
}
// Whenever async construction is necessary, we fall into a pattern of needing
// a placeholder that behaves correctly for the period between kickoff and set.
open var nullModel: BookmarksModel {
let children = Cursor<BookmarkNode>(status: .failure, msg: "Null model")
let folder = SQLiteBookmarkFolder(guid: "Null", title: "Null", children: children)
return BookmarksModel(modelFactory: self, root: folder)
}
open func isBookmarked(_ url: String) -> Deferred<Maybe<Bool>> {
// We don't include buffer items in this check, because we can't un-star them!
return self.localFactory.isBookmarked(url)
}
open func removeByGUID(_ guid: GUID) -> Success {
return self.localFactory.removeByGUID(guid)
}
open func removeByURL(_ url: String) -> Success {
return self.localFactory.removeByURL(url)
}
}
open class MergedSQLiteBookmarks: BookmarksModelFactorySource, KeywordSearchSource {
let local: SQLiteBookmarks
let buffer: SQLiteBookmarkBufferStorage
// Figuring out our factory can require hitting the DB, so this is async.
// Note that we check *every time* -- we don't want to get stuck in a dead
// end when you might sync soon.
open var modelFactory: Deferred<Maybe<BookmarksModelFactory>> {
return self.local.hasOnlyUnmergedRemoteBookmarks() >>== { yes in
if yes {
log.debug("Only unmerged remote bookmarks; using fallback factory.")
return deferMaybe(UnsyncedBookmarksFallbackModelFactory(bookmarks: self.local))
}
log.debug("Using local+mirror bookmark factory.")
return self.local.modelFactory
}
}
public init(db: BrowserDB) {
self.local = SQLiteBookmarks(db: db)
self.buffer = SQLiteBookmarkBufferStorage(db: db)
}
open func getURLForKeywordSearch(_ keyword: String) -> Deferred<Maybe<String>> {
return self.local.getURLForKeywordSearch(keyword)
}
}

View file

@ -0,0 +1,146 @@
/* 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 XCGLogger
private let log = Logger.syncLogger
extension MergedSQLiteBookmarks: AccountRemovalDelegate {
public func onRemovedAccount() -> Success {
return self.local.onRemovedAccount() >>> self.buffer.onRemovedAccount
}
}
extension MergedSQLiteBookmarks: ResettableSyncStorage {
public func resetClient() -> Success {
return self.local.resetClient() >>> self.buffer.resetClient
}
}
extension SQLiteBookmarkBufferStorage: AccountRemovalDelegate {
public func onRemovedAccount() -> Success {
return self.resetClient()
}
}
extension SQLiteBookmarkBufferStorage: ResettableSyncStorage {
/**
* Our buffer is simply a copy of server contents. That means we should
* be very willing to drop it and re-populate it from the server whenever we might
* be out of sync. See Bug 1212431 Comment 2.
*/
public func resetClient() -> Success {
return self.wipeBookmarks()
}
public func wipeBookmarks() -> Success {
return self.db.run([
"DELETE FROM \(TableBookmarksBufferStructure)",
"DELETE FROM \(TableBookmarksBuffer)",
])
}
}
extension SQLiteBookmarks {
/**
* If a synced record is deleted locally, but hasn't been synced to the server,
* then `preserveDeletions=true` will result in that deletion being kept.
*
* During a reset, we'll redownload all server records. If we don't keep the
* local deletion, then when we re-process the (non-deleted) server counterpart
* to the now-missing local record, it'll be reinserted: the user's deletion will
* be undone.
*
* Right now we don't preserve deletions when removing the Firefox Account, but
* we could do so if we were willing to trade local database space to handle this
* possible situation.
*/
fileprivate func collapseMirrorIntoLocalPreservingDeletions(_ preserveDeletions: Bool) -> Success {
// As implemented, this won't work correctly without ON DELETE CASCADE.
assert(SwiftData.EnableForeignKeys)
// 1. Wait until we commit to complain about constraint violations.
let deferForeignKeys =
"PRAGMA defer_foreign_keys = ON"
// 2. Drop anything from local that's deleted. We don't need to track the
// deletion now. Optional: keep them around if they're non-uploaded changes.
let removeLocalDeletions =
"DELETE FROM \(TableBookmarksLocal) WHERE is_deleted IS 1 " +
(preserveDeletions ? "AND sync_status IS NOT \(SyncStatus.changed.rawValue)" : "")
// 3. Mark everything in local as New.
let markLocalAsNew =
"UPDATE \(TableBookmarksLocal) SET sync_status = \(SyncStatus.new.rawValue)"
// 4. Insert into local anything not overridden left in mirror.
// Note that we use the server modified time as our substitute local modified time.
// This will provide an ounce of conflict avoidance if the user re-links the same
// account at a later date.
let copyMirrorContents =
"INSERT OR IGNORE INTO \(TableBookmarksLocal) " +
"(sync_status, local_modified, " +
" guid, date_added, type, bmkUri, title, parentid, parentName, feedUri, siteUri, pos," +
" description, tags, keyword, folderName, queryId, faviconID) " +
"SELECT " +
"\(SyncStatus.new.rawValue) AS sync_status, " +
"server_modified AS local_modified, " +
"guid, date_added, type, bmkUri, title, parentid, parentName, " +
"feedUri, siteUri, pos, description, tags, keyword, folderName, queryId, faviconID " +
"FROM \(TableBookmarksMirror) WHERE is_overridden IS 0"
// 5.(pre) I have a database right in front of me that violates an assumption: a full
// bookmarksMirrorStructure and an empty bookmarksMirror. Clean up, just in case.
let removeOverriddenStructure =
"DELETE FROM \(TableBookmarksMirrorStructure) WHERE parent IN (SELECT guid FROM \(TableBookmarksMirror) WHERE is_overridden IS 1)"
// 5. Insert into localStructure anything left in mirrorStructure.
// This won't copy the structure of any folders that were already overridden --
// we already deleted those, and the deletions cascaded.
let copyMirrorStructure =
"INSERT INTO \(TableBookmarksLocalStructure) SELECT * FROM \(TableBookmarksMirrorStructure)"
// 6. Blank the mirror.
let removeMirrorStructure =
"DELETE FROM \(TableBookmarksMirrorStructure)"
let removeMirrorContents =
"DELETE FROM \(TableBookmarksMirror)"
return db.run([
deferForeignKeys,
removeLocalDeletions,
markLocalAsNew,
copyMirrorContents,
removeOverriddenStructure,
copyMirrorStructure,
removeMirrorStructure,
removeMirrorContents,
])
}
}
extension SQLiteBookmarks: AccountRemovalDelegate {
public func onRemovedAccount() -> Success {
return self.collapseMirrorIntoLocalPreservingDeletions(false)
}
}
extension SQLiteBookmarks: ResettableSyncStorage {
public func resetClient() -> Success {
// Flip flags to prompt a re-sync.
//
// We copy the mirror to local, preserving local changes, apart from
// deletions of records that were never synced.
//
// Records that match the server record that we'll redownload will be
// marked as Synced and won't be reuploaded.
//
// Records that are present locally but aren't on the server will be
// uploaded.
//
return self.collapseMirrorIntoLocalPreservingDeletions(true)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,87 @@
/* 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 Deferred
import Shared
open class SQLiteFavicons {
let db: BrowserDB
required public init(db: BrowserDB) {
self.db = db
}
public func getFaviconIDQuery(url: String) -> (sql: String, args: Args?) {
var args: Args = []
args.append(url)
return (sql: "SELECT id FROM \(TableFavicons) WHERE url = ? LIMIT 1", args: args)
}
public func getInsertFaviconQuery(favicon: Favicon) -> (sql: String, args: Args?) {
var args: Args = []
args.append(favicon.url)
args.append(favicon.width)
args.append(favicon.height)
args.append(favicon.date)
args.append(favicon.type.rawValue)
return (sql: "INSERT INTO \(TableFavicons) (url, width, height, date, type) VALUES (?,?,?,?,?)", args: args)
}
public func getUpdateFaviconQuery(favicon: Favicon) -> (sql: String, args: Args?) {
var args = Args()
args.append(favicon.width)
args.append(favicon.height)
args.append(favicon.date)
args.append(favicon.type.rawValue)
args.append(favicon.url)
return (sql: "UPDATE \(TableFavicons) SET width = ?, height = ?, date = ?, type = ? WHERE url = ?", args: args)
}
public func getCleanupFaviconsQuery() -> (sql: String, args: Args?) {
return (sql: "DELETE FROM \(TableFavicons) " +
"WHERE \(TableFavicons).id NOT IN (" +
"SELECT faviconID FROM \(TableFaviconSites) " +
"UNION ALL " +
"SELECT faviconID FROM \(TableBookmarksLocal) WHERE faviconID IS NOT NULL " +
"UNION ALL " +
"SELECT faviconID FROM \(TableBookmarksMirror) WHERE faviconID IS NOT NULL" +
")", args: nil)
}
public func insertOrUpdateFavicon(_ favicon: Favicon) -> Deferred<Maybe<Int>> {
return db.withConnection { conn -> Int in
self.insertOrUpdateFaviconInTransaction(favicon, conn: conn) ?? 0
}
}
func insertOrUpdateFaviconInTransaction(_ favicon: Favicon, conn: SQLiteDBConnection) -> Int? {
let query = self.getFaviconIDQuery(url: favicon.url)
let cursor = conn.executeQuery(query.sql, factory: IntFactory, withArgs: query.args)
if let id = cursor[0] {
let updateQuery = self.getUpdateFaviconQuery(favicon: favicon)
do {
try conn.executeChange(updateQuery.sql, withArgs: updateQuery.args)
} catch {
return nil
}
return id
}
let insertQuery = self.getInsertFaviconQuery(favicon: favicon)
do {
try conn.executeChange(insertQuery.sql, withArgs: insertQuery.args)
} catch {
return nil
}
return conn.lastInsertedRowID
}
public func cleanupFavicons() -> Success {
return self.db.run([getCleanupFaviconsQuery()])
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,75 @@
/* 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
/*
* Factory methods for converting rows from SQLite into model objects
*/
extension SQLiteHistory {
class func basicHistoryColumnFactory(_ row: SDRow) -> Site {
let id = row["historyID"] as? Int
let url = row["url"] as! String
let title = row["title"] as! String
let guid = row["guid"] as! String
// Extract a boolean from the row if it's present.
let iB = row["is_bookmarked"] as? Int
let isBookmarked: Bool? = (iB == nil) ? nil : (iB! != 0)
let site = Site(url: url, title: title, bookmarked: isBookmarked)
site.guid = guid
site.id = id
// Find the most recent visit, regardless of which column it might be in.
let local = row.getTimestamp("localVisitDate") ?? 0
let remote = row.getTimestamp("remoteVisitDate") ?? 0
let either = row.getTimestamp("visitDate") ?? 0
let latest = max(local, remote, either)
if latest > 0 {
site.latestVisit = Visit(date: latest, type: VisitType.unknown)
}
return site
}
class func iconColumnFactory(_ row: SDRow) -> Favicon? {
if let iconType = row["iconType"] as? Int,
let iconURL = row["iconURL"] as? String,
let iconDate = row["iconDate"] as? Double,
let _ = row["iconID"] as? Int {
let date = Date(timeIntervalSince1970: iconDate)
return Favicon(url: iconURL, date: date, type: IconType(rawValue: iconType)!)
}
return nil
}
class func pageMetadataColumnFactory(_ row: SDRow) -> PageMetadata? {
guard let siteURL = row["url"] as? String else {
return nil
}
return PageMetadata(id: row["metadata_id"] as? Int, siteURL: siteURL, mediaURL: row["media_url"] as? String, title: row["metadata_title"] as? String, description: row["description"] as? String, type: row["type"] as? String, providerName: row["provider_name"] as? String, mediaDataURI: nil)
}
class func iconHistoryColumnFactory(_ row: SDRow) -> Site {
let site = basicHistoryColumnFactory(row)
site.icon = iconColumnFactory(row)
return site
}
class func iconHistoryMetadataColumnFactory(_ row: SDRow) -> Site {
let site = iconHistoryColumnFactory(row)
site.metadata = pageMetadataColumnFactory(row)
return site
}
class func basicHistoryMetadataColumnFactory(_ row: SDRow) -> Site {
let site = basicHistoryColumnFactory(row)
site.metadata = pageMetadataColumnFactory(row)
return site
}
}

View file

@ -0,0 +1,179 @@
/* 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 XCGLogger
import Deferred
fileprivate let log = Logger.syncLogger
extension SQLiteHistory: HistoryRecommendations {
// Bookmarks Query
static let removeMultipleDomainsSubquery =
" INNER JOIN (SELECT \(ViewHistoryVisits).domain_id AS domain_id" +
" FROM \(ViewHistoryVisits)" +
" GROUP BY \(ViewHistoryVisits).domain_id) AS domains ON domains.domain_id = \(TableHistory).domain_id"
static let urisForSimpleSyncedBookmarks =
"SELECT bmkUri FROM \(TableBookmarksBuffer) WHERE server_modified > ? AND is_deleted = 0 " +
"UNION ALL " +
"SELECT bmkUri FROM \(TableBookmarksLocal) WHERE local_modified > ? AND is_deleted = 0"
static let urisForLocalBookmarks =
"SELECT bmkUri" +
"FROM \(ViewBookmarksLocalOnMirror) " +
"WHERE \(ViewBookmarksLocalOnMirror).server_modified > ? OR " +
"\(ViewBookmarksLocalOnMirror).local_modified > ?"
static let bookmarkHighlights =
"SELECT historyID, url, siteTitle, guid, is_bookmarked FROM (" +
" SELECT \(TableHistory).id AS historyID, \(TableHistory).url AS url, \(TableHistory).title AS siteTitle, guid, \(TableHistory).domain_id, NULL AS visitDate, 1 AS is_bookmarked" +
" FROM (" +
(AppConstants.MOZ_SIMPLE_BOOKMARKS_SYNCING ? urisForSimpleSyncedBookmarks : urisForLocalBookmarks) +
" )" +
" LEFT JOIN \(TableHistory) ON \(TableHistory).url = bmkUri" + removeMultipleDomainsSubquery +
" WHERE \(TableHistory).title NOT NULL and \(TableHistory).title != '' AND url NOT IN" +
" (SELECT \(TableActivityStreamBlocklist).url FROM \(TableActivityStreamBlocklist))" +
" LIMIT ?" +
")"
static let bookmarksQuery =
"SELECT historyID, url, siteTitle AS title, guid, is_bookmarked, iconID, iconURL, iconType, iconDate, iconWidth, \(TablePageMetadata).title AS metadata_title, media_url, type, description, provider_name " +
"FROM (\(bookmarkHighlights) ) " +
"LEFT JOIN \(ViewHistoryIDsWithWidestFavicons) ON \(ViewHistoryIDsWithWidestFavicons).id = historyID " +
"LEFT OUTER JOIN \(TablePageMetadata) ON \(TablePageMetadata).cache_key = url " +
"GROUP BY url"
// Highlights Query
static let highlightsLimit = 8
static let blacklistedHosts: Args = [
"google.com",
"google.ca",
"calendar.google.com",
"mail.google.com",
"mail.yahoo.com",
"search.yahoo.com",
"localhost",
"t.co"
]
static let blacklistSubquery = "SELECT \(TableDomains).id FROM \(TableDomains) WHERE \(TableDomains).domain IN " + BrowserDB.varlist(blacklistedHosts.count)
static let removeMultipleDomainsSubqueryFromHighlights =
" INNER JOIN (SELECT \(ViewHistoryVisits).domain_id AS domain_id, MAX(\(ViewHistoryVisits).visitDate) AS visit_date" +
" FROM \(ViewHistoryVisits)" +
" GROUP BY \(ViewHistoryVisits).domain_id) AS domains ON domains.domain_id = \(TableHistory).domain_id AND visitDate = domains.visit_date"
static let nonRecentHistory =
"SELECT historyID, url, siteTitle, guid, visitCount, visitDate, is_bookmarked, visitCount * icon_url_score * media_url_score AS score FROM (" +
" SELECT \(TableHistory).id as historyID, url, \(TableHistory).title AS siteTitle, guid, visitDate, \(TableHistory).domain_id," +
" (SELECT COUNT(1) FROM \(TableVisits) WHERE s = \(TableVisits).siteID) AS visitCount," +
" (SELECT COUNT(1) FROM \(ViewBookmarksLocalOnMirror) WHERE \(ViewBookmarksLocalOnMirror).bmkUri == url) AS is_bookmarked," +
" CASE WHEN iconURL IS NULL THEN 1 ELSE 2 END AS icon_url_score," +
" CASE WHEN media_url IS NULL THEN 1 ELSE 4 END AS media_url_score" +
" FROM (" +
" SELECT siteID AS s, MAX(date) AS visitDate" +
" FROM \(TableVisits)" +
" WHERE date < ?" +
" GROUP BY siteID" +
" ORDER BY visitDate DESC" +
" )" +
" LEFT JOIN \(TableHistory) ON \(TableHistory).id = s" +
removeMultipleDomainsSubqueryFromHighlights +
" LEFT OUTER JOIN \(ViewHistoryIDsWithWidestFavicons) ON" +
" \(ViewHistoryIDsWithWidestFavicons).id = \(TableHistory).id" +
" LEFT OUTER JOIN \(TablePageMetadata) ON" +
" \(TablePageMetadata).site_url = \(TableHistory).url" +
" WHERE visitCount <= 3 AND \(TableHistory).title NOT NULL AND \(TableHistory).title != '' AND is_bookmarked == 0 AND url NOT IN" +
" (SELECT url FROM \(TableActivityStreamBlocklist))" +
" AND \(TableHistory).domain_id NOT IN ("
+ blacklistSubquery + ")" +
")"
public func getHighlights() -> Deferred<Maybe<Cursor<Site>>> {
let highlightsProjection = [
"historyID",
"\(TableHighlights).cache_key AS cache_key",
"url",
"\(TableHighlights).title AS title",
"guid",
"visitCount",
"visitDate",
"is_bookmarked"
]
let faviconsProjection = ["iconID", "iconURL", "iconType", "iconDate", "iconWidth"]
let metadataProjections = [
"\(TablePageMetadata).title AS metadata_title",
"media_url",
"type",
"description",
"provider_name"
]
let allProjection = highlightsProjection + faviconsProjection + metadataProjections
let highlightsHistoryIDs =
"SELECT historyID FROM \(TableHighlights)"
// Search the history/favicon view with our limited set of highlight IDs
// to avoid doing a full table scan on history
let faviconSearch =
"SELECT * FROM \(ViewHistoryIDsWithWidestFavicons) WHERE id IN (\(highlightsHistoryIDs))"
let sql =
"SELECT \(allProjection.joined(separator: ",")) " +
"FROM \(TableHighlights) " +
"LEFT JOIN (\(faviconSearch)) AS f1 ON f1.id = historyID " +
"LEFT OUTER JOIN \(TablePageMetadata) ON " +
"\(TablePageMetadata).cache_key = \(TableHighlights).cache_key"
return self.db.runQuery(sql, args: nil, factory: SQLiteHistory.iconHistoryMetadataColumnFactory)
}
public func removeHighlightForURL(_ url: String) -> Success {
return self.db.run([("INSERT INTO \(TableActivityStreamBlocklist) (url) VALUES (?)", [url])])
}
private func repopulateHighlightsQuery() -> [(String, Args?)] {
let (query, args) = computeHighlightsQuery()
let clearHighlightsQuery = "DELETE FROM \(TableHighlights)"
let sql = "INSERT INTO \(TableHighlights) " +
"SELECT historyID, url as cache_key, url, title, guid, visitCount, visitDate, is_bookmarked " +
"FROM (\(query))"
return [(clearHighlightsQuery, nil), (sql, args)]
}
public func repopulate(invalidateTopSites shouldInvalidateTopSites: Bool, invalidateHighlights shouldInvalidateHighlights: Bool) -> Success {
var queries: [(String, Args?)] = []
if shouldInvalidateTopSites {
queries.append(contentsOf: self.refreshTopSitesQuery())
}
if shouldInvalidateHighlights {
queries.append(contentsOf: self.repopulateHighlightsQuery())
}
return self.db.run(queries)
}
public func getRecentBookmarks(_ limit: Int = 3) -> Deferred<Maybe<Cursor<Site>>> {
let fiveDaysAgo: UInt64 = Date.now() - (OneDayInMilliseconds * 5) // The data is joined with a millisecond not a microsecond one. (History)
let args = [fiveDaysAgo, fiveDaysAgo, limit] as Args
return self.db.runQuery(SQLiteHistory.bookmarksQuery, args: args, factory: SQLiteHistory.iconHistoryMetadataColumnFactory)
}
private func computeHighlightsQuery() -> (String, Args) {
let microsecondsPerMinute: UInt64 = 60_000_000 // 1000 * 1000 * 60
let now = Date.nowMicroseconds()
let thirtyMinutesAgo: UInt64 = now - 30 * microsecondsPerMinute
let highlightsQuery =
"SELECT historyID, url, siteTitle AS title, guid, visitCount, visitDate, is_bookmarked, score " +
"FROM ( \(SQLiteHistory.nonRecentHistory) ) " +
"GROUP BY url " +
"ORDER BY score DESC " +
"LIMIT \(SQLiteHistory.highlightsLimit)"
let args: Args = [thirtyMinutesAgo] + SQLiteHistory.blacklistedHosts
return (highlightsQuery, args)
}
}

View file

@ -0,0 +1,894 @@
/* 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 XCGLogger
import Deferred
private let log = Logger.syncLogger
open class SQLiteLogins: BrowserLogins {
fileprivate let db: BrowserDB
fileprivate static let MainColumns: String = "guid, username, password, hostname, httpRealm, formSubmitURL, usernameField, passwordField"
fileprivate static let MainWithLastUsedColumns: String = MainColumns + ", timeLastUsed, timesUsed"
fileprivate static let LoginColumns: String = MainColumns + ", timeCreated, timeLastUsed, timePasswordChanged, timesUsed"
public init(db: BrowserDB) {
self.db = db
}
fileprivate class func populateLogin(_ login: Login, row: SDRow) {
login.formSubmitURL = row["formSubmitURL"] as? String
login.usernameField = row["usernameField"] as? String
login.passwordField = row["passwordField"] as? String
login.guid = row["guid"] as! String
if let timeCreated = row.getTimestamp("timeCreated"),
let timeLastUsed = row.getTimestamp("timeLastUsed"),
let timePasswordChanged = row.getTimestamp("timePasswordChanged"),
let timesUsed = row["timesUsed"] as? Int {
login.timeCreated = timeCreated
login.timeLastUsed = timeLastUsed
login.timePasswordChanged = timePasswordChanged
login.timesUsed = timesUsed
}
}
fileprivate class func constructLogin<T: Login>(_ row: SDRow, c: T.Type) -> T {
let credential = URLCredential(user: row["username"] as? String ?? "",
password: row["password"] as! String,
persistence: URLCredential.Persistence.none)
// There was a bug in previous versions of the app where we saved only the hostname and not the
// scheme and port in the DB. To work with these scheme-less hostnames, we try to extract the scheme and
// hostname by converting to a URL first. If there is no valid hostname or scheme for the URL,
// fallback to returning the raw hostname value from the DB as the host and allow NSURLProtectionSpace
// to use the default (http) scheme. See https://bugzilla.mozilla.org/show_bug.cgi?id=1238103.
let hostnameString = (row["hostname"] as? String) ?? ""
let hostnameURL = hostnameString.asURL
let scheme = hostnameURL?.scheme
let port = hostnameURL?.port ?? 0
// Check for malformed hostname urls in the DB
let host: String
var malformedHostname = false
if let h = hostnameURL?.host {
host = h
} else {
host = hostnameString
malformedHostname = true
}
let protectionSpace = URLProtectionSpace(host: host,
port: port,
protocol: scheme,
realm: row["httpRealm"] as? String,
authenticationMethod: nil)
let login = T(credential: credential, protectionSpace: protectionSpace)
self.populateLogin(login, row: row)
login.hasMalformedHostname = malformedHostname
return login
}
class func LocalLoginFactory(_ row: SDRow) -> LocalLogin {
let login = self.constructLogin(row, c: LocalLogin.self)
login.localModified = row.getTimestamp("local_modified") ?? 0
login.isDeleted = row.getBoolean("is_deleted")
login.syncStatus = SyncStatus(rawValue: row["sync_status"] as! Int)!
return login
}
class func MirrorLoginFactory(_ row: SDRow) -> MirrorLogin {
let login = self.constructLogin(row, c: MirrorLogin.self)
login.serverModified = row.getTimestamp("server_modified")!
login.isOverridden = row.getBoolean("is_overridden")
return login
}
fileprivate class func LoginFactory(_ row: SDRow) -> Login {
return self.constructLogin(row, c: Login.self)
}
fileprivate class func LoginDataFactory(_ row: SDRow) -> LoginData {
return LoginFactory(row) as LoginData
}
fileprivate class func LoginUsageDataFactory(_ row: SDRow) -> LoginUsageData {
return LoginFactory(row) as LoginUsageData
}
func notifyLoginDidChange() {
log.debug("Notifying login did change.")
// For now we don't care about the contents.
// This posts immediately to the shared notification center.
NotificationCenter.default.post(name: NotificationDataLoginDidChange, object: nil)
}
open func getUsageDataForLoginByGUID(_ guid: GUID) -> Deferred<Maybe<LoginUsageData>> {
let projection = SQLiteLogins.LoginColumns
let sql =
"SELECT \(projection) FROM " +
"\(TableLoginsLocal) WHERE is_deleted = 0 AND guid = ? " +
"UNION ALL " +
"SELECT \(projection) FROM " +
"\(TableLoginsMirror) WHERE is_overridden = 0 AND guid = ? " +
"LIMIT 1"
let args: Args = [guid, guid]
return db.runQuery(sql, args: args, factory: SQLiteLogins.LoginUsageDataFactory)
>>== { value in
deferMaybe(value[0]!)
}
}
open func getLoginDataForGUID(_ guid: GUID) -> Deferred<Maybe<Login>> {
let projection = SQLiteLogins.LoginColumns
let sql =
"SELECT \(projection) FROM " +
"\(TableLoginsLocal) WHERE is_deleted = 0 AND guid = ? " +
"UNION ALL " +
"SELECT \(projection) FROM " +
"\(TableLoginsMirror) WHERE is_overriden IS NOT 1 AND guid = ? " +
"ORDER BY hostname ASC " +
"LIMIT 1"
let args: Args = [guid, guid]
return db.runQuery(sql, args: args, factory: SQLiteLogins.LoginFactory)
>>== { value in
if let login = value[0] {
return deferMaybe(login)
} else {
return deferMaybe(LoginDataError(description: "Login not found for GUID \(guid)"))
}
}
}
open func getLoginsForProtectionSpace(_ protectionSpace: URLProtectionSpace) -> Deferred<Maybe<Cursor<LoginData>>> {
let projection = SQLiteLogins.MainWithLastUsedColumns
let sql =
"SELECT \(projection) FROM " +
"\(TableLoginsLocal) WHERE is_deleted = 0 AND hostname IS ? OR hostname IS ?" +
"UNION ALL " +
"SELECT \(projection) FROM " +
"\(TableLoginsMirror) WHERE is_overridden = 0 AND hostname IS ? OR hostname IS ?" +
"ORDER BY timeLastUsed DESC"
// Since we store hostnames as the full scheme/protocol + host, combine the two to look up in our DB.
// In the case of https://bugzilla.mozilla.org/show_bug.cgi?id=1238103, there may be hostnames without
// a scheme. Check for these as well.
let args: Args = [
protectionSpace.urlString(),
protectionSpace.host,
protectionSpace.urlString(),
protectionSpace.host,
]
if Logger.logPII {
log.debug("Looking for login: \(protectionSpace.urlString()) && \(protectionSpace.host)")
}
return db.runQuery(sql, args: args, factory: SQLiteLogins.LoginDataFactory)
}
// username is really Either<String, NULL>; we explicitly match no username.
open func getLoginsForProtectionSpace(_ protectionSpace: URLProtectionSpace, withUsername username: String?) -> Deferred<Maybe<Cursor<LoginData>>> {
let projection = SQLiteLogins.MainWithLastUsedColumns
let args: Args
let usernameMatch: String
if let username = username {
args = [
protectionSpace.urlString(), username, protectionSpace.host,
protectionSpace.urlString(), username, protectionSpace.host
]
usernameMatch = "username = ?"
} else {
args = [
protectionSpace.urlString(), protectionSpace.host,
protectionSpace.urlString(), protectionSpace.host
]
usernameMatch = "username IS NULL"
}
if Logger.logPII {
log.debug("Looking for login with username: \(username ?? "nil"), first arg: \(args[0] ?? "nil")")
}
let sql =
"SELECT \(projection) FROM " +
"\(TableLoginsLocal) WHERE is_deleted = 0 AND hostname IS ? AND \(usernameMatch) OR hostname IS ?" +
"UNION ALL " +
"SELECT \(projection) FROM " +
"\(TableLoginsMirror) WHERE is_overridden = 0 AND hostname IS ? AND \(usernameMatch) OR hostname IS ?" +
"ORDER BY timeLastUsed DESC"
return db.runQuery(sql, args: args, factory: SQLiteLogins.LoginDataFactory)
}
open func getAllLogins() -> Deferred<Maybe<Cursor<Login>>> {
return searchLoginsWithQuery(nil)
}
open func searchLoginsWithQuery(_ query: String?) -> Deferred<Maybe<Cursor<Login>>> {
let projection = SQLiteLogins.LoginColumns
var searchClauses = [String]()
var args: Args? = nil
if let query = query, !query.isEmpty {
// Add wildcards to change query to 'contains in' and add them to args. We need 6 args because
// we include the where clause twice: Once for the local table and another for the remote.
args = (0..<6).map { _ in
return "%\(query)%" as String?
}
searchClauses.append("username LIKE ? ")
searchClauses.append(" password LIKE ? ")
searchClauses.append(" hostname LIKE ?")
}
let whereSearchClause = searchClauses.count > 0 ? "AND (" + searchClauses.joined(separator: "OR") + ") " : ""
let sql =
"SELECT \(projection) FROM " +
"\(TableLoginsLocal) WHERE is_deleted = 0 " + whereSearchClause +
"UNION ALL " +
"SELECT \(projection) FROM " +
"\(TableLoginsMirror) WHERE is_overridden = 0 " + whereSearchClause +
"ORDER BY hostname ASC"
return db.runQuery(sql, args: args, factory: SQLiteLogins.LoginFactory)
}
open func addLogin(_ login: LoginData) -> Success {
if let error = login.isValid.failureValue {
return deferMaybe(error)
}
let nowMicro = Date.nowMicroseconds()
let nowMilli = nowMicro / 1000
let dateMicro = nowMicro
let dateMilli = nowMilli
let args: Args = [
login.hostname,
login.httpRealm,
login.formSubmitURL,
login.usernameField,
login.passwordField,
login.username,
login.password,
login.guid,
dateMicro, // timeCreated
dateMicro, // timeLastUsed
dateMicro, // timePasswordChanged
dateMilli, // localModified
]
let sql =
"INSERT OR IGNORE INTO \(TableLoginsLocal) " +
// Shared fields.
"( hostname" +
", httpRealm" +
", formSubmitURL" +
", usernameField" +
", passwordField" +
", timesUsed" +
", username" +
", password " +
// Local metadata.
", guid " +
", timeCreated" +
", timeLastUsed" +
", timePasswordChanged" +
", local_modified " +
", is_deleted " +
", sync_status " +
") " +
"VALUES (?,?,?,?,?,1,?,?,?,?,?, " +
"?, ?, 0, \(SyncStatus.new.rawValue)" + // Metadata.
")"
return db.run(sql, withArgs: args)
>>> effect(self.notifyLoginDidChange)
}
fileprivate func cloneMirrorToOverlay(whereClause: String?, args: Args?) -> Deferred<Maybe<Int>> {
let shared = "guid, hostname, httpRealm, formSubmitURL, usernameField, passwordField, timeCreated, timeLastUsed, timePasswordChanged, timesUsed, username, password "
let local = ", local_modified, is_deleted, sync_status "
let sql = "INSERT OR IGNORE INTO \(TableLoginsLocal) (\(shared)\(local)) SELECT \(shared), NULL AS local_modified, 0 AS is_deleted, 0 AS sync_status FROM \(TableLoginsMirror) \(whereClause ?? "")"
return self.db.write(sql, withArgs: args)
}
/**
* Returns success if either a local row already existed, or
* one could be copied from the mirror.
*/
fileprivate func ensureLocalOverlayExistsForGUID(_ guid: GUID) -> Success {
let sql = "SELECT guid FROM \(TableLoginsLocal) WHERE guid = ?"
let args: Args = [guid]
let c = db.runQuery(sql, args: args, factory: { _ in 1 })
return c >>== { rows in
if rows.count > 0 {
return succeed()
}
log.debug("No overlay; cloning one for GUID \(guid).")
return self.cloneMirrorToOverlay(guid)
>>== { count in
if count > 0 {
return succeed()
}
log.warning("Failed to create local overlay for GUID \(guid).")
return deferMaybe(NoSuchRecordError(guid: guid))
}
}
}
fileprivate func cloneMirrorToOverlay(_ guid: GUID) -> Deferred<Maybe<Int>> {
let whereClause = "WHERE guid = ?"
let args: Args = [guid]
return self.cloneMirrorToOverlay(whereClause: whereClause, args: args)
}
fileprivate func markMirrorAsOverridden(_ guid: GUID) -> Success {
let args: Args = [guid]
let sql =
"UPDATE \(TableLoginsMirror) SET " +
"is_overridden = 1 " +
"WHERE guid = ?"
return self.db.run(sql, withArgs: args)
}
/**
* Replace the local DB row with the provided GUID.
* If no local overlay exists, one is first created.
*
* If `significant` is `true`, the `sync_status` of the row is bumped to at least `Changed`.
* If it's already `New`, it remains marked as `New`.
*
* This flag allows callers to make minor changes (such as incrementing a usage count)
* without triggering an upload or a conflict.
*/
open func updateLoginByGUID(_ guid: GUID, new: LoginData, significant: Bool) -> Success {
if let error = new.isValid.failureValue {
return deferMaybe(error)
}
// Right now this method is only ever called if the password changes at
// point of use, so we always set `timePasswordChanged` and `timeLastUsed`.
// We can (but don't) also assume that `significant` will always be `true`,
// at least for the time being.
let nowMicro = Date.nowMicroseconds()
let nowMilli = nowMicro / 1000
let dateMicro = nowMicro
let dateMilli = nowMilli
let args: Args = [
dateMilli, // local_modified
dateMicro, // timeLastUsed
dateMicro, // timePasswordChanged
new.httpRealm,
new.formSubmitURL,
new.usernameField,
new.passwordField,
new.password,
new.hostname,
new.username,
guid,
]
let update =
"UPDATE \(TableLoginsLocal) SET " +
" local_modified = ?, timeLastUsed = ?, timePasswordChanged = ?" +
", httpRealm = ?, formSubmitURL = ?, usernameField = ?" +
", passwordField = ?, timesUsed = timesUsed + 1" +
", password = ?, hostname = ?, username = ?" +
// We keep rows marked as New in preference to marking them as changed. This allows us to
// delete them immediately if they don't reach the server.
(significant ? ", sync_status = max(sync_status, 1) " : "") +
" WHERE guid = ?"
return self.ensureLocalOverlayExistsForGUID(guid)
>>> { self.markMirrorAsOverridden(guid) }
>>> { self.db.run(update, withArgs: args) }
>>> effect(self.notifyLoginDidChange)
}
open func addUseOfLoginByGUID(_ guid: GUID) -> Success {
let sql =
"UPDATE \(TableLoginsLocal) SET " +
"timesUsed = timesUsed + 1, timeLastUsed = ?, local_modified = ? " +
"WHERE guid = ? AND is_deleted = 0"
// For now, mere use is not enough to flip sync_status to Changed.
let nowMicro = Date.nowMicroseconds()
let nowMilli = nowMicro / 1000
let args: Args = [nowMicro, nowMilli, guid]
return self.ensureLocalOverlayExistsForGUID(guid)
>>> { self.markMirrorAsOverridden(guid) }
>>> { self.db.run(sql, withArgs: args) }
}
open func removeLoginByGUID(_ guid: GUID) -> Success {
return removeLoginsWithGUIDs([guid])
}
fileprivate func getDeletionStatementsForGUIDs(_ guids: ArraySlice<GUID>, nowMillis: Timestamp) -> [(sql: String, args: Args?)] {
let inClause = BrowserDB.varlist(guids.count)
// Immediately delete anything that's marked as new -- i.e., it's never reached
// the server.
let delete =
"DELETE FROM \(TableLoginsLocal) WHERE guid IN \(inClause) AND sync_status = \(SyncStatus.new.rawValue)"
// Otherwise, mark it as changed.
let update =
"UPDATE \(TableLoginsLocal) SET " +
" local_modified = \(nowMillis)" +
", sync_status = \(SyncStatus.changed.rawValue)" +
", is_deleted = 1" +
", password = ''" +
", hostname = ''" +
", username = ''" +
" WHERE guid IN \(inClause)"
let markMirrorAsOverridden =
"UPDATE \(TableLoginsMirror) SET " +
"is_overridden = 1 " +
"WHERE guid IN \(inClause)"
let insert =
"INSERT OR IGNORE INTO \(TableLoginsLocal) " +
"(guid, local_modified, is_deleted, sync_status, hostname, timeCreated, timePasswordChanged, password, username) " +
"SELECT guid, \(nowMillis), 1, \(SyncStatus.changed.rawValue), '', timeCreated, \(nowMillis)000, '', '' FROM \(TableLoginsMirror) WHERE guid IN \(inClause)"
let args: Args = guids.map { $0 }
return [ (delete, args), (update, args), (markMirrorAsOverridden, args), (insert, args)]
}
open func removeLoginsWithGUIDs(_ guids: [GUID]) -> Success {
let timestamp = Date.now()
return db.run(chunk(guids, by: BrowserDB.MaxVariableNumber).flatMap {
self.getDeletionStatementsForGUIDs($0, nowMillis: timestamp)
}) >>> effect(self.notifyLoginDidChange)
}
open func removeAll() -> Success {
// Immediately delete anything that's marked as new -- i.e., it's never reached
// the server. If Sync isn't set up, this will be everything.
let delete =
"DELETE FROM \(TableLoginsLocal) WHERE sync_status = \(SyncStatus.new.rawValue)"
let nowMillis = Date.now()
// Mark anything we haven't already deleted.
let update =
"UPDATE \(TableLoginsLocal) SET local_modified = \(nowMillis), sync_status = \(SyncStatus.changed.rawValue), is_deleted = 1, password = '', hostname = '', username = '' WHERE is_deleted = 0"
// Copy all the remaining rows from our mirror, marking them as locally deleted. The
// OR IGNORE will cause conflicts due to non-unique guids to be dropped, preserving
// anything we already deleted.
let insert =
"INSERT OR IGNORE INTO \(TableLoginsLocal) (guid, local_modified, is_deleted, sync_status, hostname, timeCreated, timePasswordChanged, password, username) " +
"SELECT guid, \(nowMillis), 1, \(SyncStatus.changed.rawValue), '', timeCreated, \(nowMillis)000, '', '' FROM \(TableLoginsMirror)"
// After that, we mark all of the mirror rows as overridden.
return self.db.run(delete)
>>> { self.db.run(update) }
>>> { self.db.run("UPDATE \(TableLoginsMirror) SET is_overridden = 1") }
>>> { self.db.run(insert) }
>>> effect(self.notifyLoginDidChange)
}
}
// When a server change is detected (e.g., syncID changes), we should consider shifting the contents
// of the mirror into the local overlay, allowing a content-based reconciliation to occur on the next
// full sync. Or we could flag the mirror as to-clear, download the server records and un-clear, and
// resolve the remainder on completion. This assumes that a fresh start will typically end up with
// the exact same records, so we might as well keep the shared parents around and double-check.
extension SQLiteLogins: SyncableLogins {
/**
* Delete the login with the provided GUID. Succeeds if the GUID is unknown.
*/
public func deleteByGUID(_ guid: GUID, deletedAt: Timestamp) -> Success {
// Simply ignore the possibility of a conflicting local change for now.
let local = "DELETE FROM \(TableLoginsLocal) WHERE guid = ?"
let remote = "DELETE FROM \(TableLoginsMirror) WHERE guid = ?"
let args: Args = [guid]
return self.db.run(local, withArgs: args) >>> { self.db.run(remote, withArgs: args) }
}
func getExistingMirrorRecordByGUID(_ guid: GUID) -> Deferred<Maybe<MirrorLogin?>> {
let sql = "SELECT * FROM \(TableLoginsMirror) WHERE guid = ? LIMIT 1"
let args: Args = [guid]
return self.db.runQuery(sql, args: args, factory: SQLiteLogins.MirrorLoginFactory) >>== { deferMaybe($0[0]) }
}
func getExistingLocalRecordByGUID(_ guid: GUID) -> Deferred<Maybe<LocalLogin?>> {
let sql = "SELECT * FROM \(TableLoginsLocal) WHERE guid = ? LIMIT 1"
let args: Args = [guid]
return self.db.runQuery(sql, args: args, factory: SQLiteLogins.LocalLoginFactory) >>== { deferMaybe($0[0]) }
}
fileprivate func storeReconciledLogin(_ login: Login) -> Success {
let dateMilli = Date.now()
let args: Args = [
dateMilli, // local_modified
login.httpRealm,
login.formSubmitURL,
login.usernameField,
login.passwordField,
login.timeLastUsed,
login.timePasswordChanged,
login.timesUsed,
login.password,
login.hostname,
login.username,
login.guid,
]
let update =
"UPDATE \(TableLoginsLocal) SET " +
" local_modified = ?" +
", httpRealm = ?, formSubmitURL = ?, usernameField = ?" +
", passwordField = ?, timeLastUsed = ?, timePasswordChanged = ?, timesUsed = ?" +
", password = ?" +
", hostname = ?, username = ?" +
", sync_status = \(SyncStatus.changed.rawValue) " +
" WHERE guid = ?"
return self.db.run(update, withArgs: args)
}
public func applyChangedLogin(_ upstream: ServerLogin) -> Success {
// Our login storage tracks the shared parent from the last sync (the "mirror").
// This allows us to conclusively determine what changed in the case of conflict.
//
// Our first step is to determine whether the record is changed or new: i.e., whether
// or not it's present in the mirror.
//
// TODO: these steps can be done in a single query. Make it work, make it right, make it fast.
// TODO: if there's no mirror record, all incoming records can be applied in one go; the only
// reason we need to fetch first is to establish the shared parent. That would be nice.
let guid = upstream.guid
return self.getExistingMirrorRecordByGUID(guid) >>== { mirror in
return self.getExistingLocalRecordByGUID(guid) >>== { local in
return self.applyChangedLogin(upstream, local: local, mirror: mirror)
}
}
}
fileprivate func applyChangedLogin(_ upstream: ServerLogin, local: LocalLogin?, mirror: MirrorLogin?) -> Success {
// Once we have the server record, the mirror record (if any), and the local overlay (if any),
// we can always know which state a record is in.
// If it's present in the mirror, then we can proceed directly to handling the change;
// we assume that once a record makes it into the mirror, that the local record association
// has already taken place, and we're tracking local changes correctly.
if let mirror = mirror {
log.debug("Mirror record found for changed record \(mirror.guid).")
if let local = local {
log.debug("Changed local overlay found for \(local.guid). Resolving conflict with 3WM.")
// * Changed remotely and locally (conflict). Resolve the conflict using a three-way merge: the
// local mirror is the shared parent of both the local overlay and the new remote record.
// Apply results as in the co-creation case.
return self.resolveConflictBetween(local: local, upstream: upstream, shared: mirror)
}
log.debug("No local overlay found. Updating mirror to upstream.")
// * Changed remotely but not locally. Apply the remote changes to the mirror.
// There is no local overlay to discard or resolve against.
return self.updateMirrorToLogin(upstream, fromPrevious: mirror)
}
// * New both locally and remotely with no shared parent (cocreation).
// Or we matched the GUID, and we're assuming we just forgot the mirror.
//
// Merge and apply the results remotely, writing the result into the mirror and discarding the overlay
// if the upload succeeded. (Doing it in this order allows us to safely replay on failure.)
//
// If the local and remote record are the same, this is trivial.
// At this point we also switch our local GUID to match the remote.
if let local = local {
// We might have randomly computed the same GUID on two devices connected
// to the same Sync account.
// With our 9-byte GUIDs, the chance of that happening is very small, so we
// assume that this device has previously connected to this account, and we
// go right ahead with a merge.
log.debug("Local record with GUID \(local.guid) but no mirror. This is unusual; assuming disconnect-reconnect scenario. Smushing.")
return self.resolveConflictWithoutParentBetween(local: local, upstream: upstream)
}
// If it's not present, we must first check whether we have a local record that's substantially
// the same -- the co-creation or re-sync case.
//
// In this case, we apply the server record to the mirror, change the local record's GUID,
// and proceed to reconcile the change on a content basis.
return self.findLocalRecordByContent(upstream) >>== { local in
if let local = local {
log.debug("Local record \(local.guid) content-matches new remote record \(upstream.guid). Smushing.")
return self.resolveConflictWithoutParentBetween(local: local, upstream: upstream)
}
// * New upstream only; no local overlay, content-based merge,
// or shared parent in the mirror. Insert it in the mirror.
log.debug("Never seen remote record \(upstream.guid). Mirroring.")
return self.insertNewMirror(upstream)
}
}
// N.B., the final guid is sometimes a WHERE and sometimes inserted.
fileprivate func mirrorArgs(_ login: ServerLogin) -> Args {
let args: Args = [
login.serverModified,
login.httpRealm,
login.formSubmitURL,
login.usernameField,
login.passwordField,
login.timesUsed,
login.timeLastUsed,
login.timePasswordChanged,
login.timeCreated,
login.password,
login.hostname,
login.username,
login.guid,
]
return args
}
/**
* Called when we have a changed upstream record and no local changes.
* There's no need to flip the is_overridden flag.
*/
fileprivate func updateMirrorToLogin(_ login: ServerLogin, fromPrevious previous: Login) -> Success {
let args = self.mirrorArgs(login)
let sql =
"UPDATE \(TableLoginsMirror) SET " +
" server_modified = ?" +
", httpRealm = ?, formSubmitURL = ?, usernameField = ?" +
", passwordField = ?" +
// These we need to coalesce, because we might be supplying zeroes if the remote has
// been overwritten by an older client. In this case, preserve the old value in the
// mirror.
", timesUsed = coalesce(nullif(?, 0), timesUsed)" +
", timeLastUsed = coalesce(nullif(?, 0), timeLastUsed)" +
", timePasswordChanged = coalesce(nullif(?, 0), timePasswordChanged)" +
", timeCreated = coalesce(nullif(?, 0), timeCreated)" +
", password = ?, hostname = ?, username = ?" +
" WHERE guid = ?"
return self.db.run(sql, withArgs: args)
}
/**
* Called when we have a completely new record. Naturally the new record
* is marked as non-overridden.
*/
fileprivate func insertNewMirror(_ login: ServerLogin, isOverridden: Int = 0) -> Success {
let args = self.mirrorArgs(login)
let sql =
"INSERT OR IGNORE INTO \(TableLoginsMirror) (" +
" is_overridden, server_modified" +
", httpRealm, formSubmitURL, usernameField" +
", passwordField, timesUsed, timeLastUsed, timePasswordChanged, timeCreated" +
", password, hostname, username, guid" +
") VALUES (\(isOverridden), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
return self.db.run(sql, withArgs: args)
}
/**
* We assume a local record matches if it has the same username (password can differ),
* hostname, httpRealm. We also check that the formSubmitURLs are either blank or have the
* same host and port.
*
* This is roughly the same as desktop's .matches():
* <https://mxr.mozilla.org/mozilla-central/source/toolkit/components/passwordmgr/nsLoginInfo.js#41>
*/
fileprivate func findLocalRecordByContent(_ login: Login) -> Deferred<Maybe<LocalLogin?>> {
let primary =
"SELECT * FROM \(TableLoginsLocal) WHERE " +
"hostname IS ? AND httpRealm IS ? AND username IS ?"
var args: Args = [login.hostname, login.httpRealm, login.username]
let sql: String
if login.formSubmitURL == nil {
sql = primary + " AND formSubmitURL IS NULL"
} else if login.formSubmitURL!.isEmpty {
sql = primary
} else {
if let hostPort = login.formSubmitURL?.asURL?.hostPort {
// Substring check will suffice for now. TODO: proper host/port check after fetching the cursor.
sql = primary + " AND (formSubmitURL = '' OR (instr(formSubmitURL, ?) > 0))"
args.append(hostPort)
} else {
log.warning("Incoming formSubmitURL is non-empty but is not a valid URL with a host. Not matching local.")
return deferMaybe(nil)
}
}
return self.db.runQuery(sql, args: args, factory: SQLiteLogins.LocalLoginFactory)
>>== { cursor in
switch cursor.count {
case 0:
return deferMaybe(nil)
case 1:
// Great!
return deferMaybe(cursor[0])
default:
// TODO: join against the mirror table to exclude local logins that
// already match a server record.
// Right now just take the first.
log.warning("Got \(cursor.count) local logins with matching details! This is most unexpected.")
return deferMaybe(cursor[0])
}
}
}
fileprivate func resolveConflictBetween(local: LocalLogin, upstream: ServerLogin, shared: Login) -> Success {
// Attempt to compute two delta sets by comparing each new record to the shared record.
// Then we can merge the two delta sets -- either perfectly or by picking a winner in the case
// of a true conflict -- and produce a resultant record.
let localDeltas = (local.localModified, local.deltas(from: shared))
let upstreamDeltas = (upstream.serverModified, upstream.deltas(from: shared))
let mergedDeltas = Login.mergeDeltas(a: localDeltas, b: upstreamDeltas)
// Not all Sync clients handle the optional timestamp fields introduced in Bug 555755.
// We might get a server record with no timestamps, and it will differ from the original
// mirror!
// We solve that by refusing to generate deltas that discard information. We'll preserve
// the local values -- either from the local record or from the last shared parent that
// still included them -- and propagate them back to the server.
// It's OK for us to reconcile and reupload; it causes extra work for every client, but
// should not cause looping.
let resultant = shared.applyDeltas(mergedDeltas)
// We can immediately write the downloaded upstream record -- the old one -- to
// the mirror store.
// We then apply this record to the local store, and mark it as needing upload.
// When the reconciled record is uploaded, it'll be flushed into the mirror
// with the correct modified time.
return self.updateMirrorToLogin(upstream, fromPrevious: shared)
>>> { self.storeReconciledLogin(resultant) }
}
fileprivate func resolveConflictWithoutParentBetween(local: LocalLogin, upstream: ServerLogin) -> Success {
// Do the best we can. Either the local wins and will be
// uploaded, or the remote wins and we delete our overlay.
if local.timePasswordChanged > upstream.timePasswordChanged {
log.debug("Conflicting records with no shared parent. Using newer local record.")
return self.insertNewMirror(upstream, isOverridden: 1)
}
log.debug("Conflicting records with no shared parent. Using newer remote record.")
let args: Args = [local.guid]
return self.insertNewMirror(upstream, isOverridden: 0)
>>> { self.db.run("DELETE FROM \(TableLoginsLocal) WHERE guid = ?", withArgs: args) }
}
public func getModifiedLoginsToUpload() -> Deferred<Maybe<[Login]>> {
let sql =
"SELECT * FROM \(TableLoginsLocal) " +
"WHERE sync_status IS NOT \(SyncStatus.synced.rawValue) AND is_deleted = 0"
// Swift 2.0: use Cursor.asArray directly.
return self.db.runQuery(sql, args: nil, factory: SQLiteLogins.LoginFactory)
>>== { deferMaybe($0.asArray()) }
}
public func getDeletedLoginsToUpload() -> Deferred<Maybe<[GUID]>> {
// There are no logins that are marked as deleted that were not originally synced --
// others are deleted immediately.
let sql =
"SELECT guid FROM \(TableLoginsLocal) " +
"WHERE is_deleted = 1"
// Swift 2.0: use Cursor.asArray directly.
return self.db.runQuery(sql, args: nil, factory: { return $0["guid"] as! GUID })
>>== { deferMaybe($0.asArray()) }
}
/**
* Chains through the provided timestamp.
*/
public func markAsSynchronized<T: Collection>(_ guids: T, modified: Timestamp) -> Deferred<Maybe<Timestamp>> where T.Iterator.Element == GUID {
// Update the mirror from the local record that we just uploaded.
// sqlite doesn't support UPDATE FROM, so instead of running 10 subqueries * n GUIDs,
// we issue a single DELETE and a single INSERT on the mirror, then throw away the
// local overlay that we just uploaded with another DELETE.
log.debug("Marking \(guids.count) GUIDs as synchronized.")
let queries: [(String, Args?)] = chunkCollection(guids, by: BrowserDB.MaxVariableNumber) { guids in
let args: Args = guids.map { $0 }
let inClause = BrowserDB.varlist(args.count)
let delMirror = "DELETE FROM \(TableLoginsMirror) WHERE guid IN \(inClause)"
let insMirror =
"INSERT OR IGNORE INTO \(TableLoginsMirror) (" +
" is_overridden, server_modified" +
", httpRealm, formSubmitURL, usernameField" +
", passwordField, timesUsed, timeLastUsed, timePasswordChanged, timeCreated" +
", password, hostname, username, guid" +
") SELECT 0, \(modified)" +
", httpRealm, formSubmitURL, usernameField" +
", passwordField, timesUsed, timeLastUsed, timePasswordChanged, timeCreated" +
", password, hostname, username, guid " +
"FROM \(TableLoginsLocal) " +
"WHERE guid IN \(inClause)"
let delLocal = "DELETE FROM \(TableLoginsLocal) WHERE guid IN \(inClause)"
return [(delMirror, args),
(insMirror, args),
(delLocal, args)]
}
return self.db.run(queries)
>>> always(modified)
}
public func markAsDeleted<T: Collection>(_ guids: T) -> Success where T.Iterator.Element == GUID {
log.debug("Marking \(guids.count) GUIDs as deleted.")
let queries: [(String, Args?)] = chunkCollection(guids, by: BrowserDB.MaxVariableNumber) { guids in
let args: Args = guids.map { $0 }
let inClause = BrowserDB.varlist(args.count)
return [("DELETE FROM \(TableLoginsMirror) WHERE guid IN \(inClause)", args),
("DELETE FROM \(TableLoginsLocal) WHERE guid IN \(inClause)", args)]
}
return self.db.run(queries)
}
public func hasSyncedLogins() -> Deferred<Maybe<Bool>> {
let checkLoginsMirror = "SELECT 1 FROM \(TableLoginsMirror)"
let checkLoginsLocal = "SELECT 1 FROM \(TableLoginsLocal) WHERE sync_status IS NOT \(SyncStatus.new.rawValue)"
let sql = "\(checkLoginsMirror) UNION ALL \(checkLoginsLocal)"
return self.db.queryReturnsResults(sql)
}
}
extension SQLiteLogins: ResettableSyncStorage {
/**
* Clean up any metadata.
* TODO: is this safe for a regular reset? It forces a content-based merge.
*/
public func resetClient() -> Success {
// Clone all the mirrors so we don't lose data.
return self.cloneMirrorToOverlay(whereClause: nil, args: nil)
// Drop all of the mirror data.
>>> { self.db.run("DELETE FROM \(TableLoginsMirror)") }
// Mark all of the local data as new.
>>> { self.db.run("UPDATE \(TableLoginsLocal) SET sync_status = \(SyncStatus.new.rawValue)") }
}
}
extension SQLiteLogins: AccountRemovalDelegate {
public func onRemovedAccount() -> Success {
return self.resetClient()
}
}

View file

@ -0,0 +1,58 @@
/* 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 Deferred
import Shared
/// The sqlite-backed implementation of the metadata protocol containing images and content for pages.
open class SQLiteMetadata {
let db: BrowserDB
required public init(db: BrowserDB) {
self.db = db
}
}
extension SQLiteMetadata: Metadata {
// A cache key is a conveninent, readable identifier for a site in the metadata database which helps
// with deduping entries for the same page.
typealias CacheKey = String
/// Persists the given PageMetadata object to browser.db in the page_metadata table.
///
/// - parameter metadata: Metadata object
/// - parameter pageURL: URL of page metadata was fetched from
/// - parameter expireAt: Expiration/TTL interval for when this metadata should expire at.
///
/// - returns: Deferred on success
public func storeMetadata(_ metadata: PageMetadata, forPageURL pageURL: URL,
expireAt: UInt64) -> Success {
guard let cacheKey = pageURL.displayURL?.absoluteString else {
return succeed()
}
// Replace any matching cache_key entries if they exist
let selectUniqueCacheKey = "COALESCE((SELECT cache_key FROM \(TablePageMetadata) WHERE cache_key = ?), ?)"
let args: Args = [cacheKey, cacheKey, metadata.siteURL, metadata.mediaURL, metadata.title,
metadata.type, metadata.description, metadata.providerName,
expireAt]
let insert =
"INSERT OR REPLACE INTO \(TablePageMetadata)" +
"(cache_key, site_url, media_url, title, type, description, provider_name, expired_at) " +
"VALUES ( \(selectUniqueCacheKey), ?, ?, ?, ?, ?, ?, ?)"
return self.db.run(insert, withArgs: args)
}
/// Purges any metadata items living in page_metadata that are expired.
///
/// - returns: Deferred on success
public func deleteExpiredMetadata() -> Success {
let sql = "DELETE FROM page_metadata WHERE expired_at <= (CAST(strftime('%s', 'now') AS LONG)*1000)"
return self.db.run(sql)
}
}

View file

@ -0,0 +1,35 @@
/* 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 XCGLogger
import Deferred
private let log = Logger.syncLogger
open class SQLiteQueue: TabQueue {
let db: BrowserDB
public init(db: BrowserDB) {
self.db = db
}
open func addToQueue(_ tab: ShareItem) -> Success {
let args: Args = [tab.url, tab.title]
return db.run("INSERT OR IGNORE INTO \(TableQueuedTabs) (url, title) VALUES (?, ?)", withArgs: args)
}
fileprivate func factory(_ row: SDRow) -> ShareItem {
return ShareItem(url: row["url"] as! String, title: row["title"] as? String, favicon: nil)
}
open func getQueuedTabs() -> Deferred<Maybe<Cursor<ShareItem>>> {
return db.runQuery("SELECT url, title FROM \(TableQueuedTabs)", args: nil, factory: self.factory)
}
open func clearQueuedTabs() -> Success {
return db.run("DELETE FROM \(TableQueuedTabs)")
}
}

View file

@ -0,0 +1,382 @@
/* 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 XCGLogger
import Deferred
private let log = Logger.syncLogger
open class SQLiteRemoteClientsAndTabs: RemoteClientsAndTabs {
let db: BrowserDB
public init(db: BrowserDB) {
self.db = db
}
class func remoteClientFactory(_ row: SDRow) -> RemoteClient {
let guid = row["guid"] as? String
let name = row["name"] as! String
let mod = (row["modified"] as! NSNumber).uint64Value
let type = row["type"] as? String
let form = row["formfactor"] as? String
let os = row["os"] as? String
let version = row["version"] as? String
let fxaDeviceId = row["fxaDeviceId"] as? String
return RemoteClient(guid: guid, name: name, modified: mod, type: type, formfactor: form, os: os, version: version, fxaDeviceId: fxaDeviceId)
}
class func remoteTabFactory(_ row: SDRow) -> RemoteTab {
let clientGUID = row["client_guid"] as? String
let url = URL(string: row["url"] as! String)! // TODO: find a way to make this less dangerous.
let title = row["title"] as! String
let history = SQLiteRemoteClientsAndTabs.convertStringToHistory(row["history"] as? String)
let lastUsed = row.getTimestamp("last_used")!
return RemoteTab(clientGUID: clientGUID, URL: url, title: title, history: history, lastUsed: lastUsed, icon: nil)
}
class func convertStringToHistory(_ history: String?) -> [URL] {
guard let data = history?.data(using: String.Encoding.utf8),
let decoded = try? JSONSerialization.jsonObject(with: data, options: [JSONSerialization.ReadingOptions.allowFragments]),
let urlStrings = decoded as? [String] else {
return []
}
return optFilter(urlStrings.flatMap { URL(string: $0) })
}
class func convertHistoryToString(_ history: [URL]) -> String? {
let historyAsStrings = optFilter(history.map { $0.absoluteString })
guard let data = try? JSONSerialization.data(withJSONObject: historyAsStrings, options: []) else {
return nil
}
return String(data: data, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue))
}
open func wipeClients() -> Success {
return db.run("DELETE FROM \(TableClients)")
}
open func wipeRemoteTabs() -> Success {
return db.run("DELETE FROM \(TableTabs) WHERE client_guid IS NOT NULL")
}
open func wipeTabs() -> Success {
return db.run("DELETE FROM \(TableTabs)")
}
open func insertOrUpdateTabs(_ tabs: [RemoteTab]) -> Deferred<Maybe<Int>> {
return self.insertOrUpdateTabsForClientGUID(nil, tabs: tabs)
}
open func insertOrUpdateTabsForClientGUID(_ clientGUID: String?, tabs: [RemoteTab]) -> Deferred<Maybe<Int>> {
let deleteQuery = "DELETE FROM \(TableTabs) WHERE client_guid IS ?"
let deleteArgs: Args = [clientGUID]
return db.transaction { connection -> Int in
// Delete any existing tabs.
try connection.executeChange(deleteQuery, withArgs: deleteArgs)
// Insert replacement tabs.
var inserted = 0
for tab in tabs {
let args: Args = [
tab.clientGUID,
tab.URL.absoluteString,
tab.title,
SQLiteRemoteClientsAndTabs.convertHistoryToString(tab.history),
NSNumber(value: tab.lastUsed)
]
let lastInsertedRowID = connection.lastInsertedRowID
// We trust that each tab's clientGUID matches the supplied client!
// Really tabs shouldn't have a GUID at all. Future cleanup!
try connection.executeChange("INSERT INTO \(TableTabs) (client_guid, url, title, history, last_used) VALUES (?, ?, ?, ?, ?)", withArgs: args)
if connection.lastInsertedRowID == lastInsertedRowID {
log.debug("Unable to INSERT RemoteTab!")
} else {
inserted += 1
}
}
return inserted
}
}
open func insertOrUpdateClients(_ clients: [RemoteClient]) -> Deferred<Maybe<Int>> {
// TODO: insert multiple clients in a single query.
// ORM systems are foolish.
return db.transaction { connection -> Int in
var succeeded = 0
// Update or insert client records.
for client in clients {
let args: Args = [
client.name,
NSNumber(value: client.modified),
client.type,
client.formfactor,
client.os,
client.version,
client.fxaDeviceId,
client.guid
]
try connection.executeChange("UPDATE \(TableClients) SET name = ?, modified = ?, type = ?, formfactor = ?, os = ?, version = ?, fxaDeviceId = ? WHERE guid = ?", withArgs: args)
if connection.numberOfRowsModified == 0 {
let args: Args = [
client.guid,
client.name,
NSNumber(value: client.modified),
client.type,
client.formfactor,
client.os,
client.version,
client.fxaDeviceId
]
let lastInsertedRowID = connection.lastInsertedRowID
try connection.executeChange("INSERT INTO \(TableClients) (guid, name, modified, type, formfactor, os, version, fxaDeviceId) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", withArgs: args)
if connection.lastInsertedRowID == lastInsertedRowID {
log.debug("INSERT did not change last inserted row ID.")
}
}
succeeded += 1
}
return succeeded
}
}
open func insertOrUpdateClient(_ client: RemoteClient) -> Deferred<Maybe<Int>> {
return insertOrUpdateClients([client])
}
open func deleteClient(guid: GUID) -> Success {
let deleteTabsQuery = "DELETE FROM \(TableTabs) WHERE client_guid = ?"
let deleteClientQuery = "DELETE FROM \(TableClients) WHERE guid = ?"
let deleteArgs: Args = [guid]
return db.transaction { connection -> Void in
try connection.executeChange(deleteClientQuery, withArgs: deleteArgs)
try connection.executeChange(deleteTabsQuery, withArgs: deleteArgs)
}
}
open func getClient(guid: GUID) -> Deferred<Maybe<RemoteClient?>> {
let factory = SQLiteRemoteClientsAndTabs.remoteClientFactory
return self.db.runQuery("SELECT * FROM \(TableClients) WHERE guid = ?", args: [guid], factory: factory) >>== { deferMaybe($0[0]) }
}
open func getClient(fxaDeviceId: String) -> Deferred<Maybe<RemoteClient?>> {
let factory = SQLiteRemoteClientsAndTabs.remoteClientFactory
return self.db.runQuery("SELECT * FROM \(TableClients) WHERE fxaDeviceId = ?", args: [fxaDeviceId], factory: factory) >>== { deferMaybe($0[0]) }
}
open func getClientWithId(_ clientID: GUID) -> Deferred<Maybe<RemoteClient?>> {
return self.getClient(guid: clientID)
}
open func getClients() -> Deferred<Maybe<[RemoteClient]>> {
return db.withConnection { connection -> [RemoteClient] in
let cursor = connection.executeQuery("SELECT * FROM \(TableClients) WHERE EXISTS (SELECT 1 FROM \(TableRemoteDevices) rd WHERE rd.guid = fxaDeviceId) ORDER BY modified DESC", factory: SQLiteRemoteClientsAndTabs.remoteClientFactory)
defer {
cursor.close()
}
return cursor.asArray()
}
}
open func getClientGUIDs() -> Deferred<Maybe<Set<GUID>>> {
let c = db.runQuery("SELECT guid FROM \(TableClients) WHERE guid IS NOT NULL", args: nil, factory: { $0["guid"] as! String })
return c >>== { cursor in
let guids = Set<GUID>(cursor.asArray())
return deferMaybe(guids)
}
}
open func getTabsForClientWithGUID(_ guid: GUID?) -> Deferred<Maybe<[RemoteTab]>> {
let tabsSQL: String
let clientArgs: Args?
if let _ = guid {
tabsSQL = "SELECT * FROM \(TableTabs) WHERE client_guid = ?"
clientArgs = [guid]
} else {
tabsSQL = "SELECT * FROM \(TableTabs) WHERE client_guid IS NULL"
clientArgs = nil
}
log.debug("Looking for tabs for client with guid: \(guid ?? "nil")")
return db.runQuery(tabsSQL, args: clientArgs, factory: SQLiteRemoteClientsAndTabs.remoteTabFactory) >>== {
let tabs = $0.asArray()
log.debug("Found \(tabs.count) tabs for client with guid: \(guid ?? "nil")")
return deferMaybe(tabs)
}
}
open func getClientsAndTabs() -> Deferred<Maybe<[ClientAndTabs]>> {
return db.withConnection { conn -> ([RemoteClient], [RemoteTab]) in
let clientsCursor = conn.executeQuery("SELECT * FROM \(TableClients) WHERE EXISTS (SELECT 1 FROM \(TableRemoteDevices) rd WHERE rd.guid = fxaDeviceId) ORDER BY modified DESC", factory: SQLiteRemoteClientsAndTabs.remoteClientFactory)
let tabsCursor = conn.executeQuery("SELECT * FROM \(TableTabs) WHERE client_guid IS NOT NULL ORDER BY client_guid DESC, last_used DESC", factory: SQLiteRemoteClientsAndTabs.remoteTabFactory)
defer {
clientsCursor.close()
tabsCursor.close()
}
return (clientsCursor.asArray(), tabsCursor.asArray())
} >>== { clients, tabs in
var acc = [String: [RemoteTab]]()
for tab in tabs {
if let guid = tab.clientGUID {
if acc[guid] == nil {
acc[guid] = [tab]
} else {
acc[guid]!.append(tab)
}
} else {
log.error("RemoteTab (\(tab)) has a nil clientGUID")
}
}
// Most recent first.
let fillTabs: (RemoteClient) -> ClientAndTabs = { client in
var tabs: [RemoteTab]? = nil
if let guid: String = client.guid {
tabs = acc[guid]
}
return ClientAndTabs(client: client, tabs: tabs ?? [])
}
return deferMaybe(clients.map(fillTabs))
}
}
open func deleteCommands() -> Success {
return db.run("DELETE FROM \(TableSyncCommands)")
}
open func deleteCommands(_ clientGUID: GUID) -> Success {
return db.run("DELETE FROM \(TableSyncCommands) WHERE client_guid = ?", withArgs: [clientGUID] as Args)
}
open func insertCommand(_ command: SyncCommand, forClients clients: [RemoteClient]) -> Deferred<Maybe<Int>> {
return insertCommands([command], forClients: clients)
}
open func insertCommands(_ commands: [SyncCommand], forClients clients: [RemoteClient]) -> Deferred<Maybe<Int>> {
return db.transaction { connection -> Int in
var numberOfInserts = 0
// Update or insert client records.
for command in commands {
for client in clients {
do {
if let commandID = try self.insert(connection, sql: "INSERT INTO \(TableSyncCommands) (client_guid, value) VALUES (?, ?)", args: [client.guid, command.value] as Args) {
log.verbose("Inserted command: \(commandID)")
numberOfInserts += 1
} else {
log.warning("Command not inserted, but no error!")
}
} catch let err as NSError {
log.error("insertCommands(_:, forClients:) failed: \(err.localizedDescription) (numberOfInserts: \(numberOfInserts)")
throw err
}
}
}
return numberOfInserts
}
}
open func getCommands() -> Deferred<Maybe<[GUID: [SyncCommand]]>> {
return db.withConnection { connection -> [GUID: [SyncCommand]] in
let cursor = connection.executeQuery("SELECT * FROM \(TableSyncCommands)", factory: { row -> SyncCommand in
SyncCommand(
id: row["command_id"] as? Int,
value: row["value"] as! String,
clientGUID: row["client_guid"] as? GUID)
})
defer {
cursor.close()
}
return self.clientsFromCommands(cursor.asArray())
}
}
func clientsFromCommands(_ commands: [SyncCommand]) -> [GUID: [SyncCommand]] {
var syncCommands = [GUID: [SyncCommand]]()
for command in commands {
var cmds: [SyncCommand] = syncCommands[command.clientGUID!] ?? [SyncCommand]()
cmds.append(command)
syncCommands[command.clientGUID!] = cmds
}
return syncCommands
}
func insert(_ db: SQLiteDBConnection, sql: String, args: Args?) throws -> Int? {
let lastID = db.lastInsertedRowID
try db.executeChange(sql, withArgs: args)
let id = db.lastInsertedRowID
if id == lastID {
log.debug("INSERT did not change last inserted row ID.")
return nil
}
return id
}
}
extension SQLiteRemoteClientsAndTabs: RemoteDevices {
open func replaceRemoteDevices(_ remoteDevices: [RemoteDevice]) -> Success {
// Drop corrupted records and our own record too.
let remoteDevices = remoteDevices.filter { $0.id != nil && $0.type != nil && !$0.isCurrentDevice }
return db.transaction { conn -> Void in
try conn.executeChange("DELETE FROM \(TableRemoteDevices)")
let now = Date.now()
for device in remoteDevices {
let sql =
"INSERT INTO \(TableRemoteDevices) (guid, name, type, is_current_device, date_created, date_modified, last_access_time) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)"
let args: Args = [device.id, device.name, device.type, device.isCurrentDevice, now, now, device.lastAccessTime]
try conn.executeChange(sql, withArgs: args)
}
}
}
}
extension SQLiteRemoteClientsAndTabs: ResettableSyncStorage {
public func resetClient() -> Success {
// For this engine, resetting is equivalent to wiping.
return self.clear()
}
public func clear() -> Success {
return db.transaction { conn -> Void in
try conn.executeChange("DELETE FROM \(TableTabs) WHERE client_guid IS NOT NULL")
try conn.executeChange("DELETE FROM \(TableClients)")
}
}
}
extension SQLiteRemoteClientsAndTabs: AccountRemovalDelegate {
public func onRemovedAccount() -> Success {
log.info("Clearing clients and tabs after account removal.")
// TODO: Bug 1168690 - delete our client and tabs records from the server.
return self.resetClient()
}
}

View file

@ -0,0 +1,23 @@
/* 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
/**
* Something that knows how to construct a database.
*/
public protocol Schema {
var name: String { get }
var version: Int { get }
func create(_ db: SQLiteDBConnection) -> Bool
func update(_ db: SQLiteDBConnection, from: Int) -> Bool
func drop(_ db: SQLiteDBConnection) -> Bool
}
enum SchemaUpgradeResult {
case success
case failure
case skipped
}

View file

@ -0,0 +1,30 @@
/* 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 UIKit
import Shared
// A small structure to encapsulate all the possible data that we can get
// from an application sharing a web page or a URL.
public struct ShareItem {
public let url: String
public let title: String?
public let favicon: Favicon?
public init(url: String, title: String?, favicon: Favicon?) {
self.url = url
self.title = title
self.favicon = favicon
}
// We only support sharing HTTP and HTTPS URLs, as well as data URIs.
public var isShareable: Bool {
return URL(string: url)?.isWebPage() ?? false
}
}
public protocol ShareToDestination {
func shareItem(_ item: ShareItem) -> Success
}

View file

@ -0,0 +1,95 @@
/* 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 UIKit
import Shared
public protocol Identifiable: Equatable {
var id: Int? { get set }
}
public func ==<T>(lhs: T, rhs: T) -> Bool where T: Identifiable {
return lhs.id == rhs.id
}
public enum IconType: Int {
public func isPreferredTo (_ other: IconType) -> Bool {
return rank > other.rank
}
fileprivate var rank: Int {
switch self {
case .appleIconPrecomposed:
return 5
case .appleIcon:
return 4
case .icon:
return 3
case .local:
return 2
case .guess:
return 1
case .noneFound:
return 0
}
}
case icon = 0
case appleIcon = 1
case appleIconPrecomposed = 2
case guess = 3
case local = 4
case noneFound = 5
}
open class Favicon: Identifiable {
open var id: Int?
open let url: String
open let date: Date
open var width: Int?
open var height: Int?
open let type: IconType
public init(url: String, date: Date = Date(), type: IconType) {
self.url = url
self.date = date
self.type = type
}
}
// TODO: Site shouldn't have all of these optional decorators. Include those in the
// cursor results, perhaps as a tuple.
open class Site: Identifiable {
open var id: Int?
var guid: String?
open var tileURL: URL {
return URL(string: url)?.domainURL ?? URL(string: "about:blank")!
}
open let url: String
open let title: String
open var metadata: PageMetadata?
// Sites may have multiple favicons. We'll return the largest.
open var icon: Favicon?
open var latestVisit: Visit?
open fileprivate(set) var bookmarked: Bool?
public convenience init(url: String, title: String) {
self.init(url: url, title: title, bookmarked: false, guid: nil)
}
public init(url: String, title: String, bookmarked: Bool?, guid: String? = nil) {
self.url = url
self.title = title
self.bookmarked = bookmarked
self.guid = guid
}
open func setBookmarked(_ bookmarked: Bool) {
self.bookmarked = bookmarked
}
}

View file

@ -0,0 +1,10 @@
#ifndef Client_Storage_Bridging_Header_h
#define Client_Storage_Bridging_Header_h
#define SQLITE_HAS_CODEC 1
#import "Shared-Bridging-Header.h"
#import <Foundation/Foundation.h>
#import "ThirdParty/sqlcipher/sqlite3.h"
#endif

View file

@ -0,0 +1,727 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 46;
objects = {
/* Begin PBXBuildFile section */
0B2770451A89276900CE7692 /* BookmarksSqlite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B2770441A89276900CE7692 /* BookmarksSqlite.swift */; };
0B2770471A893C2C00CE7692 /* TestBookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B2770461A893C2C00CE7692 /* TestBookmarks.swift */; };
0B2770481A893C4700CE7692 /* BookmarksSqlite.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B2770441A89276900CE7692 /* BookmarksSqlite.swift */; };
0B2770491A893C9500CE7692 /* Bookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA497801A7B094B004C8E17 /* Bookmarks.swift */; };
0B2770DD1A8AD83200CE7692 /* Bytes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B2770DC1A8AD83200CE7692 /* Bytes.swift */; };
0B2770E91A8AD85000CE7692 /* Bytes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B2770DC1A8AD83200CE7692 /* Bytes.swift */; };
0B30602F1A80338F0085B8BC /* Passwords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B30602E1A80338F0085B8BC /* Passwords.swift */; };
0B3060311A8151B70085B8BC /* SQLitePasswords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3060301A8151B70085B8BC /* SQLitePasswords.swift */; };
0BA4977E1A7B0941004C8E17 /* SQLiteHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA4977A1A7B0941004C8E17 /* SQLiteHistory.swift */; };
0BA497821A7B094B004C8E17 /* Bookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA497801A7B094B004C8E17 /* Bookmarks.swift */; };
0BA497831A7B094B004C8E17 /* Visit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA497811A7B094B004C8E17 /* Visit.swift */; };
0BA4978C1A7B0E44004C8E17 /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282DA4C01A699EF200A406E2 /* Cursor.swift */; };
0BA4978D1A7B0E55004C8E17 /* FileAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282DA4D01A69A14500A406E2 /* FileAccessor.swift */; };
0BAE69561A7E1FD100B3609D /* SchemaTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BAE69551A7E1FD100B3609D /* SchemaTable.swift */; };
0BAE69571A7E209200B3609D /* SchemaTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BAE69551A7E1FD100B3609D /* SchemaTable.swift */; };
0BAE69591A7EEF8E00B3609D /* TestTableTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BAE69581A7EEF8E00B3609D /* TestTableTable.swift */; };
0BCFBBAF1A77650E0087E26D /* GenericTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59B3F1AAD619686DB9450C /* GenericTable.swift */; };
0BCFBBB41A7769A30087E26D /* JoinedHistoryVisitsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59BCB8D38CE6E6F5F5F2DE /* JoinedHistoryVisitsTable.swift */; };
0BCFBBB51A7769B70087E26D /* VisitsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59B22E909B6CAC95DBE879 /* VisitsTable.swift */; };
0BCFBBB81A776A200087E26D /* MockFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BCFBBB71A776A200087E26D /* MockFiles.swift */; };
0BF42D3E1A7C148F00889E28 /* Favicons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BF42D3D1A7C148F00889E28 /* Favicons.swift */; };
0BF42D401A7C18CD00889E28 /* SQLFavicons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BF42D3F1A7C18CD00889E28 /* SQLFavicons.swift */; };
0BF42D421A7C31E300889E28 /* JoinedFaviconsHistoryTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BF42D411A7C31E300889E28 /* JoinedFaviconsHistoryTable.swift */; };
0BF42D441A7C31EE00889E28 /* FaviconsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BF42D431A7C31EE00889E28 /* FaviconsTable.swift */; };
0BF42D461A7C52C800889E28 /* TestFaviconsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BF42D451A7C52C800889E28 /* TestFaviconsTable.swift */; };
0BF42D471A7C558000889E28 /* Visit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA497811A7B094B004C8E17 /* Visit.swift */; };
0BF42D481A7C55D200889E28 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282DA4D61A69A28000A406E2 /* Site.swift */; };
0BF42D4A1A7C55F000889E28 /* FaviconsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BF42D431A7C31EE00889E28 /* FaviconsTable.swift */; };
0BF42D4B1A7C55F300889E28 /* JoinedFaviconsHistoryTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BF42D411A7C31E300889E28 /* JoinedFaviconsHistoryTable.swift */; };
0BF42D4D1A7CC4ED00889E28 /* TestJoinedFaviconTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BF42D4C1A7CC4ED00889E28 /* TestJoinedFaviconTable.swift */; };
282DA49B1A699E0300A406E2 /* Storage.h in Headers */ = {isa = PBXBuildFile; fileRef = 282DA49A1A699E0300A406E2 /* Storage.h */; settings = {ATTRIBUTES = (Public, ); }; };
282DA4A11A699E0300A406E2 /* Storage.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 282DA4951A699E0300A406E2 /* Storage.framework */; };
282DA4A81A699E0300A406E2 /* StorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282DA4A71A699E0300A406E2 /* StorageTests.swift */; };
282DA4C21A699EF200A406E2 /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282DA4C01A699EF200A406E2 /* Cursor.swift */; };
282DA4C51A699F8A00A406E2 /* SwiftData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282DA4C41A699F8A00A406E2 /* SwiftData.swift */; };
282DA4C61A699F8A00A406E2 /* SwiftData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282DA4C41A699F8A00A406E2 /* SwiftData.swift */; };
282DA4D21A69A14500A406E2 /* FileAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282DA4D01A69A14500A406E2 /* FileAccessor.swift */; };
282DA4D41A69A24300A406E2 /* History.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282DA4D31A69A24300A406E2 /* History.swift */; };
282DA4D71A69A28000A406E2 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282DA4D61A69A28000A406E2 /* Site.swift */; };
282DA5211A69BCB700A406E2 /* module.modulemap in Sources */ = {isa = PBXBuildFile; fileRef = 282DA51F1A69BCB700A406E2 /* module.modulemap */; };
282DA5241A69BCBB00A406E2 /* libsqlite3.0.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 282DA5231A69BCBB00A406E2 /* libsqlite3.0.dylib */; };
4A59B21913DCFA81C7CD9C91 /* GenericTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59B3F1AAD619686DB9450C /* GenericTable.swift */; };
4A59B334A243792894D6E614 /* TestHistoryTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59B6E9AB0A08F28227C050 /* TestHistoryTable.swift */; };
4A59BA1E93D5603E198EDCB9 /* HistoryTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59BB07A2812FE4B516FD30 /* HistoryTable.swift */; };
4A59BAEB154EA6E1B621E067 /* BrowserDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59BF61F0D956D2A1BDAE29 /* BrowserDB.swift */; };
4A59BB4F36A20F30229034C6 /* VisitsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59B22E909B6CAC95DBE879 /* VisitsTable.swift */; };
4A59BC6033BD93D1DC36E18F /* TestJoinedHistoryVisits.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59B33FF96A39A59CBBBBD7 /* TestJoinedHistoryVisits.swift */; };
4A59BD24F6210866F59EF0F6 /* JoinedHistoryVisitsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59BCB8D38CE6E6F5F5F2DE /* JoinedHistoryVisitsTable.swift */; };
4A59BF0BBF50FC218A6D1143 /* BrowserDB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59BF61F0D956D2A1BDAE29 /* BrowserDB.swift */; };
4A59BF2F408CDEF8D50AF9D3 /* TestVisitsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59BE48DBF43989C51D3F98 /* TestVisitsTable.swift */; };
4A59BFF7C37812C7955B9E79 /* HistoryTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A59BB07A2812FE4B516FD30 /* HistoryTable.swift */; };
E47726181A8A794B00FC058B /* ReadingList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47726171A8A794B00FC058B /* ReadingList.swift */; };
E47726191A8A794B00FC058B /* ReadingList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47726171A8A794B00FC058B /* ReadingList.swift */; };
E477261B1A8A7DE500FC058B /* SQLiteReadingList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E477261A1A8A7DE500FC058B /* SQLiteReadingList.swift */; };
E477261C1A8A7DE500FC058B /* SQLiteReadingList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E477261A1A8A7DE500FC058B /* SQLiteReadingList.swift */; };
E477261E1A8A7EDA00FC058B /* ReadingListTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E477261D1A8A7EDA00FC058B /* ReadingListTable.swift */; };
E477261F1A8A7EDA00FC058B /* ReadingListTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E477261D1A8A7EDA00FC058B /* ReadingListTable.swift */; };
E47726211A8A87F400FC058B /* TestReadingListTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E47726201A8A87F400FC058B /* TestReadingListTable.swift */; };
E4F43AC21A8CF9A200ACAC05 /* TestSQLiteReadingList.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4F43AC11A8CF9A200ACAC05 /* TestSQLiteReadingList.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
282DA4A21A699E0300A406E2 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 282DA48C1A699E0300A406E2 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 282DA4941A699E0300A406E2;
remoteInfo = Storage;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
0B2770441A89276900CE7692 /* BookmarksSqlite.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BookmarksSqlite.swift; sourceTree = "<group>"; };
0B2770461A893C2C00CE7692 /* TestBookmarks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestBookmarks.swift; sourceTree = "<group>"; };
0B2770DC1A8AD83200CE7692 /* Bytes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = Bytes.swift; path = ../../Sync/Bytes.swift; sourceTree = "<group>"; };
0B30602E1A80338F0085B8BC /* Passwords.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Passwords.swift; sourceTree = "<group>"; };
0B3060301A8151B70085B8BC /* SQLitePasswords.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SQLitePasswords.swift; sourceTree = "<group>"; };
0BA4977A1A7B0941004C8E17 /* SQLiteHistory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SQLiteHistory.swift; path = SQL/SQLiteHistory.swift; sourceTree = "<group>"; };
0BA497801A7B094B004C8E17 /* Bookmarks.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Bookmarks.swift; sourceTree = "<group>"; };
0BA497811A7B094B004C8E17 /* Visit.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Visit.swift; sourceTree = "<group>"; };
0BAE69551A7E1FD100B3609D /* SchemaTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SchemaTable.swift; sourceTree = "<group>"; };
0BAE69581A7EEF8E00B3609D /* TestTableTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestTableTable.swift; sourceTree = "<group>"; };
0BCFBBB71A776A200087E26D /* MockFiles.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockFiles.swift; sourceTree = "<group>"; };
0BF42D3D1A7C148F00889E28 /* Favicons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Favicons.swift; sourceTree = "<group>"; };
0BF42D3F1A7C18CD00889E28 /* SQLFavicons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SQLFavicons.swift; sourceTree = "<group>"; };
0BF42D411A7C31E300889E28 /* JoinedFaviconsHistoryTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JoinedFaviconsHistoryTable.swift; sourceTree = "<group>"; };
0BF42D431A7C31EE00889E28 /* FaviconsTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FaviconsTable.swift; sourceTree = "<group>"; };
0BF42D451A7C52C800889E28 /* TestFaviconsTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestFaviconsTable.swift; sourceTree = "<group>"; };
0BF42D4C1A7CC4ED00889E28 /* TestJoinedFaviconTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestJoinedFaviconTable.swift; sourceTree = "<group>"; };
282DA4951A699E0300A406E2 /* Storage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Storage.framework; sourceTree = BUILT_PRODUCTS_DIR; };
282DA4991A699E0300A406E2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
282DA49A1A699E0300A406E2 /* Storage.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Storage.h; sourceTree = "<group>"; };
282DA4A01A699E0300A406E2 /* StorageTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StorageTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
282DA4A61A699E0300A406E2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
282DA4A71A699E0300A406E2 /* StorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageTests.swift; sourceTree = "<group>"; };
282DA4C01A699EF200A406E2 /* Cursor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = "<group>"; };
282DA4C41A699F8A00A406E2 /* SwiftData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SwiftData.swift; path = ThirdParty/SwiftData.swift; sourceTree = "<group>"; };
282DA4D01A69A14500A406E2 /* FileAccessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileAccessor.swift; sourceTree = "<group>"; };
282DA4D31A69A24300A406E2 /* History.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = History.swift; sourceTree = "<group>"; };
282DA4D61A69A28000A406E2 /* Site.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = "<group>"; };
282DA51F1A69BCB700A406E2 /* module.modulemap */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = "<group>"; };
282DA5231A69BCBB00A406E2 /* libsqlite3.0.dylib */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.dylib"; name = libsqlite3.0.dylib; path = /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS8.1.sdk/usr/lib/libsqlite3.0.dylib; sourceTree = "<absolute>"; };
4A59B22E909B6CAC95DBE879 /* VisitsTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = VisitsTable.swift; path = SQL/VisitsTable.swift; sourceTree = "<group>"; };
4A59B33FF96A39A59CBBBBD7 /* TestJoinedHistoryVisits.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestJoinedHistoryVisits.swift; sourceTree = "<group>"; };
4A59B3F1AAD619686DB9450C /* GenericTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = GenericTable.swift; path = SQL/GenericTable.swift; sourceTree = "<group>"; };
4A59B6E9AB0A08F28227C050 /* TestHistoryTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestHistoryTable.swift; sourceTree = "<group>"; };
4A59BB07A2812FE4B516FD30 /* HistoryTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = HistoryTable.swift; path = SQL/HistoryTable.swift; sourceTree = "<group>"; };
4A59BCB8D38CE6E6F5F5F2DE /* JoinedHistoryVisitsTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = JoinedHistoryVisitsTable.swift; path = SQL/JoinedHistoryVisitsTable.swift; sourceTree = "<group>"; };
4A59BE48DBF43989C51D3F98 /* TestVisitsTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestVisitsTable.swift; sourceTree = "<group>"; };
4A59BF61F0D956D2A1BDAE29 /* BrowserDB.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = BrowserDB.swift; path = SQL/BrowserDB.swift; sourceTree = "<group>"; };
E47726171A8A794B00FC058B /* ReadingList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReadingList.swift; sourceTree = "<group>"; };
E477261A1A8A7DE500FC058B /* SQLiteReadingList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SQLiteReadingList.swift; path = SQL/SQLiteReadingList.swift; sourceTree = "<group>"; };
E477261D1A8A7EDA00FC058B /* ReadingListTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReadingListTable.swift; sourceTree = "<group>"; };
E47726201A8A87F400FC058B /* TestReadingListTable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestReadingListTable.swift; sourceTree = "<group>"; };
E4F43AC11A8CF9A200ACAC05 /* TestSQLiteReadingList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestSQLiteReadingList.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
282DA4911A699E0300A406E2 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
282DA5241A69BCBB00A406E2 /* libsqlite3.0.dylib in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
282DA49D1A699E0300A406E2 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
282DA4A11A699E0300A406E2 /* Storage.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
282DA48B1A699E0300A406E2 = {
isa = PBXGroup;
children = (
282DA5231A69BCBB00A406E2 /* libsqlite3.0.dylib */,
282DA5201A69BCB700A406E2 /* modules */,
282DA4971A699E0300A406E2 /* Storage */,
282DA4C31A699F5600A406E2 /* Third-Party Source */,
282DA4A41A699E0300A406E2 /* StorageTests */,
282DA4961A699E0300A406E2 /* Products */,
);
sourceTree = "<group>";
};
282DA4961A699E0300A406E2 /* Products */ = {
isa = PBXGroup;
children = (
282DA4951A699E0300A406E2 /* Storage.framework */,
282DA4A01A699E0300A406E2 /* StorageTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
282DA4971A699E0300A406E2 /* Storage */ = {
isa = PBXGroup;
children = (
0B2770DC1A8AD83200CE7692 /* Bytes.swift */,
0BA497801A7B094B004C8E17 /* Bookmarks.swift */,
E47726171A8A794B00FC058B /* ReadingList.swift */,
0BA497811A7B094B004C8E17 /* Visit.swift */,
282DA4D91A69A2C400A406E2 /* SQL */,
282DA4D01A69A14500A406E2 /* FileAccessor.swift */,
282DA4D61A69A28000A406E2 /* Site.swift */,
282DA4D31A69A24300A406E2 /* History.swift */,
282DA4C01A699EF200A406E2 /* Cursor.swift */,
282DA49A1A699E0300A406E2 /* Storage.h */,
282DA4981A699E0300A406E2 /* Supporting Files */,
0BF42D3D1A7C148F00889E28 /* Favicons.swift */,
0B30602E1A80338F0085B8BC /* Passwords.swift */,
);
path = Storage;
sourceTree = "<group>";
};
282DA4981A699E0300A406E2 /* Supporting Files */ = {
isa = PBXGroup;
children = (
282DA4991A699E0300A406E2 /* Info.plist */,
);
name = "Supporting Files";
sourceTree = "<group>";
};
282DA4A41A699E0300A406E2 /* StorageTests */ = {
isa = PBXGroup;
children = (
282DA4A71A699E0300A406E2 /* StorageTests.swift */,
282DA4A51A699E0300A406E2 /* Supporting Files */,
4A59B6E9AB0A08F28227C050 /* TestHistoryTable.swift */,
0BF42D451A7C52C800889E28 /* TestFaviconsTable.swift */,
4A59B33FF96A39A59CBBBBD7 /* TestJoinedHistoryVisits.swift */,
0BF42D4C1A7CC4ED00889E28 /* TestJoinedFaviconTable.swift */,
4A59BE48DBF43989C51D3F98 /* TestVisitsTable.swift */,
0BCFBBB71A776A200087E26D /* MockFiles.swift */,
0BAE69581A7EEF8E00B3609D /* TestTableTable.swift */,
0B2770461A893C2C00CE7692 /* TestBookmarks.swift */,
E47726201A8A87F400FC058B /* TestReadingListTable.swift */,
E4F43AC11A8CF9A200ACAC05 /* TestSQLiteReadingList.swift */,
);
path = StorageTests;
sourceTree = "<group>";
};
282DA4A51A699E0300A406E2 /* Supporting Files */ = {
isa = PBXGroup;
children = (
282DA4A61A699E0300A406E2 /* Info.plist */,
);
name = "Supporting Files";
sourceTree = "<group>";
};
282DA4C31A699F5600A406E2 /* Third-Party Source */ = {
isa = PBXGroup;
children = (
282DA4C41A699F8A00A406E2 /* SwiftData.swift */,
);
name = "Third-Party Source";
sourceTree = "<group>";
};
282DA4D91A69A2C400A406E2 /* SQL */ = {
isa = PBXGroup;
children = (
0B2770441A89276900CE7692 /* BookmarksSqlite.swift */,
4A59BF61F0D956D2A1BDAE29 /* BrowserDB.swift */,
0BF42D431A7C31EE00889E28 /* FaviconsTable.swift */,
4A59B3F1AAD619686DB9450C /* GenericTable.swift */,
4A59BB07A2812FE4B516FD30 /* HistoryTable.swift */,
0BF42D411A7C31E300889E28 /* JoinedFaviconsHistoryTable.swift */,
4A59BCB8D38CE6E6F5F5F2DE /* JoinedHistoryVisitsTable.swift */,
E477261D1A8A7EDA00FC058B /* ReadingListTable.swift */,
0BAE69551A7E1FD100B3609D /* SchemaTable.swift */,
0BF42D3F1A7C18CD00889E28 /* SQLFavicons.swift */,
0BA4977A1A7B0941004C8E17 /* SQLiteHistory.swift */,
0B3060301A8151B70085B8BC /* SQLitePasswords.swift */,
E477261A1A8A7DE500FC058B /* SQLiteReadingList.swift */,
4A59B22E909B6CAC95DBE879 /* VisitsTable.swift */,
);
name = SQL;
sourceTree = "<group>";
};
282DA5201A69BCB700A406E2 /* modules */ = {
isa = PBXGroup;
children = (
282DA51F1A69BCB700A406E2 /* module.modulemap */,
);
path = modules;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXHeadersBuildPhase section */
282DA4921A699E0300A406E2 /* Headers */ = {
isa = PBXHeadersBuildPhase;
buildActionMask = 2147483647;
files = (
282DA49B1A699E0300A406E2 /* Storage.h in Headers */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXHeadersBuildPhase section */
/* Begin PBXNativeTarget section */
282DA4941A699E0300A406E2 /* Storage */ = {
isa = PBXNativeTarget;
buildConfigurationList = 282DA4AB1A699E0300A406E2 /* Build configuration list for PBXNativeTarget "Storage" */;
buildPhases = (
282DA4901A699E0300A406E2 /* Sources */,
282DA4911A699E0300A406E2 /* Frameworks */,
282DA4921A699E0300A406E2 /* Headers */,
282DA4931A699E0300A406E2 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Storage;
productName = Storage;
productReference = 282DA4951A699E0300A406E2 /* Storage.framework */;
productType = "com.apple.product-type.framework";
};
282DA49F1A699E0300A406E2 /* StorageTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 282DA4AE1A699E0300A406E2 /* Build configuration list for PBXNativeTarget "StorageTests" */;
buildPhases = (
282DA49C1A699E0300A406E2 /* Sources */,
282DA49D1A699E0300A406E2 /* Frameworks */,
282DA49E1A699E0300A406E2 /* Resources */,
);
buildRules = (
);
dependencies = (
282DA4A31A699E0300A406E2 /* PBXTargetDependency */,
);
name = StorageTests;
productName = StorageTests;
productReference = 282DA4A01A699E0300A406E2 /* StorageTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
282DA48C1A699E0300A406E2 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 0610;
ORGANIZATIONNAME = Mozilla;
TargetAttributes = {
282DA4941A699E0300A406E2 = {
CreatedOnToolsVersion = 6.1.1;
};
282DA49F1A699E0300A406E2 = {
CreatedOnToolsVersion = 6.1.1;
};
};
};
buildConfigurationList = 282DA48F1A699E0300A406E2 /* Build configuration list for PBXProject "Storage" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = English;
hasScannedForEncodings = 0;
knownRegions = (
en,
);
mainGroup = 282DA48B1A699E0300A406E2;
productRefGroup = 282DA4961A699E0300A406E2 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
282DA4941A699E0300A406E2 /* Storage */,
282DA49F1A699E0300A406E2 /* StorageTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
282DA4931A699E0300A406E2 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
282DA49E1A699E0300A406E2 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
282DA4901A699E0300A406E2 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
0B30602F1A80338F0085B8BC /* Passwords.swift in Sources */,
0BA497821A7B094B004C8E17 /* Bookmarks.swift in Sources */,
282DA4C51A699F8A00A406E2 /* SwiftData.swift in Sources */,
E47726181A8A794B00FC058B /* ReadingList.swift in Sources */,
E477261E1A8A7EDA00FC058B /* ReadingListTable.swift in Sources */,
0BA4977E1A7B0941004C8E17 /* SQLiteHistory.swift in Sources */,
282DA5211A69BCB700A406E2 /* module.modulemap in Sources */,
0BA4978C1A7B0E44004C8E17 /* Cursor.swift in Sources */,
0BF42D401A7C18CD00889E28 /* SQLFavicons.swift in Sources */,
0B2770451A89276900CE7692 /* BookmarksSqlite.swift in Sources */,
282DA4D71A69A28000A406E2 /* Site.swift in Sources */,
0B2770DD1A8AD83200CE7692 /* Bytes.swift in Sources */,
282DA4D41A69A24300A406E2 /* History.swift in Sources */,
0BA497831A7B094B004C8E17 /* Visit.swift in Sources */,
0BAE69561A7E1FD100B3609D /* SchemaTable.swift in Sources */,
4A59BB4F36A20F30229034C6 /* VisitsTable.swift in Sources */,
0BF42D421A7C31E300889E28 /* JoinedFaviconsHistoryTable.swift in Sources */,
0BF42D441A7C31EE00889E28 /* FaviconsTable.swift in Sources */,
E477261B1A8A7DE500FC058B /* SQLiteReadingList.swift in Sources */,
4A59B21913DCFA81C7CD9C91 /* GenericTable.swift in Sources */,
0BF42D3E1A7C148F00889E28 /* Favicons.swift in Sources */,
0BA4978D1A7B0E55004C8E17 /* FileAccessor.swift in Sources */,
4A59BD24F6210866F59EF0F6 /* JoinedHistoryVisitsTable.swift in Sources */,
0B3060311A8151B70085B8BC /* SQLitePasswords.swift in Sources */,
4A59BFF7C37812C7955B9E79 /* HistoryTable.swift in Sources */,
4A59BAEB154EA6E1B621E067 /* BrowserDB.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
282DA49C1A699E0300A406E2 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
282DA4D21A69A14500A406E2 /* FileAccessor.swift in Sources */,
0BCFBBB81A776A200087E26D /* MockFiles.swift in Sources */,
0BF42D4B1A7C55F300889E28 /* JoinedFaviconsHistoryTable.swift in Sources */,
E4F43AC21A8CF9A200ACAC05 /* TestSQLiteReadingList.swift in Sources */,
0BCFBBB51A7769B70087E26D /* VisitsTable.swift in Sources */,
282DA4A81A699E0300A406E2 /* StorageTests.swift in Sources */,
0BCFBBB41A7769A30087E26D /* JoinedHistoryVisitsTable.swift in Sources */,
0BF42D461A7C52C800889E28 /* TestFaviconsTable.swift in Sources */,
0BCFBBAF1A77650E0087E26D /* GenericTable.swift in Sources */,
E477261C1A8A7DE500FC058B /* SQLiteReadingList.swift in Sources */,
282DA4C61A699F8A00A406E2 /* SwiftData.swift in Sources */,
0B2770481A893C4700CE7692 /* BookmarksSqlite.swift in Sources */,
0B2770471A893C2C00CE7692 /* TestBookmarks.swift in Sources */,
0BF42D471A7C558000889E28 /* Visit.swift in Sources */,
E47726191A8A794B00FC058B /* ReadingList.swift in Sources */,
E47726211A8A87F400FC058B /* TestReadingListTable.swift in Sources */,
282DA4C21A699EF200A406E2 /* Cursor.swift in Sources */,
0B2770491A893C9500CE7692 /* Bookmarks.swift in Sources */,
0BF42D481A7C55D200889E28 /* Site.swift in Sources */,
0B2770E91A8AD85000CE7692 /* Bytes.swift in Sources */,
4A59BA1E93D5603E198EDCB9 /* HistoryTable.swift in Sources */,
0BAE69571A7E209200B3609D /* SchemaTable.swift in Sources */,
4A59BF0BBF50FC218A6D1143 /* BrowserDB.swift in Sources */,
E477261F1A8A7EDA00FC058B /* ReadingListTable.swift in Sources */,
4A59B334A243792894D6E614 /* TestHistoryTable.swift in Sources */,
4A59BC6033BD93D1DC36E18F /* TestJoinedHistoryVisits.swift in Sources */,
0BF42D4D1A7CC4ED00889E28 /* TestJoinedFaviconTable.swift in Sources */,
0BF42D4A1A7C55F000889E28 /* FaviconsTable.swift in Sources */,
4A59BF2F408CDEF8D50AF9D3 /* TestVisitsTable.swift in Sources */,
0BAE69591A7EEF8E00B3609D /* TestTableTable.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
282DA4A31A699E0300A406E2 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 282DA4941A699E0300A406E2 /* Storage */;
targetProxy = 282DA4A21A699E0300A406E2 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
282DA4A91A699E0300A406E2 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.1;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/modules";
SWIFT_OBJC_BRIDGING_HEADER = "";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
VERSION_INFO_PREFIX = "";
};
name = Debug;
};
282DA4AA1A699E0300A406E2 /* FennecAurora */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_IDENTITY = "iPhone Distribution";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";
CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)";
COPY_PHASE_STRIP = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.1;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/modules";
SWIFT_OBJC_BRIDGING_HEADER = "";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
VERSIONING_SYSTEM = "apple-generic";
VERSION_INFO_PREFIX = "";
};
name = FennecAurora;
};
282DA4AC1A699E0300A406E2 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEFINES_MODULE = YES;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"/Users/rnewman/moz/git/firefox-ios/SQLite/build/Debug-iphoneos",
"$(USER_LIBRARY_DIR)/Developer/Xcode/DerivedData/Client-fhgufntxvuxtwfbmkckrmnagytst/Build/Products/Debug-iphoneos",
);
INFOPLIST_FILE = Storage/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/modules";
};
name = Debug;
};
282DA4AD1A699E0300A406E2 /* FennecAurora */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES;
DEFINES_MODULE = YES;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"/Users/rnewman/moz/git/firefox-ios/SQLite/build/Debug-iphoneos",
"$(USER_LIBRARY_DIR)/Developer/Xcode/DerivedData/Client-fhgufntxvuxtwfbmkckrmnagytst/Build/Products/Debug-iphoneos",
);
INFOPLIST_FILE = Storage/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/modules";
};
name = FennecAurora;
};
282DA4AF1A699E0300A406E2 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
FRAMEWORK_SEARCH_PATHS = (
"$(SDKROOT)/Developer/Library/Frameworks",
"$(inherited)",
);
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
INFOPLIST_FILE = StorageTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = Debug;
};
282DA4B01A699E0300A406E2 /* FennecAurora */ = {
isa = XCBuildConfiguration;
buildSettings = {
FRAMEWORK_SEARCH_PATHS = (
"$(SDKROOT)/Developer/Library/Frameworks",
"$(inherited)",
);
INFOPLIST_FILE = StorageTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = FennecAurora;
};
E4D438F51A8D3197003FCF55 /* FennecNightly */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CONFIGURATION_BUILD_DIR = "$(BUILD_DIR)/Release$(EFFECTIVE_PLATFORM_NAME)";
COPY_PHASE_STRIP = YES;
CURRENT_PROJECT_VERSION = 1;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 8.1;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/modules";
SWIFT_OBJC_BRIDGING_HEADER = "";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
VERSIONING_SYSTEM = "apple-generic";
VERSION_INFO_PREFIX = "";
};
name = FennecNightly;
};
E4D438F61A8D3197003FCF55 /* FennecNightly */ = {
isa = XCBuildConfiguration;
buildSettings = {
APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEFINES_MODULE = YES;
DYLIB_COMPATIBILITY_VERSION = 1;
DYLIB_CURRENT_VERSION = 1;
DYLIB_INSTALL_NAME_BASE = "@rpath";
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"/Users/rnewman/moz/git/firefox-ios/SQLite/build/Debug-iphoneos",
"$(USER_LIBRARY_DIR)/Developer/Xcode/DerivedData/Client-fhgufntxvuxtwfbmkckrmnagytst/Build/Products/Debug-iphoneos",
);
INFOPLIST_FILE = Storage/Info.plist;
INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks";
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/modules";
};
name = FennecNightly;
};
E4D438F71A8D3197003FCF55 /* FennecNightly */ = {
isa = XCBuildConfiguration;
buildSettings = {
FRAMEWORK_SEARCH_PATHS = (
"$(SDKROOT)/Developer/Library/Frameworks",
"$(inherited)",
);
INFOPLIST_FILE = StorageTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks";
PRODUCT_NAME = "$(TARGET_NAME)";
};
name = FennecNightly;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
282DA48F1A699E0300A406E2 /* Build configuration list for PBXProject "Storage" */ = {
isa = XCConfigurationList;
buildConfigurations = (
282DA4A91A699E0300A406E2 /* Debug */,
282DA4AA1A699E0300A406E2 /* FennecAurora */,
E4D438F51A8D3197003FCF55 /* FennecNightly */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = FennecAurora;
};
282DA4AB1A699E0300A406E2 /* Build configuration list for PBXNativeTarget "Storage" */ = {
isa = XCConfigurationList;
buildConfigurations = (
282DA4AC1A699E0300A406E2 /* Debug */,
282DA4AD1A699E0300A406E2 /* FennecAurora */,
E4D438F61A8D3197003FCF55 /* FennecNightly */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = FennecAurora;
};
282DA4AE1A699E0300A406E2 /* Build configuration list for PBXNativeTarget "StorageTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
282DA4AF1A699E0300A406E2 /* Debug */,
282DA4B01A699E0300A406E2 /* FennecAurora */,
E4D438F71A8D3197003FCF55 /* FennecNightly */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = FennecAurora;
};
/* End XCConfigurationList section */
};
rootObject = 282DA48C1A699E0300A406E2 /* Project object */;
}

View file

@ -0,0 +1,46 @@
/* 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 UIKit
import Shared
open class SuggestedSite: Site {
override open var tileURL: URL {
return URL(string: url as String) ?? URL(string: "about:blank")!
}
let trackingId: Int
init(data: SuggestedSiteData) {
self.trackingId = data.trackingId
super.init(url: data.url, title: data.title, bookmarked: nil)
self.guid = "default" + data.title // A guid is required in the case the site might become a pinned site
}
}
public let SuggestedSites: SuggestedSitesCursor = SuggestedSitesCursor()
open class SuggestedSitesCursor: ArrayCursor<SuggestedSite> {
fileprivate init() {
let locale = Locale.current
let sites = DefaultSuggestedSites.sites[locale.identifier] ??
DefaultSuggestedSites.sites["default"]! as Array<SuggestedSiteData>
let tiles = sites.map({ data -> SuggestedSite in
var site = data
if let domainMap = DefaultSuggestedSites.urlMap[data.url], let localizedURL = domainMap[locale.identifier] {
site.url = localizedURL
}
return SuggestedSite(data: site)
})
super.init(data: tiles, status: .success, statusMessage: "Loaded")
}
}
public struct SuggestedSiteData {
var url: String
var bgColor: String
var imageUrl: String
var faviconUrl: String
var trackingId: Int
var title: String
}

View file

@ -0,0 +1,65 @@
/* 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 Shared
import Deferred
import SwiftyJSON
public struct SyncCommand: Equatable {
public let value: String
public var commandID: Int?
public var clientGUID: GUID?
let version: String?
public init(value: String) {
self.value = value
self.version = nil
self.commandID = nil
self.clientGUID = nil
}
public init(id: Int, value: String) {
self.value = value
self.version = nil
self.commandID = id
self.clientGUID = nil
}
public init(id: Int?, value: String, clientGUID: GUID?) {
self.value = value
self.version = nil
self.clientGUID = clientGUID
self.commandID = id
}
/**
* Sent displayURI commands include the sender client GUID.
*/
public static func displayURIFromShareItem(_ shareItem: ShareItem, asClient sender: GUID) -> SyncCommand {
let jsonObj: [String: Any] = [
"command": "displayURI",
"args": [shareItem.url, sender, shareItem.title ?? ""]
]
return SyncCommand(value: JSON(object: jsonObj).stringValue()!)
}
public func withClientGUID(_ clientGUID: String?) -> SyncCommand {
return SyncCommand(id: self.commandID, value: self.value, clientGUID: clientGUID)
}
}
public func ==(lhs: SyncCommand, rhs: SyncCommand) -> Bool {
return lhs.value == rhs.value
}
public protocol SyncCommands {
func deleteCommands() -> Success
func deleteCommands(_ clientGUID: GUID) -> Success
func getCommands() -> Deferred<Maybe<[GUID: [SyncCommand]]>>
func insertCommand(_ command: SyncCommand, forClients clients: [RemoteClient]) -> Deferred<Maybe<Int>>
func insertCommands(_ commands: [SyncCommand], forClients clients: [RemoteClient]) -> Deferred<Maybe<Int>>
}

View file

@ -0,0 +1,24 @@
/* 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 Shared
/**
* This exists to allow resets after meta/global or crypto/key changes.
*
* 'Reset' in this case means that timestamps and progress tracking are
* discarded: this storage is reconfigured such that all data will be
* reuploaded, and all data will be re-merged as necessary.
*
* This protocol is primarily consumed by `ResettableSynchronizer`, and
* is invoked when a significant server change is observed changed keys,
* changed engine elections or syncIDs, or a node reassignment.
*/
public protocol ResettableSyncStorage {
func resetClient() -> Success
}
public protocol AccountRemovalDelegate {
func onRemovedAccount() -> Success
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,121 @@
/* 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 SwiftyJSON
// These are taken from the Places docs
// http://mxr.mozilla.org/mozilla-central/source/toolkit/components/places/nsINavHistoryService.idl#1187
@objc public enum VisitType: Int {
case unknown = 0
/**
* This transition type means the user followed a link and got a new toplevel
* window.
*/
case link = 1
/**
* This transition type means that the user typed the page's URL in the
* URL bar or selected it from URL bar autocomplete results, clicked on
* it from a history query (from the History sidebar, History menu,
* or history query in the personal toolbar or Places organizer).
*/
case typed = 2
case bookmark = 3
case embed = 4
case permanentRedirect = 5
case temporaryRedirect = 6
case download = 7
case framedLink = 8
}
// WKWebView has these:
/*
WKNavigationTypeLinkActivated,
WKNavigationTypeFormSubmitted,
WKNavigationTypeBackForward,
WKNavigationTypeReload,
WKNavigationTypeFormResubmitted,
WKNavigationTypeOther = -1,
*/
/**
* SiteVisit is a sop to the existing API, which expects to be able to go
* backwards from a visit to a site, and preserve the ID of the database row.
* Visit is the model of what lives on the wire: just a date and a type.
* Ultimately we'll end up with something similar to ClientAndTabs: the tabs
* don't need to know about the client, and visits don't need to know about
* the site, because they're bound together.
*
* (Furthermore, we probably shouldn't ever need something like SiteVisit
* to reach the UI: we care about "last visited", "visit count", or just
* "places ordered by frecency" we don't care about lists of visits.)
*/
open class Visit: Hashable {
open let date: MicrosecondTimestamp
open let type: VisitType
open var hashValue: Int {
return date.hashValue ^ type.hashValue
}
public init(date: MicrosecondTimestamp, type: VisitType = .unknown) {
self.date = date
self.type = type
}
open class func fromJSON(_ json: [String: Any]) -> Visit? {
if let type = json["type"] as? Int,
let typeEnum = VisitType(rawValue: type),
let date = json["date"] as? Int64, date >= 0 {
return Visit(date: MicrosecondTimestamp(date), type: typeEnum)
}
return nil
}
open func toJSON() -> [String: Any] {
let d = NSNumber(value: self.date)
let o: [String: Any] = ["type": self.type.rawValue, "date": d]
return o
}
}
public func ==(lhs: Visit, rhs: Visit) -> Bool {
return lhs.date == rhs.date &&
lhs.type == rhs.type
}
open class SiteVisit: Visit {
var id: Int?
open let site: Site
open override var hashValue: Int {
return date.hashValue ^ type.hashValue ^ (id?.hashValue ?? 0) ^ (site.id ?? 0)
}
public init(site: Site, date: MicrosecondTimestamp, type: VisitType = .unknown) {
self.site = site
super.init(date: date, type: type)
}
}
public func ==(lhs: SiteVisit, rhs: SiteVisit) -> Bool {
if let lhsID = lhs.id, let rhsID = rhs.id {
if lhsID != rhsID {
return false
}
} else {
if lhs.id != nil || rhs.id != nil {
return false
}
}
// TODO: compare Site.
return lhs.date == rhs.date &&
lhs.type == rhs.type
}