mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-09-08 16:58:38 +09:00
Dactyloidae iOS initial commit
This commit is contained in:
parent
daa6179d22
commit
7154a0497e
2123 changed files with 197052 additions and 0 deletions
529
mobile/ios/Storage/Bookmarks/Bookmarks.swift
Normal file
529
mobile/ios/Storage/Bookmarks/Bookmarks.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
417
mobile/ios/Storage/Bookmarks/BookmarksModel.swift
Normal file
417
mobile/ios/Storage/Bookmarks/BookmarksModel.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
192
mobile/ios/Storage/Bookmarks/CachingItemSource.swift
Normal file
192
mobile/ios/Storage/Bookmarks/CachingItemSource.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
319
mobile/ios/Storage/Bookmarks/Trees.swift
Normal file
319
mobile/ios/Storage/Bookmarks/Trees.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
31
mobile/ios/Storage/CertStore.swift
Normal file
31
mobile/ios/Storage/CertStore.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
75
mobile/ios/Storage/Clients.swift
Normal file
75
mobile/ios/Storage/Clients.swift
Normal 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")>"
|
||||
}
|
||||
}
|
||||
89
mobile/ios/Storage/CompletionOps.swift
Normal file
89
mobile/ios/Storage/CompletionOps.swift
Normal 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
|
||||
}
|
||||
}
|
||||
124
mobile/ios/Storage/Cursor.swift
Normal file
124
mobile/ios/Storage/Cursor.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
24
mobile/ios/Storage/DatabaseError.swift
Normal file
24
mobile/ios/Storage/DatabaseError.swift
Normal 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
|
||||
}
|
||||
}
|
||||
130
mobile/ios/Storage/DefaultSuggestedSites.swift
Normal file
130
mobile/ios/Storage/DefaultSuggestedSites.swift
Normal 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: "京东"
|
||||
)
|
||||
]
|
||||
]
|
||||
}
|
||||
105
mobile/ios/Storage/DiskImageStore.swift
Normal file
105
mobile/ios/Storage/DiskImageStore.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
47
mobile/ios/Storage/ExtensionUtils.swift
Normal file
47
mobile/ios/Storage/ExtensionUtils.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
22
mobile/ios/Storage/Favicons.swift
Normal file
22
mobile/ios/Storage/Favicons.swift
Normal 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>>
|
||||
}
|
||||
117
mobile/ios/Storage/FileAccessor.swift
Normal file
117
mobile/ios/Storage/FileAccessor.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
108
mobile/ios/Storage/History.swift
Normal file
108
mobile/ios/Storage/History.swift
Normal 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
|
||||
}
|
||||
}
|
||||
26
mobile/ios/Storage/Info.plist
Normal file
26
mobile/ios/Storage/Info.plist
Normal 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>
|
||||
621
mobile/ios/Storage/Logins.swift
Normal file
621
mobile/ios/Storage/Logins.swift
Normal 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
|
||||
}
|
||||
}
|
||||
13
mobile/ios/Storage/Metadata.swift
Normal file
13
mobile/ios/Storage/Metadata.swift
Normal 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
|
||||
}
|
||||
154
mobile/ios/Storage/MockLogins.swift
Normal file
154
mobile/ios/Storage/MockLogins.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
106
mobile/ios/Storage/PageMetadata.swift
Normal file
106
mobile/ios/Storage/PageMetadata.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
13
mobile/ios/Storage/Queue.swift
Normal file
13
mobile/ios/Storage/Queue.swift
Normal 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
|
||||
}
|
||||
76
mobile/ios/Storage/RecentlyClosedTabs.swift
Normal file
76
mobile/ios/Storage/RecentlyClosedTabs.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
109
mobile/ios/Storage/RemoteTabs.swift
Normal file
109
mobile/ios/Storage/RemoteTabs.swift
Normal 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)>"
|
||||
}
|
||||
}
|
||||
226
mobile/ios/Storage/SQL/BrowserDB.swift
Normal file
226
mobile/ios/Storage/SQL/BrowserDB.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
1379
mobile/ios/Storage/SQL/BrowserSchema.swift
Normal file
1379
mobile/ios/Storage/SQL/BrowserSchema.swift
Normal file
File diff suppressed because it is too large
Load diff
123
mobile/ios/Storage/SQL/LoginsSchema.swift
Normal file
123
mobile/ios/Storage/SQL/LoginsSchema.swift
Normal 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
|
||||
}
|
||||
}
|
||||
65
mobile/ios/Storage/SQL/SQLiteBookmarksBase.swift
Normal file
65
mobile/ios/Storage/SQL/SQLiteBookmarksBase.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
75
mobile/ios/Storage/SQL/SQLiteBookmarksHelpers.swift
Normal file
75
mobile/ios/Storage/SQL/SQLiteBookmarksHelpers.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
948
mobile/ios/Storage/SQL/SQLiteBookmarksModel.swift
Normal file
948
mobile/ios/Storage/SQL/SQLiteBookmarksModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
146
mobile/ios/Storage/SQL/SQLiteBookmarksResetting.swift
Normal file
146
mobile/ios/Storage/SQL/SQLiteBookmarksResetting.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
1216
mobile/ios/Storage/SQL/SQLiteBookmarksSyncing.swift
Normal file
1216
mobile/ios/Storage/SQL/SQLiteBookmarksSyncing.swift
Normal file
File diff suppressed because it is too large
Load diff
87
mobile/ios/Storage/SQL/SQLiteFavicons.swift
Normal file
87
mobile/ios/Storage/SQL/SQLiteFavicons.swift
Normal 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()])
|
||||
}
|
||||
}
|
||||
1118
mobile/ios/Storage/SQL/SQLiteHistory.swift
Normal file
1118
mobile/ios/Storage/SQL/SQLiteHistory.swift
Normal file
File diff suppressed because it is too large
Load diff
75
mobile/ios/Storage/SQL/SQLiteHistoryFactories.swift
Normal file
75
mobile/ios/Storage/SQL/SQLiteHistoryFactories.swift
Normal 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
|
||||
}
|
||||
}
|
||||
179
mobile/ios/Storage/SQL/SQLiteHistoryRecommendations.swift
Normal file
179
mobile/ios/Storage/SQL/SQLiteHistoryRecommendations.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
894
mobile/ios/Storage/SQL/SQLiteLogins.swift
Normal file
894
mobile/ios/Storage/SQL/SQLiteLogins.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
58
mobile/ios/Storage/SQL/SQLiteMetadata.swift
Normal file
58
mobile/ios/Storage/SQL/SQLiteMetadata.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
35
mobile/ios/Storage/SQL/SQLiteQueue.swift
Normal file
35
mobile/ios/Storage/SQL/SQLiteQueue.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
382
mobile/ios/Storage/SQL/SQLiteRemoteClientsAndTabs.swift
Normal file
382
mobile/ios/Storage/SQL/SQLiteRemoteClientsAndTabs.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
23
mobile/ios/Storage/SQL/Schema.swift
Normal file
23
mobile/ios/Storage/SQL/Schema.swift
Normal 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
|
||||
}
|
||||
30
mobile/ios/Storage/Sharing.swift
Normal file
30
mobile/ios/Storage/Sharing.swift
Normal 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
|
||||
}
|
||||
95
mobile/ios/Storage/Site.swift
Normal file
95
mobile/ios/Storage/Site.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
10
mobile/ios/Storage/Storage-Bridging-Header.h
Normal file
10
mobile/ios/Storage/Storage-Bridging-Header.h
Normal 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
|
||||
727
mobile/ios/Storage/Storage.xcodeproj/project.pbxproj
Normal file
727
mobile/ios/Storage/Storage.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
46
mobile/ios/Storage/SuggestedSites.swift
Normal file
46
mobile/ios/Storage/SuggestedSites.swift
Normal 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
|
||||
}
|
||||
65
mobile/ios/Storage/SyncQueue.swift
Normal file
65
mobile/ios/Storage/SyncQueue.swift
Normal 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>>
|
||||
}
|
||||
24
mobile/ios/Storage/Syncable.swift
Normal file
24
mobile/ios/Storage/Syncable.swift
Normal 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
|
||||
}
|
||||
1488
mobile/ios/Storage/ThirdParty/SwiftData.swift
vendored
Normal file
1488
mobile/ios/Storage/ThirdParty/SwiftData.swift
vendored
Normal file
File diff suppressed because it is too large
Load diff
121
mobile/ios/Storage/Visit.swift
Normal file
121
mobile/ios/Storage/Visit.swift
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue