Dactyloidae iOS initial commit
38
mobile/ios/Client/Frontend/Browser/AboutHomeHandler.swift
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/* 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/. */
|
||||
|
||||
/**
|
||||
* Handles the page request to about/home/ so that the page loads and does not throw an error (404) on initialization
|
||||
*/
|
||||
import GCDWebServers
|
||||
|
||||
struct AboutHomeHandler {
|
||||
static func register(_ webServer: WebServer) {
|
||||
webServer.registerHandlerForMethod("GET", module: "about", resource: "home") { (request: GCDWebServerRequest?) -> GCDWebServerResponse! in
|
||||
return GCDWebServerResponse(statusCode: 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AboutLicenseHandler {
|
||||
static func register(_ webServer: WebServer) {
|
||||
webServer.registerHandlerForMethod("GET", module: "about", resource: "license") { (request: GCDWebServerRequest?) -> GCDWebServerResponse! in
|
||||
let path = Bundle.main.path(forResource: "Licenses", ofType: "html")
|
||||
do {
|
||||
let html = try NSString(contentsOfFile: path!, encoding: String.Encoding.utf8.rawValue) as String
|
||||
return GCDWebServerDataResponse(html: html)
|
||||
} catch {
|
||||
print("Unable to register webserver \(error)")
|
||||
}
|
||||
return GCDWebServerResponse(statusCode: 200)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GCDWebServerDataResponse {
|
||||
convenience init(XHTML: String) {
|
||||
let data = XHTML.data(using: String.Encoding.utf8, allowLossyConversion: false)
|
||||
self.init(data: data, contentType: "application/xhtml+xml; charset=utf-8")
|
||||
}
|
||||
}
|
||||
153
mobile/ios/Client/Frontend/Browser/Authenticator.swift
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import Shared
|
||||
import Storage
|
||||
import Deferred
|
||||
|
||||
private let CancelButtonTitle = NSLocalizedString("Cancel", comment: "Label for Cancel button")
|
||||
private let LogInButtonTitle = NSLocalizedString("Log in", comment: "Authentication prompt log in button")
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
class Authenticator {
|
||||
fileprivate static let MaxAuthenticationAttempts = 3
|
||||
|
||||
static func handleAuthRequest(_ viewController: UIViewController, challenge: URLAuthenticationChallenge, loginsHelper: LoginsHelper?) -> Deferred<Maybe<LoginData>> {
|
||||
// If there have already been too many login attempts, we'll just fail.
|
||||
if challenge.previousFailureCount >= Authenticator.MaxAuthenticationAttempts {
|
||||
return deferMaybe(LoginDataError(description: "Too many attempts to open site"))
|
||||
}
|
||||
|
||||
var credential = challenge.proposedCredential
|
||||
|
||||
// If we were passed an initial set of credentials from iOS, try and use them.
|
||||
if let proposed = credential {
|
||||
if !(proposed.user?.isEmpty ?? true) {
|
||||
if challenge.previousFailureCount == 0 {
|
||||
return deferMaybe(Login.createWithCredential(credential!, protectionSpace: challenge.protectionSpace))
|
||||
}
|
||||
} else {
|
||||
credential = nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we have some credentials, we'll show a prompt with them.
|
||||
if let credential = credential {
|
||||
return promptForUsernamePassword(viewController, credentials: credential, protectionSpace: challenge.protectionSpace, loginsHelper: loginsHelper)
|
||||
}
|
||||
|
||||
// Otherwise, try to look them up and show the prompt.
|
||||
if let loginsHelper = loginsHelper {
|
||||
return findMatchingCredentialsForChallenge(challenge, fromLoginsProvider: loginsHelper.logins).bindQueue(DispatchQueue.main) { result in
|
||||
guard let credentials = result.successValue else {
|
||||
return deferMaybe(result.failureValue ?? LoginDataError(description: "Unknown error when finding credentials"))
|
||||
}
|
||||
return self.promptForUsernamePassword(viewController, credentials: credentials, protectionSpace: challenge.protectionSpace, loginsHelper: loginsHelper)
|
||||
}
|
||||
}
|
||||
|
||||
// No credentials, so show an empty prompt.
|
||||
return self.promptForUsernamePassword(viewController, credentials: nil, protectionSpace: challenge.protectionSpace, loginsHelper: nil)
|
||||
}
|
||||
|
||||
static func findMatchingCredentialsForChallenge(_ challenge: URLAuthenticationChallenge, fromLoginsProvider loginsProvider: BrowserLogins) -> Deferred<Maybe<URLCredential?>> {
|
||||
return loginsProvider.getLoginsForProtectionSpace(challenge.protectionSpace) >>== { cursor in
|
||||
guard cursor.count >= 1 else {
|
||||
return deferMaybe(nil)
|
||||
}
|
||||
|
||||
let logins = cursor.asArray()
|
||||
var credentials: URLCredential? = nil
|
||||
|
||||
// It is possible that we might have duplicate entries since we match against host and scheme://host.
|
||||
// This is a side effect of https://bugzilla.mozilla.org/show_bug.cgi?id=1238103.
|
||||
if logins.count > 1 {
|
||||
credentials = (logins.find { login in
|
||||
(login.protectionSpace.`protocol` == challenge.protectionSpace.`protocol`) && !login.hasMalformedHostname
|
||||
})?.credentials
|
||||
|
||||
let malformedGUIDs: [GUID] = logins.flatMap { login in
|
||||
if login.hasMalformedHostname {
|
||||
return login.guid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
loginsProvider.removeLoginsWithGUIDs(malformedGUIDs).upon { log.debug("Removed malformed logins. Success :\($0.isSuccess)") }
|
||||
}
|
||||
|
||||
// Found a single entry but the schemes don't match. This is a result of a schemeless entry that we
|
||||
// saved in a previous iteration of the app so we need to migrate it. We only care about the
|
||||
// the username/password so we can rewrite the scheme to be correct.
|
||||
else if logins.count == 1 && logins[0].protectionSpace.`protocol` != challenge.protectionSpace.`protocol` {
|
||||
let login = logins[0]
|
||||
credentials = login.credentials
|
||||
let new = Login(credential: login.credentials, protectionSpace: challenge.protectionSpace)
|
||||
return loginsProvider.updateLoginByGUID(login.guid, new: new, significant: true)
|
||||
>>> { deferMaybe(credentials) }
|
||||
}
|
||||
|
||||
// Found a single entry that matches the scheme and host - good to go.
|
||||
else {
|
||||
credentials = logins[0].credentials
|
||||
}
|
||||
|
||||
return deferMaybe(credentials)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate static func promptForUsernamePassword(_ viewController: UIViewController, credentials: URLCredential?, protectionSpace: URLProtectionSpace, loginsHelper: LoginsHelper?) -> Deferred<Maybe<LoginData>> {
|
||||
if protectionSpace.host.isEmpty {
|
||||
print("Unable to show a password prompt without a hostname")
|
||||
return deferMaybe(LoginDataError(description: "Unable to show a password prompt without a hostname"))
|
||||
}
|
||||
|
||||
let deferred = Deferred<Maybe<LoginData>>()
|
||||
let alert: UIAlertController
|
||||
let title = NSLocalizedString("Authentication required", comment: "Authentication prompt title")
|
||||
if !(protectionSpace.realm?.isEmpty ?? true) {
|
||||
let msg = NSLocalizedString("A username and password are being requested by %@. The site says: %@", comment: "Authentication prompt message with a realm. First parameter is the hostname. Second is the realm string")
|
||||
let formatted = NSString(format: msg as NSString, protectionSpace.host, protectionSpace.realm ?? "") as String
|
||||
alert = UIAlertController(title: title, message: formatted, preferredStyle: UIAlertControllerStyle.alert)
|
||||
} else {
|
||||
let msg = NSLocalizedString("A username and password are being requested by %@.", comment: "Authentication prompt message with no realm. Parameter is the hostname of the site")
|
||||
let formatted = NSString(format: msg as NSString, protectionSpace.host) as String
|
||||
alert = UIAlertController(title: title, message: formatted, preferredStyle: UIAlertControllerStyle.alert)
|
||||
}
|
||||
|
||||
// Add a button to log in.
|
||||
let action = UIAlertAction(title: LogInButtonTitle,
|
||||
style: UIAlertActionStyle.default) { (action) -> Void in
|
||||
guard let user = alert.textFields?[0].text, let pass = alert.textFields?[1].text else { deferred.fill(Maybe(failure: LoginDataError(description: "Username and Password required"))); return }
|
||||
|
||||
let login = Login.createWithCredential(URLCredential(user: user, password: pass, persistence: .forSession), protectionSpace: protectionSpace)
|
||||
deferred.fill(Maybe(success: login))
|
||||
loginsHelper?.setCredentials(login)
|
||||
}
|
||||
alert.addAction(action)
|
||||
|
||||
// Add a cancel button.
|
||||
let cancel = UIAlertAction(title: CancelButtonTitle, style: UIAlertActionStyle.cancel) { (action) -> Void in
|
||||
deferred.fill(Maybe(failure: LoginDataError(description: "Save password cancelled")))
|
||||
}
|
||||
alert.addAction(cancel)
|
||||
|
||||
// Add a username textfield.
|
||||
alert.addTextField { (textfield) -> Void in
|
||||
textfield.placeholder = NSLocalizedString("Username", comment: "Username textbox in Authentication prompt")
|
||||
textfield.text = credentials?.user
|
||||
}
|
||||
|
||||
// Add a password textfield.
|
||||
alert.addTextField { (textfield) -> Void in
|
||||
textfield.placeholder = NSLocalizedString("Password", comment: "Password textbox in Authentication prompt")
|
||||
textfield.isSecureTextEntry = true
|
||||
textfield.text = credentials?.password
|
||||
}
|
||||
|
||||
viewController.present(alert, animated: true) { () -> Void in }
|
||||
return deferred
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/* 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
|
||||
|
||||
class BackForwardListAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
|
||||
var presenting: Bool = false
|
||||
let animationDuration = 0.4
|
||||
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
let screens = (from: transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)!, to: transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)!)
|
||||
|
||||
guard let backForwardViewController = !self.presenting ? screens.from as? BackForwardListViewController : screens.to as? BackForwardListViewController else {
|
||||
return
|
||||
}
|
||||
|
||||
var bottomViewController = !self.presenting ? screens.to as UIViewController : screens.from as UIViewController
|
||||
|
||||
if let navController = bottomViewController as? UINavigationController {
|
||||
bottomViewController = navController.viewControllers.last ?? bottomViewController
|
||||
}
|
||||
|
||||
if let browserViewController = bottomViewController as? BrowserViewController {
|
||||
animateWithBackForward(backForwardViewController, browserViewController: browserViewController, transitionContext: transitionContext)
|
||||
}
|
||||
}
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return animationDuration
|
||||
}
|
||||
}
|
||||
|
||||
extension BackForwardListAnimator: UIViewControllerTransitioningDelegate {
|
||||
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
self.presenting = true
|
||||
return self
|
||||
}
|
||||
|
||||
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
self.presenting = false
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
extension BackForwardListAnimator {
|
||||
fileprivate func animateWithBackForward(_ backForward: BackForwardListViewController, browserViewController bvc: BrowserViewController, transitionContext: UIViewControllerContextTransitioning) {
|
||||
let containerView = transitionContext.containerView
|
||||
|
||||
if presenting {
|
||||
backForward.view.frame = bvc.view.frame
|
||||
backForward.view.alpha = 0
|
||||
containerView.addSubview(backForward.view)
|
||||
backForward.view.snp.updateConstraints { make in
|
||||
make.edges.equalTo(containerView)
|
||||
}
|
||||
backForward.view.layoutIfNeeded()
|
||||
|
||||
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.3, options: [], animations: { () -> Void in
|
||||
backForward.view.alpha = 1
|
||||
backForward.tableView.snp.updateConstraints { make in
|
||||
make.height.equalTo(backForward.tableHeight)
|
||||
}
|
||||
backForward.view.layoutIfNeeded()
|
||||
}, completion: { (completed) -> Void in
|
||||
transitionContext.completeTransition(completed)
|
||||
})
|
||||
|
||||
} else {
|
||||
UIView.animate(withDuration: transitionDuration(using: transitionContext), delay: 0, usingSpringWithDamping: 1.2, initialSpringVelocity: 0.0, options: [], animations: { () -> Void in
|
||||
backForward.view.alpha = 0
|
||||
backForward.tableView.snp.updateConstraints { make in
|
||||
make.height.equalTo(0)
|
||||
}
|
||||
backForward.view.layoutIfNeeded()
|
||||
}, completion: { (completed) -> Void in
|
||||
backForward.view.removeFromSuperview()
|
||||
transitionContext.completeTransition(completed)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
/* 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
|
||||
import WebKit
|
||||
import Storage
|
||||
import SnapKit
|
||||
|
||||
struct BackForwardViewUX {
|
||||
static let RowHeight: CGFloat = 50
|
||||
static let BackgroundColor = UIColor(rgb: 0xf9f9fa).withAlphaComponent(0.4)
|
||||
}
|
||||
|
||||
class BackForwardListViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, UIGestureRecognizerDelegate {
|
||||
|
||||
fileprivate let BackForwardListCellIdentifier = "BackForwardListViewController"
|
||||
fileprivate var profile: Profile
|
||||
fileprivate lazy var sites = [String: Site]()
|
||||
fileprivate var dismissing = false
|
||||
fileprivate var currentRow = 0
|
||||
fileprivate var verticalConstraints: [Constraint] = []
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let tableView = UITableView()
|
||||
tableView.separatorStyle = .none
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
tableView.alwaysBounceVertical = false
|
||||
tableView.register(BackForwardTableViewCell.self, forCellReuseIdentifier: self.BackForwardListCellIdentifier)
|
||||
tableView.backgroundColor = BackForwardViewUX.BackgroundColor
|
||||
let blurEffect = UIBlurEffect(style: .extraLight)
|
||||
let blurEffectView = UIVisualEffectView(effect: blurEffect)
|
||||
tableView.backgroundView = blurEffectView
|
||||
|
||||
return tableView
|
||||
}()
|
||||
|
||||
lazy var shadow: UIView = {
|
||||
let shadow = UIView()
|
||||
shadow.backgroundColor = UIColor(white: 0, alpha: 0.2)
|
||||
return shadow
|
||||
}()
|
||||
|
||||
var tabManager: TabManager!
|
||||
weak var bvc: BrowserViewController?
|
||||
var currentItem: WKBackForwardListItem?
|
||||
var listData = [WKBackForwardListItem]()
|
||||
|
||||
var tableHeight: CGFloat {
|
||||
get {
|
||||
assert(Thread.isMainThread, "tableHeight interacts with UIKit components - cannot call from background thread.")
|
||||
return min(BackForwardViewUX.RowHeight * CGFloat(listData.count), self.view.frame.height/2)
|
||||
}
|
||||
}
|
||||
|
||||
var backForwardTransitionDelegate: UIViewControllerTransitioningDelegate? {
|
||||
didSet {
|
||||
self.transitioningDelegate = backForwardTransitionDelegate
|
||||
}
|
||||
}
|
||||
|
||||
var snappedToBottom: Bool = true
|
||||
|
||||
init(profile: Profile, backForwardList: WKBackForwardList) {
|
||||
self.profile = profile
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
loadSites(backForwardList)
|
||||
loadSitesFromProfile()
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.addSubview(shadow)
|
||||
view.addSubview(tableView)
|
||||
snappedToBottom = bvc?.toolbar != nil
|
||||
tableView.snp.makeConstraints { make in
|
||||
make.height.equalTo(0)
|
||||
make.left.right.equalTo(self.view)
|
||||
}
|
||||
shadow.snp.makeConstraints { make in
|
||||
make.left.right.equalTo(self.view)
|
||||
}
|
||||
remakeVerticalConstraints()
|
||||
view.layoutIfNeeded()
|
||||
scrollTableViewToIndex(currentRow)
|
||||
setupDismissTap()
|
||||
}
|
||||
|
||||
func loadSitesFromProfile() {
|
||||
let sql = profile.favicons as! SQLiteHistory
|
||||
let urls = listData.flatMap {$0.url.isLocal ? $0.url.getQuery()["url"]?.unescape() : $0.url.absoluteString}
|
||||
|
||||
sql.getSitesForURLs(urls).uponQueue(.main) { result in
|
||||
guard let results = result.successValue else {
|
||||
return
|
||||
}
|
||||
// Add all results into the sites dictionary
|
||||
results.flatMap({$0}).forEach({site in
|
||||
if let url = site?.url {
|
||||
self.sites[url] = site
|
||||
}
|
||||
})
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
func homeAndNormalPagesOnly(_ bfList: WKBackForwardList) {
|
||||
let items = bfList.forwardList.reversed() + [bfList.currentItem].flatMap({$0}) + bfList.backList.reversed()
|
||||
|
||||
//error url's are OK as they are used to populate history on session restore.
|
||||
listData = items.filter({return !($0.url.isLocal && ($0.url.originalURLFromErrorURL?.isLocal ?? true)) || $0.url.isAboutHomeURL})
|
||||
}
|
||||
|
||||
func loadSites(_ bfList: WKBackForwardList) {
|
||||
currentItem = bfList.currentItem
|
||||
|
||||
homeAndNormalPagesOnly(bfList)
|
||||
}
|
||||
|
||||
func scrollTableViewToIndex(_ index: Int) {
|
||||
guard index > 1 else {
|
||||
return
|
||||
}
|
||||
let moveToIndexPath = IndexPath(row: index-2, section: 0)
|
||||
tableView.reloadRows(at: [moveToIndexPath], with: .none)
|
||||
tableView.scrollToRow(at: moveToIndexPath, at: UITableViewScrollPosition.middle, animated: false)
|
||||
}
|
||||
|
||||
override func willTransition(to newCollection: UITraitCollection, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.willTransition(to: newCollection, with: coordinator)
|
||||
guard let bvc = self.bvc else {
|
||||
return
|
||||
}
|
||||
if bvc.shouldShowFooterForTraitCollection(newCollection) != snappedToBottom {
|
||||
tableView.snp.updateConstraints { make in
|
||||
if snappedToBottom {
|
||||
make.bottom.equalTo(self.view).offset(0)
|
||||
} else {
|
||||
make.top.equalTo(self.view).offset(0)
|
||||
}
|
||||
make.height.equalTo(0)
|
||||
}
|
||||
snappedToBottom = !snappedToBottom
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
let correctHeight = {
|
||||
self.tableView.snp.updateConstraints { make in
|
||||
make.height.equalTo(min(BackForwardViewUX.RowHeight * CGFloat(self.listData.count), size.height / 2))
|
||||
}
|
||||
}
|
||||
coordinator.animate(alongsideTransition: nil) { _ in
|
||||
self.remakeVerticalConstraints()
|
||||
correctHeight()
|
||||
}
|
||||
}
|
||||
|
||||
func remakeVerticalConstraints() {
|
||||
guard let bvc = self.bvc else {
|
||||
return
|
||||
}
|
||||
for constraint in self.verticalConstraints {
|
||||
constraint.deactivate()
|
||||
}
|
||||
self.verticalConstraints = []
|
||||
tableView.snp.makeConstraints { make in
|
||||
if snappedToBottom {
|
||||
verticalConstraints += [make.bottom.equalTo(self.view).offset(-bvc.footer.frame.height).constraint]
|
||||
} else {
|
||||
verticalConstraints += [make.top.equalTo(self.view).offset(bvc.header.frame.height + UIApplication.shared.statusBarFrame.size.height).constraint]
|
||||
}
|
||||
}
|
||||
shadow.snp.makeConstraints() { make in
|
||||
if snappedToBottom {
|
||||
verticalConstraints += [
|
||||
make.bottom.equalTo(tableView.snp.top).constraint,
|
||||
make.top.equalTo(self.view).constraint
|
||||
]
|
||||
|
||||
} else {
|
||||
verticalConstraints += [
|
||||
make.top.equalTo(tableView.snp.bottom).constraint,
|
||||
make.bottom.equalTo(self.view).constraint
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupDismissTap() {
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(BackForwardListViewController.handleTap))
|
||||
tap.cancelsTouchesInView = false
|
||||
tap.delegate = self
|
||||
view.addGestureRecognizer(tap)
|
||||
}
|
||||
|
||||
func handleTap() {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
if touch.view?.isDescendant(of: tableView) ?? true {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Table view
|
||||
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return listData.count
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = self.tableView.dequeueReusableCell(withIdentifier: BackForwardListCellIdentifier, for: indexPath) as! BackForwardTableViewCell
|
||||
let item = listData[indexPath.item]
|
||||
let urlString = item.url.isLocal ? item.url.getQuery()["url"]?.unescape() : item.url.absoluteString
|
||||
|
||||
cell.isCurrentTab = listData[indexPath.item] == self.currentItem
|
||||
cell.connectingBackwards = indexPath.item != listData.count-1
|
||||
cell.connectingForwards = indexPath.item != 0
|
||||
|
||||
guard let url = urlString, !item.url.isAboutHomeURL else {
|
||||
cell.site = Site(url: item.url.absoluteString, title: Strings.FirefoxHomePage)
|
||||
return cell
|
||||
}
|
||||
|
||||
cell.site = sites[url] ?? Site(url: url, title: item.title ?? "")
|
||||
cell.setNeedsDisplay()
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tabManager.selectedTab?.goToBackForwardListItem(listData[indexPath.item])
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
return BackForwardViewUX.RowHeight
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
/* 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 Storage
|
||||
|
||||
class BackForwardTableViewCell: UITableViewCell {
|
||||
|
||||
struct BackForwardViewCellUX {
|
||||
static let bgColor = UIColor.gray
|
||||
static let faviconWidth = 29
|
||||
static let faviconPadding: CGFloat = 20
|
||||
static let labelPadding = 20
|
||||
static let borderSmall = 2
|
||||
static let borderBold = 5
|
||||
static let IconSize = 23
|
||||
static let fontSize: CGFloat = 12.0
|
||||
}
|
||||
|
||||
lazy var faviconView: UIImageView = {
|
||||
let faviconView = UIImageView(image: FaviconFetcher.defaultFavicon)
|
||||
faviconView.backgroundColor = UIColor.white
|
||||
faviconView.layer.cornerRadius = 6
|
||||
faviconView.layer.borderWidth = 0.5
|
||||
faviconView.layer.borderColor = UIColor(white: 0, alpha: 0.1).cgColor
|
||||
faviconView.layer.masksToBounds = true
|
||||
faviconView.contentMode = .center
|
||||
return faviconView
|
||||
}()
|
||||
|
||||
lazy var label: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = " "
|
||||
label.font = label.font.withSize(BackForwardViewCellUX.fontSize)
|
||||
label.textColor = UIColor(rgb: 0x272727)
|
||||
return label
|
||||
}()
|
||||
|
||||
var connectingForwards = true
|
||||
var connectingBackwards = true
|
||||
|
||||
var isCurrentTab = false {
|
||||
didSet {
|
||||
if isCurrentTab {
|
||||
label.font = UIFont(name: "HelveticaNeue-Bold", size: BackForwardViewCellUX.fontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var site: Site? {
|
||||
didSet {
|
||||
if let s = site {
|
||||
faviconView.setFavicon(forSite: s, onCompletion: { [weak self] (color, url) in
|
||||
if s.tileURL.isLocal {
|
||||
self?.faviconView.image = UIImage(named: "faviconFox")
|
||||
self?.faviconView.image = self?.faviconView.image?.createScaled(CGSize(width: BackForwardViewCellUX.IconSize, height: BackForwardViewCellUX.IconSize))
|
||||
self?.faviconView.backgroundColor = UIColor.white
|
||||
return
|
||||
}
|
||||
|
||||
self?.faviconView.image = self?.faviconView.image?.createScaled(CGSize(width: BackForwardViewCellUX.IconSize, height: BackForwardViewCellUX.IconSize))
|
||||
self?.faviconView.backgroundColor = color == .clear ? .white : color
|
||||
})
|
||||
var title = s.title
|
||||
if title.isEmpty {
|
||||
title = s.url
|
||||
}
|
||||
label.text = title
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
backgroundColor = UIColor.clear
|
||||
selectionStyle = .none
|
||||
|
||||
contentView.addSubview(faviconView)
|
||||
contentView.addSubview(label)
|
||||
|
||||
faviconView.snp.makeConstraints { make in
|
||||
make.height.equalTo(BackForwardViewCellUX.faviconWidth)
|
||||
make.width.equalTo(BackForwardViewCellUX.faviconWidth)
|
||||
make.centerY.equalTo(self)
|
||||
make.leading.equalTo(self.snp.leading).offset(BackForwardViewCellUX.faviconPadding)
|
||||
}
|
||||
|
||||
label.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self)
|
||||
make.leading.equalTo(faviconView.snp.trailing).offset(BackForwardViewCellUX.labelPadding)
|
||||
make.trailing.equalTo(self.snp.trailing).offset(-BackForwardViewCellUX.labelPadding)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
required init(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
super.draw(rect)
|
||||
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||||
|
||||
var startPoint = CGPoint(x: rect.origin.x + BackForwardViewCellUX.faviconPadding + CGFloat(Double(BackForwardViewCellUX.faviconWidth)*0.5),
|
||||
y: rect.origin.y + (connectingForwards ? 0 : rect.size.height/2))
|
||||
var endPoint = CGPoint(x: rect.origin.x + BackForwardViewCellUX.faviconPadding + CGFloat(Double(BackForwardViewCellUX.faviconWidth)*0.5),
|
||||
y: rect.origin.y + rect.size.height - (connectingBackwards ? 0 : rect.size.height/2))
|
||||
|
||||
// flip the x component if RTL
|
||||
if UIApplication.shared.userInterfaceLayoutDirection == .rightToLeft {
|
||||
startPoint.x = rect.origin.x - startPoint.x + rect.size.width
|
||||
endPoint.x = rect.origin.x - endPoint.x + rect.size.width
|
||||
}
|
||||
|
||||
context.saveGState()
|
||||
context.setLineCap(CGLineCap.square)
|
||||
context.setStrokeColor(BackForwardViewCellUX.bgColor.cgColor)
|
||||
context.setLineWidth(1.0)
|
||||
context.move(to: CGPoint(x: startPoint.x, y: startPoint.y))
|
||||
context.addLine(to: CGPoint(x: endPoint.x, y: endPoint.y))
|
||||
context.strokePath()
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
override func setHighlighted(_ highlighted: Bool, animated: Bool) {
|
||||
if highlighted {
|
||||
self.backgroundColor = UIColor(white: 0, alpha: 0.1)
|
||||
} else {
|
||||
self.backgroundColor = UIColor.clear
|
||||
}
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
connectingForwards = true
|
||||
connectingBackwards = true
|
||||
isCurrentTab = false
|
||||
label.font = UIFont(name: "HelveticaNeue", size: BackForwardViewCellUX.fontSize)
|
||||
}
|
||||
}
|
||||
135
mobile/ios/Client/Frontend/Browser/BrowserPrompts.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/* 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
|
||||
|
||||
@objc protocol JSPromptAlertControllerDelegate: class {
|
||||
func promptAlertControllerDidDismiss(_ alertController: JSPromptAlertController)
|
||||
}
|
||||
|
||||
/// A simple version of UIAlertController that attaches a delegate to the viewDidDisappear method
|
||||
/// to allow forwarding the event. The reason this is needed for prompts from Javascript is we
|
||||
/// need to invoke the completionHandler passed to us from the WKWebView delegate or else
|
||||
/// a runtime exception is thrown.
|
||||
class JSPromptAlertController: UIAlertController {
|
||||
var alertInfo: JSAlertInfo?
|
||||
|
||||
weak var delegate: JSPromptAlertControllerDelegate?
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
delegate?.promptAlertControllerDidDismiss(self)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An JSAlertInfo is used to store information about an alert we want to show either immediately or later.
|
||||
* Since alerts are generated by web pages and have no upper limit it would be unwise to allocate a
|
||||
* UIAlertController instance for each generated prompt which could potentially be queued in the background.
|
||||
* Instead, the JSAlertInfo structure retains the relevant data needed for the prompt along with a copy
|
||||
* of the provided completionHandler to let us generate the UIAlertController when needed.
|
||||
*/
|
||||
protocol JSAlertInfo {
|
||||
func alertController() -> JSPromptAlertController
|
||||
func cancel()
|
||||
}
|
||||
|
||||
struct MessageAlert: JSAlertInfo {
|
||||
let message: String
|
||||
let frame: WKFrameInfo
|
||||
let completionHandler: () -> Void
|
||||
|
||||
func alertController() -> JSPromptAlertController {
|
||||
let alertController = JSPromptAlertController(title: titleForJavaScriptPanelInitiatedByFrame(frame),
|
||||
message: message,
|
||||
preferredStyle: UIAlertControllerStyle.alert)
|
||||
alertController.addAction(UIAlertAction(title: UIConstants.OKString, style: UIAlertActionStyle.default) { _ in
|
||||
self.completionHandler()
|
||||
})
|
||||
alertController.alertInfo = self
|
||||
return alertController
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfirmPanelAlert: JSAlertInfo {
|
||||
let message: String
|
||||
let frame: WKFrameInfo
|
||||
let completionHandler: (Bool) -> Void
|
||||
|
||||
init(message: String, frame: WKFrameInfo, completionHandler: @escaping (Bool) -> Void) {
|
||||
self.message = message
|
||||
self.frame = frame
|
||||
self.completionHandler = completionHandler
|
||||
}
|
||||
|
||||
func alertController() -> JSPromptAlertController {
|
||||
// Show JavaScript confirm dialogs.
|
||||
let alertController = JSPromptAlertController(title: titleForJavaScriptPanelInitiatedByFrame(frame), message: message, preferredStyle: UIAlertControllerStyle.alert)
|
||||
alertController.addAction(UIAlertAction(title: UIConstants.OKString, style: UIAlertActionStyle.default) { _ in
|
||||
self.completionHandler(true)
|
||||
})
|
||||
alertController.addAction(UIAlertAction(title: UIConstants.CancelString, style: UIAlertActionStyle.cancel) { _ in
|
||||
self.cancel()
|
||||
})
|
||||
alertController.alertInfo = self
|
||||
return alertController
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
completionHandler(false)
|
||||
}
|
||||
}
|
||||
|
||||
struct TextInputAlert: JSAlertInfo {
|
||||
let message: String
|
||||
let frame: WKFrameInfo
|
||||
let completionHandler: (String?) -> Void
|
||||
let defaultText: String?
|
||||
|
||||
var input: UITextField!
|
||||
|
||||
init(message: String, frame: WKFrameInfo, completionHandler: @escaping (String?) -> Void, defaultText: String?) {
|
||||
self.message = message
|
||||
self.frame = frame
|
||||
self.completionHandler = completionHandler
|
||||
self.defaultText = defaultText
|
||||
}
|
||||
|
||||
func alertController() -> JSPromptAlertController {
|
||||
let alertController = JSPromptAlertController(title: titleForJavaScriptPanelInitiatedByFrame(frame), message: message, preferredStyle: UIAlertControllerStyle.alert)
|
||||
var input: UITextField!
|
||||
alertController.addTextField(configurationHandler: { (textField: UITextField) in
|
||||
input = textField
|
||||
input.text = self.defaultText
|
||||
})
|
||||
alertController.addAction(UIAlertAction(title: UIConstants.OKString, style: UIAlertActionStyle.default) { _ in
|
||||
self.completionHandler(input.text)
|
||||
})
|
||||
alertController.addAction(UIAlertAction(title: UIConstants.CancelString, style: UIAlertActionStyle.cancel) { _ in
|
||||
self.cancel()
|
||||
})
|
||||
alertController.alertInfo = self
|
||||
return alertController
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Show a title for a JavaScript Panel (alert) based on the WKFrameInfo. On iOS9 we will use the new securityOrigin
|
||||
/// and on iOS 8 we will fall back to the request URL. If the request URL is nil, which happens for JavaScript pages,
|
||||
/// we fall back to "JavaScript" as a title.
|
||||
private func titleForJavaScriptPanelInitiatedByFrame(_ frame: WKFrameInfo) -> String {
|
||||
var title = "\(frame.securityOrigin.`protocol`)://\(frame.securityOrigin.host)"
|
||||
if frame.securityOrigin.port != 0 {
|
||||
title += ":\(frame.securityOrigin.port)"
|
||||
}
|
||||
return title
|
||||
}
|
||||
322
mobile/ios/Client/Frontend/Browser/BrowserTrayAnimators.swift
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
/* 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
|
||||
|
||||
class TrayToBrowserAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
if let bvc = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) as? BrowserViewController,
|
||||
let tabTray = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) as? TabTrayController {
|
||||
transitionFromTray(tabTray, toBrowser: bvc, usingContext: transitionContext)
|
||||
}
|
||||
}
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return 0.4
|
||||
}
|
||||
}
|
||||
|
||||
private extension TrayToBrowserAnimator {
|
||||
func transitionFromTray(_ tabTray: TabTrayController, toBrowser bvc: BrowserViewController, usingContext transitionContext: UIViewControllerContextTransitioning) {
|
||||
let container = transitionContext.containerView
|
||||
guard let selectedTab = bvc.tabManager.selectedTab else { return }
|
||||
|
||||
let tabManager = bvc.tabManager
|
||||
let displayedTabs = selectedTab.isPrivate ? tabManager.privateTabs : tabManager.normalTabs
|
||||
guard let expandFromIndex = displayedTabs.index(of: selectedTab) else { return }
|
||||
|
||||
bvc.view.frame = transitionContext.finalFrame(for: bvc)
|
||||
|
||||
// Hide browser components
|
||||
bvc.toggleSnackBarVisibility(show: false)
|
||||
toggleWebViewVisibility(false, usingTabManager: bvc.tabManager)
|
||||
bvc.homePanelController?.view.isHidden = true
|
||||
bvc.webViewContainerBackdrop.isHidden = true
|
||||
bvc.statusBarOverlay.isHidden = false
|
||||
if let url = selectedTab.url, !url.isReaderModeURL {
|
||||
bvc.hideReaderModeBar(animated: false)
|
||||
}
|
||||
|
||||
// Take a snapshot of the collection view that we can scale/fade out. We don't need to wait for screen updates since it's already rendered on the screen
|
||||
let tabCollectionViewSnapshot = tabTray.collectionView.snapshotView(afterScreenUpdates: false)!
|
||||
tabTray.collectionView.alpha = 0
|
||||
tabCollectionViewSnapshot.frame = tabTray.collectionView.frame
|
||||
container.insertSubview(tabCollectionViewSnapshot, at: 0)
|
||||
|
||||
// Create a fake cell to use for the upscaling animation
|
||||
let startingFrame = calculateCollapsedCellFrameUsingCollectionView(tabTray.collectionView, atIndex: expandFromIndex)
|
||||
let cell = createTransitionCellFromTab(bvc.tabManager.selectedTab, withFrame: startingFrame)
|
||||
cell.backgroundHolder.layer.cornerRadius = 0
|
||||
|
||||
container.insertSubview(bvc.view, aboveSubview: tabCollectionViewSnapshot)
|
||||
container.insertSubview(cell, aboveSubview: bvc.view)
|
||||
|
||||
// Flush any pending layout/animation code in preperation of the animation call
|
||||
container.layoutIfNeeded()
|
||||
|
||||
let finalFrame = calculateExpandedCellFrameFromBVC(bvc)
|
||||
bvc.footer.alpha = shouldDisplayFooterForBVC(bvc) ? 1 : 0
|
||||
bvc.urlBar.isTransitioning = true
|
||||
|
||||
// Re-calculate the starting transforms for header/footer views in case we switch orientation
|
||||
resetTransformsForViews([bvc.header, bvc.readerModeBar, bvc.footer])
|
||||
transformHeaderFooterForBVC(bvc, toFrame: startingFrame, container: container)
|
||||
|
||||
UIView.animate(withDuration: self.transitionDuration(using: transitionContext),
|
||||
delay: 0, usingSpringWithDamping: 1,
|
||||
initialSpringVelocity: 0,
|
||||
options: UIViewAnimationOptions(),
|
||||
animations: {
|
||||
// Scale up the cell and reset the transforms for the header/footers
|
||||
cell.frame = finalFrame
|
||||
container.layoutIfNeeded()
|
||||
cell.title.transform = CGAffineTransform(translationX: 0, y: -cell.title.frame.height)
|
||||
|
||||
bvc.tabTrayDidDismiss(tabTray)
|
||||
UIApplication.shared.windows.first?.backgroundColor = UIConstants.AppBackgroundColor
|
||||
tabTray.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
tabTray.toolbar.transform = CGAffineTransform(translationX: 0, y: UIConstants.BottomToolbarHeight)
|
||||
tabCollectionViewSnapshot.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
|
||||
tabCollectionViewSnapshot.alpha = 0
|
||||
}, completion: { finished in
|
||||
// Remove any of the views we used for the animation
|
||||
cell.removeFromSuperview()
|
||||
tabCollectionViewSnapshot.removeFromSuperview()
|
||||
bvc.footer.alpha = 1
|
||||
bvc.toggleSnackBarVisibility(show: true)
|
||||
toggleWebViewVisibility(true, usingTabManager: bvc.tabManager)
|
||||
bvc.webViewContainerBackdrop.isHidden = false
|
||||
bvc.homePanelController?.view.isHidden = false
|
||||
bvc.urlBar.isTransitioning = false
|
||||
transitionContext.completeTransition(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class BrowserToTrayAnimator: NSObject, UIViewControllerAnimatedTransitioning {
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
if let bvc = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) as? BrowserViewController,
|
||||
let tabTray = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) as? TabTrayController {
|
||||
transitionFromBrowser(bvc, toTabTray: tabTray, usingContext: transitionContext)
|
||||
}
|
||||
}
|
||||
|
||||
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
|
||||
return 0.4
|
||||
}
|
||||
}
|
||||
|
||||
private extension BrowserToTrayAnimator {
|
||||
func transitionFromBrowser(_ bvc: BrowserViewController, toTabTray tabTray: TabTrayController, usingContext transitionContext: UIViewControllerContextTransitioning) {
|
||||
|
||||
let container = transitionContext.containerView
|
||||
guard let selectedTab = bvc.tabManager.selectedTab else { return }
|
||||
|
||||
let tabManager = bvc.tabManager
|
||||
let displayedTabs = selectedTab.isPrivate ? tabManager.privateTabs : tabManager.normalTabs
|
||||
guard let scrollToIndex = displayedTabs.index(of: selectedTab) else { return }
|
||||
|
||||
tabTray.view.frame = transitionContext.finalFrame(for: tabTray)
|
||||
|
||||
// Insert tab tray below the browser and force a layout so the collection view can get it's frame right
|
||||
container.insertSubview(tabTray.view, belowSubview: bvc.view)
|
||||
|
||||
// Force subview layout on the collection view so we can calculate the correct end frame for the animation
|
||||
tabTray.view.layoutSubviews()
|
||||
|
||||
tabTray.collectionView.scrollToItem(at: IndexPath(item: scrollToIndex, section: 0), at: .centeredVertically, animated: false)
|
||||
|
||||
// Build a tab cell that we will use to animate the scaling of the browser to the tab
|
||||
let expandedFrame = calculateExpandedCellFrameFromBVC(bvc)
|
||||
let cell = createTransitionCellFromTab(bvc.tabManager.selectedTab, withFrame: expandedFrame)
|
||||
cell.backgroundHolder.layer.cornerRadius = TabTrayControllerUX.CornerRadius
|
||||
|
||||
// Take a snapshot of the collection view to perform the scaling/alpha effect
|
||||
let tabCollectionViewSnapshot = tabTray.collectionView.snapshotView(afterScreenUpdates: true)!
|
||||
tabCollectionViewSnapshot.frame = tabTray.collectionView.frame
|
||||
tabCollectionViewSnapshot.transform = CGAffineTransform(scaleX: 0.9, y: 0.9)
|
||||
tabCollectionViewSnapshot.alpha = 0
|
||||
tabTray.view.insertSubview(tabCollectionViewSnapshot, belowSubview: tabTray.toolbar)
|
||||
|
||||
if let toast = bvc.clipboardBarDisplayHandler?.clipboardToast {
|
||||
toast.removeFromSuperview()
|
||||
}
|
||||
|
||||
container.addSubview(cell)
|
||||
cell.layoutIfNeeded()
|
||||
cell.title.transform = CGAffineTransform(translationX: 0, y: -cell.title.frame.size.height)
|
||||
|
||||
// Hide views we don't want to show during the animation in the BVC
|
||||
bvc.homePanelController?.view.isHidden = true
|
||||
bvc.statusBarOverlay.isHidden = true
|
||||
bvc.toggleSnackBarVisibility(show: false)
|
||||
toggleWebViewVisibility(false, usingTabManager: bvc.tabManager)
|
||||
bvc.urlBar.isTransitioning = true
|
||||
|
||||
// Since we are hiding the collection view and the snapshot API takes the snapshot after the next screen update,
|
||||
// the screenshot ends up being blank unless we set the collection view hidden after the screen update happens.
|
||||
// To work around this, we dispatch the setting of collection view to hidden after the screen update is completed.
|
||||
|
||||
DispatchQueue.main.async {
|
||||
tabTray.collectionView.isHidden = true
|
||||
let finalFrame = calculateCollapsedCellFrameUsingCollectionView(tabTray.collectionView,
|
||||
atIndex: scrollToIndex)
|
||||
tabTray.toolbar.transform = CGAffineTransform(translationX: 0, y: UIConstants.BottomToolbarHeight)
|
||||
|
||||
UIView.animate(withDuration: self.transitionDuration(using: transitionContext),
|
||||
delay: 0, usingSpringWithDamping: 1,
|
||||
initialSpringVelocity: 0,
|
||||
options: UIViewAnimationOptions(),
|
||||
animations: {
|
||||
cell.frame = finalFrame
|
||||
cell.title.transform = CGAffineTransform.identity
|
||||
cell.layoutIfNeeded()
|
||||
|
||||
UIApplication.shared.windows.first?.backgroundColor = TabTrayControllerUX.BackgroundColor
|
||||
tabTray.navigationController?.setNeedsStatusBarAppearanceUpdate()
|
||||
|
||||
transformHeaderFooterForBVC(bvc, toFrame: finalFrame, container: container)
|
||||
|
||||
bvc.urlBar.updateAlphaForSubviews(0)
|
||||
bvc.footer.alpha = 0
|
||||
tabCollectionViewSnapshot.alpha = 1
|
||||
|
||||
tabTray.toolbar.transform = CGAffineTransform.identity
|
||||
resetTransformsForViews([tabCollectionViewSnapshot])
|
||||
}, completion: { finished in
|
||||
// Remove any of the views we used for the animation
|
||||
cell.removeFromSuperview()
|
||||
tabCollectionViewSnapshot.removeFromSuperview()
|
||||
tabTray.collectionView.isHidden = false
|
||||
|
||||
bvc.toggleSnackBarVisibility(show: true)
|
||||
toggleWebViewVisibility(true, usingTabManager: bvc.tabManager)
|
||||
bvc.homePanelController?.view.isHidden = false
|
||||
|
||||
bvc.urlBar.isTransitioning = false
|
||||
transitionContext.completeTransition(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func transformHeaderFooterForBVC(_ bvc: BrowserViewController, toFrame finalFrame: CGRect, container: UIView) {
|
||||
let footerForTransform = footerTransform(bvc.footer.frame, toFrame: finalFrame, container: container)
|
||||
let headerForTransform = headerTransform(bvc.header.frame, toFrame: finalFrame, container: container)
|
||||
|
||||
bvc.footer.transform = footerForTransform
|
||||
bvc.header.transform = headerForTransform
|
||||
bvc.readerModeBar?.transform = headerForTransform
|
||||
}
|
||||
|
||||
private func footerTransform( _ frame: CGRect, toFrame finalFrame: CGRect, container: UIView) -> CGAffineTransform {
|
||||
let frame = container.convert(frame, to: container)
|
||||
let endY = finalFrame.maxY - (frame.size.height / 2)
|
||||
let endX = finalFrame.midX
|
||||
let translation = CGPoint(x: endX - frame.midX, y: endY - frame.midY)
|
||||
|
||||
let scaleX = finalFrame.width / frame.width
|
||||
|
||||
var transform = CGAffineTransform.identity
|
||||
transform = transform.translatedBy(x: translation.x, y: translation.y)
|
||||
transform = transform.scaledBy(x: scaleX, y: scaleX)
|
||||
return transform
|
||||
}
|
||||
|
||||
private func headerTransform(_ frame: CGRect, toFrame finalFrame: CGRect, container: UIView) -> CGAffineTransform {
|
||||
let frame = container.convert(frame, to: container)
|
||||
let endY = finalFrame.minY + (frame.size.height / 2)
|
||||
let endX = finalFrame.midX
|
||||
let translation = CGPoint(x: endX - frame.midX, y: endY - frame.midY)
|
||||
|
||||
let scaleX = finalFrame.width / frame.width
|
||||
|
||||
var transform = CGAffineTransform.identity
|
||||
transform = transform.translatedBy(x: translation.x, y: translation.y)
|
||||
transform = transform.scaledBy(x: scaleX, y: scaleX)
|
||||
return transform
|
||||
}
|
||||
|
||||
//MARK: Private Helper Methods
|
||||
private func calculateCollapsedCellFrameUsingCollectionView(_ collectionView: UICollectionView, atIndex index: Int) -> CGRect {
|
||||
if let attr = collectionView.collectionViewLayout.layoutAttributesForItem(at: IndexPath(item: index, section: 0)) {
|
||||
return collectionView.convert(attr.frame, to: collectionView.superview)
|
||||
} else {
|
||||
return CGRect.zero
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateExpandedCellFrameFromBVC(_ bvc: BrowserViewController) -> CGRect {
|
||||
var frame = bvc.webViewContainer.frame
|
||||
|
||||
// If we're navigating to a home panel and we were expecting to show the toolbar, add more height to end frame since
|
||||
// there is no toolbar for home panels
|
||||
if !bvc.shouldShowFooterForTraitCollection(bvc.traitCollection) {
|
||||
return frame
|
||||
} else if let url = bvc.tabManager.selectedTab?.url, url.isAboutURL && bvc.toolbar == nil {
|
||||
frame.size.height += UIConstants.BottomToolbarHeight
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
private func shouldDisplayFooterForBVC(_ bvc: BrowserViewController) -> Bool {
|
||||
if bvc.shouldShowFooterForTraitCollection(bvc.traitCollection) {
|
||||
if let url = bvc.tabManager.selectedTab?.url {
|
||||
return !url.isAboutURL
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func toggleWebViewVisibility(_ show: Bool, usingTabManager tabManager: TabManager) {
|
||||
for i in 0..<tabManager.count {
|
||||
if let tab = tabManager[i] {
|
||||
tab.webView?.isHidden = !show
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func resetTransformsForViews(_ views: [UIView?]) {
|
||||
for view in views {
|
||||
// Reset back to origin
|
||||
view?.transform = CGAffineTransform.identity
|
||||
}
|
||||
}
|
||||
|
||||
private func transformToolbarsToFrame(_ toolbars: [UIView?], toRect endRect: CGRect) {
|
||||
for toolbar in toolbars {
|
||||
// Reset back to origin
|
||||
toolbar?.transform = CGAffineTransform.identity
|
||||
|
||||
// Transform from origin to where we want them to end up
|
||||
if let toolbarFrame = toolbar?.frame {
|
||||
toolbar?.transform = CGAffineTransformMakeRectToRect(toolbarFrame, toFrame: endRect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createTransitionCellFromTab(_ tab: Tab?, withFrame frame: CGRect) -> TabCell {
|
||||
let cell = TabCell(frame: frame)
|
||||
cell.screenshotView.image = tab?.screenshot
|
||||
cell.titleText.text = tab?.displayTitle
|
||||
|
||||
if let tab = tab, tab.isPrivate {
|
||||
cell.style = .dark
|
||||
}
|
||||
|
||||
if let favIcon = tab?.displayFavicon {
|
||||
cell.favicon.sd_setImage(with: URL(string: favIcon.url)!)
|
||||
} else {
|
||||
let defaultFavicon = UIImage(named: "defaultFavicon")
|
||||
if tab?.isPrivate ?? false {
|
||||
cell.favicon.image = defaultFavicon
|
||||
cell.favicon.tintColor = (tab?.isPrivate ?? false) ? UIColor.white : UIColor.darkGray
|
||||
} else {
|
||||
cell.favicon.image = defaultFavicon
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
2986
mobile/ios/Client/Frontend/Browser/BrowserViewController.swift
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/* 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
|
||||
|
||||
// Naming functions: use the suffix 'KeyCommand' for an additional level of namespacing (bug 1415830)
|
||||
|
||||
extension BrowserViewController {
|
||||
|
||||
@objc private func reloadTabKeyCommand() {
|
||||
if homePanelController == nil {
|
||||
tabManager.selectedTab?.reload()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func goBackKeyCommand() {
|
||||
if tabManager.selectedTab?.canGoBack == true && homePanelController == nil {
|
||||
tabManager.selectedTab?.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func goForwardKeyCommand() {
|
||||
if tabManager.selectedTab?.canGoForward == true && homePanelController == nil {
|
||||
tabManager.selectedTab?.goForward()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func findOnPageKeyCommand() {
|
||||
if homePanelController == nil {
|
||||
tab( (tabManager.selectedTab)!, didSelectFindInPageForSelection: "")
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func selectLocationBarKeyCommand() {
|
||||
scrollController.showToolbars(animated: true)
|
||||
urlBar.tabLocationViewDidTapLocation(urlBar.locationView)
|
||||
}
|
||||
|
||||
@objc private func newTabKeyCommand() {
|
||||
openBlankNewTab(focusLocationField: false, isPrivate: false)
|
||||
}
|
||||
|
||||
@objc private func newPrivateTabKeyCommand() {
|
||||
openBlankNewTab(focusLocationField: false, isPrivate: true)
|
||||
}
|
||||
|
||||
@objc private func closeTabKeyCommand() {
|
||||
guard let currentTab = tabManager.selectedTab else {
|
||||
return
|
||||
}
|
||||
tabManager.removeTab(currentTab)
|
||||
}
|
||||
|
||||
@objc private func nextTabKeyCommand() {
|
||||
guard let currentTab = tabManager.selectedTab else {
|
||||
return
|
||||
}
|
||||
|
||||
let tabs = currentTab.isPrivate ? tabManager.privateTabs : tabManager.normalTabs
|
||||
if let index = tabs.index(of: currentTab), index + 1 < tabs.count {
|
||||
tabManager.selectTab(tabs[index + 1])
|
||||
} else if let firstTab = tabs.first {
|
||||
tabManager.selectTab(firstTab)
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func previousTabKeyCommand() {
|
||||
guard let currentTab = tabManager.selectedTab else {
|
||||
return
|
||||
}
|
||||
|
||||
let tabs = currentTab.isPrivate ? tabManager.privateTabs : tabManager.normalTabs
|
||||
if let index = tabs.index(of: currentTab), index - 1 < tabs.count && index != 0 {
|
||||
tabManager.selectTab(tabs[index - 1])
|
||||
} else if let lastTab = tabs.last {
|
||||
tabManager.selectTab(lastTab)
|
||||
}
|
||||
}
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
return [
|
||||
UIKeyCommand(input: "r", modifierFlags: .command, action: #selector(BrowserViewController.reloadTabKeyCommand), discoverabilityTitle: Strings.ReloadPageTitle),
|
||||
UIKeyCommand(input: "[", modifierFlags: .command, action: #selector(BrowserViewController.goBackKeyCommand), discoverabilityTitle: Strings.BackTitle),
|
||||
UIKeyCommand(input: UIKeyInputLeftArrow, modifierFlags: .command, action: #selector(BrowserViewController.goBackKeyCommand), discoverabilityTitle: Strings.BackTitle),
|
||||
UIKeyCommand(input: "]", modifierFlags: .command, action: #selector(BrowserViewController.goForwardKeyCommand), discoverabilityTitle: Strings.ForwardTitle),
|
||||
UIKeyCommand(input: UIKeyInputRightArrow, modifierFlags: .command, action: #selector(BrowserViewController.goForwardKeyCommand), discoverabilityTitle: Strings.ForwardTitle),
|
||||
|
||||
UIKeyCommand(input: "f", modifierFlags: .command, action: #selector(BrowserViewController.findOnPageKeyCommand), discoverabilityTitle: Strings.FindTitle),
|
||||
UIKeyCommand(input: "l", modifierFlags: .command, action: #selector(BrowserViewController.selectLocationBarKeyCommand), discoverabilityTitle: Strings.SelectLocationBarTitle),
|
||||
UIKeyCommand(input: "t", modifierFlags: .command, action: #selector(BrowserViewController.newTabKeyCommand), discoverabilityTitle: Strings.NewTabTitle),
|
||||
UIKeyCommand(input: "p", modifierFlags: [.command, .shift], action: #selector(BrowserViewController.newPrivateTabKeyCommand), discoverabilityTitle: Strings.NewPrivateTabTitle),
|
||||
UIKeyCommand(input: "w", modifierFlags: .command, action: #selector(BrowserViewController.closeTabKeyCommand), discoverabilityTitle: Strings.CloseTabTitle),
|
||||
UIKeyCommand(input: "\t", modifierFlags: .control, action: #selector(BrowserViewController.nextTabKeyCommand), discoverabilityTitle: Strings.ShowNextTabTitle),
|
||||
UIKeyCommand(input: UIKeyInputRightArrow, modifierFlags: [.command, .shift], action: #selector(BrowserViewController.nextTabKeyCommand), discoverabilityTitle: Strings.ShowNextTabTitle),
|
||||
UIKeyCommand(input: "\t", modifierFlags: [.control, .shift], action: #selector(BrowserViewController.previousTabKeyCommand), discoverabilityTitle: Strings.ShowPreviousTabTitle),
|
||||
UIKeyCommand(input: UIKeyInputLeftArrow, modifierFlags: [.command, .shift], action: #selector(BrowserViewController.previousTabKeyCommand), discoverabilityTitle: Strings.ShowPreviousTabTitle),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
/* 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
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
extension WKNavigationAction {
|
||||
/// Allow local requests only if the request is privileged.
|
||||
var isAllowed: Bool {
|
||||
guard let url = request.url else {
|
||||
return true
|
||||
}
|
||||
|
||||
return !url.isWebPage(includeDataURIs: false) || !url.isLocal || request.isPrivileged
|
||||
}
|
||||
}
|
||||
|
||||
extension BrowserViewController: WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
if tabManager.selectedTab?.webView !== webView {
|
||||
return
|
||||
}
|
||||
|
||||
updateFindInPageVisibility(visible: false)
|
||||
|
||||
// If we are going to navigate to a new page, hide the reader mode button. Unless we
|
||||
// are going to a about:reader page. Then we keep it on screen: it will change status
|
||||
// (orange color) as soon as the page has loaded.
|
||||
if let url = webView.url {
|
||||
if !url.isReaderModeURL {
|
||||
urlBar.updateReaderModeState(ReaderModeState.unavailable)
|
||||
hideReaderModeBar(animated: false)
|
||||
}
|
||||
|
||||
// remove the open in overlay view if it is present
|
||||
removeOpenInView()
|
||||
}
|
||||
}
|
||||
|
||||
// Recognize an Apple Maps URL. This will trigger the native app. But only if a search query is present. Otherwise
|
||||
// it could just be a visit to a regular page on maps.apple.com.
|
||||
fileprivate func isAppleMapsURL(_ url: URL) -> Bool {
|
||||
if url.scheme == "http" || url.scheme == "https" {
|
||||
if url.host == "maps.apple.com" && url.query != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Recognize a iTunes Store URL. These all trigger the native apps. Note that appstore.com and phobos.apple.com
|
||||
// used to be in this list. I have removed them because they now redirect to itunes.apple.com. If we special case
|
||||
// them then iOS will actually first open Safari, which then redirects to the app store. This works but it will
|
||||
// leave a 'Back to Safari' button in the status bar, which we do not want.
|
||||
fileprivate func isStoreURL(_ url: URL) -> Bool {
|
||||
if url.scheme == "http" || url.scheme == "https" {
|
||||
if url.host == "itunes.apple.com" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// This is the place where we decide what to do with a new navigation action. There are a number of special schemes
|
||||
// and http(s) urls that need to be handled in a different way. All the logic for that is inside this delegate
|
||||
// method.
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
guard let url = navigationAction.request.url else {
|
||||
decisionHandler(WKNavigationActionPolicy.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
if url.scheme == "about" {
|
||||
decisionHandler(WKNavigationActionPolicy.allow)
|
||||
return
|
||||
}
|
||||
|
||||
if !navigationAction.isAllowed && navigationAction.navigationType != .backForward {
|
||||
log.warning("Denying unprivileged request: \(navigationAction.request)")
|
||||
decisionHandler(WKNavigationActionPolicy.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// First special case are some schemes that are about Calling. We prompt the user to confirm this action. This
|
||||
// gives us the exact same behaviour as Safari.
|
||||
if url.scheme == "tel" || url.scheme == "facetime" || url.scheme == "facetime-audio" {
|
||||
UIApplication.shared.openURL(url)
|
||||
decisionHandler(WKNavigationActionPolicy.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// Second special case are a set of URLs that look like regular http links, but should be handed over to iOS
|
||||
// instead of being loaded in the webview. Note that there is no point in calling canOpenURL() here, because
|
||||
// iOS will always say yes. TODO Is this the same as isWhitelisted?
|
||||
|
||||
if isAppleMapsURL(url) {
|
||||
UIApplication.shared.openURL(url)
|
||||
decisionHandler(WKNavigationActionPolicy.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
if let tab = tabManager.selectedTab, isStoreURL(url) {
|
||||
decisionHandler(WKNavigationActionPolicy.cancel)
|
||||
|
||||
let alreadyShowingSnackbarOnThisTab = tab.bars.count > 0
|
||||
if !alreadyShowingSnackbarOnThisTab {
|
||||
TimerSnackBar.showAppStoreConfirmationBar(forTab: tab, appStoreURL: url)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Handles custom mailto URL schemes.
|
||||
if url.scheme == "mailto" {
|
||||
if let mailToMetadata = url.mailToMetadata(), let mailScheme = self.profile.prefs.stringForKey(PrefsKeys.KeyMailToOption), mailScheme != "mailto" {
|
||||
self.mailtoLinkHandler.launchMailClientForScheme(mailScheme, metadata: mailToMetadata, defaultMailtoURL: url)
|
||||
} else {
|
||||
UIApplication.shared.openURL(url)
|
||||
}
|
||||
|
||||
LeanPlumClient.shared.track(event: .openedMailtoLink)
|
||||
decisionHandler(WKNavigationActionPolicy.cancel)
|
||||
return
|
||||
}
|
||||
|
||||
// This is the normal case, opening a http or https url, which we handle by loading them in this WKWebView. We
|
||||
// always allow this. Additionally, data URIs are also handled just like normal web pages.
|
||||
|
||||
if url.scheme == "http" || url.scheme == "https" || url.scheme == "data" || url.scheme == "blob" {
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
resetSpoofedUserAgentIfRequired(webView, newURL: url)
|
||||
} else if navigationAction.navigationType == .backForward {
|
||||
restoreSpoofedUserAgentIfRequired(webView, newRequest: navigationAction.request)
|
||||
}
|
||||
decisionHandler(WKNavigationActionPolicy.allow)
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore JS navigated links, the intention is to match Safari and native WKWebView behaviour.
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
UIApplication.shared.open(url, options: [:]) { openedURL in
|
||||
if !openedURL {
|
||||
let alert = UIAlertController(title: Strings.UnableToOpenURLErrorTitle, message: Strings.UnableToOpenURLError, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: UIConstants.OKString, style: UIAlertActionStyle.default, handler: nil))
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
decisionHandler(WKNavigationActionPolicy.cancel)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
|
||||
// If this is a certificate challenge, see if the certificate has previously been
|
||||
// accepted by the user.
|
||||
let origin = "\(challenge.protectionSpace.host):\(challenge.protectionSpace.port)"
|
||||
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust,
|
||||
let cert = SecTrustGetCertificateAtIndex(trust, 0), profile.certStore.containsCertificate(cert, forOrigin: origin) {
|
||||
completionHandler(URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: trust))
|
||||
return
|
||||
}
|
||||
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodHTTPBasic ||
|
||||
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodHTTPDigest ||
|
||||
challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodNTLM,
|
||||
let tab = tabManager[webView] else {
|
||||
completionHandler(URLSession.AuthChallengeDisposition.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// If this is a request to our local web server, use our private credentials.
|
||||
if challenge.protectionSpace.host == "localhost" && challenge.protectionSpace.port == Int(WebServer.sharedInstance.server.port) {
|
||||
completionHandler(.useCredential, WebServer.sharedInstance.credentials)
|
||||
return
|
||||
}
|
||||
|
||||
// The challenge may come from a background tab, so ensure it's the one visible.
|
||||
tabManager.selectTab(tab)
|
||||
|
||||
let loginsHelper = tab.getContentScript(name: LoginsHelper.name()) as? LoginsHelper
|
||||
Authenticator.handleAuthRequest(self, challenge: challenge, loginsHelper: loginsHelper).uponQueue(DispatchQueue.main) { res in
|
||||
if let credentials = res.successValue {
|
||||
completionHandler(.useCredential, credentials.credentials)
|
||||
} else {
|
||||
completionHandler(URLSession.AuthChallengeDisposition.rejectProtectionSpace, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||||
guard let tab = tabManager[webView] else { return }
|
||||
|
||||
tab.url = webView.url
|
||||
self.scrollController.resetZoomState()
|
||||
|
||||
if tabManager.selectedTab === tab {
|
||||
updateUIForReaderHomeStateForTab(tab)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
if let tab = tabManager[webView] {
|
||||
navigateInTab(tab: tab, to: navigation)
|
||||
}
|
||||
}
|
||||
}
|
||||
181
mobile/ios/Client/Frontend/Browser/ButtonToast.swift
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/* 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 SnapKit
|
||||
|
||||
struct ButtonToastUX {
|
||||
static let ToastPadding = 15.0
|
||||
static let TitleSpacing = 2.0
|
||||
static let ToastButtonPadding: CGFloat = 10.0
|
||||
static let TitleButtonPadding: CGFloat = 5.0
|
||||
static let ToastDelay = DispatchTimeInterval.milliseconds(900)
|
||||
static let ToastButtonBorderRadius: CGFloat = 5
|
||||
static let ToastButtonBorderWidth: CGFloat = 1
|
||||
}
|
||||
|
||||
private class HighlightableButton: UIButton {
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
if isHighlighted {
|
||||
self.backgroundColor = UIColor.white
|
||||
} else {
|
||||
self.backgroundColor = UIColor.clear
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ButtonToast: UIView {
|
||||
|
||||
fileprivate var dismissed = false
|
||||
fileprivate var completionHandler: ((Bool) -> Void)?
|
||||
fileprivate lazy var toast: UIView = {
|
||||
let toast = UIView()
|
||||
toast.backgroundColor = SimpleToastUX.ToastDefaultColor
|
||||
return toast
|
||||
}()
|
||||
fileprivate var animationConstraint: Constraint?
|
||||
fileprivate lazy var gestureRecognizer: UITapGestureRecognizer = {
|
||||
let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(ButtonToast.handleTap(_:)))
|
||||
gestureRecognizer.cancelsTouchesInView = false
|
||||
return gestureRecognizer
|
||||
}()
|
||||
|
||||
init(labelText: String, descriptionText: String? = nil, buttonText: String, completion:@escaping (_ buttonPressed: Bool) -> Void) {
|
||||
super.init(frame: CGRect.zero)
|
||||
completionHandler = completion
|
||||
|
||||
self.clipsToBounds = true
|
||||
self.addSubview(createView(labelText, descriptionText: descriptionText, buttonText: buttonText))
|
||||
|
||||
toast.snp.makeConstraints { make in
|
||||
make.left.right.height.equalTo(self)
|
||||
animationConstraint = make.top.equalTo(self).offset(SimpleToastUX.ToastHeight).constraint
|
||||
}
|
||||
self.snp.makeConstraints { make in
|
||||
make.height.equalTo(SimpleToastUX.ToastHeight)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
fileprivate func createView(_ labelText: String, descriptionText: String?, buttonText: String) -> UIView {
|
||||
let label = UILabel()
|
||||
label.textColor = UIColor.white
|
||||
label.font = SimpleToastUX.ToastFont
|
||||
label.text = labelText
|
||||
label.lineBreakMode = .byWordWrapping
|
||||
label.numberOfLines = 0
|
||||
toast.addSubview(label)
|
||||
|
||||
let button = HighlightableButton()
|
||||
button.layer.cornerRadius = ButtonToastUX.ToastButtonBorderRadius
|
||||
button.layer.borderWidth = ButtonToastUX.ToastButtonBorderWidth
|
||||
button.layer.borderColor = UIColor.white.cgColor
|
||||
button.setTitle(buttonText, for: UIControlState())
|
||||
button.setTitleColor(self.toast.backgroundColor, for: .highlighted)
|
||||
button.titleLabel?.font = SimpleToastUX.ToastFont
|
||||
button.titleLabel?.numberOfLines = 1
|
||||
button.titleLabel?.lineBreakMode = .byClipping
|
||||
button.titleLabel?.adjustsFontSizeToFitWidth = true
|
||||
button.titleLabel?.minimumScaleFactor = 0.1
|
||||
|
||||
let recognizer = UITapGestureRecognizer(target: self, action: #selector(ButtonToast.buttonPressed(_:)))
|
||||
button.addGestureRecognizer(recognizer)
|
||||
toast.addSubview(button)
|
||||
var descriptionLabel: UILabel?
|
||||
|
||||
if let text = descriptionText {
|
||||
let textLabel = UILabel()
|
||||
textLabel.textColor = UIColor.white
|
||||
textLabel.font = SimpleToastUX.ToastFont
|
||||
textLabel.text = text
|
||||
textLabel.lineBreakMode = .byTruncatingTail
|
||||
toast.addSubview(textLabel)
|
||||
descriptionLabel = textLabel
|
||||
}
|
||||
|
||||
if let description = descriptionLabel {
|
||||
label.numberOfLines = 1 // if showing a description we cant wrap to the second line
|
||||
label.lineBreakMode = .byClipping
|
||||
label.adjustsFontSizeToFitWidth = true
|
||||
label.snp.makeConstraints { (make) in
|
||||
make.leading.equalTo(toast).offset(ButtonToastUX.ToastPadding)
|
||||
make.top.equalTo(toast).offset(ButtonToastUX.TitleButtonPadding)
|
||||
make.trailing.equalTo(button.snp.leading).offset(-ButtonToastUX.TitleButtonPadding)
|
||||
}
|
||||
description.snp.makeConstraints { (make) in
|
||||
make.leading.equalTo(toast).offset(ButtonToastUX.ToastPadding)
|
||||
make.top.equalTo(label.snp.bottom).offset(ButtonToastUX.TitleSpacing)
|
||||
make.trailing.equalTo(button.snp.leading).offset(-ButtonToastUX.TitleButtonPadding)
|
||||
}
|
||||
} else {
|
||||
label.snp.makeConstraints { (make) in
|
||||
make.leading.equalTo(toast).offset(ButtonToastUX.ToastPadding)
|
||||
make.centerY.equalTo(toast)
|
||||
make.trailing.equalTo(button.snp.leading).offset(-ButtonToastUX.TitleButtonPadding)
|
||||
}
|
||||
}
|
||||
|
||||
button.snp.makeConstraints { (make) in
|
||||
make.trailing.equalTo(toast).offset(-ButtonToastUX.ToastPadding)
|
||||
make.centerY.equalTo(toast)
|
||||
make.width.equalTo(button.titleLabel!.intrinsicContentSize.width + 2*ButtonToastUX.ToastButtonPadding)
|
||||
}
|
||||
|
||||
return toast
|
||||
}
|
||||
|
||||
fileprivate func dismiss(_ buttonPressed: Bool) {
|
||||
guard dismissed == false else {
|
||||
return
|
||||
}
|
||||
dismissed = true
|
||||
superview?.removeGestureRecognizer(gestureRecognizer)
|
||||
|
||||
UIView.animate(withDuration: SimpleToastUX.ToastAnimationDuration, animations: {
|
||||
self.animationConstraint?.update(offset: SimpleToastUX.ToastHeight)
|
||||
self.layoutIfNeeded()
|
||||
},
|
||||
completion: { finished in
|
||||
self.removeFromSuperview()
|
||||
if !buttonPressed {
|
||||
self.completionHandler?(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func showToast(duration: DispatchTimeInterval = SimpleToastUX.ToastDismissAfter) {
|
||||
layoutIfNeeded()
|
||||
UIView.animate(withDuration: SimpleToastUX.ToastAnimationDuration, animations: {
|
||||
self.animationConstraint?.update(offset: 0)
|
||||
self.layoutIfNeeded()
|
||||
},
|
||||
completion: { finished in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
|
||||
self.dismiss(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@objc func buttonPressed(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
self.completionHandler?(true)
|
||||
self.dismiss(true)
|
||||
}
|
||||
|
||||
override func didMoveToSuperview() {
|
||||
super.didMoveToSuperview()
|
||||
superview?.addGestureRecognizer(gestureRecognizer)
|
||||
}
|
||||
|
||||
func handleTap(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
dismiss(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
/* 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 struct ClipboardBarToastUX {
|
||||
static let ToastDelay = DispatchTimeInterval.milliseconds(4000)
|
||||
}
|
||||
|
||||
protocol ClipboardBarDisplayHandlerDelegate: class {
|
||||
func shouldDisplay(clipboardBar bar: ButtonToast)
|
||||
}
|
||||
|
||||
class ClipboardBarDisplayHandler: NSObject, URLChangeDelegate {
|
||||
weak var delegate: (ClipboardBarDisplayHandlerDelegate & SettingsDelegate)?
|
||||
weak var settingsDelegate: SettingsDelegate?
|
||||
weak var tabManager: TabManager?
|
||||
private var sessionStarted = true
|
||||
private var sessionRestored = false
|
||||
private var firstTabLoaded = false
|
||||
private var prefs: Prefs
|
||||
private var lastDisplayedURL: String?
|
||||
private weak var firstTab: Tab?
|
||||
var clipboardToast: ButtonToast?
|
||||
|
||||
init(prefs: Prefs, tabManager: TabManager) {
|
||||
self.prefs = prefs
|
||||
self.tabManager = tabManager
|
||||
|
||||
super.init()
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SELUIPasteboardChanged), name: NSNotification.Name.UIPasteboardChanged, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SELAppWillEnterForegroundNotification), name: NSNotification.Name.UIApplicationWillEnterForeground, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SELDidRestoreSession), name: NotificationDidRestoreSession, object: nil)
|
||||
}
|
||||
|
||||
@objc private func SELUIPasteboardChanged() {
|
||||
// UIPasteboardChanged gets triggered when calling UIPasteboard.general.
|
||||
NotificationCenter.default.removeObserver(self, name: NSNotification.Name.UIPasteboardChanged, object: nil)
|
||||
|
||||
UIPasteboard.general.asyncURL().uponQueue(.main) { res in
|
||||
defer {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(self.SELUIPasteboardChanged), name: NSNotification.Name.UIPasteboardChanged, object: nil)
|
||||
}
|
||||
|
||||
guard let copiedURL: URL? = res.successValue,
|
||||
let url = copiedURL else {
|
||||
return
|
||||
}
|
||||
self.lastDisplayedURL = url.absoluteString
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func SELAppWillEnterForegroundNotification() {
|
||||
sessionStarted = true
|
||||
checkIfShouldDisplayBar()
|
||||
}
|
||||
|
||||
private func observeURLForFirstTab(firstTab: Tab) {
|
||||
if firstTab.webView == nil {
|
||||
// Nothing to do; bail out.
|
||||
firstTabLoaded = true
|
||||
return
|
||||
}
|
||||
self.firstTab = firstTab
|
||||
firstTab.observeURLChanges(delegate: self)
|
||||
}
|
||||
|
||||
@objc private func SELDidRestoreSession() {
|
||||
DispatchQueue.main.sync {
|
||||
if let tabManager = self.tabManager,
|
||||
let firstTab = tabManager.selectedTab {
|
||||
self.observeURLForFirstTab(firstTab: firstTab)
|
||||
} else {
|
||||
firstTabLoaded = true
|
||||
}
|
||||
|
||||
NotificationCenter.default.removeObserver(self, name: NotificationDidRestoreSession, object: nil)
|
||||
|
||||
sessionRestored = true
|
||||
checkIfShouldDisplayBar()
|
||||
}
|
||||
}
|
||||
|
||||
func tab(_ tab: Tab, urlDidChangeTo url: URL) {
|
||||
// Ugly hack to ensure we wait until we're finished restoring the session on the first tab
|
||||
// before checking if we should display the clipboard bar.
|
||||
guard sessionRestored,
|
||||
!url.absoluteString.startsWith("\(WebServer.sharedInstance.base)/about/sessionrestore?history=") else {
|
||||
return
|
||||
}
|
||||
|
||||
tab.removeURLChangeObserver(delegate: self)
|
||||
firstTabLoaded = true
|
||||
checkIfShouldDisplayBar()
|
||||
}
|
||||
|
||||
private func shouldDisplayBar(_ copiedURL: String) -> Bool {
|
||||
if !sessionStarted ||
|
||||
!sessionRestored ||
|
||||
!firstTabLoaded ||
|
||||
isClipboardURLAlreadyDisplayed(copiedURL) ||
|
||||
self.prefs.intForKey(IntroViewControllerSeenProfileKey) == nil {
|
||||
return false
|
||||
}
|
||||
sessionStarted = false
|
||||
return true
|
||||
}
|
||||
|
||||
// If we already displayed this URL on the previous session, or in an already open
|
||||
// tab, we shouldn't display it again
|
||||
private func isClipboardURLAlreadyDisplayed(_ clipboardURL: String) -> Bool {
|
||||
if lastDisplayedURL == clipboardURL {
|
||||
return true
|
||||
}
|
||||
|
||||
if let url = URL(string: clipboardURL),
|
||||
let _ = tabManager?.getTabFor(url) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkIfShouldDisplayBar() {
|
||||
guard self.prefs.boolForKey("showClipboardBar") ?? false else {
|
||||
// There's no point in doing any of this work unless the
|
||||
// user has asked for it in settings.
|
||||
return
|
||||
}
|
||||
UIPasteboard.general.asyncURL().uponQueue(.main) { res in
|
||||
guard let copiedURL: URL? = res.successValue,
|
||||
let url = copiedURL else {
|
||||
return
|
||||
}
|
||||
|
||||
let absoluteString = url.absoluteString
|
||||
|
||||
guard self.shouldDisplayBar(absoluteString) else {
|
||||
return
|
||||
}
|
||||
|
||||
self.lastDisplayedURL = absoluteString
|
||||
|
||||
self.clipboardToast =
|
||||
ButtonToast(
|
||||
labelText: Strings.GoToCopiedLink,
|
||||
descriptionText: url.absoluteDisplayString,
|
||||
buttonText: Strings.GoButtonTittle,
|
||||
completion: { buttonPressed in
|
||||
if buttonPressed {
|
||||
self.delegate?.settingsOpenURLInNewTab(url)
|
||||
}
|
||||
})
|
||||
|
||||
if let toast = self.clipboardToast {
|
||||
self.delegate?.shouldDisplay(clipboardBar: toast)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
mobile/ios/Client/Frontend/Browser/ContextMenuHelper.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 WebKit
|
||||
|
||||
protocol ContextMenuHelperDelegate: class {
|
||||
func contextMenuHelper(_ contextMenuHelper: ContextMenuHelper, didLongPressElements elements: ContextMenuHelper.Elements, gestureRecognizer: UIGestureRecognizer)
|
||||
func contextMenuHelper(_ contextMenuHelper: ContextMenuHelper, didCancelGestureRecognizer: UIGestureRecognizer)
|
||||
}
|
||||
|
||||
class ContextMenuHelper: NSObject {
|
||||
struct Elements {
|
||||
let link: URL?
|
||||
let image: URL?
|
||||
}
|
||||
|
||||
fileprivate weak var tab: Tab?
|
||||
|
||||
weak var delegate: ContextMenuHelperDelegate?
|
||||
|
||||
fileprivate var nativeHighlightLongPressRecognizer: UILongPressGestureRecognizer?
|
||||
fileprivate var elements: Elements?
|
||||
|
||||
required init(tab: Tab) {
|
||||
super.init()
|
||||
|
||||
self.tab = tab
|
||||
|
||||
guard let path = Bundle.main.path(forResource: "ContextMenu", ofType: "js"),
|
||||
let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String,
|
||||
let webView = tab.webView else {
|
||||
return
|
||||
}
|
||||
|
||||
let userScript = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: false)
|
||||
webView.configuration.userContentController.addUserScript(userScript)
|
||||
|
||||
nativeHighlightLongPressRecognizer = gestureRecognizerWithDescriptionFragment("action=_highlightLongPressRecognized:") as? UILongPressGestureRecognizer
|
||||
|
||||
if let nativeLongPressRecognizer = gestureRecognizerWithDescriptionFragment("action=_longPressRecognized:") as? UILongPressGestureRecognizer {
|
||||
nativeLongPressRecognizer.removeTarget(nil, action: nil)
|
||||
nativeLongPressRecognizer.addTarget(self, action: #selector(longPressGestureDetected(_:)))
|
||||
}
|
||||
}
|
||||
|
||||
func gestureRecognizerWithDescriptionFragment(_ descriptionFragment: String) -> UIGestureRecognizer? {
|
||||
return tab?.webView?.scrollView.subviews.flatMap({ $0.gestureRecognizers }).joined().first(where: { $0.description.contains(descriptionFragment) })
|
||||
}
|
||||
|
||||
func longPressGestureDetected(_ sender: UIGestureRecognizer) {
|
||||
if sender.state == .cancelled {
|
||||
delegate?.contextMenuHelper(self, didCancelGestureRecognizer: sender)
|
||||
return
|
||||
}
|
||||
|
||||
guard sender.state == .began, let elements = self.elements else {
|
||||
return
|
||||
}
|
||||
|
||||
delegate?.contextMenuHelper(self, didLongPressElements: elements, gestureRecognizer: sender)
|
||||
|
||||
// To prevent the tapped link from proceeding with navigation, "cancel" the native WKWebView
|
||||
// `_highlightLongPressRecognizer`. This preserves the original behavior as seen here:
|
||||
// https://github.com/WebKit/webkit/blob/d591647baf54b4b300ca5501c21a68455429e182/Source/WebKit/UIProcess/ios/WKContentViewInteraction.mm#L1600-L1614
|
||||
if let nativeHighlightLongPressRecognizer = self.nativeHighlightLongPressRecognizer,
|
||||
nativeHighlightLongPressRecognizer.isEnabled {
|
||||
nativeHighlightLongPressRecognizer.isEnabled = false
|
||||
nativeHighlightLongPressRecognizer.isEnabled = true
|
||||
}
|
||||
|
||||
self.elements = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension ContextMenuHelper: TabContentScript {
|
||||
class func name() -> String {
|
||||
return "ContextMenuHelper"
|
||||
}
|
||||
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "contextMenuMessageHandler"
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
guard let data = message.body as? [String: AnyObject] else {
|
||||
return
|
||||
}
|
||||
|
||||
var linkURL: URL?
|
||||
if let urlString = data["link"] as? String,
|
||||
let escapedURLString = urlString.addingPercentEncoding(withAllowedCharacters: CharacterSet.URLAllowedCharacterSet()) {
|
||||
linkURL = URL(string: escapedURLString)
|
||||
}
|
||||
|
||||
var imageURL: URL?
|
||||
if let urlString = data["image"] as? String,
|
||||
let escapedURLString = urlString.addingPercentEncoding(withAllowedCharacters: CharacterSet.URLAllowedCharacterSet()) {
|
||||
imageURL = URL(string: escapedURLString)
|
||||
}
|
||||
|
||||
if linkURL != nil || imageURL != nil {
|
||||
elements = Elements(link: linkURL, image: imageURL)
|
||||
} else {
|
||||
elements = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
32
mobile/ios/Client/Frontend/Browser/CustomSearchHandler.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/* 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
|
||||
|
||||
class CustomSearchHelper: TabContentScript {
|
||||
fileprivate weak var tab: Tab?
|
||||
|
||||
required init(tab: Tab) {
|
||||
self.tab = tab
|
||||
if let path = Bundle.main.path(forResource: "CustomSearchHelper", ofType: "js") {
|
||||
if let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
let userScript = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: true)
|
||||
tab.webView!.configuration.userContentController.addUserScript(userScript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "customSearchHelper"
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
//We don't listen to messages because the BVC calls the searchHelper script by itself.
|
||||
}
|
||||
|
||||
class func name() -> String {
|
||||
return "CustomSearchHelper"
|
||||
}
|
||||
}
|
||||
298
mobile/ios/Client/Frontend/Browser/ErrorPageHelper.swift
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
/* 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 GCDWebServers
|
||||
import Shared
|
||||
import Storage
|
||||
|
||||
class ErrorPageHelper {
|
||||
static let MozDomain = "mozilla"
|
||||
static let MozErrorDownloadsNotEnabled = 100
|
||||
|
||||
fileprivate static let MessageOpenInSafari = "openInSafari"
|
||||
fileprivate static let MessageCertVisitOnce = "certVisitOnce"
|
||||
|
||||
// When an error page is intentionally loaded, its added to this set. If its in the set, we show
|
||||
// it as an error page. If its not, we assume someone is trying to reload this page somehow, and
|
||||
// we'll instead redirect back to the original URL.
|
||||
fileprivate static var redirecting = [URL]()
|
||||
|
||||
fileprivate static weak var certStore: CertStore?
|
||||
|
||||
// Regardless of cause, NSURLErrorServerCertificateUntrusted is currently returned in all cases.
|
||||
// Check the other cases in case this gets fixed in the future.
|
||||
fileprivate static let CertErrors = [
|
||||
NSURLErrorServerCertificateUntrusted,
|
||||
NSURLErrorServerCertificateHasBadDate,
|
||||
NSURLErrorServerCertificateHasUnknownRoot,
|
||||
NSURLErrorServerCertificateNotYetValid
|
||||
]
|
||||
|
||||
// Error codes copied from Gecko. The ints corresponding to these codes were determined
|
||||
// by inspecting the NSError in each of these cases.
|
||||
fileprivate static let CertErrorCodes = [
|
||||
-9813: "SEC_ERROR_UNKNOWN_ISSUER",
|
||||
-9814: "SEC_ERROR_EXPIRED_CERTIFICATE",
|
||||
-9843: "SSL_ERROR_BAD_CERT_DOMAIN",
|
||||
]
|
||||
|
||||
class func cfErrorToName(_ err: CFNetworkErrors) -> String {
|
||||
switch err {
|
||||
case .cfHostErrorHostNotFound: return "CFHostErrorHostNotFound"
|
||||
case .cfHostErrorUnknown: return "CFHostErrorUnknown"
|
||||
case .cfsocksErrorUnknownClientVersion: return "CFSOCKSErrorUnknownClientVersion"
|
||||
case .cfsocksErrorUnsupportedServerVersion: return "CFSOCKSErrorUnsupportedServerVersion"
|
||||
case .cfsocks4ErrorRequestFailed: return "CFSOCKS4ErrorRequestFailed"
|
||||
case .cfsocks4ErrorIdentdFailed: return "CFSOCKS4ErrorIdentdFailed"
|
||||
case .cfsocks4ErrorIdConflict: return "CFSOCKS4ErrorIdConflict"
|
||||
case .cfsocks4ErrorUnknownStatusCode: return "CFSOCKS4ErrorUnknownStatusCode"
|
||||
case .cfsocks5ErrorBadState: return "CFSOCKS5ErrorBadState"
|
||||
case .cfsocks5ErrorBadResponseAddr: return "CFSOCKS5ErrorBadResponseAddr"
|
||||
case .cfsocks5ErrorBadCredentials: return "CFSOCKS5ErrorBadCredentials"
|
||||
case .cfsocks5ErrorUnsupportedNegotiationMethod: return "CFSOCKS5ErrorUnsupportedNegotiationMethod"
|
||||
case .cfsocks5ErrorNoAcceptableMethod: return "CFSOCKS5ErrorNoAcceptableMethod"
|
||||
case .cfftpErrorUnexpectedStatusCode: return "CFFTPErrorUnexpectedStatusCode"
|
||||
case .cfErrorHTTPAuthenticationTypeUnsupported: return "CFErrorHTTPAuthenticationTypeUnsupported"
|
||||
case .cfErrorHTTPBadCredentials: return "CFErrorHTTPBadCredentials"
|
||||
case .cfErrorHTTPConnectionLost: return "CFErrorHTTPConnectionLost"
|
||||
case .cfErrorHTTPParseFailure: return "CFErrorHTTPParseFailure"
|
||||
case .cfErrorHTTPRedirectionLoopDetected: return "CFErrorHTTPRedirectionLoopDetected"
|
||||
case .cfErrorHTTPBadURL: return "CFErrorHTTPBadURL"
|
||||
case .cfErrorHTTPProxyConnectionFailure: return "CFErrorHTTPProxyConnectionFailure"
|
||||
case .cfErrorHTTPBadProxyCredentials: return "CFErrorHTTPBadProxyCredentials"
|
||||
case .cfErrorPACFileError: return "CFErrorPACFileError"
|
||||
case .cfErrorPACFileAuth: return "CFErrorPACFileAuth"
|
||||
case .cfErrorHTTPSProxyConnectionFailure: return "CFErrorHTTPSProxyConnectionFailure"
|
||||
case .cfStreamErrorHTTPSProxyFailureUnexpectedResponseToCONNECTMethod: return "CFStreamErrorHTTPSProxyFailureUnexpectedResponseToCONNECTMethod"
|
||||
|
||||
case .cfurlErrorBackgroundSessionInUseByAnotherProcess: return "CFURLErrorBackgroundSessionInUseByAnotherProcess"
|
||||
case .cfurlErrorBackgroundSessionWasDisconnected: return "CFURLErrorBackgroundSessionWasDisconnected"
|
||||
case .cfurlErrorUnknown: return "CFURLErrorUnknown"
|
||||
case .cfurlErrorCancelled: return "CFURLErrorCancelled"
|
||||
case .cfurlErrorBadURL: return "CFURLErrorBadURL"
|
||||
case .cfurlErrorTimedOut: return "CFURLErrorTimedOut"
|
||||
case .cfurlErrorUnsupportedURL: return "CFURLErrorUnsupportedURL"
|
||||
case .cfurlErrorCannotFindHost: return "CFURLErrorCannotFindHost"
|
||||
case .cfurlErrorCannotConnectToHost: return "CFURLErrorCannotConnectToHost"
|
||||
case .cfurlErrorNetworkConnectionLost: return "CFURLErrorNetworkConnectionLost"
|
||||
case .cfurlErrorDNSLookupFailed: return "CFURLErrorDNSLookupFailed"
|
||||
case .cfurlErrorHTTPTooManyRedirects: return "CFURLErrorHTTPTooManyRedirects"
|
||||
case .cfurlErrorResourceUnavailable: return "CFURLErrorResourceUnavailable"
|
||||
case .cfurlErrorNotConnectedToInternet: return "CFURLErrorNotConnectedToInternet"
|
||||
case .cfurlErrorRedirectToNonExistentLocation: return "CFURLErrorRedirectToNonExistentLocation"
|
||||
case .cfurlErrorBadServerResponse: return "CFURLErrorBadServerResponse"
|
||||
case .cfurlErrorUserCancelledAuthentication: return "CFURLErrorUserCancelledAuthentication"
|
||||
case .cfurlErrorUserAuthenticationRequired: return "CFURLErrorUserAuthenticationRequired"
|
||||
case .cfurlErrorZeroByteResource: return "CFURLErrorZeroByteResource"
|
||||
case .cfurlErrorCannotDecodeRawData: return "CFURLErrorCannotDecodeRawData"
|
||||
case .cfurlErrorCannotDecodeContentData: return "CFURLErrorCannotDecodeContentData"
|
||||
case .cfurlErrorCannotParseResponse: return "CFURLErrorCannotParseResponse"
|
||||
case .cfurlErrorInternationalRoamingOff: return "CFURLErrorInternationalRoamingOff"
|
||||
case .cfurlErrorCallIsActive: return "CFURLErrorCallIsActive"
|
||||
case .cfurlErrorDataNotAllowed: return "CFURLErrorDataNotAllowed"
|
||||
case .cfurlErrorRequestBodyStreamExhausted: return "CFURLErrorRequestBodyStreamExhausted"
|
||||
case .cfurlErrorFileDoesNotExist: return "CFURLErrorFileDoesNotExist"
|
||||
case .cfurlErrorFileIsDirectory: return "CFURLErrorFileIsDirectory"
|
||||
case .cfurlErrorNoPermissionsToReadFile: return "CFURLErrorNoPermissionsToReadFile"
|
||||
case .cfurlErrorDataLengthExceedsMaximum: return "CFURLErrorDataLengthExceedsMaximum"
|
||||
case .cfurlErrorSecureConnectionFailed: return "CFURLErrorSecureConnectionFailed"
|
||||
case .cfurlErrorServerCertificateHasBadDate: return "CFURLErrorServerCertificateHasBadDate"
|
||||
case .cfurlErrorServerCertificateUntrusted: return "CFURLErrorServerCertificateUntrusted"
|
||||
case .cfurlErrorServerCertificateHasUnknownRoot: return "CFURLErrorServerCertificateHasUnknownRoot"
|
||||
case .cfurlErrorServerCertificateNotYetValid: return "CFURLErrorServerCertificateNotYetValid"
|
||||
case .cfurlErrorClientCertificateRejected: return "CFURLErrorClientCertificateRejected"
|
||||
case .cfurlErrorClientCertificateRequired: return "CFURLErrorClientCertificateRequired"
|
||||
case .cfurlErrorCannotLoadFromNetwork: return "CFURLErrorCannotLoadFromNetwork"
|
||||
case .cfurlErrorCannotCreateFile: return "CFURLErrorCannotCreateFile"
|
||||
case .cfurlErrorCannotOpenFile: return "CFURLErrorCannotOpenFile"
|
||||
case .cfurlErrorCannotCloseFile: return "CFURLErrorCannotCloseFile"
|
||||
case .cfurlErrorCannotWriteToFile: return "CFURLErrorCannotWriteToFile"
|
||||
case .cfurlErrorCannotRemoveFile: return "CFURLErrorCannotRemoveFile"
|
||||
case .cfurlErrorCannotMoveFile: return "CFURLErrorCannotMoveFile"
|
||||
case .cfurlErrorDownloadDecodingFailedMidStream: return "CFURLErrorDownloadDecodingFailedMidStream"
|
||||
case .cfurlErrorDownloadDecodingFailedToComplete: return "CFURLErrorDownloadDecodingFailedToComplete"
|
||||
|
||||
case .cfhttpCookieCannotParseCookieFile: return "CFHTTPCookieCannotParseCookieFile"
|
||||
case .cfNetServiceErrorUnknown: return "CFNetServiceErrorUnknown"
|
||||
case .cfNetServiceErrorCollision: return "CFNetServiceErrorCollision"
|
||||
case .cfNetServiceErrorNotFound: return "CFNetServiceErrorNotFound"
|
||||
case .cfNetServiceErrorInProgress: return "CFNetServiceErrorInProgress"
|
||||
case .cfNetServiceErrorBadArgument: return "CFNetServiceErrorBadArgument"
|
||||
case .cfNetServiceErrorCancel: return "CFNetServiceErrorCancel"
|
||||
case .cfNetServiceErrorInvalid: return "CFNetServiceErrorInvalid"
|
||||
case .cfNetServiceErrorTimeout: return "CFNetServiceErrorTimeout"
|
||||
case .cfNetServiceErrorDNSServiceFailure: return "CFNetServiceErrorDNSServiceFailure"
|
||||
default: return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
class func register(_ server: WebServer, certStore: CertStore?) {
|
||||
self.certStore = certStore
|
||||
|
||||
server.registerHandlerForMethod("GET", module: "errors", resource: "error.html", handler: { (request) -> GCDWebServerResponse! in
|
||||
guard let url = request?.url.originalURLFromErrorURL else {
|
||||
return GCDWebServerResponse(statusCode: 404)
|
||||
}
|
||||
|
||||
guard let index = self.redirecting.index(of: url) else {
|
||||
return GCDWebServerDataResponse(redirect: url, permanent: false)
|
||||
}
|
||||
|
||||
self.redirecting.remove(at: index)
|
||||
|
||||
guard let code = request?.query["code"] as? String,
|
||||
let errCode = Int(code),
|
||||
let errDescription = request?.query["description"] as? String,
|
||||
let errURLString = request?.query["url"] as? String,
|
||||
let errURLDomain = URL(string: errURLString)?.host,
|
||||
var errDomain = request?.query["domain"] as? String else {
|
||||
return GCDWebServerResponse(statusCode: 404)
|
||||
}
|
||||
|
||||
var asset = Bundle.main.path(forResource: "NetError", ofType: "html")
|
||||
var variables = [
|
||||
"error_code": "\(errCode)",
|
||||
"error_title": errDescription,
|
||||
"short_description": errDomain,
|
||||
]
|
||||
|
||||
let tryAgain = NSLocalizedString("Try again", tableName: "ErrorPages", comment: "Shown in error pages on a button that will try to load the page again")
|
||||
var actions = "<button onclick='webkit.messageHandlers.localRequestHelper.postMessage({ type: \"reload\" })'>\(tryAgain)</button>"
|
||||
|
||||
if errDomain == kCFErrorDomainCFNetwork as String {
|
||||
if let code = CFNetworkErrors(rawValue: Int32(errCode)) {
|
||||
errDomain = self.cfErrorToName(code)
|
||||
}
|
||||
} else if errDomain == ErrorPageHelper.MozDomain {
|
||||
if errCode == ErrorPageHelper.MozErrorDownloadsNotEnabled {
|
||||
let downloadInSafari = NSLocalizedString("Open in Safari", tableName: "ErrorPages", comment: "Shown in error pages for files that can't be shown and need to be downloaded.")
|
||||
|
||||
// Overwrite the normal try-again action.
|
||||
actions = "<button onclick='webkit.messageHandlers.errorPageHelperMessageManager.postMessage({type: \"\(MessageOpenInSafari)\"})'>\(downloadInSafari)</button>"
|
||||
}
|
||||
errDomain = ""
|
||||
} else if CertErrors.contains(errCode) {
|
||||
guard let certError = request?.query["certerror"] as? String else {
|
||||
return GCDWebServerResponse(statusCode: 404)
|
||||
}
|
||||
|
||||
asset = Bundle.main.path(forResource: "CertError", ofType: "html")
|
||||
actions = "<button onclick='history.back()'>\(Strings.ErrorPagesGoBackButton)</button>"
|
||||
variables["error_title"] = Strings.ErrorPagesCertWarningTitle
|
||||
variables["cert_error"] = certError
|
||||
variables["long_description"] = String(format: Strings.ErrorPagesCertWarningDescription, "<b>\(errURLDomain)</b>")
|
||||
variables["advanced_button"] = Strings.ErrorPagesAdvancedButton
|
||||
variables["warning_description"] = Strings.ErrorPagesCertWarningDescription
|
||||
variables["warning_advanced1"] = Strings.ErrorPagesAdvancedWarning1
|
||||
variables["warning_advanced2"] = Strings.ErrorPagesAdvancedWarning2
|
||||
variables["warning_actions"] =
|
||||
"<p><a href='javascript:webkit.messageHandlers.errorPageHelperMessageManager.postMessage({type: \"\(MessageCertVisitOnce)\"})'>\(Strings.ErrorPagesVisitOnceButton)</button></p>"
|
||||
}
|
||||
|
||||
variables["actions"] = actions
|
||||
|
||||
let response = GCDWebServerDataResponse(htmlTemplate: asset, variables: variables)
|
||||
response?.setValue("no cache", forAdditionalHeader: "Pragma")
|
||||
response?.setValue("no-cache,must-revalidate", forAdditionalHeader: "Cache-Control")
|
||||
response?.setValue(Date().description, forAdditionalHeader: "Expires")
|
||||
return response
|
||||
})
|
||||
|
||||
server.registerHandlerForMethod("GET", module: "errors", resource: "NetError.css", handler: { (request) -> GCDWebServerResponse! in
|
||||
let path = Bundle(for: self).path(forResource: "NetError", ofType: "css")!
|
||||
return GCDWebServerDataResponse(data: try? Data(contentsOf: URL(fileURLWithPath: path)), contentType: "text/css")
|
||||
})
|
||||
|
||||
server.registerHandlerForMethod("GET", module: "errors", resource: "CertError.css", handler: { (request) -> GCDWebServerResponse! in
|
||||
let path = Bundle(for: self).path(forResource: "CertError", ofType: "css")!
|
||||
return GCDWebServerDataResponse(data: try? Data(contentsOf: URL(fileURLWithPath: path)), contentType: "text/css")
|
||||
})
|
||||
}
|
||||
|
||||
func showPage(_ error: NSError, forUrl url: URL, inWebView webView: WKWebView) {
|
||||
// Don't show error pages for error pages.
|
||||
if url.isErrorPageURL {
|
||||
if let previousURL = url.originalURLFromErrorURL,
|
||||
let index = ErrorPageHelper.redirecting.index(of: previousURL) {
|
||||
ErrorPageHelper.redirecting.remove(at: index)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Add this page to the redirecting list. This will cause the server to actually show the error page
|
||||
// (instead of redirecting to the original URL).
|
||||
ErrorPageHelper.redirecting.append(url)
|
||||
|
||||
var components = URLComponents(string: WebServer.sharedInstance.base + "/errors/error.html")!
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "url", value: url.absoluteString),
|
||||
URLQueryItem(name: "code", value: String(error.code)),
|
||||
URLQueryItem(name: "domain", value: error.domain),
|
||||
URLQueryItem(name: "description", value: error.localizedDescription)
|
||||
]
|
||||
|
||||
// If this is an invalid certificate, show a certificate error allowing the
|
||||
// user to go back or continue. The certificate itself is encoded and added as
|
||||
// a query parameter to the error page URL; we then read the certificate from
|
||||
// the URL if the user wants to continue.
|
||||
if ErrorPageHelper.CertErrors.contains(error.code),
|
||||
let certChain = error.userInfo["NSErrorPeerCertificateChainKey"] as? [SecCertificate],
|
||||
let cert = certChain.first,
|
||||
let underlyingError = error.userInfo[NSUnderlyingErrorKey] as? NSError,
|
||||
let certErrorCode = underlyingError.userInfo["_kCFStreamErrorCodeKey"] as? Int {
|
||||
let encodedCert = (SecCertificateCopyData(cert) as Data).base64EncodedString
|
||||
queryItems.append(URLQueryItem(name: "badcert", value: encodedCert))
|
||||
|
||||
let certError = ErrorPageHelper.CertErrorCodes[certErrorCode] ?? ""
|
||||
queryItems.append(URLQueryItem(name: "certerror", value: String(certError)))
|
||||
}
|
||||
|
||||
components.queryItems = queryItems
|
||||
webView.load(PrivilegedRequest(url: components.url!) as URLRequest)
|
||||
}
|
||||
}
|
||||
|
||||
extension ErrorPageHelper: TabContentScript {
|
||||
static func name() -> String {
|
||||
return "ErrorPageHelper"
|
||||
}
|
||||
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "errorPageHelperMessageManager"
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
if let errorURL = message.frameInfo.request.url, errorURL.isErrorPageURL,
|
||||
let res = message.body as? [String: String],
|
||||
let originalURL = errorURL.originalURLFromErrorURL,
|
||||
let type = res["type"] {
|
||||
|
||||
switch type {
|
||||
case ErrorPageHelper.MessageOpenInSafari:
|
||||
UIApplication.shared.openURL(originalURL)
|
||||
case ErrorPageHelper.MessageCertVisitOnce:
|
||||
if let cert = certFromErrorURL(errorURL),
|
||||
let host = originalURL.host {
|
||||
let origin = "\(host):\(originalURL.port ?? 443)"
|
||||
ErrorPageHelper.certStore?.addCertificate(cert, forOrigin: origin)
|
||||
_ = message.webView?.reload()
|
||||
}
|
||||
default:
|
||||
assertionFailure("Unknown error message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func certFromErrorURL(_ url: URL) -> SecCertificate? {
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
if let encodedCert = components?.queryItems?.filter({ $0.name == "badcert" }).first?.value,
|
||||
let certData = Data(base64Encoded: encodedCert, options: []) {
|
||||
return SecCertificateCreateWithData(nil, certData as CFData)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
145
mobile/ios/Client/Frontend/Browser/FaviconManager.swift
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/* 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 Storage
|
||||
import SDWebImage
|
||||
import Deferred
|
||||
import Sync
|
||||
|
||||
class FaviconManager: TabContentScript {
|
||||
static let FaviconDidLoad = "FaviconManagerFaviconDidLoad"
|
||||
|
||||
let profile: Profile!
|
||||
weak var tab: Tab?
|
||||
|
||||
static let maximumFaviconSize = 1 * 1024 * 1024 // 1 MiB file size limit
|
||||
|
||||
init(tab: Tab, profile: Profile) {
|
||||
self.profile = profile
|
||||
self.tab = tab
|
||||
|
||||
if let path = Bundle.main.path(forResource: "Favicons", ofType: "js") {
|
||||
if let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
let userScript = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: true)
|
||||
tab.webView!.configuration.userContentController.addUserScript(userScript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class func name() -> String {
|
||||
return "FaviconsManager"
|
||||
}
|
||||
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "faviconsMessageHandler"
|
||||
}
|
||||
|
||||
fileprivate func loadFavicons(_ tab: Tab, profile: Profile, favicons: [Favicon]) -> Deferred<[Maybe<Favicon>]> {
|
||||
var deferreds: [() -> Deferred<Maybe<Favicon>>]
|
||||
deferreds = favicons.map { favicon in
|
||||
return { [weak tab] () -> Deferred<Maybe<Favicon>> in
|
||||
if let tab = tab,
|
||||
let url = URL(string: favicon.url),
|
||||
let currentURL = tab.url {
|
||||
return self.getFavicon(tab, iconUrl: url, currentURL: currentURL, icon: favicon, profile: profile)
|
||||
} else {
|
||||
return deferMaybe(FaviconError())
|
||||
}
|
||||
}
|
||||
}
|
||||
return all(deferreds.map({$0()}))
|
||||
}
|
||||
|
||||
func getFavicon(_ tab: Tab, iconUrl: URL, currentURL: URL, icon: Favicon, profile: Profile) -> Deferred<Maybe<Favicon>> {
|
||||
let deferred = Deferred<Maybe<Favicon>>()
|
||||
let manager = SDWebImageManager.shared()
|
||||
let options: [SDWebImageOptions] = tab.isPrivate ? [.lowPriority, .cacheMemoryOnly] : [.lowPriority]
|
||||
let url = currentURL.absoluteString
|
||||
let site = Site(url: url, title: "")
|
||||
|
||||
weak var tab = tab
|
||||
|
||||
func loadImageCompleted(_ img: UIImage?, _ url: URL?) {
|
||||
guard let tab = tab, let img = img, let urlString = url?.absoluteString else {
|
||||
deferred.fill(Maybe(failure: FaviconError()))
|
||||
return
|
||||
}
|
||||
|
||||
let fav = Favicon(url: urlString, date: Date(), type: icon.type)
|
||||
fav.width = Int(img.size.width)
|
||||
fav.height = Int(img.size.height)
|
||||
|
||||
if !tab.isPrivate {
|
||||
if tab.favicons.isEmpty {
|
||||
self.makeFaviconAvailable(tab, atURL: currentURL, favicon: fav, withImage: img)
|
||||
}
|
||||
tab.favicons.append(fav)
|
||||
self.profile.favicons.addFavicon(fav, forSite: site).upon { _ in
|
||||
deferred.fill(Maybe(success: fav))
|
||||
}
|
||||
} else {
|
||||
tab.favicons.append(fav)
|
||||
deferred.fill(Maybe(success: fav))
|
||||
}
|
||||
}
|
||||
|
||||
var fetch: SDWebImageOperation? = nil
|
||||
fetch = manager.loadImage(with: iconUrl, options: SDWebImageOptions(options),
|
||||
progress: { (receivedSize, expectedSize, _) in
|
||||
if receivedSize > FaviconManager.maximumFaviconSize || expectedSize > FaviconManager.maximumFaviconSize {
|
||||
fetch?.cancel()
|
||||
}
|
||||
},
|
||||
completed: { (img, _, _, _, _, url) in
|
||||
loadImageCompleted(img, url)
|
||||
})
|
||||
return deferred
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
self.tab?.favicons.removeAll(keepingCapacity: false)
|
||||
if let tab = self.tab, let currentURL = tab.url {
|
||||
var favicons = [Favicon]()
|
||||
if let icons = message.body as? [String: Int] {
|
||||
for icon in icons {
|
||||
if let _ = URL(string: icon.0), let iconType = IconType(rawValue: icon.1) {
|
||||
let favicon = Favicon(url: icon.0, date: Date(), type: iconType)
|
||||
favicons.append(favicon)
|
||||
}
|
||||
}
|
||||
}
|
||||
loadFavicons(tab, profile: profile, favicons: favicons).uponQueue(DispatchQueue.main) { result in
|
||||
let results = result.flatMap({ $0.successValue })
|
||||
let faviconsReadOnly = favicons
|
||||
if results.count == 1 && faviconsReadOnly[0].type == .guess {
|
||||
// No favicon is indicated in the HTML
|
||||
self.noFaviconAvailable(tab, atURL: currentURL as URL)
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: NSNotification.Name(rawValue: FaviconManager.FaviconDidLoad), object: tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeFaviconAvailable(_ tab: Tab, atURL url: URL, favicon: Favicon, withImage image: UIImage) {
|
||||
// XXX: Bug 1390200 - Disable NSUserActivity/CoreSpotlight temporarily
|
||||
// let helper = tab.getHelper(name: "SpotlightHelper") as? SpotlightHelper
|
||||
// helper?.updateImage(image, forURL: url)
|
||||
}
|
||||
|
||||
func noFaviconAvailable(_ tab: Tab, atURL url: URL) {
|
||||
// XXX: Bug 1390200 - Disable NSUserActivity/CoreSpotlight temporarily
|
||||
// let helper = tab.getHelper(name: "SpotlightHelper") as? SpotlightHelper
|
||||
// helper?.updateImage(forURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
class FaviconError: MaybeErrorType {
|
||||
internal var description: String {
|
||||
return "No Image Loaded"
|
||||
}
|
||||
}
|
||||
167
mobile/ios/Client/Frontend/Browser/FindInPageBar.swift
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/* 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
|
||||
|
||||
protocol FindInPageBarDelegate: class {
|
||||
func findInPage(_ findInPage: FindInPageBar, didTextChange text: String)
|
||||
func findInPage(_ findInPage: FindInPageBar, didFindPreviousWithText text: String)
|
||||
func findInPage(_ findInPage: FindInPageBar, didFindNextWithText text: String)
|
||||
func findInPageDidPressClose(_ findInPage: FindInPageBar)
|
||||
}
|
||||
|
||||
private struct FindInPageUX {
|
||||
static let ButtonColor = UIColor.black
|
||||
static let MatchCountColor = UIColor.lightGray
|
||||
static let MatchCountFont = UIConstants.DefaultChromeFont
|
||||
static let SearchTextColor = UIColor(rgb: 0xe66000)
|
||||
static let SearchTextFont = UIConstants.DefaultChromeFont
|
||||
static let TopBorderColor = UIColor(rgb: 0xEEEEEE)
|
||||
}
|
||||
|
||||
class FindInPageBar: UIView {
|
||||
weak var delegate: FindInPageBarDelegate?
|
||||
fileprivate let searchText = UITextField()
|
||||
fileprivate let matchCountView = UILabel()
|
||||
fileprivate let previousButton = UIButton()
|
||||
fileprivate let nextButton = UIButton()
|
||||
|
||||
var currentResult = 0 {
|
||||
didSet {
|
||||
matchCountView.text = "\(currentResult)/\(totalResults)"
|
||||
}
|
||||
}
|
||||
|
||||
var totalResults = 0 {
|
||||
didSet {
|
||||
matchCountView.text = "\(currentResult)/\(totalResults)"
|
||||
previousButton.isEnabled = totalResults > 1
|
||||
nextButton.isEnabled = previousButton.isEnabled
|
||||
}
|
||||
}
|
||||
|
||||
var text: String? {
|
||||
get {
|
||||
return searchText.text
|
||||
}
|
||||
|
||||
set {
|
||||
searchText.text = newValue
|
||||
SELdidTextChange(searchText)
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
backgroundColor = UIColor.white
|
||||
|
||||
searchText.addTarget(self, action: #selector(FindInPageBar.SELdidTextChange(_:)), for: UIControlEvents.editingChanged)
|
||||
searchText.textColor = FindInPageUX.SearchTextColor
|
||||
searchText.font = FindInPageUX.SearchTextFont
|
||||
searchText.autocapitalizationType = UITextAutocapitalizationType.none
|
||||
searchText.autocorrectionType = UITextAutocorrectionType.no
|
||||
searchText.inputAssistantItem.leadingBarButtonGroups = []
|
||||
searchText.inputAssistantItem.trailingBarButtonGroups = []
|
||||
searchText.enablesReturnKeyAutomatically = true
|
||||
searchText.returnKeyType = .search
|
||||
searchText.accessibilityIdentifier = "FindInPage.searchField"
|
||||
addSubview(searchText)
|
||||
|
||||
matchCountView.textColor = FindInPageUX.MatchCountColor
|
||||
matchCountView.font = FindInPageUX.MatchCountFont
|
||||
matchCountView.isHidden = true
|
||||
matchCountView.accessibilityIdentifier = "FindInPage.matchCount"
|
||||
addSubview(matchCountView)
|
||||
|
||||
previousButton.setImage(UIImage(named: "find_previous"), for: UIControlState())
|
||||
previousButton.setTitleColor(FindInPageUX.ButtonColor, for: UIControlState())
|
||||
previousButton.accessibilityLabel = NSLocalizedString("Previous in-page result", tableName: "FindInPage", comment: "Accessibility label for previous result button in Find in Page Toolbar.")
|
||||
previousButton.addTarget(self, action: #selector(FindInPageBar.SELdidFindPrevious(_:)), for: UIControlEvents.touchUpInside)
|
||||
previousButton.accessibilityIdentifier = "FindInPage.find_previous"
|
||||
addSubview(previousButton)
|
||||
|
||||
nextButton.setImage(UIImage(named: "find_next"), for: UIControlState())
|
||||
nextButton.setTitleColor(FindInPageUX.ButtonColor, for: UIControlState())
|
||||
nextButton.accessibilityLabel = NSLocalizedString("Next in-page result", tableName: "FindInPage", comment: "Accessibility label for next result button in Find in Page Toolbar.")
|
||||
nextButton.addTarget(self, action: #selector(FindInPageBar.SELdidFindNext(_:)), for: UIControlEvents.touchUpInside)
|
||||
nextButton.accessibilityIdentifier = "FindInPage.find_next"
|
||||
addSubview(nextButton)
|
||||
|
||||
let closeButton = UIButton()
|
||||
closeButton.setImage(UIImage(named: "find_close"), for: UIControlState())
|
||||
closeButton.setTitleColor(FindInPageUX.ButtonColor, for: UIControlState())
|
||||
closeButton.accessibilityLabel = NSLocalizedString("Done", tableName: "FindInPage", comment: "Done button in Find in Page Toolbar.")
|
||||
closeButton.addTarget(self, action: #selector(FindInPageBar.SELdidPressClose(_:)), for: UIControlEvents.touchUpInside)
|
||||
closeButton.accessibilityIdentifier = "FindInPage.close"
|
||||
addSubview(closeButton)
|
||||
|
||||
let topBorder = UIView()
|
||||
topBorder.backgroundColor = FindInPageUX.TopBorderColor
|
||||
addSubview(topBorder)
|
||||
|
||||
searchText.snp.makeConstraints { make in
|
||||
make.leading.top.bottom.equalTo(self).inset(UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0))
|
||||
}
|
||||
searchText.setContentHuggingPriority(UILayoutPriorityDefaultLow, for: UILayoutConstraintAxis.horizontal)
|
||||
searchText.setContentCompressionResistancePriority(UILayoutPriorityDefaultLow, for: UILayoutConstraintAxis.horizontal)
|
||||
|
||||
matchCountView.snp.makeConstraints { make in
|
||||
make.leading.equalTo(searchText.snp.trailing)
|
||||
make.centerY.equalTo(self)
|
||||
}
|
||||
matchCountView.setContentHuggingPriority(UILayoutPriorityDefaultHigh, for: UILayoutConstraintAxis.horizontal)
|
||||
matchCountView.setContentCompressionResistancePriority(UILayoutPriorityDefaultHigh, for: UILayoutConstraintAxis.horizontal)
|
||||
|
||||
previousButton.snp.makeConstraints { make in
|
||||
make.leading.equalTo(matchCountView.snp.trailing)
|
||||
make.size.equalTo(self.snp.height)
|
||||
make.centerY.equalTo(self)
|
||||
}
|
||||
|
||||
nextButton.snp.makeConstraints { make in
|
||||
make.leading.equalTo(previousButton.snp.trailing)
|
||||
make.size.equalTo(self.snp.height)
|
||||
make.centerY.equalTo(self)
|
||||
}
|
||||
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.leading.equalTo(nextButton.snp.trailing)
|
||||
make.size.equalTo(self.snp.height)
|
||||
make.trailing.centerY.equalTo(self)
|
||||
}
|
||||
|
||||
topBorder.snp.makeConstraints { make in
|
||||
make.height.equalTo(1)
|
||||
make.left.right.top.equalTo(self)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@discardableResult override func becomeFirstResponder() -> Bool {
|
||||
searchText.becomeFirstResponder()
|
||||
return super.becomeFirstResponder()
|
||||
}
|
||||
|
||||
@objc fileprivate func SELdidFindPrevious(_ sender: UIButton) {
|
||||
delegate?.findInPage(self, didFindPreviousWithText: searchText.text ?? "")
|
||||
}
|
||||
|
||||
@objc fileprivate func SELdidFindNext(_ sender: UIButton) {
|
||||
delegate?.findInPage(self, didFindNextWithText: searchText.text ?? "")
|
||||
}
|
||||
|
||||
@objc fileprivate func SELdidTextChange(_ sender: UITextField) {
|
||||
matchCountView.isHidden = searchText.text?.isEmpty ?? true
|
||||
delegate?.findInPage(self, didTextChange: searchText.text ?? "")
|
||||
}
|
||||
|
||||
@objc fileprivate func SELdidPressClose(_ sender: UIButton) {
|
||||
delegate?.findInPageDidPressClose(self)
|
||||
}
|
||||
}
|
||||
46
mobile/ios/Client/Frontend/Browser/FindInPageHelper.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 Foundation
|
||||
import Shared
|
||||
import WebKit
|
||||
|
||||
protocol FindInPageHelperDelegate: class {
|
||||
func findInPageHelper(_ findInPageHelper: FindInPageHelper, didUpdateCurrentResult currentResult: Int)
|
||||
func findInPageHelper(_ findInPageHelper: FindInPageHelper, didUpdateTotalResults totalResults: Int)
|
||||
}
|
||||
|
||||
class FindInPageHelper: TabContentScript {
|
||||
weak var delegate: FindInPageHelperDelegate?
|
||||
fileprivate weak var tab: Tab?
|
||||
|
||||
class func name() -> String {
|
||||
return "FindInPage"
|
||||
}
|
||||
|
||||
required init(tab: Tab) {
|
||||
self.tab = tab
|
||||
|
||||
if let path = Bundle.main.path(forResource: "FindInPage", ofType: "js"), let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
let userScript = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: true)
|
||||
tab.webView!.configuration.userContentController.addUserScript(userScript)
|
||||
}
|
||||
}
|
||||
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "findInPageHandler"
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
let data = message.body as! [String: Int]
|
||||
|
||||
if let currentResult = data["currentResult"] {
|
||||
delegate?.findInPageHelper(self, didUpdateCurrentResult: currentResult)
|
||||
}
|
||||
|
||||
if let totalResults = data["totalResults"] {
|
||||
delegate?.findInPageHelper(self, didUpdateTotalResults: totalResults)
|
||||
}
|
||||
}
|
||||
}
|
||||
45
mobile/ios/Client/Frontend/Browser/HistoryStateHelper.swift
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/* 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
|
||||
|
||||
protocol HistoryStateHelperDelegate: class {
|
||||
func historyStateHelper(_ historyStateHelper: HistoryStateHelper, didPushOrReplaceStateInTab tab: Tab)
|
||||
}
|
||||
|
||||
// This tab helper is needed for injecting a user script into the
|
||||
// WKWebView that intercepts calls to `history.pushState()` and
|
||||
// `history.replaceState()` so that the BrowserViewController is
|
||||
// notified when the user navigates a single-page web application.
|
||||
class HistoryStateHelper: TabContentScript {
|
||||
weak var delegate: HistoryStateHelperDelegate?
|
||||
fileprivate weak var tab: Tab?
|
||||
|
||||
required init(tab: Tab) {
|
||||
self.tab = tab
|
||||
if let path = Bundle.main.path(forResource: "HistoryStateHelper", ofType: "js") {
|
||||
if let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
let userScript = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: true)
|
||||
tab.webView!.configuration.userContentController.addUserScript(userScript)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "historyStateHelper"
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
if let tab = tab {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.historyStateHelper(self, didPushOrReplaceStateInTab: tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class func name() -> String {
|
||||
return "HistoryStateHelper"
|
||||
}
|
||||
}
|
||||
71
mobile/ios/Client/Frontend/Browser/HomePageHelper.swift
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
/* 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.browserLogger
|
||||
|
||||
struct HomePageConstants {
|
||||
static let HomePageURLPrefKey = "HomePageURLPref"
|
||||
static let DefaultHomePageURLPrefKey = PrefsKeys.KeyDefaultHomePageURL
|
||||
}
|
||||
|
||||
class HomePageHelper {
|
||||
|
||||
let prefs: Prefs
|
||||
|
||||
var currentURL: URL? {
|
||||
get {
|
||||
return HomePageAccessors.getHomePage(prefs)
|
||||
}
|
||||
set {
|
||||
if let url = newValue, url.isWebPage(includeDataURIs: false) && !url.isLocal {
|
||||
prefs.setString(url.absoluteString, forKey: HomePageConstants.HomePageURLPrefKey)
|
||||
} else {
|
||||
prefs.removeObjectForKey(HomePageConstants.HomePageURLPrefKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var defaultURLString: String? {
|
||||
return HomePageAccessors.getDefaultHomePageString(prefs)
|
||||
}
|
||||
|
||||
var isHomePageAvailable: Bool { return currentURL != nil }
|
||||
|
||||
init(prefs: Prefs) {
|
||||
self.prefs = prefs
|
||||
}
|
||||
|
||||
func openHomePage(_ tab: Tab) {
|
||||
guard let url = currentURL else {
|
||||
// this should probably never happen.
|
||||
log.error("User requested a homepage that wasn't a valid URL")
|
||||
return
|
||||
}
|
||||
tab.loadRequest(URLRequest(url: url))
|
||||
}
|
||||
|
||||
func openHomePage(inTab tab: Tab, presentAlertOn viewController: UIViewController?) {
|
||||
if isHomePageAvailable {
|
||||
openHomePage(tab)
|
||||
} else {
|
||||
setHomePage(toTab: tab, presentAlertOn: viewController)
|
||||
}
|
||||
}
|
||||
|
||||
func setHomePage(toTab tab: Tab, presentAlertOn viewController: UIViewController?) {
|
||||
let alertController = UIAlertController(
|
||||
title: Strings.SetHomePageDialogTitle,
|
||||
message: Strings.SetHomePageDialogMessage,
|
||||
preferredStyle: UIAlertControllerStyle.alert)
|
||||
alertController.addAction(UIAlertAction(title: Strings.SetHomePageDialogNo, style: .cancel, handler: nil))
|
||||
alertController.addAction(UIAlertAction(title: Strings.SetHomePageDialogYes, style: .default) { _ in
|
||||
self.currentURL = tab.url as URL?
|
||||
})
|
||||
viewController?.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
32
mobile/ios/Client/Frontend/Browser/LocalRequestHelper.swift
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/* 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
|
||||
|
||||
class LocalRequestHelper: TabContentScript {
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "localRequestHelper"
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
guard message.frameInfo.request.url?.isLocal ?? false else { return }
|
||||
|
||||
let params = message.body as! [String: String]
|
||||
|
||||
if params["type"] == "load",
|
||||
let urlString = params["url"],
|
||||
let url = URL(string: urlString) {
|
||||
_ = message.webView?.load(PrivilegedRequest(url: url) as URLRequest)
|
||||
} else if params["type"] == "reload" {
|
||||
_ = message.webView?.reload()
|
||||
} else {
|
||||
assertionFailure("Invalid message: \(message.body)")
|
||||
}
|
||||
}
|
||||
|
||||
class func name() -> String {
|
||||
return "LocalRequestHelper"
|
||||
}
|
||||
}
|
||||
234
mobile/ios/Client/Frontend/Browser/LoginsHelper.swift
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import Shared
|
||||
import Storage
|
||||
import XCGLogger
|
||||
import WebKit
|
||||
import Deferred
|
||||
import SwiftyJSON
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
class LoginsHelper: TabContentScript {
|
||||
fileprivate weak var tab: Tab?
|
||||
fileprivate let profile: Profile
|
||||
fileprivate var snackBar: SnackBar?
|
||||
|
||||
// Exposed for mocking purposes
|
||||
var logins: BrowserLogins {
|
||||
return profile.logins
|
||||
}
|
||||
|
||||
class func name() -> String {
|
||||
return "LoginsHelper"
|
||||
}
|
||||
|
||||
required init(tab: Tab, profile: Profile) {
|
||||
self.tab = tab
|
||||
self.profile = profile
|
||||
|
||||
if let path = Bundle.main.path(forResource: "LoginsHelper", ofType: "js"), let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
let userScript = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: false)
|
||||
tab.webView!.configuration.userContentController.addUserScript(userScript)
|
||||
}
|
||||
}
|
||||
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "loginsManagerMessageHandler"
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
guard var res = message.body as? [String: AnyObject] else { return }
|
||||
guard let type = res["type"] as? String else { return }
|
||||
|
||||
// Check to see that we're in the foreground before trying to check the logins. We want to
|
||||
// make sure we don't try accessing the logins database while we're backgrounded to avoid
|
||||
// the system from terminating our app due to background disk access.
|
||||
//
|
||||
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1307822 for details.
|
||||
guard UIApplication.shared.applicationState == .active && !profile.isShutdown else {
|
||||
return
|
||||
}
|
||||
|
||||
// We don't use the WKWebView's URL since the page can spoof the URL by using document.location
|
||||
// right before requesting login data. See bug 1194567 for more context.
|
||||
if let url = message.frameInfo.request.url {
|
||||
// Since responses go to the main frame, make sure we only listen for main frame requests
|
||||
// to avoid XSS attacks.
|
||||
if message.frameInfo.isMainFrame && type == "request" {
|
||||
res["username"] = "" as AnyObject?
|
||||
res["password"] = "" as AnyObject?
|
||||
if let login = Login.fromScript(url, script: res),
|
||||
let requestId = res["requestId"] as? String {
|
||||
requestLogins(login, requestId: requestId)
|
||||
}
|
||||
} else if type == "submit" {
|
||||
if self.profile.prefs.boolForKey("saveLogins") ?? true {
|
||||
if let login = Login.fromScript(url, script: res) {
|
||||
setCredentials(login)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class func replace(_ base: String, keys: [String], replacements: [String]) -> NSMutableAttributedString {
|
||||
var ranges = [NSRange]()
|
||||
var string = base
|
||||
for (index, key) in keys.enumerated() {
|
||||
let replace = replacements[index]
|
||||
let range = string.range(of: key,
|
||||
options: NSString.CompareOptions.literal,
|
||||
range: nil,
|
||||
locale: nil)!
|
||||
string.replaceSubrange(range, with: replace)
|
||||
let nsRange = NSRange(location: string.characters.distance(from: string.startIndex, to: range.lowerBound),
|
||||
length: replace.characters.count)
|
||||
ranges.append(nsRange)
|
||||
}
|
||||
|
||||
var attributes = [String: AnyObject]()
|
||||
attributes[NSFontAttributeName] = UIFont.systemFont(ofSize: 13, weight: UIFontWeightRegular)
|
||||
attributes[NSForegroundColorAttributeName] = UIColor.darkGray
|
||||
let attr = NSMutableAttributedString(string: string, attributes: attributes)
|
||||
let font: UIFont = UIFont.systemFont(ofSize: 13, weight: UIFontWeightMedium)
|
||||
for range in ranges {
|
||||
attr.addAttribute(NSFontAttributeName, value: font, range: range)
|
||||
}
|
||||
return attr
|
||||
}
|
||||
|
||||
func getLoginsForProtectionSpace(_ protectionSpace: URLProtectionSpace) -> Deferred<Maybe<Cursor<LoginData>>> {
|
||||
return profile.logins.getLoginsForProtectionSpace(protectionSpace)
|
||||
}
|
||||
|
||||
func updateLoginByGUID(_ guid: GUID, new: LoginData, significant: Bool) -> Success {
|
||||
return profile.logins.updateLoginByGUID(guid, new: new, significant: significant)
|
||||
}
|
||||
|
||||
func setCredentials(_ login: LoginData) {
|
||||
if login.password.isEmpty {
|
||||
log.debug("Empty password")
|
||||
return
|
||||
}
|
||||
|
||||
profile.logins
|
||||
.getLoginsForProtectionSpace(login.protectionSpace, withUsername: login.username)
|
||||
.uponQueue(DispatchQueue.main) { res in
|
||||
if let data = res.successValue {
|
||||
log.debug("Found \(data.count) logins.")
|
||||
for saved in data {
|
||||
if let saved = saved {
|
||||
if saved.password == login.password {
|
||||
self.profile.logins.addUseOfLoginByGUID(saved.guid)
|
||||
return
|
||||
}
|
||||
|
||||
self.promptUpdateFromLogin(login: saved, toLogin: login)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.promptSave(login)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func promptSave(_ login: LoginData) {
|
||||
guard login.isValid.isSuccess else {
|
||||
return
|
||||
}
|
||||
|
||||
let promptMessage: NSAttributedString
|
||||
if let username = login.username {
|
||||
let promptStringFormat = NSLocalizedString("LoginsHelper.PromptSaveLogin.Title", value: "Save login %@ for %@?", comment: "Prompt for saving a login. The first parameter is the username being saved. The second parameter is the hostname of the site.")
|
||||
promptMessage = NSAttributedString(string: String(format: promptStringFormat, username, login.hostname))
|
||||
} else {
|
||||
let promptStringFormat = NSLocalizedString("LoginsHelper.PromptSavePassword.Title", value: "Save password for %@?", comment: "Prompt for saving a password with no username. The parameter is the hostname of the site.")
|
||||
promptMessage = NSAttributedString(string: String(format: promptStringFormat, login.hostname))
|
||||
}
|
||||
|
||||
if snackBar != nil {
|
||||
tab?.removeSnackbar(snackBar!)
|
||||
}
|
||||
|
||||
snackBar = TimerSnackBar(attrText: promptMessage,
|
||||
img: UIImage(named: "key"),
|
||||
buttons: [
|
||||
SnackButton(title: Strings.LoginsHelperDontSaveButtonTitle, accessibilityIdentifier: "SaveLoginPrompt.dontSaveButton", callback: { (bar: SnackBar) -> Void in
|
||||
self.tab?.removeSnackbar(bar)
|
||||
self.snackBar = nil
|
||||
return
|
||||
}),
|
||||
|
||||
SnackButton(title: Strings.LoginsHelperSaveLoginButtonTitle, accessibilityIdentifier: "SaveLoginPrompt.saveLoginButton", callback: { (bar: SnackBar) -> Void in
|
||||
self.tab?.removeSnackbar(bar)
|
||||
self.snackBar = nil
|
||||
self.profile.logins.addLogin(login)
|
||||
|
||||
LeanPlumClient.shared.track(event: .savedLoginAndPassword)
|
||||
})
|
||||
])
|
||||
tab?.addSnackbar(snackBar!)
|
||||
}
|
||||
|
||||
fileprivate func promptUpdateFromLogin(login old: LoginData, toLogin new: LoginData) {
|
||||
guard new.isValid.isSuccess else {
|
||||
return
|
||||
}
|
||||
|
||||
let guid = old.guid
|
||||
|
||||
let formatted: String
|
||||
if let username = new.username {
|
||||
let promptStringFormat = NSLocalizedString("LoginsHelper.PromptUpdateLogin.Title", value: "Update login %@ for %@?", comment: "Prompt for updating a login. The first parameter is the username for which the password will be updated for. The second parameter is the hostname of the site.")
|
||||
formatted = String(format: promptStringFormat, username, new.hostname)
|
||||
} else {
|
||||
let promptStringFormat = NSLocalizedString("LoginsHelper.PromptUpdatePassword.Title", value: "Update password for %@?", comment: "Prompt for updating a password with no username. The parameter is the hostname of the site.")
|
||||
formatted = String(format: promptStringFormat, new.hostname)
|
||||
}
|
||||
let promptMessage = NSAttributedString(string: formatted)
|
||||
|
||||
if snackBar != nil {
|
||||
tab?.removeSnackbar(snackBar!)
|
||||
}
|
||||
|
||||
snackBar = TimerSnackBar(attrText: promptMessage,
|
||||
img: UIImage(named: "key"),
|
||||
buttons: [
|
||||
SnackButton(title: Strings.LoginsHelperDontSaveButtonTitle, accessibilityIdentifier: "UpdateLoginPrompt.dontSaveButton", callback: { (bar: SnackBar) -> Void in
|
||||
self.tab?.removeSnackbar(bar)
|
||||
self.snackBar = nil
|
||||
return
|
||||
}),
|
||||
|
||||
SnackButton(title: Strings.LoginsHelperUpdateButtonTitle, accessibilityIdentifier: "UpdateLoginPrompt.updateButton", callback: { (bar: SnackBar) -> Void in
|
||||
self.tab?.removeSnackbar(bar)
|
||||
self.snackBar = nil
|
||||
self.profile.logins.updateLoginByGUID(guid, new: new,
|
||||
significant: new.isSignificantlyDifferentFrom(old))
|
||||
})
|
||||
])
|
||||
tab?.addSnackbar(snackBar!)
|
||||
}
|
||||
|
||||
fileprivate func requestLogins(_ login: LoginData, requestId: String) {
|
||||
profile.logins.getLoginsForProtectionSpace(login.protectionSpace).uponQueue(DispatchQueue.main) { res in
|
||||
var jsonObj = [String: Any]()
|
||||
if let cursor = res.successValue {
|
||||
log.debug("Found \(cursor.count) logins.")
|
||||
jsonObj["requestId"] = requestId
|
||||
jsonObj["name"] = "RemoteLogins:loginsFound"
|
||||
jsonObj["logins"] = cursor.map { $0!.toDict() }
|
||||
}
|
||||
|
||||
let json = JSON(jsonObj)
|
||||
let src = "window.__firefox__.logins.inject(\(json.stringValue()!))"
|
||||
self.tab?.webView?.evaluateJavaScript(src, completionHandler: { (obj, err) -> Void in
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
156
mobile/ios/Client/Frontend/Browser/MailProviders.swift
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/* 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
|
||||
|
||||
// mailto headers: subject, body, cc, bcc
|
||||
|
||||
protocol MailProvider {
|
||||
var beginningScheme: String {get set}
|
||||
var supportedHeaders: [String] {get set}
|
||||
func newEmailURLFromMetadata(_ metadata: MailToMetadata) -> URL?
|
||||
}
|
||||
|
||||
private func constructEmailURLString(_ beginningURLString: String, metadata: MailToMetadata, supportedHeaders: [String], bodyHName: String = "body", toHName: String = "to") -> String {
|
||||
var lowercasedHeaders = [String: String]()
|
||||
metadata.headers.forEach { (hname, hvalue) in
|
||||
lowercasedHeaders[hname.lowercased()] = hvalue
|
||||
}
|
||||
|
||||
var toParam: String
|
||||
if let toHValue = lowercasedHeaders["to"] {
|
||||
let value = metadata.to.isEmpty ? toHValue : [metadata.to, toHValue].joined(separator: "%2C%20")
|
||||
lowercasedHeaders.removeValue(forKey: "to")
|
||||
toParam = "\(toHName)=\(value)"
|
||||
} else {
|
||||
toParam = "\(toHName)=\(metadata.to)"
|
||||
}
|
||||
|
||||
var queryParams: [String] = []
|
||||
lowercasedHeaders.forEach({ (hname, hvalue) in
|
||||
if supportedHeaders.contains(hname) {
|
||||
queryParams.append("\(hname)=\(hvalue)")
|
||||
} else if hname == "body" {
|
||||
queryParams.append("\(bodyHName)=\(hvalue)")
|
||||
}
|
||||
})
|
||||
let stringParams = queryParams.joined(separator: "&")
|
||||
let finalURLString = beginningURLString + (stringParams.isEmpty ? toParam : [toParam, stringParams].joined(separator: "&"))
|
||||
return finalURLString
|
||||
}
|
||||
|
||||
class ReaddleSparkIntegration: MailProvider {
|
||||
var beginningScheme = "readdle-spark://compose?"
|
||||
var supportedHeaders = [
|
||||
"subject",
|
||||
"recipient",
|
||||
"textbody",
|
||||
"html",
|
||||
"cc",
|
||||
"bcc"
|
||||
]
|
||||
|
||||
func newEmailURLFromMetadata(_ metadata: MailToMetadata) -> URL? {
|
||||
return constructEmailURLString(beginningScheme, metadata: metadata, supportedHeaders: supportedHeaders, bodyHName: "textbody", toHName: "recipient").asURL
|
||||
}
|
||||
}
|
||||
|
||||
class AirmailIntegration: MailProvider {
|
||||
var beginningScheme = "airmail://compose?"
|
||||
var supportedHeaders = [
|
||||
"subject",
|
||||
"from",
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"plainBody",
|
||||
"htmlBody"
|
||||
]
|
||||
|
||||
func newEmailURLFromMetadata(_ metadata: MailToMetadata) -> URL? {
|
||||
return constructEmailURLString(beginningScheme, metadata: metadata, supportedHeaders: supportedHeaders, bodyHName: "htmlBody").asURL
|
||||
}
|
||||
}
|
||||
|
||||
class MyMailIntegration: MailProvider {
|
||||
var beginningScheme = "mymail-mailto://?"
|
||||
var supportedHeaders = [
|
||||
"to",
|
||||
"subject",
|
||||
"body",
|
||||
"cc",
|
||||
"bcc"
|
||||
]
|
||||
|
||||
func newEmailURLFromMetadata(_ metadata: MailToMetadata) -> URL? {
|
||||
return constructEmailURLString(beginningScheme, metadata: metadata, supportedHeaders: supportedHeaders).asURL
|
||||
}
|
||||
}
|
||||
|
||||
class MailRuIntegration: MyMailIntegration {
|
||||
override init() {
|
||||
super.init()
|
||||
self.beginningScheme = "mailru-mailto://?"
|
||||
}
|
||||
}
|
||||
|
||||
class MSOutlookIntegration: MailProvider {
|
||||
var beginningScheme = "ms-outlook://emails/new?"
|
||||
var supportedHeaders = [
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"subject",
|
||||
"body"
|
||||
]
|
||||
|
||||
func newEmailURLFromMetadata(_ metadata: MailToMetadata) -> URL? {
|
||||
return constructEmailURLString(beginningScheme, metadata: metadata, supportedHeaders: supportedHeaders).asURL
|
||||
}
|
||||
}
|
||||
|
||||
class YMailIntegration: MailProvider {
|
||||
var beginningScheme = "ymail://mail/any/compose?"
|
||||
var supportedHeaders = [
|
||||
"to",
|
||||
"cc",
|
||||
"subject",
|
||||
"body"
|
||||
]
|
||||
|
||||
func newEmailURLFromMetadata(_ metadata: MailToMetadata) -> URL? {
|
||||
return constructEmailURLString(beginningScheme, metadata: metadata, supportedHeaders: supportedHeaders).asURL
|
||||
}
|
||||
}
|
||||
|
||||
class GoogleGmailIntegration: MailProvider {
|
||||
var beginningScheme = "googlegmail:///co?"
|
||||
var supportedHeaders = [
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"subject",
|
||||
"body"
|
||||
]
|
||||
|
||||
func newEmailURLFromMetadata(_ metadata: MailToMetadata) -> URL? {
|
||||
return constructEmailURLString(beginningScheme, metadata: metadata, supportedHeaders: supportedHeaders).asURL
|
||||
}
|
||||
}
|
||||
|
||||
class GoogleInboxIntegration: MailProvider {
|
||||
var beginningScheme = "inbox-gmail://co?"
|
||||
var supportedHeaders = [
|
||||
"to",
|
||||
"cc",
|
||||
"bcc",
|
||||
"subject",
|
||||
"body"
|
||||
]
|
||||
|
||||
func newEmailURLFromMetadata(_ metadata: MailToMetadata) -> URL? {
|
||||
return constructEmailURLString(beginningScheme, metadata: metadata, supportedHeaders: supportedHeaders).asURL
|
||||
}
|
||||
}
|
||||
52
mobile/ios/Client/Frontend/Browser/MailtoLinkHandler.swift
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/* 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
|
||||
|
||||
open class MailtoLinkHandler {
|
||||
|
||||
lazy var mailSchemeProviders: [String: MailProvider] = self.fetchMailSchemeProviders()
|
||||
|
||||
func launchMailClientForScheme(_ scheme: String, metadata: MailToMetadata, defaultMailtoURL: URL) {
|
||||
guard let provider = mailSchemeProviders[scheme], let mailURL = provider.newEmailURLFromMetadata(metadata) else {
|
||||
UIApplication.shared.openURL(defaultMailtoURL)
|
||||
return
|
||||
}
|
||||
|
||||
if UIApplication.shared.canOpenURL(mailURL) {
|
||||
UIApplication.shared.openURL(mailURL)
|
||||
} else {
|
||||
UIApplication.shared.openURL(defaultMailtoURL)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchMailSchemeProviders() -> [String: MailProvider] {
|
||||
var providerDict = [String: MailProvider]()
|
||||
if let path = Bundle.main.path(forResource: "MailSchemes", ofType: "plist"), let dictRoot = NSArray(contentsOfFile: path) {
|
||||
dictRoot.forEach({ dict in
|
||||
if let schemeDict = dict as? [String: Any], let scheme = schemeDict["scheme"] as? String {
|
||||
if scheme == "readdle-spark://" {
|
||||
providerDict[scheme] = ReaddleSparkIntegration()
|
||||
} else if scheme == "mymail-mailto://" {
|
||||
providerDict[scheme] = MyMailIntegration()
|
||||
} else if scheme == "mailru-mailto://" {
|
||||
providerDict[scheme] = MailRuIntegration()
|
||||
} else if scheme == "airmail://" {
|
||||
providerDict[scheme] = AirmailIntegration()
|
||||
} else if scheme == "ms-outlook://" {
|
||||
providerDict[scheme] = MSOutlookIntegration()
|
||||
} else if scheme == "ymail://" {
|
||||
providerDict[scheme] = YMailIntegration()
|
||||
} else if scheme == "googlegmail://" {
|
||||
providerDict[scheme] = GoogleGmailIntegration()
|
||||
} else if scheme == "inbox-gmail://" {
|
||||
providerDict[scheme] = GoogleInboxIntegration()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return providerDict
|
||||
}
|
||||
}
|
||||
58
mobile/ios/Client/Frontend/Browser/NightModeHelper.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 WebKit
|
||||
import Shared
|
||||
|
||||
struct NightModePrefsKey {
|
||||
static let NightModeButtonIsInMenu = PrefsKeys.KeyNightModeButtonIsInMenu
|
||||
static let NightModeStatus = PrefsKeys.KeyNightModeStatus
|
||||
}
|
||||
|
||||
class NightModeHelper: TabContentScript {
|
||||
fileprivate weak var tab: Tab?
|
||||
|
||||
required init(tab: Tab) {
|
||||
self.tab = tab
|
||||
if let path = Bundle.main.path(forResource: "NightModeHelper", ofType: "js"), let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
let userScript = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentStart, forMainFrameOnly: true)
|
||||
tab.webView!.configuration.userContentController.addUserScript(userScript)
|
||||
}
|
||||
}
|
||||
|
||||
static func name() -> String {
|
||||
return "NightMode"
|
||||
}
|
||||
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "NightMode"
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
static func toggle(_ prefs: Prefs, tabManager: TabManager) {
|
||||
let isActive = prefs.boolForKey(NightModePrefsKey.NightModeStatus) ?? false
|
||||
setNightMode(prefs, tabManager: tabManager, enabled: !isActive)
|
||||
}
|
||||
|
||||
static func setNightMode(_ prefs: Prefs, tabManager: TabManager, enabled: Bool) {
|
||||
prefs.setBool(enabled, forKey: NightModePrefsKey.NightModeStatus)
|
||||
for tab in tabManager.tabs {
|
||||
tab.setNightMode(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
static func isActivated(_ prefs: Prefs) -> Bool {
|
||||
return prefs.boolForKey(NightModePrefsKey.NightModeStatus) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
class NightModeAccessors {
|
||||
static func isNightMode(_ prefs: Prefs) -> Bool {
|
||||
return prefs.boolForKey(NightModePrefsKey.NightModeStatus) ?? false
|
||||
}
|
||||
}
|
||||
47
mobile/ios/Client/Frontend/Browser/NoImageModeHelper.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 Foundation
|
||||
import WebKit
|
||||
import Shared
|
||||
|
||||
struct NoImageModePrefsKey {
|
||||
static let NoImageModeStatus = PrefsKeys.KeyNoImageModeStatus
|
||||
}
|
||||
|
||||
class NoImageModeHelper: TabContentScript {
|
||||
fileprivate weak var tab: Tab?
|
||||
|
||||
required init(tab: Tab) {
|
||||
self.tab = tab
|
||||
if let path = Bundle.main.path(forResource: "NoImageModeHelper", ofType: "js"), let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
let userScript = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentStart, forMainFrameOnly: true)
|
||||
tab.webView!.configuration.userContentController.addUserScript(userScript)
|
||||
}
|
||||
}
|
||||
|
||||
static func name() -> String {
|
||||
return "NoImageMode"
|
||||
}
|
||||
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "NoImageMode"
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
// Do nothing.
|
||||
}
|
||||
|
||||
static func isActivated(_ prefs: Prefs) -> Bool {
|
||||
return prefs.boolForKey(NoImageModePrefsKey.NoImageModeStatus) ?? false
|
||||
}
|
||||
|
||||
static func toggle(profile: Profile, tabManager: TabManager) {
|
||||
if #available(iOS 11, *) {
|
||||
let enabled = !isActivated(profile.prefs)
|
||||
profile.prefs.setBool(enabled, forKey: PrefsKeys.KeyNoImageModeStatus)
|
||||
tabManager.tabs.forEach { $0.noImageMode = enabled }
|
||||
}
|
||||
}
|
||||
}
|
||||
232
mobile/ios/Client/Frontend/Browser/OpenInHelper.swift
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/* 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 PassKit
|
||||
import WebKit
|
||||
import SnapKit
|
||||
|
||||
import Shared
|
||||
|
||||
import XCGLogger
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
struct OpenInViewUX {
|
||||
static let ViewHeight: CGFloat = 40.0
|
||||
static let TextFont = UIFont.systemFont(ofSize: 16)
|
||||
static let TextColor = UIColor(red: 74.0/255.0, green: 144.0/255.0, blue: 226.0/255.0, alpha: 1.0)
|
||||
static let TextOffset = -15
|
||||
static let OpenInString = NSLocalizedString("Open in…", comment: "String indicating that the file can be opened in another application on the device")
|
||||
}
|
||||
|
||||
enum MimeType: String {
|
||||
case PDF = "application/pdf"
|
||||
case PASS = "application/vnd.apple.pkpass"
|
||||
}
|
||||
|
||||
protocol OpenInHelper {
|
||||
init?(response: URLResponse)
|
||||
var openInView: UIView? { get set }
|
||||
func open()
|
||||
}
|
||||
|
||||
struct OpenIn {
|
||||
static let helpers: [OpenInHelper.Type] = [OpenPdfInHelper.self, OpenPassBookHelper.self, ShareFileHelper.self]
|
||||
|
||||
static func helperForResponse(_ response: URLResponse) -> OpenInHelper? {
|
||||
return helpers.flatMap { $0.init(response: response) }.first
|
||||
}
|
||||
}
|
||||
|
||||
class ShareFileHelper: NSObject, OpenInHelper {
|
||||
var openInView: UIView?
|
||||
|
||||
fileprivate var url: URL
|
||||
var pathExtension: String?
|
||||
|
||||
required init?(response: URLResponse) {
|
||||
guard let MIMEType = response.mimeType, !(MIMEType == MimeType.PASS.rawValue || MIMEType == MimeType.PDF.rawValue),
|
||||
let responseURL = response.url else { return nil }
|
||||
url = (responseURL as NSURL) as URL
|
||||
super.init()
|
||||
}
|
||||
|
||||
func open() {
|
||||
let alertController = UIAlertController(
|
||||
title: Strings.OpenInDownloadHelperAlertTitle,
|
||||
message: Strings.OpenInDownloadHelperAlertMessage,
|
||||
preferredStyle: UIAlertControllerStyle.alert)
|
||||
alertController.addAction( UIAlertAction(title: Strings.OpenInDownloadHelperAlertCancel, style: .cancel, handler: nil))
|
||||
alertController.addAction(UIAlertAction(title: Strings.OpenInDownloadHelperAlertConfirm, style: .default) { (action) in
|
||||
let objectsToShare = [self.url]
|
||||
let activityVC = UIActivityViewController(activityItems: objectsToShare, applicationActivities: nil)
|
||||
if let sourceView = self.openInView, let popoverController = activityVC.popoverPresentationController {
|
||||
popoverController.sourceView = sourceView
|
||||
popoverController.sourceRect = CGRect(origin: CGPoint(x: sourceView.bounds.midX, y: sourceView.bounds.maxY), size: .zero)
|
||||
popoverController.permittedArrowDirections = .up
|
||||
}
|
||||
UIApplication.shared.keyWindow?.rootViewController?.present(activityVC, animated: true, completion: nil)
|
||||
})
|
||||
UIApplication.shared.keyWindow?.rootViewController?.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
class OpenPassBookHelper: NSObject, OpenInHelper {
|
||||
var openInView: UIView?
|
||||
|
||||
fileprivate var url: URL
|
||||
|
||||
required init?(response: URLResponse) {
|
||||
guard let MIMEType = response.mimeType, MIMEType == MimeType.PASS.rawValue && PKAddPassesViewController.canAddPasses(),
|
||||
let responseURL = response.url else { return nil }
|
||||
url = responseURL
|
||||
super.init()
|
||||
}
|
||||
|
||||
func open() {
|
||||
guard let passData = try? Data(contentsOf: url) else { return }
|
||||
var error: NSError? = nil
|
||||
let pass = PKPass(data: passData, error: &error)
|
||||
if let _ = error {
|
||||
// display an error
|
||||
let alertController = UIAlertController(
|
||||
title: Strings.UnableToAddPassErrorTitle,
|
||||
message: Strings.UnableToAddPassErrorMessage,
|
||||
preferredStyle: UIAlertControllerStyle.alert)
|
||||
alertController.addAction(
|
||||
UIAlertAction(title: Strings.UnableToAddPassErrorDismiss, style: .cancel) { (action) in
|
||||
// Do nothing.
|
||||
})
|
||||
UIApplication.shared.keyWindow?.rootViewController?.present(alertController, animated: true, completion: nil)
|
||||
return
|
||||
}
|
||||
let passLibrary = PKPassLibrary()
|
||||
if passLibrary.containsPass(pass) {
|
||||
UIApplication.shared.openURL(pass.passURL!)
|
||||
} else {
|
||||
let addController = PKAddPassesViewController(pass: pass)
|
||||
UIApplication.shared.keyWindow?.rootViewController?.present(addController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class OpenPdfInHelper: NSObject, OpenInHelper, UIDocumentInteractionControllerDelegate {
|
||||
fileprivate var url: URL
|
||||
fileprivate var docController: UIDocumentInteractionController?
|
||||
fileprivate var openInURL: URL?
|
||||
|
||||
lazy var openInView: UIView? = getOpenInView(self)()
|
||||
|
||||
lazy var documentDirectory: URL = {
|
||||
return URL(string: NSTemporaryDirectory())!.appendingPathComponent("pdfs")
|
||||
}()
|
||||
|
||||
fileprivate var filepath: URL?
|
||||
|
||||
required init?(response: URLResponse) {
|
||||
guard let MIMEType = response.mimeType, MIMEType == MimeType.PDF.rawValue && UIApplication.shared.canOpenURL(URL(string: "itms-books:")!),
|
||||
let responseURL = response.url else { return nil }
|
||||
url = responseURL
|
||||
super.init()
|
||||
setFilePath(response.suggestedFilename ?? url.lastPathComponent )
|
||||
}
|
||||
|
||||
fileprivate func setFilePath(_ suggestedFilename: String) {
|
||||
var filename = suggestedFilename
|
||||
let pathExtension = filename.asURL?.pathExtension
|
||||
if pathExtension == nil {
|
||||
filename.append(".pdf")
|
||||
}
|
||||
filepath = documentDirectory.appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
deinit {
|
||||
guard let url = openInURL else { return }
|
||||
let fileManager = FileManager.default
|
||||
do {
|
||||
try fileManager.removeItem(at: url)
|
||||
} catch {
|
||||
log.error("failed to delete file at \(url): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func getOpenInView() -> OpenInView {
|
||||
let overlayView = OpenInView()
|
||||
|
||||
overlayView.openInButton.addTarget(self, action: #selector(OpenPdfInHelper.open), for: .touchUpInside)
|
||||
return overlayView
|
||||
}
|
||||
|
||||
func createDocumentControllerForURL(url: URL) {
|
||||
docController = UIDocumentInteractionController(url: url)
|
||||
docController?.delegate = self
|
||||
self.openInURL = url
|
||||
}
|
||||
|
||||
func createLocalCopyOfPDF() {
|
||||
guard let filePath = filepath else {
|
||||
log.error("failed to create proper URL")
|
||||
return
|
||||
}
|
||||
if docController == nil {
|
||||
// if we already have a URL but no document controller, just create the document controller
|
||||
if let url = openInURL {
|
||||
createDocumentControllerForURL(url: url)
|
||||
return
|
||||
}
|
||||
let contentsOfFile = try? Data(contentsOf: url)
|
||||
let fileManager = FileManager.default
|
||||
do {
|
||||
try fileManager.createDirectory(atPath: documentDirectory.absoluteString, withIntermediateDirectories: true, attributes: nil)
|
||||
if fileManager.createFile(atPath: filePath.absoluteString, contents: contentsOfFile, attributes: nil) {
|
||||
let openInURL = URL(fileURLWithPath: filePath.absoluteString)
|
||||
createDocumentControllerForURL(url: openInURL)
|
||||
} else {
|
||||
log.error("Unable to create local version of PDF file at \(filePath)")
|
||||
}
|
||||
} catch {
|
||||
log.error("Error on creating directory at \(self.documentDirectory)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func open() {
|
||||
createLocalCopyOfPDF()
|
||||
guard let _parentView = self.openInView!.superview, let docController = self.docController else { log.error("view doesn't have a superview so can't open anything"); return }
|
||||
// iBooks should be installed by default on all devices we care about, so regardless of whether or not there are other pdf-capable
|
||||
// apps on this device, if we can open in iBooks we can open this PDF
|
||||
// simulators do not have iBooks so the open in view will not work on the simulator
|
||||
if UIApplication.shared.canOpenURL(URL(string: "itms-books:")!) {
|
||||
log.info("iBooks installed: attempting to open pdf")
|
||||
docController.presentOpenInMenu(from: .zero, in: _parentView, animated: true)
|
||||
} else {
|
||||
log.info("iBooks is not installed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OpenInView: UIView {
|
||||
let openInButton = UIButton()
|
||||
|
||||
init() {
|
||||
super.init(frame: .zero)
|
||||
openInButton.setTitleColor(OpenInViewUX.TextColor, for: UIControlState.normal)
|
||||
openInButton.setTitle(OpenInViewUX.OpenInString, for: UIControlState.normal)
|
||||
openInButton.titleLabel?.font = OpenInViewUX.TextFont
|
||||
openInButton.sizeToFit()
|
||||
self.addSubview(openInButton)
|
||||
openInButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self)
|
||||
make.height.equalTo(self)
|
||||
make.trailing.equalTo(self).offset(OpenInViewUX.TextOffset)
|
||||
}
|
||||
self.backgroundColor = UIColor.white
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
281
mobile/ios/Client/Frontend/Browser/OpenSearch.swift
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
/* 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 Fuzi
|
||||
|
||||
private let TypeSearch = "text/html"
|
||||
private let TypeSuggest = "application/x-suggestions+json"
|
||||
|
||||
class OpenSearchEngine: NSObject, NSCoding {
|
||||
static let PreferredIconSize = 30
|
||||
|
||||
let shortName: String
|
||||
let engineID: String?
|
||||
let image: UIImage
|
||||
let isCustomEngine: Bool
|
||||
let searchTemplate: String
|
||||
fileprivate let suggestTemplate: String?
|
||||
|
||||
fileprivate let SearchTermComponent = "{searchTerms}"
|
||||
fileprivate let LocaleTermComponent = "{moz:locale}"
|
||||
|
||||
fileprivate lazy var searchQueryComponentKey: String? = self.getQueryArgFromTemplate()
|
||||
|
||||
init(engineID: String?, shortName: String, image: UIImage, searchTemplate: String, suggestTemplate: String?, isCustomEngine: Bool) {
|
||||
self.shortName = shortName
|
||||
self.image = image
|
||||
self.searchTemplate = searchTemplate
|
||||
self.suggestTemplate = suggestTemplate
|
||||
self.isCustomEngine = isCustomEngine
|
||||
self.engineID = engineID
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
// this catches the cases where bool encoded in Swift 2 needs to be decoded with decodeObject, but a Bool encoded in swift 3 needs
|
||||
// to be decoded using decodeBool. This catches the upgrade case to ensure that we are always able to fetch a keyed valye for isCustomEngine
|
||||
// http://stackoverflow.com/a/40034694
|
||||
let isCustomEngine = aDecoder.decodeAsBool(forKey: "isCustomEngine")
|
||||
guard let searchTemplate = aDecoder.decodeObject(forKey: "searchTemplate") as? String,
|
||||
let shortName = aDecoder.decodeObject(forKey: "shortName") as? String,
|
||||
let image = aDecoder.decodeObject(forKey: "image") as? UIImage else {
|
||||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
|
||||
self.searchTemplate = searchTemplate
|
||||
self.shortName = shortName
|
||||
self.isCustomEngine = isCustomEngine
|
||||
self.image = image
|
||||
self.engineID = aDecoder.decodeObject(forKey: "engineID") as? String
|
||||
self.suggestTemplate = nil
|
||||
}
|
||||
|
||||
func encode(with aCoder: NSCoder) {
|
||||
aCoder.encode(searchTemplate, forKey: "searchTemplate")
|
||||
aCoder.encode(shortName, forKey: "shortName")
|
||||
aCoder.encode(isCustomEngine, forKey: "isCustomEngine")
|
||||
aCoder.encode(image, forKey: "image")
|
||||
aCoder.encode(engineID, forKey: "engineID")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the search URL for the given query.
|
||||
*/
|
||||
func searchURLForQuery(_ query: String) -> URL? {
|
||||
return getURLFromTemplate(searchTemplate, query: query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the arg that we use for searching for this engine
|
||||
* Problem: the search terms may not be a query arg, they may be part of the URL - how to deal with this?
|
||||
**/
|
||||
fileprivate func getQueryArgFromTemplate() -> String? {
|
||||
// we have the replace the templates SearchTermComponent in order to make the template
|
||||
// a valid URL, otherwise we cannot do the conversion to NSURLComponents
|
||||
// and have to do flaky pattern matching instead.
|
||||
let placeholder = "PLACEHOLDER"
|
||||
let template = searchTemplate.replacingOccurrences(of: SearchTermComponent, with: placeholder)
|
||||
let components = URLComponents(string: template)
|
||||
let searchTerm = components?.queryItems?.filter { item in
|
||||
return item.value == placeholder
|
||||
}
|
||||
guard let term = searchTerm, !term.isEmpty else { return nil }
|
||||
return term[0].name
|
||||
}
|
||||
|
||||
/**
|
||||
* check that the URL host contains the name of the search engine somewhere inside it
|
||||
**/
|
||||
fileprivate func isSearchURLForEngine(_ url: URL?) -> Bool {
|
||||
guard let urlHost = url?.hostSLD,
|
||||
let queryEndIndex = searchTemplate.range(of: "?")?.lowerBound,
|
||||
let templateURL = URL(string: searchTemplate.substring(to: queryEndIndex)) else { return false }
|
||||
return urlHost == templateURL.hostSLD
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the query that was used to construct a given search URL
|
||||
**/
|
||||
func queryForSearchURL(_ url: URL?) -> String? {
|
||||
if isSearchURLForEngine(url) {
|
||||
if let key = searchQueryComponentKey,
|
||||
let value = url?.getQuery()[key] {
|
||||
return value.replacingOccurrences(of: "+", with: " ").removingPercentEncoding
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the search suggestion URL for the given query.
|
||||
*/
|
||||
func suggestURLForQuery(_ query: String) -> URL? {
|
||||
if let suggestTemplate = suggestTemplate {
|
||||
return getURLFromTemplate(suggestTemplate, query: query)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fileprivate func getURLFromTemplate(_ searchTemplate: String, query: String) -> URL? {
|
||||
if let escapedQuery = query.addingPercentEncoding(withAllowedCharacters: CharacterSet.SearchTermsAllowedCharacterSet()) {
|
||||
// Escape the search template as well in case it contains not-safe characters like symbols
|
||||
let templateAllowedSet = NSMutableCharacterSet()
|
||||
templateAllowedSet.formUnion(with: CharacterSet.URLAllowedCharacterSet())
|
||||
|
||||
// Allow brackets since we use them in our template as our insertion point
|
||||
templateAllowedSet.formUnion(with: CharacterSet(charactersIn: "{}"))
|
||||
|
||||
if let encodedSearchTemplate = searchTemplate.addingPercentEncoding(withAllowedCharacters: templateAllowedSet as CharacterSet) {
|
||||
let localeString = Locale.current.identifier
|
||||
let urlString = encodedSearchTemplate
|
||||
.replacingOccurrences(of: SearchTermComponent, with: escapedQuery, options: String.CompareOptions.literal, range: nil)
|
||||
.replacingOccurrences(of: LocaleTermComponent, with: localeString, options: String.CompareOptions.literal, range: nil)
|
||||
return URL(string: urlString)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenSearch XML parser.
|
||||
*
|
||||
* This parser accepts standards-compliant OpenSearch 1.1 XML documents in addition to
|
||||
* the Firefox-specific search plugin format.
|
||||
*
|
||||
* OpenSearch spec: http://www.opensearch.org/Specifications/OpenSearch/1.1
|
||||
*/
|
||||
class OpenSearchParser {
|
||||
fileprivate let pluginMode: Bool
|
||||
|
||||
init(pluginMode: Bool) {
|
||||
self.pluginMode = pluginMode
|
||||
}
|
||||
|
||||
func parse(_ file: String, engineID: String) -> OpenSearchEngine? {
|
||||
guard let data = try? Data(contentsOf: URL(fileURLWithPath: file)) else {
|
||||
print("Invalid search file")
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let indexer = try? XMLDocument(data: data),
|
||||
let docIndexer = indexer.root else {
|
||||
print("Invalid XML document")
|
||||
return nil
|
||||
}
|
||||
|
||||
let shortNameIndexer = docIndexer.children(tag: "ShortName")
|
||||
if shortNameIndexer.count != 1 {
|
||||
print("ShortName must appear exactly once")
|
||||
return nil
|
||||
}
|
||||
|
||||
let shortName = shortNameIndexer[0].stringValue
|
||||
if shortName == "" {
|
||||
print("ShortName must contain text")
|
||||
return nil
|
||||
}
|
||||
|
||||
let urlIndexers = docIndexer.children(tag: "Url")
|
||||
if urlIndexers.isEmpty {
|
||||
print("Url must appear at least once")
|
||||
return nil
|
||||
}
|
||||
|
||||
var searchTemplate: String!
|
||||
var suggestTemplate: String?
|
||||
for urlIndexer in urlIndexers {
|
||||
let type = urlIndexer.attributes["type"]
|
||||
if type == nil {
|
||||
print("Url element requires a type attribute", terminator: "\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
if type != TypeSearch && type != TypeSuggest {
|
||||
// Not a supported search type.
|
||||
continue
|
||||
}
|
||||
|
||||
var template = urlIndexer.attributes["template"]
|
||||
if template == nil {
|
||||
print("Url element requires a template attribute", terminator: "\n")
|
||||
return nil
|
||||
}
|
||||
|
||||
if pluginMode {
|
||||
let paramIndexers = urlIndexer.children(tag: "Param")
|
||||
|
||||
if !paramIndexers.isEmpty {
|
||||
template! += "?"
|
||||
var firstAdded = false
|
||||
for paramIndexer in paramIndexers {
|
||||
if firstAdded {
|
||||
template! += "&"
|
||||
} else {
|
||||
firstAdded = true
|
||||
}
|
||||
|
||||
let name = paramIndexer.attributes["name"]
|
||||
let value = paramIndexer.attributes["value"]
|
||||
if name == nil || value == nil {
|
||||
print("Param element must have name and value attributes", terminator: "\n")
|
||||
return nil
|
||||
}
|
||||
template! += name! + "=" + value!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if type == TypeSearch {
|
||||
searchTemplate = template
|
||||
} else {
|
||||
suggestTemplate = template
|
||||
}
|
||||
}
|
||||
|
||||
if searchTemplate == nil {
|
||||
print("Search engine must have a text/html type")
|
||||
return nil
|
||||
}
|
||||
|
||||
let imageIndexers = docIndexer.children(tag: "Image")
|
||||
var largestImage = 0
|
||||
var largestImageElement: XMLElement?
|
||||
|
||||
// TODO: For now, just use the largest icon.
|
||||
for imageIndexer in imageIndexers {
|
||||
let imageWidth = Int(imageIndexer.attributes["width"] ?? "")
|
||||
let imageHeight = Int(imageIndexer.attributes["height"] ?? "")
|
||||
|
||||
// Only accept square images.
|
||||
if imageWidth != imageHeight {
|
||||
continue
|
||||
}
|
||||
|
||||
if let imageWidth = imageWidth {
|
||||
if imageWidth > largestImage {
|
||||
largestImage = imageWidth
|
||||
largestImageElement = imageIndexer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let uiImage: UIImage
|
||||
if let imageElement = largestImageElement,
|
||||
let imageURL = URL(string: imageElement.stringValue),
|
||||
let imageData = try? Data(contentsOf: imageURL),
|
||||
let image = UIImage.imageFromDataThreadSafe(imageData) {
|
||||
uiImage = image
|
||||
} else {
|
||||
print("Error: Invalid search image data")
|
||||
return nil
|
||||
}
|
||||
|
||||
return OpenSearchEngine(engineID: engineID, shortName: shortName, image: uiImage, searchTemplate: searchTemplate, suggestTemplate: suggestTemplate, isCustomEngine: false)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
class OpenWithSettingsViewController: UITableViewController {
|
||||
typealias MailtoProviderEntry = (name: String, scheme: String, enabled: Bool)
|
||||
var mailProviderSource = [MailtoProviderEntry]()
|
||||
|
||||
fileprivate let prefs: Prefs
|
||||
fileprivate var currentChoice: String = "mailto"
|
||||
|
||||
fileprivate let BasicCheckmarkCell = "BasicCheckmarkCell"
|
||||
|
||||
init(prefs: Prefs) {
|
||||
self.prefs = prefs
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
title = Strings.SettingsOpenWithSectionName
|
||||
|
||||
tableView.accessibilityIdentifier = "OpenWithPage.Setting.Options"
|
||||
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: BasicCheckmarkCell)
|
||||
tableView.backgroundColor = UIConstants.TableViewHeaderBackgroundColor
|
||||
|
||||
let headerFooterFrame = CGRect(origin: CGPoint.zero, size: CGSize(width: self.view.frame.width, height: UIConstants.TableViewHeaderFooterHeight))
|
||||
let headerView = SettingsTableSectionHeaderFooterView(frame: headerFooterFrame)
|
||||
headerView.titleLabel.text = Strings.SettingsOpenWithPageTitle.uppercased()
|
||||
headerView.showTopBorder = false
|
||||
headerView.showBottomBorder = true
|
||||
|
||||
let footerView = SettingsTableSectionHeaderFooterView(frame: headerFooterFrame)
|
||||
footerView.showTopBorder = true
|
||||
footerView.showBottomBorder = false
|
||||
|
||||
tableView.tableHeaderView = headerView
|
||||
tableView.tableFooterView = footerView
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(OpenWithSettingsViewController.appDidBecomeActive), name: NSNotification.Name.UIApplicationDidBecomeActive, object: nil)
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
appDidBecomeActive()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
self.prefs.setString(currentChoice, forKey: PrefsKeys.KeyMailToOption)
|
||||
}
|
||||
|
||||
func appDidBecomeActive() {
|
||||
reloadMailProviderSource()
|
||||
updateCurrentChoice()
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
func updateCurrentChoice() {
|
||||
var previousChoiceAvailable: Bool = false
|
||||
if let prefMailtoScheme = self.prefs.stringForKey(PrefsKeys.KeyMailToOption) {
|
||||
mailProviderSource.forEach({ (name, scheme, enabled) in
|
||||
if scheme == prefMailtoScheme {
|
||||
previousChoiceAvailable = enabled
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if !previousChoiceAvailable {
|
||||
self.prefs.setString(mailProviderSource[0].scheme, forKey: PrefsKeys.KeyMailToOption)
|
||||
}
|
||||
|
||||
if let updatedMailToClient = self.prefs.stringForKey(PrefsKeys.KeyMailToOption) {
|
||||
self.currentChoice = updatedMailToClient
|
||||
}
|
||||
}
|
||||
|
||||
func reloadMailProviderSource() {
|
||||
if let path = Bundle.main.path(forResource: "MailSchemes", ofType: "plist"), let dictRoot = NSArray(contentsOfFile: path) {
|
||||
mailProviderSource = dictRoot.map { dict in
|
||||
let nsDict = dict as! NSDictionary
|
||||
return (name: nsDict["name"] as! String, scheme: nsDict["scheme"] as! String,
|
||||
enabled: canOpenMailScheme(nsDict["scheme"] as! String))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func canOpenMailScheme(_ scheme: String) -> Bool {
|
||||
if let url = URL(string: scheme) {
|
||||
return UIApplication.shared.canOpenURL(url)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: BasicCheckmarkCell, for: indexPath)
|
||||
|
||||
let option = mailProviderSource[indexPath.row]
|
||||
|
||||
cell.textLabel?.attributedText = NSAttributedString.tableRowTitle(option.name, enabled: option.enabled)
|
||||
cell.accessoryType = (currentChoice == option.scheme && option.enabled) ? .checkmark : .none
|
||||
cell.isUserInteractionEnabled = option.enabled
|
||||
|
||||
return cell
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return mailProviderSource.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
self.currentChoice = mailProviderSource[indexPath.row].scheme
|
||||
tableView.reloadData()
|
||||
}
|
||||
}
|
||||
35
mobile/ios/Client/Frontend/Browser/PrintHelper.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 WebKit
|
||||
|
||||
class PrintHelper: TabContentScript {
|
||||
fileprivate weak var tab: Tab?
|
||||
|
||||
class func name() -> String {
|
||||
return "PrintHelper"
|
||||
}
|
||||
|
||||
required init(tab: Tab) {
|
||||
self.tab = tab
|
||||
if let path = Bundle.main.path(forResource: "PrintHelper", ofType: "js"), let source = try? NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) as String {
|
||||
let userScript = WKUserScript(source: source, injectionTime: WKUserScriptInjectionTime.atDocumentEnd, forMainFrameOnly: false)
|
||||
tab.webView!.configuration.userContentController.addUserScript(userScript)
|
||||
}
|
||||
}
|
||||
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "printHandler"
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
if let tab = tab, let webView = tab.webView {
|
||||
let printController = UIPrintInteractionController.shared
|
||||
printController.printFormatter = webView.viewPrintFormatter()
|
||||
printController.present(animated: true, completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
41
mobile/ios/Client/Frontend/Browser/PrivilegedRequest.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/* 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
|
||||
|
||||
private let REQUEST_KEY_PRIVILEGED = "privileged"
|
||||
|
||||
/**
|
||||
Request that is allowed to load local resources.
|
||||
|
||||
Pages running on the local server have same origin access all resources
|
||||
on the server, so we need to prevent arbitrary web pages from accessing
|
||||
these resources. We do so by explicitly requiring "privileged" requests
|
||||
in our navigation policy when loading local resources.
|
||||
|
||||
Be careful: creating a privileged request for an arbitrary URL provided
|
||||
by the page will break this model. Only use a privileged request when
|
||||
needed, and when you are sure the URL is from a trustworthy source!
|
||||
**/
|
||||
class PrivilegedRequest: NSMutableURLRequest {
|
||||
override init(url URL: URL, cachePolicy: NSURLRequest.CachePolicy, timeoutInterval: TimeInterval) {
|
||||
super.init(url: URL, cachePolicy: cachePolicy, timeoutInterval: timeoutInterval)
|
||||
setPrivileged()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
setPrivileged()
|
||||
}
|
||||
|
||||
fileprivate func setPrivileged() {
|
||||
URLProtocol.setProperty(true, forKey: REQUEST_KEY_PRIVILEGED, in: self)
|
||||
}
|
||||
}
|
||||
|
||||
extension URLRequest {
|
||||
var isPrivileged: Bool {
|
||||
return URLProtocol.property(forKey: REQUEST_KEY_PRIVILEGED, in: self) != nil
|
||||
}
|
||||
}
|
||||
198
mobile/ios/Client/Frontend/Browser/Punycode.swift
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
/* 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
|
||||
|
||||
private let base = 36
|
||||
private let tMin = 1
|
||||
private let tMax = 26
|
||||
private let initialBias = 72
|
||||
private let initialN: Int = 128 // 0x80
|
||||
private let delimiter: Character = "-"; // '\x2D'
|
||||
private let prefixPunycode = "xn--"
|
||||
private let asciiPunycode = [Character]("abcdefghijklmnopqrstuvwxyz0123456789".characters)
|
||||
|
||||
extension String {
|
||||
fileprivate func toValue(_ index: Int) -> Character {
|
||||
return asciiPunycode[index]
|
||||
}
|
||||
|
||||
fileprivate func toIndex(_ value: Character) -> Int {
|
||||
return asciiPunycode.index(of: value)!
|
||||
}
|
||||
|
||||
fileprivate func adapt(_ delta: Int, numPoints: Int, firstTime: Bool) -> Int {
|
||||
let skew = 38
|
||||
let damp = firstTime ? 700 : 2
|
||||
var delta = delta
|
||||
delta = delta / damp
|
||||
delta += delta / numPoints
|
||||
var k = 0
|
||||
while delta > ((base - tMin) * tMax) / 2 {
|
||||
delta /= (base - tMin)
|
||||
k += base
|
||||
}
|
||||
return k + ((base - tMin + 1) * delta) / (delta + skew)
|
||||
}
|
||||
|
||||
fileprivate func encode(_ input: String) -> String {
|
||||
var output = ""
|
||||
var d: Int = 0
|
||||
var extendedChars = [Int]()
|
||||
for c in input.unicodeScalars {
|
||||
if Int(c.value) < initialN {
|
||||
d += 1
|
||||
output.append(String(c))
|
||||
} else {
|
||||
extendedChars.append(Int(c.value))
|
||||
}
|
||||
}
|
||||
if extendedChars.count == 0 {
|
||||
return output
|
||||
}
|
||||
if d > 0 {
|
||||
output.append(delimiter)
|
||||
}
|
||||
|
||||
var n = initialN
|
||||
var delta = 0
|
||||
var bias = initialBias
|
||||
var h: Int = 0
|
||||
var b: Int = 0
|
||||
|
||||
if d > 0 {
|
||||
h = output.unicodeScalars.count - 1
|
||||
b = output.unicodeScalars.count - 1
|
||||
} else {
|
||||
h = output.unicodeScalars.count
|
||||
b = output.unicodeScalars.count
|
||||
}
|
||||
|
||||
while h < input.unicodeScalars.count {
|
||||
var char = Int(0x7fffffff)
|
||||
for c in input.unicodeScalars {
|
||||
let ci = Int(c.value)
|
||||
if char > ci && ci >= n {
|
||||
char = ci
|
||||
}
|
||||
}
|
||||
delta = delta + (char - n) * (h + 1)
|
||||
if delta < 0 {
|
||||
print("error: invalid char:")
|
||||
output = ""
|
||||
return output
|
||||
}
|
||||
n = char
|
||||
for c in input.unicodeScalars {
|
||||
let ci = Int(c.value)
|
||||
if ci < n || ci < initialN {
|
||||
delta += 1
|
||||
continue
|
||||
}
|
||||
if ci > n {
|
||||
continue
|
||||
}
|
||||
var q = delta
|
||||
var k = base
|
||||
while true {
|
||||
let t = max(min(k - bias, tMax), tMin)
|
||||
if q < t {
|
||||
break
|
||||
}
|
||||
let code = t + ((q - t) % (base - t))
|
||||
output.append(toValue(code))
|
||||
q = (q - t) / (base - t)
|
||||
k += base
|
||||
}
|
||||
output.append(toValue(q))
|
||||
bias = self.adapt(delta, numPoints: h + 1, firstTime: h == b)
|
||||
delta = 0
|
||||
h += 1
|
||||
}
|
||||
delta += 1
|
||||
n += 1
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
fileprivate func decode(_ punycode: String) -> String {
|
||||
var input = [Character](punycode.characters)
|
||||
var output = [Character]()
|
||||
var i = 0
|
||||
var n = initialN
|
||||
var bias = initialBias
|
||||
var pos = 0
|
||||
if let ipos = input.index(of: delimiter) {
|
||||
pos = ipos
|
||||
output.append(contentsOf: input[0 ..< pos])
|
||||
pos += 1
|
||||
}
|
||||
var outputLength = output.count
|
||||
let inputLength = input.count
|
||||
while pos < inputLength {
|
||||
let oldi = i
|
||||
var w = 1
|
||||
var k = base
|
||||
while true {
|
||||
let digit = toIndex(input[pos])
|
||||
pos += 1
|
||||
i += digit * w
|
||||
let t = max(min(k - bias, tMax), tMin)
|
||||
if digit < t {
|
||||
break
|
||||
}
|
||||
w = w * (base - t)
|
||||
k += base
|
||||
}
|
||||
outputLength += 1
|
||||
bias = adapt(i - oldi, numPoints: outputLength, firstTime: (oldi == 0))
|
||||
n = n + i / outputLength
|
||||
i = i % outputLength
|
||||
output.insert(Character(UnicodeScalar(n)!), at: i)
|
||||
i += 1
|
||||
}
|
||||
return String(output)
|
||||
}
|
||||
|
||||
fileprivate func isValidUnicodeScala(_ s: String) -> Bool {
|
||||
for c in s.unicodeScalars {
|
||||
let ci = Int(c.value)
|
||||
if ci >= initialN {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fileprivate func isValidPunycodeScala(_ s: String) -> Bool {
|
||||
return s.hasPrefix(prefixPunycode)
|
||||
}
|
||||
|
||||
public func utf8HostToAscii() -> String {
|
||||
if isValidUnicodeScala(self) {
|
||||
return self
|
||||
}
|
||||
var labels = self.components(separatedBy: ".")
|
||||
for (i, part) in labels.enumerated() {
|
||||
if !isValidUnicodeScala(part) {
|
||||
let a = encode(part)
|
||||
labels[i] = prefixPunycode + a
|
||||
}
|
||||
}
|
||||
let resultString = labels.joined(separator: ".")
|
||||
return resultString
|
||||
}
|
||||
|
||||
public func asciiHostToUTF8() -> String {
|
||||
var labels = self.components(separatedBy: ".")
|
||||
for (index, part) in labels.enumerated() {
|
||||
if isValidPunycodeScala(part) {
|
||||
let changeStr = part.substring(from: part.characters.index(part.startIndex, offsetBy: 4))
|
||||
labels[index] = decode(changeStr)
|
||||
}
|
||||
}
|
||||
let resultString = labels.joined(separator: ".")
|
||||
return resultString
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
23
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-goBack.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "qrcode-goBack.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "qrcode-goBack@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "qrcode-goBack@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-goBack.imageset/qrcode-goBack.png
vendored
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-goBack.imageset/qrcode-goBack@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-goBack.imageset/qrcode-goBack@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
23
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-isLighting.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "qrcode-isLighting.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "qrcode-isLighting@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "qrcode-isLighting@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-isLighting.imageset/qrcode-isLighting.png
vendored
Normal file
|
After Width: | Height: | Size: 3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
23
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-light.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "qrcode-light.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "qrcode-light@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "qrcode-light@3x.png",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-light.imageset/qrcode-light.png
vendored
Normal file
|
After Width: | Height: | Size: 431 B |
BIN
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-light.imageset/qrcode-light@2x.png
vendored
Normal file
|
After Width: | Height: | Size: 785 B |
BIN
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-light.imageset/qrcode-light@3x.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
21
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-scanBorder.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "qrcode-scanBorder.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-scanBorder.imageset/qrcode-scanBorder.png
vendored
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
21
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-scanLine.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "qrcode-scanLine.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
BIN
mobile/ios/Client/Frontend/Browser/QRCode.xcassets/qrcode-scanLine.imageset/qrcode-scanLine.png
vendored
Normal file
|
After Width: | Height: | Size: 662 B |
291
mobile/ios/Client/Frontend/Browser/QRCodeViewController.swift
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
/* 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 AVFoundation
|
||||
import SnapKit
|
||||
import Shared
|
||||
|
||||
private struct QRCodeViewControllerUX {
|
||||
static let navigationBarBackgroundColor = UIColor.black
|
||||
static let navigationBarTitleColor = UIColor.white
|
||||
static let maskViewBackgroungColor = UIColor(colorLiteralRed: 0, green: 0, blue: 0, alpha: 0.5)
|
||||
static let isLightingNavigationItemColor = UIColor(colorLiteralRed: 0.45, green: 0.67, blue: 0.84, alpha: 1)
|
||||
}
|
||||
|
||||
protocol QRCodeViewControllerDelegate {
|
||||
func didScanQRCodeWithURL(_ url: URL)
|
||||
func didScanQRCodeWithText(_ text: String)
|
||||
}
|
||||
|
||||
class QRCodeViewController: UIViewController {
|
||||
var qrCodeDelegate: QRCodeViewControllerDelegate?
|
||||
|
||||
fileprivate lazy var captureSession: AVCaptureSession = {
|
||||
let session = AVCaptureSession()
|
||||
session.sessionPreset = AVCaptureSessionPresetHigh
|
||||
return session
|
||||
}()
|
||||
|
||||
private lazy var captureDevice: AVCaptureDevice? = {
|
||||
return AVCaptureDevice.defaultDevice(withMediaType: AVMediaTypeVideo)
|
||||
}()
|
||||
|
||||
private var videoPreviewLayer: AVCaptureVideoPreviewLayer?
|
||||
private let scanLine: UIImageView = UIImageView(image: UIImage(named: "qrcode-scanLine"))
|
||||
private let scanBorder: UIImageView = UIImageView(image: UIImage(named: "qrcode-scanBorder"))
|
||||
private lazy var instructionsLabel: UILabel = {
|
||||
let label = UILabel()
|
||||
label.text = Strings.ScanQRCodeInstructionsLabel
|
||||
label.textColor = UIColor.white
|
||||
label.textAlignment = NSTextAlignment.center
|
||||
label.numberOfLines = 0
|
||||
return label
|
||||
}()
|
||||
private var maskView: UIView = UIView()
|
||||
private var isAnimationing: Bool = false
|
||||
private var isLightOn: Bool = false
|
||||
private var shapeLayer: CAShapeLayer = CAShapeLayer()
|
||||
|
||||
private var scanRange: CGRect {
|
||||
let size = UIDevice.current.userInterfaceIdiom == .pad ?
|
||||
CGSize(width: view.frame.width / 2, height: view.frame.width / 2) :
|
||||
CGSize(width: view.frame.width / 3 * 2, height: view.frame.width / 3 * 2)
|
||||
var rect = CGRect(x: 0, y: 0, width: size.width, height: size.height)
|
||||
rect.center = UIScreen.main.bounds.center
|
||||
return rect
|
||||
}
|
||||
|
||||
private var scanBorderHeight: CGFloat {
|
||||
return UIDevice.current.userInterfaceIdiom == .pad ?
|
||||
view.frame.width / 2 : view.frame.width / 3 * 2
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
guard let captureDevice = self.captureDevice else {
|
||||
dismiss(animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
self.navigationItem.title = Strings.ScanQRCodeViewTitle
|
||||
|
||||
// Setup the NavigationBar
|
||||
self.navigationController?.navigationBar.barTintColor = QRCodeViewControllerUX.navigationBarBackgroundColor
|
||||
self.navigationController?.navigationBar.titleTextAttributes = [NSForegroundColorAttributeName: QRCodeViewControllerUX.navigationBarTitleColor]
|
||||
|
||||
// Setup the NavigationItem
|
||||
self.navigationItem.leftBarButtonItem = UIBarButtonItem(image: UIImage(named: "qrcode-goBack"), style: UIBarButtonItemStyle.plain, target: self, action: #selector(goBack))
|
||||
self.navigationItem.leftBarButtonItem?.tintColor = UIColor.white
|
||||
|
||||
self.navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(named: "qrcode-light"), style: .plain, target: self, action: #selector(openLight))
|
||||
if captureDevice.hasTorch {
|
||||
self.navigationItem.rightBarButtonItem?.tintColor = UIColor.white
|
||||
} else {
|
||||
self.navigationItem.rightBarButtonItem?.tintColor = UIColor.gray
|
||||
self.navigationItem.rightBarButtonItem?.isEnabled = false
|
||||
}
|
||||
|
||||
let getAuthorizationStatus = AVCaptureDevice.authorizationStatus(forMediaType: AVMediaTypeVideo)
|
||||
if getAuthorizationStatus != AVAuthorizationStatus.denied {
|
||||
setupCamera()
|
||||
} else {
|
||||
let alert = UIAlertController(title: "", message: Strings.ScanQRCodePermissionErrorMessage, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: Strings.ScanQRCodeErrorOKButton, style: .default, handler: nil))
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
maskView.backgroundColor = QRCodeViewControllerUX.maskViewBackgroungColor
|
||||
self.view.addSubview(maskView)
|
||||
self.view.addSubview(scanBorder)
|
||||
self.view.addSubview(scanLine)
|
||||
self.view.addSubview(instructionsLabel)
|
||||
|
||||
setupConstraints()
|
||||
let rectPath = UIBezierPath(rect: UIScreen.main.bounds)
|
||||
rectPath.append(UIBezierPath(rect: scanRange).reversing())
|
||||
shapeLayer.path = rectPath.cgPath
|
||||
maskView.layer.mask = shapeLayer
|
||||
|
||||
isAnimationing = true
|
||||
startScanLineAnimation()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
self.captureSession.stopRunning()
|
||||
stopScanLineAnimation()
|
||||
}
|
||||
|
||||
private func setupConstraints() {
|
||||
maskView.snp.makeConstraints { (make) in
|
||||
make.edges.equalTo(self.view)
|
||||
}
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
scanBorder.snp.makeConstraints { (make) in
|
||||
make.center.equalTo(self.view)
|
||||
make.width.height.equalTo(view.frame.width / 2)
|
||||
}
|
||||
} else {
|
||||
scanBorder.snp.makeConstraints { (make) in
|
||||
make.center.equalTo(self.view)
|
||||
make.width.height.equalTo(view.frame.width / 3 * 2)
|
||||
}
|
||||
}
|
||||
scanLine.snp.makeConstraints { (make) in
|
||||
make.left.equalTo(scanBorder.snp.left)
|
||||
make.top.equalTo(scanBorder.snp.top).offset(6)
|
||||
make.width.equalTo(scanBorder.snp.width)
|
||||
make.height.equalTo(6)
|
||||
}
|
||||
|
||||
instructionsLabel.snp.makeConstraints { (make) in
|
||||
make.left.right.equalTo(self.view.layoutMarginsGuide)
|
||||
make.top.equalTo(scanBorder.snp.bottom).offset(30)
|
||||
}
|
||||
}
|
||||
|
||||
func startScanLineAnimation() {
|
||||
if !isAnimationing {
|
||||
return
|
||||
}
|
||||
self.view.layoutIfNeeded()
|
||||
self.view.setNeedsLayout()
|
||||
UIView.animate(withDuration: 2.4, animations: {
|
||||
self.scanLine.snp.updateConstraints({ (make) in
|
||||
make.top.equalTo(self.scanBorder.snp.top).offset(self.scanBorderHeight - 6)
|
||||
})
|
||||
self.view.layoutIfNeeded()
|
||||
}) { (value: Bool) in
|
||||
self.scanLine.snp.updateConstraints({ (make) in
|
||||
make.top.equalTo(self.scanBorder.snp.top).offset(6)
|
||||
})
|
||||
self.perform(#selector(self.startScanLineAnimation), with: nil, afterDelay: 0)
|
||||
}
|
||||
}
|
||||
|
||||
func stopScanLineAnimation() {
|
||||
isAnimationing = false
|
||||
}
|
||||
|
||||
func goBack() {
|
||||
self.dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func openLight() {
|
||||
guard let captureDevice = self.captureDevice else {
|
||||
return
|
||||
}
|
||||
|
||||
if isLightOn {
|
||||
do {
|
||||
try captureDevice.lockForConfiguration()
|
||||
captureDevice.torchMode = AVCaptureTorchMode.off
|
||||
captureDevice.unlockForConfiguration()
|
||||
navigationItem.rightBarButtonItem?.image = UIImage(named: "qrcode-light")
|
||||
navigationItem.rightBarButtonItem?.tintColor = UIColor.white
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
try captureDevice.lockForConfiguration()
|
||||
captureDevice.torchMode = AVCaptureTorchMode.on
|
||||
captureDevice.unlockForConfiguration()
|
||||
navigationItem.rightBarButtonItem?.image = UIImage(named: "qrcode-isLighting")
|
||||
navigationItem.rightBarButtonItem?.tintColor = QRCodeViewControllerUX.isLightingNavigationItemColor
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
isLightOn = !isLightOn
|
||||
}
|
||||
|
||||
func setupCamera() {
|
||||
guard let captureDevice = self.captureDevice else {
|
||||
dismiss(animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let input = try AVCaptureDeviceInput(device: captureDevice)
|
||||
captureSession.addInput(input)
|
||||
} catch {
|
||||
print(error)
|
||||
}
|
||||
let output = AVCaptureMetadataOutput()
|
||||
if captureSession.canAddOutput(output) {
|
||||
captureSession.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
output.metadataObjectTypes = [AVMetadataObjectTypeQRCode]
|
||||
}
|
||||
if let videoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession) {
|
||||
videoPreviewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill
|
||||
videoPreviewLayer.frame = UIScreen.main.bounds
|
||||
view.layer.addSublayer(videoPreviewLayer)
|
||||
self.videoPreviewLayer = videoPreviewLayer
|
||||
captureSession.startRunning()
|
||||
}
|
||||
}
|
||||
|
||||
override func willAnimateRotation(to toInterfaceOrientation: UIInterfaceOrientation, duration: TimeInterval) {
|
||||
shapeLayer.removeFromSuperlayer()
|
||||
let rectPath = UIBezierPath(rect: UIScreen.main.bounds)
|
||||
rectPath.append(UIBezierPath(rect: scanRange).reversing())
|
||||
shapeLayer.path = rectPath.cgPath
|
||||
maskView.layer.mask = shapeLayer
|
||||
|
||||
guard let videoPreviewLayer = self.videoPreviewLayer else {
|
||||
return
|
||||
}
|
||||
videoPreviewLayer.frame = UIScreen.main.bounds
|
||||
switch toInterfaceOrientation {
|
||||
case .portrait:
|
||||
videoPreviewLayer.connection.videoOrientation = AVCaptureVideoOrientation.portrait
|
||||
case .landscapeLeft:
|
||||
videoPreviewLayer.connection.videoOrientation = AVCaptureVideoOrientation.landscapeLeft
|
||||
case .landscapeRight:
|
||||
videoPreviewLayer.connection.videoOrientation = AVCaptureVideoOrientation.landscapeRight
|
||||
case .portraitUpsideDown:
|
||||
videoPreviewLayer.connection.videoOrientation = AVCaptureVideoOrientation.portraitUpsideDown
|
||||
default:
|
||||
videoPreviewLayer.connection.videoOrientation = AVCaptureVideoOrientation.portrait
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension QRCodeViewController: AVCaptureMetadataOutputObjectsDelegate {
|
||||
func captureOutput(_ captureOutput: AVCaptureOutput!, didOutputMetadataObjects metadataObjects: [Any]!, from connection: AVCaptureConnection!) {
|
||||
if metadataObjects == nil || metadataObjects.count == 0 {
|
||||
self.captureSession.stopRunning()
|
||||
let alert = UIAlertController(title: "", message: Strings.ScanQRCodeInvalidDataErrorMessage, preferredStyle: .alert)
|
||||
alert.addAction(UIAlertAction(title: Strings.ScanQRCodeErrorOKButton, style: .default, handler: { (UIAlertAction) in
|
||||
self.captureSession.startRunning()
|
||||
}))
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
} else {
|
||||
self.captureSession.stopRunning()
|
||||
stopScanLineAnimation()
|
||||
self.dismiss(animated: true, completion: {
|
||||
guard let metaData = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let qrCodeDelegate = self.qrCodeDelegate, let text = metaData.stringValue else {
|
||||
Sentry.shared.sendWithStacktrace(message: "Unable to scan QR code", tag: .general)
|
||||
return
|
||||
}
|
||||
|
||||
if let url = URIFixup.getURL(text) {
|
||||
qrCodeDelegate.didScanQRCodeWithURL(url)
|
||||
} else {
|
||||
qrCodeDelegate.didScanQRCodeWithText(text)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class QRCodeNavigationController: UINavigationController {
|
||||
override open var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return .lightContent
|
||||
}
|
||||
}
|
||||
172
mobile/ios/Client/Frontend/Browser/ReaderModeBarView.swift
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
/* 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 SnapKit
|
||||
import Shared
|
||||
import XCGLogger
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
enum ReaderModeBarButtonType {
|
||||
case markAsRead, markAsUnread, settings, addToReadingList, removeFromReadingList
|
||||
|
||||
fileprivate var localizedDescription: String {
|
||||
switch self {
|
||||
case .markAsRead: return NSLocalizedString("Mark as Read", comment: "Name for Mark as read button in reader mode")
|
||||
case .markAsUnread: return NSLocalizedString("Mark as Unread", comment: "Name for Mark as unread button in reader mode")
|
||||
case .settings: return NSLocalizedString("Display Settings", comment: "Name for display settings button in reader mode. Display in the meaning of presentation, not monitor.")
|
||||
case .addToReadingList: return NSLocalizedString("Add to Reading List", comment: "Name for button adding current article to reading list in reader mode")
|
||||
case .removeFromReadingList: return NSLocalizedString("Remove from Reading List", comment: "Name for button removing current article from reading list in reader mode")
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var imageName: String {
|
||||
switch self {
|
||||
case .markAsRead: return "MarkAsRead"
|
||||
case .markAsUnread: return "MarkAsUnread"
|
||||
case .settings: return "SettingsSerif"
|
||||
case .addToReadingList: return "addToReadingList"
|
||||
case .removeFromReadingList: return "removeFromReadingList"
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var image: UIImage? {
|
||||
let image = UIImage(named: imageName)
|
||||
image?.accessibilityLabel = localizedDescription
|
||||
return image
|
||||
}
|
||||
}
|
||||
|
||||
protocol ReaderModeBarViewDelegate {
|
||||
func readerModeBar(_ readerModeBar: ReaderModeBarView, didSelectButton buttonType: ReaderModeBarButtonType)
|
||||
}
|
||||
|
||||
struct ReaderModeBarViewUX {
|
||||
|
||||
static let Themes: [String: Theme] = {
|
||||
var themes = [String: Theme]()
|
||||
var theme = Theme()
|
||||
theme.backgroundColor = UIColor(rgb: 0x38383D)
|
||||
theme.buttonTintColor = UIColor(rgb: 0xf9f9fA)
|
||||
themes[Theme.PrivateMode] = theme
|
||||
|
||||
theme = Theme()
|
||||
theme.backgroundColor = UIColor(rgb: 0xf9f9fA)
|
||||
theme.buttonTintColor = UIColor(rgb: 0x272727)
|
||||
themes[Theme.NormalMode] = theme
|
||||
|
||||
return themes
|
||||
}()
|
||||
}
|
||||
|
||||
class ReaderModeBarView: UIView {
|
||||
var delegate: ReaderModeBarViewDelegate?
|
||||
|
||||
var readStatusButton: UIButton!
|
||||
var settingsButton: UIButton!
|
||||
var listStatusButton: UIButton!
|
||||
|
||||
dynamic var buttonTintColor: UIColor = UIColor.clear {
|
||||
didSet {
|
||||
readStatusButton.tintColor = self.buttonTintColor
|
||||
settingsButton.tintColor = self.buttonTintColor
|
||||
listStatusButton.tintColor = self.buttonTintColor
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
readStatusButton = createButton(.markAsRead, action: #selector(ReaderModeBarView.SELtappedReadStatusButton(_:)))
|
||||
readStatusButton.accessibilityIdentifier = "ReaderModeBarView.readStatusButton"
|
||||
readStatusButton.snp.makeConstraints { (make) -> Void in
|
||||
make.left.equalTo(self)
|
||||
make.height.centerY.equalTo(self)
|
||||
make.width.equalTo(80)
|
||||
}
|
||||
|
||||
settingsButton = createButton(.settings, action: #selector(ReaderModeBarView.SELtappedSettingsButton(_:)))
|
||||
settingsButton.accessibilityIdentifier = "ReaderModeBarView.settingsButton"
|
||||
settingsButton.snp.makeConstraints { (make) -> Void in
|
||||
make.height.centerX.centerY.equalTo(self)
|
||||
make.width.equalTo(80)
|
||||
}
|
||||
|
||||
listStatusButton = createButton(.addToReadingList, action: #selector(ReaderModeBarView.SELtappedListStatusButton(_:)))
|
||||
listStatusButton.accessibilityIdentifier = "ReaderModeBarView.listStatusButton"
|
||||
listStatusButton.snp.makeConstraints { (make) -> Void in
|
||||
make.right.equalTo(self)
|
||||
make.height.centerY.equalTo(self)
|
||||
make.width.equalTo(80)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
super.draw(rect)
|
||||
guard let context = UIGraphicsGetCurrentContext() else { return }
|
||||
context.setLineWidth(0.5)
|
||||
context.setStrokeColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1.0)
|
||||
context.setStrokeColor(UIColor.gray.cgColor)
|
||||
context.beginPath()
|
||||
context.move(to: CGPoint(x: 0, y: frame.height))
|
||||
context.addLine(to: CGPoint(x: frame.width, y: frame.height))
|
||||
context.strokePath()
|
||||
}
|
||||
|
||||
fileprivate func createButton(_ type: ReaderModeBarButtonType, action: Selector) -> UIButton {
|
||||
let button = UIButton()
|
||||
addSubview(button)
|
||||
button.setImage(type.image, for: UIControlState())
|
||||
button.addTarget(self, action: action, for: .touchUpInside)
|
||||
return button
|
||||
}
|
||||
|
||||
func SELtappedReadStatusButton(_ sender: UIButton!) {
|
||||
UnifiedTelemetry.recordEvent(category: .action, method: .tap, object: .readingListItem, value: unread ? .markAsRead : .markAsUnread, extras: [ "from": "reader-mode-toolbar" ])
|
||||
delegate?.readerModeBar(self, didSelectButton: unread ? .markAsRead : .markAsUnread)
|
||||
}
|
||||
|
||||
func SELtappedSettingsButton(_ sender: UIButton!) {
|
||||
delegate?.readerModeBar(self, didSelectButton: .settings)
|
||||
}
|
||||
|
||||
func SELtappedListStatusButton(_ sender: UIButton!) {
|
||||
UnifiedTelemetry.recordEvent(category: .action, method: added ? .delete : .add, object: .readingListItem, value: .readerModeToolbar)
|
||||
delegate?.readerModeBar(self, didSelectButton: added ? .removeFromReadingList : .addToReadingList)
|
||||
}
|
||||
|
||||
var unread: Bool = true {
|
||||
didSet {
|
||||
let buttonType: ReaderModeBarButtonType = unread && added ? .markAsRead : .markAsUnread
|
||||
readStatusButton.setImage(buttonType.image, for: UIControlState())
|
||||
readStatusButton.isEnabled = added
|
||||
readStatusButton.alpha = added ? 1.0 : 0.6
|
||||
}
|
||||
}
|
||||
|
||||
var added: Bool = false {
|
||||
didSet {
|
||||
let buttonType: ReaderModeBarButtonType = added ? .removeFromReadingList : .addToReadingList
|
||||
listStatusButton.setImage(buttonType.image, for: UIControlState())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ReaderModeBarView: Themeable {
|
||||
func applyTheme(_ themeName: String) {
|
||||
guard let theme = ReaderModeBarViewUX.Themes[themeName] else {
|
||||
log.error("Unable to apply unknown theme \(themeName)")
|
||||
return
|
||||
}
|
||||
|
||||
backgroundColor = theme.backgroundColor
|
||||
buttonTintColor = theme.buttonTintColor!
|
||||
}
|
||||
}
|
||||
59
mobile/ios/Client/Frontend/Browser/ScreenshotHelper.swift
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/* 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
|
||||
|
||||
/**
|
||||
* Handles screenshots for a given tab, including pages with non-webview content.
|
||||
*/
|
||||
class ScreenshotHelper {
|
||||
var viewIsVisible = false
|
||||
|
||||
fileprivate weak var controller: BrowserViewController?
|
||||
|
||||
init(controller: BrowserViewController) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
func takeScreenshot(_ tab: Tab) {
|
||||
var screenshot: UIImage?
|
||||
|
||||
if let url = tab.url {
|
||||
if url.isAboutHomeURL {
|
||||
if let homePanel = controller?.homePanelController {
|
||||
screenshot = homePanel.view.screenshot(quality: UIConstants.ActiveScreenshotQuality)
|
||||
}
|
||||
} else {
|
||||
let offset = CGPoint(x: 0, y: -(tab.webView?.scrollView.contentInset.top ?? 0))
|
||||
screenshot = tab.webView?.screenshot(offset: offset, quality: UIConstants.ActiveScreenshotQuality)
|
||||
}
|
||||
}
|
||||
|
||||
tab.setScreenshot(screenshot)
|
||||
}
|
||||
|
||||
/// Takes a screenshot after a small delay.
|
||||
/// Trying to take a screenshot immediately after didFinishNavigation results in a screenshot
|
||||
/// of the previous page, presumably due to an iOS bug. Adding a brief delay fixes this.
|
||||
func takeDelayedScreenshot(_ tab: Tab) {
|
||||
let time = DispatchTime.now() + Double(Int64(100 * NSEC_PER_MSEC)) / Double(NSEC_PER_SEC)
|
||||
DispatchQueue.main.asyncAfter(deadline: time) {
|
||||
// If the view controller isn't visible, the screenshot will be blank.
|
||||
// Wait until the view controller is visible again to take the screenshot.
|
||||
guard self.viewIsVisible else {
|
||||
tab.pendingScreenshot = true
|
||||
return
|
||||
}
|
||||
|
||||
self.takeScreenshot(tab)
|
||||
}
|
||||
}
|
||||
|
||||
func takePendingScreenshots(_ tabs: [Tab]) {
|
||||
for tab in tabs where tab.pendingScreenshot {
|
||||
tab.pendingScreenshot = false
|
||||
takeDelayedScreenshot(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
291
mobile/ios/Client/Frontend/Browser/SearchEngines.swift
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import Shared
|
||||
import Storage
|
||||
import XCGLogger
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
private let OrderedEngineNames = "search.orderedEngineNames"
|
||||
private let DisabledEngineNames = "search.disabledEngineNames"
|
||||
private let ShowSearchSuggestionsOptIn = "search.suggestions.showOptIn"
|
||||
private let ShowSearchSuggestions = "search.suggestions.show"
|
||||
private let customSearchEnginesFileName = "customEngines.plist"
|
||||
|
||||
/**
|
||||
* Manage a set of Open Search engines.
|
||||
*
|
||||
* The search engines are ordered. Individual search engines can be enabled and disabled. The
|
||||
* first search engine is distinguished and labeled the "default" search engine; it can never be
|
||||
* disabled. Search suggestions should always be sourced from the default search engine.
|
||||
*
|
||||
* Two additional bits of information are maintained: whether the user should be shown "opt-in to
|
||||
* search suggestions" UI, and whether search suggestions are enabled.
|
||||
*
|
||||
* Consumers will almost always use `defaultEngine` if they want a single search engine, and
|
||||
* `quickSearchEngines()` if they want a list of enabled quick search engines (possibly empty,
|
||||
* since the default engine is never included in the list of enabled quick search engines, and
|
||||
* it is possible to disable every non-default quick search engine).
|
||||
*
|
||||
* The search engines are backed by a write-through cache into a ProfilePrefs instance. This class
|
||||
* is not thread-safe -- you should only access it on a single thread (usually, the main thread)!
|
||||
*/
|
||||
class SearchEngines {
|
||||
fileprivate let prefs: Prefs
|
||||
fileprivate let fileAccessor: FileAccessor
|
||||
|
||||
init(prefs: Prefs, files: FileAccessor) {
|
||||
self.prefs = prefs
|
||||
// By default, show search suggestions
|
||||
self.shouldShowSearchSuggestions = prefs.boolForKey(ShowSearchSuggestions) ?? true
|
||||
self.fileAccessor = files
|
||||
self.disabledEngineNames = getDisabledEngineNames()
|
||||
self.orderedEngines = getOrderedEngines()
|
||||
}
|
||||
|
||||
var defaultEngine: OpenSearchEngine {
|
||||
get {
|
||||
return self.orderedEngines[0]
|
||||
}
|
||||
|
||||
set(defaultEngine) {
|
||||
// The default engine is always enabled.
|
||||
self.enableEngine(defaultEngine)
|
||||
// The default engine is always first in the list.
|
||||
var orderedEngines = self.orderedEngines.filter { engine in engine.shortName != defaultEngine.shortName }
|
||||
orderedEngines.insert(defaultEngine, at: 0)
|
||||
self.orderedEngines = orderedEngines
|
||||
}
|
||||
}
|
||||
|
||||
func isEngineDefault(_ engine: OpenSearchEngine) -> Bool {
|
||||
return defaultEngine.shortName == engine.shortName
|
||||
}
|
||||
|
||||
// The keys of this dictionary are used as a set.
|
||||
fileprivate var disabledEngineNames: [String: Bool]! {
|
||||
didSet {
|
||||
self.prefs.setObject(Array(self.disabledEngineNames.keys), forKey: DisabledEngineNames)
|
||||
}
|
||||
}
|
||||
|
||||
var orderedEngines: [OpenSearchEngine]! {
|
||||
didSet {
|
||||
self.prefs.setObject(self.orderedEngines.map { $0.shortName }, forKey: OrderedEngineNames)
|
||||
}
|
||||
}
|
||||
|
||||
var quickSearchEngines: [OpenSearchEngine]! {
|
||||
get {
|
||||
return self.orderedEngines.filter({ (engine) in !self.isEngineDefault(engine) && self.isEngineEnabled(engine) })
|
||||
}
|
||||
}
|
||||
|
||||
var shouldShowSearchSuggestions: Bool {
|
||||
didSet {
|
||||
self.prefs.setObject(shouldShowSearchSuggestions, forKey: ShowSearchSuggestions)
|
||||
}
|
||||
}
|
||||
|
||||
func isEngineEnabled(_ engine: OpenSearchEngine) -> Bool {
|
||||
return disabledEngineNames.index(forKey: engine.shortName) == nil
|
||||
}
|
||||
|
||||
func enableEngine(_ engine: OpenSearchEngine) {
|
||||
disabledEngineNames.removeValue(forKey: engine.shortName)
|
||||
}
|
||||
|
||||
func disableEngine(_ engine: OpenSearchEngine) {
|
||||
if isEngineDefault(engine) {
|
||||
// Can't disable default engine.
|
||||
return
|
||||
}
|
||||
disabledEngineNames[engine.shortName] = true
|
||||
}
|
||||
|
||||
func deleteCustomEngine(_ engine: OpenSearchEngine) {
|
||||
// We can't delete a preinstalled engine or an engine that is currently the default.
|
||||
if !engine.isCustomEngine || isEngineDefault(engine) {
|
||||
return
|
||||
}
|
||||
|
||||
customEngines.remove(at: customEngines.index(of: engine)!)
|
||||
saveCustomEngines()
|
||||
orderedEngines = getOrderedEngines()
|
||||
}
|
||||
|
||||
/// Adds an engine to the front of the search engines list.
|
||||
func addSearchEngine(_ engine: OpenSearchEngine) {
|
||||
customEngines.append(engine)
|
||||
orderedEngines.insert(engine, at: 1)
|
||||
saveCustomEngines()
|
||||
}
|
||||
|
||||
func queryForSearchURL(_ url: URL?) -> String? {
|
||||
for engine in orderedEngines {
|
||||
guard let searchTerm = engine.queryForSearchURL(url) else { continue }
|
||||
return searchTerm
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
fileprivate func getDisabledEngineNames() -> [String: Bool] {
|
||||
if let disabledEngineNames = self.prefs.stringArrayForKey(DisabledEngineNames) {
|
||||
var disabledEngineDict = [String: Bool]()
|
||||
for engineName in disabledEngineNames {
|
||||
disabledEngineDict[engineName] = true
|
||||
}
|
||||
return disabledEngineDict
|
||||
} else {
|
||||
return [String: Bool]()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func customEngineFilePath() -> String {
|
||||
let profilePath = try! self.fileAccessor.getAndEnsureDirectory() as NSString
|
||||
return profilePath.appendingPathComponent(customSearchEnginesFileName)
|
||||
}
|
||||
|
||||
fileprivate lazy var customEngines: [OpenSearchEngine] = {
|
||||
return NSKeyedUnarchiver.unarchiveObject(withFile: self.customEngineFilePath()) as? [OpenSearchEngine] ?? []
|
||||
}()
|
||||
|
||||
fileprivate func saveCustomEngines() {
|
||||
NSKeyedArchiver.archiveRootObject(customEngines, toFile: self.customEngineFilePath())
|
||||
}
|
||||
|
||||
/// Return all possible paths for a language identifier in the order of most specific to least specific.
|
||||
/// For example, zh-Hans-CN with a default of en will return [zh-Hans-CN, zh-CN, zh, en]. The fallback
|
||||
/// identifier must be a known one that is guaranteed to exist in the SearchPlugins directory.
|
||||
class func directoriesForLanguageIdentifier(_ languageIdentifier: String, basePath: NSString, fallbackIdentifier: String) -> [String] {
|
||||
var directories = [String]()
|
||||
let components = languageIdentifier.components(separatedBy: "-")
|
||||
if components.count == 1 {
|
||||
// zh
|
||||
directories.append(languageIdentifier)
|
||||
} else if components.count == 2 {
|
||||
// zh-CN
|
||||
directories.append(languageIdentifier)
|
||||
directories.append(components[0])
|
||||
} else if components.count == 3 {
|
||||
directories.append(languageIdentifier)
|
||||
directories.append(components[0] + "-" + components[2])
|
||||
directories.append(components[0])
|
||||
}
|
||||
if !directories.contains(fallbackIdentifier) {
|
||||
directories.append(fallbackIdentifier)
|
||||
}
|
||||
|
||||
return directories.map { (path) -> String in
|
||||
return basePath.appendingPathComponent(path)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the language identifier to be used for the search engine selection. This returns the first
|
||||
// identifier from preferredLanguages and takes into account that on iOS 8, zh-Hans-CN is returned as
|
||||
// zh-Hans. In that case it returns the longer form zh-Hans-CN. Same for traditional Chinese.
|
||||
//
|
||||
// These exceptions can go away when we drop iOS 8 or when we start using a better mechanism for search
|
||||
// engine selection that is not based on language identifier.
|
||||
class func languageIdentifierForSearchEngines() -> String {
|
||||
let languageIdentifier = Locale.preferredLanguages.first!
|
||||
switch languageIdentifier {
|
||||
case "zh-Hans":
|
||||
return "zh-Hans-CN"
|
||||
case "zh-Hant":
|
||||
return "zh-Hant-TW"
|
||||
default:
|
||||
return languageIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all bundled (not custom) search engines, with the default search engine first,
|
||||
/// but the others in no particular order.
|
||||
class func getUnorderedBundledEngines() -> [OpenSearchEngine] {
|
||||
let pluginBasePath: NSString = (Bundle.main.resourcePath! as NSString).appendingPathComponent("SearchPlugins") as NSString
|
||||
let languageIdentifier = languageIdentifierForSearchEngines()
|
||||
let fallbackDirectory: NSString = pluginBasePath.appendingPathComponent("en") as NSString
|
||||
|
||||
var directory: String?
|
||||
for path in directoriesForLanguageIdentifier(languageIdentifier, basePath: pluginBasePath, fallbackIdentifier: "en") {
|
||||
if FileManager.default.fileExists(atPath: path) {
|
||||
directory = path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// This cannot happen if we include the fallback, but if it does we return no engines at all
|
||||
guard let searchDirectory = directory else {
|
||||
return []
|
||||
}
|
||||
|
||||
let index = (searchDirectory as NSString).appendingPathComponent("list.txt")
|
||||
let listFile = try? String(contentsOfFile: index, encoding: String.Encoding.utf8)
|
||||
assert(listFile != nil, "Read the list of search engines")
|
||||
|
||||
let engineNames = listFile!
|
||||
.trimmingCharacters(in: CharacterSet.newlines)
|
||||
.components(separatedBy: CharacterSet.newlines)
|
||||
|
||||
var engines = [OpenSearchEngine]()
|
||||
let parser = OpenSearchParser(pluginMode: true)
|
||||
for engineName in engineNames {
|
||||
// Ignore hidden engines in list.txt
|
||||
if engineName.endsWith(":hidden") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Search the current localized search plugins directory for the search engine.
|
||||
// If it doesn't exist, fall back to English.
|
||||
var fullPath = (searchDirectory as NSString).appendingPathComponent("\(engineName).xml")
|
||||
if !FileManager.default.fileExists(atPath: fullPath) {
|
||||
fullPath = fallbackDirectory.appendingPathComponent("\(engineName).xml")
|
||||
}
|
||||
assert(FileManager.default.fileExists(atPath: fullPath), "\(fullPath) exists")
|
||||
|
||||
guard let engine = parser.parse(fullPath, engineID: engineName) else {
|
||||
log.error("Failed to parse search engine ID \(engineName) at \(fullPath)")
|
||||
continue
|
||||
}
|
||||
engines.append(engine)
|
||||
}
|
||||
|
||||
let defaultEngineFile = (searchDirectory as NSString).appendingPathComponent("default.txt")
|
||||
let defaultEngineName = try? String(contentsOfFile: defaultEngineFile, encoding: String.Encoding.utf8).trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
|
||||
return engines.sorted { e, _ in e.shortName == defaultEngineName }
|
||||
}
|
||||
|
||||
/// Get all known search engines, possibly as ordered by the user.
|
||||
fileprivate func getOrderedEngines() -> [OpenSearchEngine] {
|
||||
let unorderedEngines = customEngines + SearchEngines.getUnorderedBundledEngines()
|
||||
|
||||
guard let orderedEngineNames = prefs.stringArrayForKey(OrderedEngineNames) else {
|
||||
// We haven't persisted the engine order, so return whatever order we got from disk.
|
||||
return unorderedEngines
|
||||
}
|
||||
|
||||
// We have a persisted order of engines, so try to use that order.
|
||||
// We may have found engines that weren't persisted in the ordered list
|
||||
// (if the user changed locales or added a new engine); these engines
|
||||
// will be appended to the end of the list.
|
||||
return unorderedEngines.sorted { engine1, engine2 in
|
||||
let index1 = orderedEngineNames.index(of: engine1.shortName)
|
||||
let index2 = orderedEngineNames.index(of: engine2.shortName)
|
||||
|
||||
if index1 == nil && index2 == nil {
|
||||
return engine1.shortName < engine2.shortName
|
||||
}
|
||||
|
||||
// nil < N for all non-nil values of N.
|
||||
if index1 == nil || index2 == nil {
|
||||
return index1 ?? -1 > index2 ?? -1
|
||||
}
|
||||
|
||||
return index1! < index2!
|
||||
}
|
||||
}
|
||||
}
|
||||
135
mobile/ios/Client/Frontend/Browser/SearchLoader.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import Foundation
|
||||
import Shared
|
||||
import Storage
|
||||
import XCGLogger
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
private let URLBeforePathRegex = try! NSRegularExpression(pattern: "^https?://([^/]+)/", options: [])
|
||||
|
||||
// TODO: Swift currently requires that classes extending generic classes must also be generic.
|
||||
// This is a workaround until that requirement is fixed.
|
||||
typealias SearchLoader = _SearchLoader<AnyObject, AnyObject>
|
||||
|
||||
/**
|
||||
* Shared data source for the SearchViewController and the URLBar domain completion.
|
||||
* Since both of these use the same SQL query, we can perform the query once and dispatch the results.
|
||||
*/
|
||||
class _SearchLoader<UnusedA, UnusedB>: Loader<Cursor<Site>, SearchViewController> {
|
||||
fileprivate let profile: Profile
|
||||
fileprivate let urlBar: URLBarView
|
||||
fileprivate let frecentHistory: FrecentHistory
|
||||
|
||||
init(profile: Profile, urlBar: URLBarView) {
|
||||
self.profile = profile
|
||||
self.urlBar = urlBar
|
||||
frecentHistory = profile.history.getFrecentHistory()
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
fileprivate lazy var topDomains: [String] = {
|
||||
let filePath = Bundle.main.path(forResource: "topdomains", ofType: "txt")
|
||||
return try! String(contentsOfFile: filePath!).components(separatedBy: "\n")
|
||||
}()
|
||||
|
||||
// `weak` usage here allows deferred queue to be the owner. The deferred is always filled and this set to nil,
|
||||
// this is defensive against any changes to queue (or cancellation) behaviour in future.
|
||||
private weak var currentDbQuery: Cancellable?
|
||||
|
||||
var query: String = "" {
|
||||
didSet {
|
||||
guard let profile = self.profile as? BrowserProfile else {
|
||||
assertionFailure("nil profile")
|
||||
return
|
||||
}
|
||||
|
||||
if query.isEmpty {
|
||||
load(Cursor(status: .success, msg: "Empty query"))
|
||||
return
|
||||
}
|
||||
|
||||
if let currentDbQuery = currentDbQuery {
|
||||
profile.db.cancel(databaseOperation: WeakRef(currentDbQuery))
|
||||
}
|
||||
|
||||
let deferred = frecentHistory.getSites(whereURLContains: query, historyLimit: 100, bookmarksLimit: 5)
|
||||
currentDbQuery = deferred as? Cancellable
|
||||
|
||||
deferred.uponQueue(DispatchQueue.main) { result in
|
||||
defer {
|
||||
self.currentDbQuery = nil
|
||||
}
|
||||
|
||||
guard let deferred = deferred as? Cancellable, !deferred.cancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
// Failed cursors are excluded in .get().
|
||||
if let cursor = result.successValue {
|
||||
// First, see if the query matches any URLs from the user's search history.
|
||||
self.load(cursor)
|
||||
for site in cursor {
|
||||
if let url = site?.url,
|
||||
let completion = self.completionForURL(url) {
|
||||
self.urlBar.setAutocompleteSuggestion(completion)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no search history matches, try matching one of the Alexa top domains.
|
||||
for domain in self.topDomains {
|
||||
if let completion = self.completionForDomain(domain) {
|
||||
self.urlBar.setAutocompleteSuggestion(completion)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func completionForURL(_ url: String) -> String? {
|
||||
// Extract the pre-path substring from the URL. This should be more efficient than parsing via
|
||||
// NSURL since we need to only look at the beginning of the string.
|
||||
// Note that we won't match non-HTTP(S) URLs.
|
||||
guard let match = URLBeforePathRegex.firstMatch(in: url, options: NSRegularExpression.MatchingOptions(), range: NSRange(location: 0, length: url.characters.count)) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the pre-path component (including the scheme) starts with the query, just use it as is.
|
||||
var prePathURL = (url as NSString).substring(with: match.rangeAt(0))
|
||||
if prePathURL.startsWith(query) {
|
||||
// Trailing slashes in the autocompleteTextField cause issues with Swype keyboard. Bug 1194714
|
||||
if prePathURL.endsWith("/") {
|
||||
prePathURL.remove(at: prePathURL.index(before: prePathURL.endIndex))
|
||||
}
|
||||
return prePathURL
|
||||
}
|
||||
|
||||
// Otherwise, find and use any matching domain.
|
||||
// To simplify the search, prepend a ".", and search the string for ".query".
|
||||
// For example, for http://en.m.wikipedia.org, domainWithDotPrefix will be ".en.m.wikipedia.org".
|
||||
// This allows us to use the "." as a separator, so we can match "en", "m", "wikipedia", and "org",
|
||||
let domain = (url as NSString).substring(with: match.rangeAt(1))
|
||||
return completionForDomain(domain)
|
||||
}
|
||||
|
||||
fileprivate func completionForDomain(_ domain: String) -> String? {
|
||||
let domainWithDotPrefix: String = ".\(domain)"
|
||||
if let range = domainWithDotPrefix.range(of: ".\(query)", options: NSString.CompareOptions.caseInsensitive, range: nil, locale: nil) {
|
||||
// We don't actually want to match the top-level domain ("com", "org", etc.) by itself, so
|
||||
// so make sure the result includes at least one ".".
|
||||
let matchedDomain: String = domainWithDotPrefix.substring(from: domainWithDotPrefix.index(range.lowerBound, offsetBy: 1))
|
||||
if matchedDomain.contains(".") {
|
||||
return matchedDomain
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
78
mobile/ios/Client/Frontend/Browser/SearchSuggestClient.swift
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/* 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 Alamofire
|
||||
import Foundation
|
||||
import Shared
|
||||
|
||||
let SearchSuggestClientErrorDomain = "org.mozilla.firefox.SearchSuggestClient"
|
||||
let SearchSuggestClientErrorInvalidEngine = 0
|
||||
let SearchSuggestClientErrorInvalidResponse = 1
|
||||
|
||||
/*
|
||||
* Clients of SearchSuggestionClient should retain the object during the
|
||||
* lifetime of the search suggestion query, as requests are canceled during destruction.
|
||||
*
|
||||
* Query callbacks that must run even if they are cancelled should wrap their contents in `withExtendendLifetime`.
|
||||
*/
|
||||
class SearchSuggestClient {
|
||||
fileprivate let searchEngine: OpenSearchEngine
|
||||
fileprivate weak var request: Request?
|
||||
fileprivate let userAgent: String
|
||||
|
||||
lazy fileprivate var alamofire: SessionManager = {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
var defaultHeaders = SessionManager.default.session.configuration.httpAdditionalHeaders ?? [:]
|
||||
defaultHeaders["User-Agent"] = self.userAgent
|
||||
configuration.httpAdditionalHeaders = defaultHeaders
|
||||
return SessionManager(configuration: configuration)
|
||||
}()
|
||||
|
||||
init(searchEngine: OpenSearchEngine, userAgent: String) {
|
||||
self.searchEngine = searchEngine
|
||||
self.userAgent = userAgent
|
||||
}
|
||||
|
||||
func query(_ query: String, callback: @escaping (_ response: [String]?, _ error: NSError?) -> Void) {
|
||||
let url = searchEngine.suggestURLForQuery(query)
|
||||
if url == nil {
|
||||
let error = NSError(domain: SearchSuggestClientErrorDomain, code: SearchSuggestClientErrorInvalidEngine, userInfo: nil)
|
||||
callback(nil, error)
|
||||
return
|
||||
}
|
||||
|
||||
request = alamofire.request(url!)
|
||||
.validate(statusCode: 200..<300)
|
||||
.responseJSON { response in
|
||||
if let error = response.result.error {
|
||||
callback(nil, error as NSError?)
|
||||
return
|
||||
}
|
||||
|
||||
// The response will be of the following format:
|
||||
// ["foobar",["foobar","foobar2000 mac","foobar skins",...]]
|
||||
// That is, an array of at least two elements: the search term and an array of suggestions.
|
||||
let array = response.result.value as? NSArray
|
||||
if array?.count ?? 0 < 2 {
|
||||
let error = NSError(domain: SearchSuggestClientErrorDomain, code: SearchSuggestClientErrorInvalidResponse, userInfo: nil)
|
||||
callback(nil, error)
|
||||
return
|
||||
}
|
||||
|
||||
let suggestions = array?[1] as? [String]
|
||||
if suggestions == nil {
|
||||
let error = NSError(domain: SearchSuggestClientErrorDomain, code: SearchSuggestClientErrorInvalidResponse, userInfo: nil)
|
||||
callback(nil, error)
|
||||
return
|
||||
}
|
||||
|
||||
callback(suggestions!, nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func cancelPendingRequest() {
|
||||
request?.cancel()
|
||||
}
|
||||
}
|
||||
635
mobile/ios/Client/Frontend/Browser/SearchViewController.swift
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
/* 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
|
||||
import Storage
|
||||
import Telemetry
|
||||
|
||||
private enum SearchListSection: Int {
|
||||
case searchSuggestions
|
||||
case bookmarksAndHistory
|
||||
static let Count = 2
|
||||
}
|
||||
|
||||
private struct SearchViewControllerUX {
|
||||
static let SearchEngineScrollViewBackgroundColor = UIColor.white.withAlphaComponent(0.8).cgColor
|
||||
static let SearchEngineScrollViewBorderColor = UIColor.black.withAlphaComponent(0.2).cgColor
|
||||
|
||||
// TODO: This should use ToolbarHeight in BVC. Fix this when we create a shared theming file.
|
||||
static let EngineButtonHeight: Float = 44
|
||||
static let EngineButtonWidth = EngineButtonHeight * 1.4
|
||||
static let EngineButtonBackgroundColor = UIColor.clear.cgColor
|
||||
|
||||
static let SearchImage = "search"
|
||||
static let SearchEngineTopBorderWidth = 0.5
|
||||
static let SearchImageHeight: Float = 44
|
||||
static let SearchImageWidth: Float = 24
|
||||
|
||||
static let SuggestionBackgroundColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0.8)
|
||||
static let SuggestionBorderColor = UIConstants.HighlightBlue
|
||||
static let SuggestionBorderWidth: CGFloat = 1
|
||||
static let SuggestionCornerRadius: CGFloat = 4
|
||||
static let SuggestionInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
|
||||
static let SuggestionMargin: CGFloat = 8
|
||||
static let SuggestionCellVerticalPadding: CGFloat = 10
|
||||
static let SuggestionCellMaxRows = 2
|
||||
|
||||
static let IconSize: CGFloat = 23
|
||||
static let FaviconSize: CGFloat = 29
|
||||
static let IconBorderColor = UIColor(white: 0, alpha: 0.1)
|
||||
static let IconBorderWidth: CGFloat = 0.5
|
||||
}
|
||||
|
||||
protocol SearchViewControllerDelegate: class {
|
||||
func searchViewController(_ searchViewController: SearchViewController, didSelectURL url: URL)
|
||||
func searchViewController(_ searchViewController: SearchViewController, didLongPressSuggestion suggestion: String)
|
||||
func presentSearchSettingsController()
|
||||
}
|
||||
|
||||
class SearchViewController: SiteTableViewController, KeyboardHelperDelegate, LoaderListener {
|
||||
var searchDelegate: SearchViewControllerDelegate?
|
||||
|
||||
fileprivate let isPrivate: Bool
|
||||
fileprivate var suggestClient: SearchSuggestClient?
|
||||
|
||||
// Views for displaying the bottom scrollable search engine list. searchEngineScrollView is the
|
||||
// scrollable container; searchEngineScrollViewContent contains the actual set of search engine buttons.
|
||||
fileprivate let searchEngineScrollView = ButtonScrollView()
|
||||
fileprivate let searchEngineScrollViewContent = UIView()
|
||||
|
||||
fileprivate lazy var bookmarkedBadge: UIImage = {
|
||||
return UIImage(named: "bookmarked_passive")!
|
||||
}()
|
||||
|
||||
// Cell for the suggestion flow layout. Since heightForHeaderInSection is called *before*
|
||||
// cellForRowAtIndexPath, we create the cell to find its height before it's added to the table.
|
||||
fileprivate let suggestionCell = SuggestionCell(style: UITableViewCellStyle.default, reuseIdentifier: nil)
|
||||
|
||||
static var userAgent: String?
|
||||
|
||||
init(isPrivate: Bool) {
|
||||
self.isPrivate = isPrivate
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
view.backgroundColor = UIConstants.PanelBackgroundColor
|
||||
let blur = UIVisualEffectView(effect: UIBlurEffect(style: UIBlurEffectStyle.light))
|
||||
view.addSubview(blur)
|
||||
|
||||
super.viewDidLoad()
|
||||
|
||||
KeyboardHelper.defaultHelper.addDelegate(self)
|
||||
|
||||
searchEngineScrollView.layer.backgroundColor = SearchViewControllerUX.SearchEngineScrollViewBackgroundColor
|
||||
searchEngineScrollView.layer.shadowRadius = 0
|
||||
searchEngineScrollView.layer.shadowOpacity = 100
|
||||
searchEngineScrollView.layer.shadowOffset = CGSize(width: 0, height: -SearchViewControllerUX.SearchEngineTopBorderWidth)
|
||||
searchEngineScrollView.layer.shadowColor = SearchViewControllerUX.SearchEngineScrollViewBorderColor
|
||||
searchEngineScrollView.clipsToBounds = false
|
||||
|
||||
searchEngineScrollView.decelerationRate = UIScrollViewDecelerationRateFast
|
||||
view.addSubview(searchEngineScrollView)
|
||||
|
||||
searchEngineScrollViewContent.layer.backgroundColor = UIColor.clear.cgColor
|
||||
searchEngineScrollView.addSubview(searchEngineScrollViewContent)
|
||||
|
||||
layoutTable()
|
||||
layoutSearchEngineScrollView()
|
||||
|
||||
searchEngineScrollViewContent.snp.makeConstraints { make in
|
||||
make.center.equalTo(self.searchEngineScrollView).priority(10)
|
||||
//left-align the engines on iphones, center on ipad
|
||||
if UIScreen.main.traitCollection.horizontalSizeClass == .compact {
|
||||
make.left.equalTo(self.searchEngineScrollView).priority(1000)
|
||||
} else {
|
||||
make.left.greaterThanOrEqualTo(self.searchEngineScrollView).priority(1000)
|
||||
}
|
||||
make.right.lessThanOrEqualTo(self.searchEngineScrollView).priority(1000)
|
||||
make.top.equalTo(self.searchEngineScrollView)
|
||||
make.bottom.equalTo(self.searchEngineScrollView)
|
||||
}
|
||||
|
||||
blur.snp.makeConstraints { make in
|
||||
make.edges.equalTo(self.view)
|
||||
}
|
||||
|
||||
suggestionCell.delegate = self
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SearchViewController.SELDynamicFontChanged(_:)), name: NotificationDynamicFontChanged, object: nil)
|
||||
}
|
||||
|
||||
func SELDynamicFontChanged(_ notification: Notification) {
|
||||
guard notification.name == NotificationDynamicFontChanged else { return }
|
||||
|
||||
reloadData()
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
reloadSearchEngines()
|
||||
reloadData()
|
||||
}
|
||||
|
||||
fileprivate func layoutSearchEngineScrollView() {
|
||||
let keyboardHeight = KeyboardHelper.defaultHelper.currentState?.intersectionHeightForView(self.view) ?? 0
|
||||
searchEngineScrollView.snp.remakeConstraints { make in
|
||||
make.left.right.equalTo(self.view)
|
||||
make.bottom.equalTo(self.view).offset(-keyboardHeight)
|
||||
}
|
||||
}
|
||||
|
||||
var searchEngines: SearchEngines! {
|
||||
didSet {
|
||||
suggestClient?.cancelPendingRequest()
|
||||
|
||||
// Query and reload the table with new search suggestions.
|
||||
querySuggestClient()
|
||||
|
||||
// Show the default search engine first.
|
||||
if !isPrivate {
|
||||
let ua = SearchViewController.userAgent as String! ?? "FxSearch"
|
||||
suggestClient = SearchSuggestClient(searchEngine: searchEngines.defaultEngine, userAgent: ua)
|
||||
}
|
||||
|
||||
// Reload the footer list of search engines.
|
||||
reloadSearchEngines()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var quickSearchEngines: [OpenSearchEngine] {
|
||||
var engines = searchEngines.quickSearchEngines
|
||||
|
||||
// If we're not showing search suggestions, the default search engine won't be visible
|
||||
// at the top of the table. Show it with the others in the bottom search bar.
|
||||
if isPrivate || !searchEngines.shouldShowSearchSuggestions {
|
||||
engines?.insert(searchEngines.defaultEngine, at: 0)
|
||||
}
|
||||
|
||||
return engines!
|
||||
}
|
||||
|
||||
var searchQuery: String = "" {
|
||||
didSet {
|
||||
// Reload the tableView to show the updated text in each engine.
|
||||
reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
override func reloadData() {
|
||||
querySuggestClient()
|
||||
}
|
||||
|
||||
fileprivate func layoutTable() {
|
||||
tableView.snp.remakeConstraints { make in
|
||||
make.top.equalTo(self.view.snp.top)
|
||||
make.leading.trailing.equalTo(self.view)
|
||||
make.bottom.equalTo(self.searchEngineScrollView.snp.top)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func reloadSearchEngines() {
|
||||
searchEngineScrollViewContent.subviews.forEach { $0.removeFromSuperview() }
|
||||
var leftEdge = searchEngineScrollViewContent.snp.left
|
||||
|
||||
//search settings icon
|
||||
let searchButton = UIButton()
|
||||
searchButton.setImage(UIImage(named: "quickSearch"), for: UIControlState())
|
||||
searchButton.imageView?.contentMode = UIViewContentMode.center
|
||||
searchButton.layer.backgroundColor = SearchViewControllerUX.EngineButtonBackgroundColor
|
||||
searchButton.addTarget(self, action: #selector(SearchViewController.SELdidClickSearchButton), for: UIControlEvents.touchUpInside)
|
||||
searchButton.accessibilityLabel = String(format: NSLocalizedString("Search Settings", tableName: "Search", comment: "Label for search settings button."))
|
||||
|
||||
searchButton.imageView?.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(SearchViewControllerUX.SearchImageWidth)
|
||||
return
|
||||
}
|
||||
|
||||
searchEngineScrollViewContent.addSubview(searchButton)
|
||||
searchButton.snp.makeConstraints { make in
|
||||
make.size.equalTo(SearchViewControllerUX.FaviconSize)
|
||||
//offset the left edge to align with search results
|
||||
make.left.equalTo(leftEdge).offset(SearchViewControllerUX.SuggestionMargin * 2)
|
||||
make.top.equalTo(self.searchEngineScrollViewContent).offset(SearchViewControllerUX.SuggestionMargin)
|
||||
make.bottom.equalTo(self.searchEngineScrollViewContent).offset(-SearchViewControllerUX.SuggestionMargin)
|
||||
}
|
||||
|
||||
//search engines
|
||||
leftEdge = searchButton.snp.right
|
||||
for engine in quickSearchEngines {
|
||||
let engineButton = UIButton()
|
||||
engineButton.setImage(engine.image, for: UIControlState())
|
||||
engineButton.imageView?.contentMode = UIViewContentMode.scaleAspectFit
|
||||
engineButton.layer.backgroundColor = SearchViewControllerUX.EngineButtonBackgroundColor
|
||||
engineButton.addTarget(self, action: #selector(SearchViewController.SELdidSelectEngine(_:)), for: UIControlEvents.touchUpInside)
|
||||
engineButton.accessibilityLabel = String(format: NSLocalizedString("%@ search", tableName: "Search", comment: "Label for search engine buttons. The argument corresponds to the name of the search engine."), engine.shortName)
|
||||
|
||||
engineButton.imageView?.snp.makeConstraints { make in
|
||||
make.width.height.equalTo(SearchViewControllerUX.FaviconSize)
|
||||
return
|
||||
}
|
||||
|
||||
searchEngineScrollViewContent.addSubview(engineButton)
|
||||
engineButton.snp.makeConstraints { make in
|
||||
make.width.equalTo(SearchViewControllerUX.EngineButtonWidth)
|
||||
make.height.equalTo(SearchViewControllerUX.EngineButtonHeight)
|
||||
make.left.equalTo(leftEdge)
|
||||
make.top.equalTo(self.searchEngineScrollViewContent)
|
||||
make.bottom.equalTo(self.searchEngineScrollViewContent)
|
||||
if engine === self.searchEngines.quickSearchEngines.last {
|
||||
make.right.equalTo(self.searchEngineScrollViewContent)
|
||||
}
|
||||
}
|
||||
leftEdge = engineButton.snp.right
|
||||
}
|
||||
}
|
||||
|
||||
func SELdidSelectEngine(_ sender: UIButton) {
|
||||
// The UIButtons are the same cardinality and order as the array of quick search engines.
|
||||
// Subtract 1 from index to account for magnifying glass accessory.
|
||||
guard let index = searchEngineScrollViewContent.subviews.index(of: sender) else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
let engine = quickSearchEngines[index - 1]
|
||||
|
||||
guard let url = engine.searchURLForQuery(searchQuery) else {
|
||||
assertionFailure()
|
||||
return
|
||||
}
|
||||
|
||||
Telemetry.default.recordSearch(location: .quickSearch, searchEngine: engine.engineID ?? "other")
|
||||
|
||||
searchDelegate?.searchViewController(self, didSelectURL: url)
|
||||
}
|
||||
|
||||
func SELdidClickSearchButton() {
|
||||
self.searchDelegate?.presentSearchSettingsController()
|
||||
}
|
||||
|
||||
func keyboardHelper(_ keyboardHelper: KeyboardHelper, keyboardWillShowWithState state: KeyboardState) {
|
||||
animateSearchEnginesWithKeyboard(state)
|
||||
}
|
||||
|
||||
func keyboardHelper(_ keyboardHelper: KeyboardHelper, keyboardDidShowWithState state: KeyboardState) {
|
||||
}
|
||||
|
||||
func keyboardHelper(_ keyboardHelper: KeyboardHelper, keyboardWillHideWithState state: KeyboardState) {
|
||||
animateSearchEnginesWithKeyboard(state)
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
// The height of the suggestions row may change, so call reloadData() to recalculate cell heights.
|
||||
coordinator.animate(alongsideTransition: { _ in
|
||||
self.tableView.reloadData()
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
fileprivate func animateSearchEnginesWithKeyboard(_ keyboardState: KeyboardState) {
|
||||
layoutSearchEngineScrollView()
|
||||
|
||||
UIView.animate(withDuration: keyboardState.animationDuration, animations: {
|
||||
UIView.setAnimationCurve(keyboardState.animationCurve)
|
||||
self.view.layoutIfNeeded()
|
||||
})
|
||||
}
|
||||
|
||||
fileprivate func querySuggestClient() {
|
||||
suggestClient?.cancelPendingRequest()
|
||||
|
||||
if searchQuery.isEmpty || !searchEngines.shouldShowSearchSuggestions || searchQuery.looksLikeAURL() {
|
||||
suggestionCell.suggestions = []
|
||||
tableView.reloadData()
|
||||
return
|
||||
}
|
||||
|
||||
suggestClient?.query(searchQuery, callback: { suggestions, error in
|
||||
if let error = error {
|
||||
let isSuggestClientError = error.domain == SearchSuggestClientErrorDomain
|
||||
|
||||
switch error.code {
|
||||
case NSURLErrorCancelled where error.domain == NSURLErrorDomain:
|
||||
// Request was cancelled. Do nothing.
|
||||
break
|
||||
case SearchSuggestClientErrorInvalidEngine where isSuggestClientError:
|
||||
// Engine does not support search suggestions. Do nothing.
|
||||
break
|
||||
case SearchSuggestClientErrorInvalidResponse where isSuggestClientError:
|
||||
print("Error: Invalid search suggestion data")
|
||||
default:
|
||||
print("Error: \(error.description)")
|
||||
}
|
||||
} else {
|
||||
self.suggestionCell.suggestions = suggestions!
|
||||
}
|
||||
|
||||
// If there are no suggestions, just use whatever the user typed.
|
||||
if suggestions?.isEmpty ?? true {
|
||||
self.suggestionCell.suggestions = [self.searchQuery]
|
||||
}
|
||||
|
||||
// Reload the tableView to show the new list of search suggestions.
|
||||
self.tableView.reloadData()
|
||||
})
|
||||
}
|
||||
|
||||
func loader(dataLoaded data: Cursor<Site>) {
|
||||
self.data = data
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, didSelectRowAtIndexPath indexPath: IndexPath) {
|
||||
let section = SearchListSection(rawValue: indexPath.section)!
|
||||
if section == SearchListSection.bookmarksAndHistory {
|
||||
if let site = data[indexPath.row] {
|
||||
if let url = URL(string: site.url) {
|
||||
searchDelegate?.searchViewController(self, didSelectURL: url)
|
||||
UnifiedTelemetry.recordEvent(category: .action, method: .open, object: .bookmark, value: .awesomebarResults)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
if let currentSection = SearchListSection(rawValue: indexPath.section) {
|
||||
switch currentSection {
|
||||
case .searchSuggestions:
|
||||
// heightForRowAtIndexPath is called *before* the cell is created, so to get the height,
|
||||
// force a layout pass first.
|
||||
suggestionCell.layoutIfNeeded()
|
||||
return suggestionCell.frame.height
|
||||
default:
|
||||
return super.tableView(tableView, heightForRowAt: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
return 0
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
switch SearchListSection(rawValue: indexPath.section)! {
|
||||
case .searchSuggestions:
|
||||
suggestionCell.imageView?.image = searchEngines.defaultEngine.image
|
||||
suggestionCell.imageView?.isAccessibilityElement = true
|
||||
suggestionCell.imageView?.accessibilityLabel = String(format: NSLocalizedString("Search suggestions from %@", tableName: "Search", comment: "Accessibility label for image of default search engine displayed left to the actual search suggestions from the engine. The parameter substituted for \"%@\" is the name of the search engine. E.g.: Search suggestions from Google"), searchEngines.defaultEngine.shortName)
|
||||
return suggestionCell
|
||||
|
||||
case .bookmarksAndHistory:
|
||||
let cell = super.tableView(tableView, cellForRowAt: indexPath)
|
||||
if let site = data[indexPath.row] {
|
||||
if let cell = cell as? TwoLineTableViewCell {
|
||||
let isBookmark = site.bookmarked ?? false
|
||||
cell.setLines(site.title, detailText: site.url)
|
||||
cell.setRightBadge(isBookmark ? self.bookmarkedBadge : nil)
|
||||
cell.imageView!.layer.borderColor = SearchViewControllerUX.IconBorderColor.cgColor
|
||||
cell.imageView!.layer.borderWidth = SearchViewControllerUX.IconBorderWidth
|
||||
cell.imageView?.setIcon(site.icon, forURL: site.tileURL, completed: { (color, url) in
|
||||
if site.tileURL == url {
|
||||
cell.imageView?.image = cell.imageView?.image?.createScaled(CGSize(width: SearchViewControllerUX.IconSize, height: SearchViewControllerUX.IconSize))
|
||||
cell.imageView?.contentMode = .center
|
||||
cell.imageView?.backgroundColor = color
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
switch SearchListSection(rawValue: section)! {
|
||||
case .searchSuggestions:
|
||||
return searchEngines.shouldShowSearchSuggestions && !searchQuery.looksLikeAURL() && !isPrivate ? 1 : 0
|
||||
case .bookmarksAndHistory:
|
||||
return data.count
|
||||
}
|
||||
}
|
||||
|
||||
func numberOfSectionsInTableView(_ tableView: UITableView) -> Int {
|
||||
return SearchListSection.Count
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchViewController: SuggestionCellDelegate {
|
||||
fileprivate func suggestionCell(_ suggestionCell: SuggestionCell, didSelectSuggestion suggestion: String) {
|
||||
// Assume that only the default search engine can provide search suggestions.
|
||||
let engine = searchEngines.defaultEngine
|
||||
|
||||
var url = URIFixup.getURL(suggestion)
|
||||
if url == nil {
|
||||
url = engine.searchURLForQuery(suggestion)
|
||||
}
|
||||
|
||||
Telemetry.default.recordSearch(location: .suggestion, searchEngine: engine.engineID ?? "other")
|
||||
|
||||
if let url = url {
|
||||
searchDelegate?.searchViewController(self, didSelectURL: url)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func suggestionCell(_ suggestionCell: SuggestionCell, didLongPressSuggestion suggestion: String) {
|
||||
searchDelegate?.searchViewController(self, didLongPressSuggestion: suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Private extension containing string operations specific to this view controller
|
||||
*/
|
||||
fileprivate extension String {
|
||||
func looksLikeAURL() -> Bool {
|
||||
// The assumption here is that if the user is typing in a forward slash and there are no spaces
|
||||
// involved, it's going to be a URL. If we type a space, any url would be invalid.
|
||||
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1192155 for additional details.
|
||||
return self.contains("/") && !self.contains(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UIScrollView that prevents buttons from interfering with scroll.
|
||||
*/
|
||||
fileprivate class ButtonScrollView: UIScrollView {
|
||||
fileprivate override func touchesShouldCancel(in view: UIView) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate protocol SuggestionCellDelegate: class {
|
||||
func suggestionCell(_ suggestionCell: SuggestionCell, didSelectSuggestion suggestion: String)
|
||||
func suggestionCell(_ suggestionCell: SuggestionCell, didLongPressSuggestion suggestion: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell that wraps a list of search suggestion buttons.
|
||||
*/
|
||||
fileprivate class SuggestionCell: UITableViewCell {
|
||||
weak var delegate: SuggestionCellDelegate?
|
||||
let container = UIView()
|
||||
|
||||
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
isAccessibilityElement = false
|
||||
accessibilityLabel = nil
|
||||
layoutMargins = UIEdgeInsets.zero
|
||||
separatorInset = UIEdgeInsets.zero
|
||||
selectionStyle = UITableViewCellSelectionStyle.none
|
||||
|
||||
container.backgroundColor = UIColor.clear
|
||||
contentView.backgroundColor = UIColor.clear
|
||||
backgroundColor = UIColor.clear
|
||||
contentView.addSubview(container)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
var suggestions: [String] = [] {
|
||||
didSet {
|
||||
for view in container.subviews {
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
|
||||
for suggestion in suggestions {
|
||||
let button = SuggestionButton()
|
||||
button.setTitle(suggestion, for: UIControlState())
|
||||
button.addTarget(self, action: #selector(SuggestionCell.SELdidSelectSuggestion(_:)), for: UIControlEvents.touchUpInside)
|
||||
button.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(SuggestionCell.SELdidLongPressSuggestion(_:))))
|
||||
|
||||
// If this is the first image, add the search icon.
|
||||
if container.subviews.isEmpty {
|
||||
let image = UIImage(named: SearchViewControllerUX.SearchImage)
|
||||
button.setImage(image, for: UIControlState())
|
||||
button.titleEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
|
||||
}
|
||||
|
||||
container.addSubview(button)
|
||||
}
|
||||
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func SELdidSelectSuggestion(_ sender: UIButton) {
|
||||
delegate?.suggestionCell(self, didSelectSuggestion: sender.titleLabel!.text!)
|
||||
}
|
||||
|
||||
@objc
|
||||
func SELdidLongPressSuggestion(_ recognizer: UILongPressGestureRecognizer) {
|
||||
if recognizer.state == UIGestureRecognizerState.began {
|
||||
if let button = recognizer.view as! UIButton? {
|
||||
delegate?.suggestionCell(self, didLongPressSuggestion: button.titleLabel!.text!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// The left bounds of the suggestions, aligned with where text would be displayed.
|
||||
let textLeft: CGFloat = 61
|
||||
|
||||
// The maximum width of the container, after which suggestions will wrap to the next line.
|
||||
let maxWidth = contentView.frame.width
|
||||
|
||||
let imageSize = CGFloat(SearchViewControllerUX.FaviconSize)
|
||||
|
||||
// The height of the suggestions container (minus margins), used to determine the frame.
|
||||
// We set it to imageSize.height as a minimum since we don't want the cell to be shorter than the icon
|
||||
var height: CGFloat = imageSize
|
||||
|
||||
var currentLeft = textLeft
|
||||
var currentTop = SearchViewControllerUX.SuggestionCellVerticalPadding
|
||||
var currentRow = 0
|
||||
|
||||
for view in container.subviews {
|
||||
let button = view as! UIButton
|
||||
var buttonSize = button.intrinsicContentSize
|
||||
|
||||
// Update our base frame height by the max size of either the image or the button so we never
|
||||
// make the cell smaller than any of the two
|
||||
if height == imageSize {
|
||||
height = max(buttonSize.height, imageSize)
|
||||
}
|
||||
|
||||
var width = currentLeft + buttonSize.width + SearchViewControllerUX.SuggestionMargin
|
||||
if width > maxWidth {
|
||||
// Only move to the next row if there's already a suggestion on this row.
|
||||
// Otherwise, the suggestion is too big to fit and will be resized below.
|
||||
if currentLeft > textLeft {
|
||||
currentRow += 1
|
||||
if currentRow >= SearchViewControllerUX.SuggestionCellMaxRows {
|
||||
// Don't draw this button if it doesn't fit on the row.
|
||||
button.frame = CGRect.zero
|
||||
continue
|
||||
}
|
||||
|
||||
currentLeft = textLeft
|
||||
currentTop += buttonSize.height + SearchViewControllerUX.SuggestionMargin
|
||||
height += buttonSize.height + SearchViewControllerUX.SuggestionMargin
|
||||
width = currentLeft + buttonSize.width + SearchViewControllerUX.SuggestionMargin
|
||||
}
|
||||
|
||||
// If the suggestion is too wide to fit on its own row, shrink it.
|
||||
if width > maxWidth {
|
||||
buttonSize.width = maxWidth - currentLeft - SearchViewControllerUX.SuggestionMargin
|
||||
}
|
||||
}
|
||||
|
||||
button.frame = CGRect(x: currentLeft, y: currentTop, width: buttonSize.width, height: buttonSize.height)
|
||||
currentLeft += buttonSize.width + SearchViewControllerUX.SuggestionMargin
|
||||
}
|
||||
|
||||
frame.size.height = height + 2 * SearchViewControllerUX.SuggestionCellVerticalPadding
|
||||
contentView.frame = bounds
|
||||
container.frame = bounds
|
||||
|
||||
let imageX = (textLeft - imageSize) / 2
|
||||
let imageY = (frame.size.height - imageSize) / 2
|
||||
imageView!.frame = CGRect(x: imageX, y: imageY, width: imageSize, height: imageSize)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded search suggestion button that highlights when selected.
|
||||
*/
|
||||
fileprivate class SuggestionButton: InsetButton {
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
setTitleColor(UIConstants.HighlightBlue, for: UIControlState())
|
||||
setTitleColor(UIColor.white, for: UIControlState.highlighted)
|
||||
titleLabel?.font = DynamicFontHelper.defaultHelper.DefaultMediumFont
|
||||
backgroundColor = SearchViewControllerUX.SuggestionBackgroundColor
|
||||
layer.borderColor = SearchViewControllerUX.SuggestionBorderColor.cgColor
|
||||
layer.borderWidth = SearchViewControllerUX.SuggestionBorderWidth
|
||||
layer.cornerRadius = SearchViewControllerUX.SuggestionCornerRadius
|
||||
contentEdgeInsets = SearchViewControllerUX.SuggestionInsets
|
||||
|
||||
accessibilityHint = NSLocalizedString("Searches for the suggestion", comment: "Accessibility hint describing the action performed when a search suggestion is clicked")
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@objc
|
||||
override var isHighlighted: Bool {
|
||||
didSet {
|
||||
backgroundColor = isHighlighted ? UIConstants.HighlightBlue : SearchViewControllerUX.SuggestionBackgroundColor
|
||||
}
|
||||
}
|
||||
}
|
||||
50
mobile/ios/Client/Frontend/Browser/SessionData.swift
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/* 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
|
||||
|
||||
class SessionData: NSObject, NSCoding {
|
||||
let currentPage: Int
|
||||
let urls: [URL]
|
||||
let lastUsedTime: Timestamp
|
||||
|
||||
var jsonDictionary: [String: Any] {
|
||||
return [
|
||||
"currentPage": String(self.currentPage),
|
||||
"lastUsedTime": String(self.lastUsedTime),
|
||||
"urls": urls.map { $0.absoluteString }
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
Creates a new SessionData object representing a serialized tab.
|
||||
|
||||
- parameter currentPage: The active page index. Must be in the range of (-N, 0],
|
||||
where 1-N is the first page in history, and 0 is the last.
|
||||
- parameter urls: The sequence of URLs in this tab's session history.
|
||||
- parameter lastUsedTime: The last time this tab was modified.
|
||||
**/
|
||||
init(currentPage: Int, urls: [URL], lastUsedTime: Timestamp) {
|
||||
self.currentPage = currentPage
|
||||
self.urls = urls
|
||||
self.lastUsedTime = lastUsedTime
|
||||
|
||||
assert(urls.count > 0, "Session has at least one entry")
|
||||
assert(currentPage > -urls.count && currentPage <= 0, "Session index is valid")
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
self.currentPage = coder.decodeAsInt(forKey: "currentPage")
|
||||
self.urls = coder.decodeObject(forKey: "urls") as? [URL] ?? []
|
||||
self.lastUsedTime = coder.decodeAsUInt64(forKey: "lastUsedTime")
|
||||
}
|
||||
|
||||
func encode(with coder: NSCoder) {
|
||||
coder.encode(currentPage, forKey: "currentPage")
|
||||
coder.encode(urls, forKey: "urls")
|
||||
coder.encode(Int64(lastUsedTime), forKey: "lastUsedTime")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 WebKit
|
||||
import GCDWebServers
|
||||
import Shared
|
||||
|
||||
/// Handles requests to /about/sessionrestore to restore session history.
|
||||
struct SessionRestoreHandler {
|
||||
static func register(_ webServer: WebServer) {
|
||||
// Register the handler that accepts /about/sessionrestore?history=...¤tpage=... requests.
|
||||
webServer.registerHandlerForMethod("GET", module: "about", resource: "sessionrestore") { _ in
|
||||
if let sessionRestorePath = Bundle.main.path(forResource: "SessionRestore", ofType: "html") {
|
||||
do {
|
||||
let sessionRestoreString = try String(contentsOfFile: sessionRestorePath)
|
||||
|
||||
defer {
|
||||
NotificationCenter.default.post(name: NotificationDidRestoreSession, object: self)
|
||||
}
|
||||
|
||||
return GCDWebServerDataResponse(html: sessionRestoreString)
|
||||
} catch _ {}
|
||||
}
|
||||
|
||||
return GCDWebServerResponse(statusCode: 404)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/* 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
|
||||
|
||||
protocol SessionRestoreHelperDelegate: class {
|
||||
func sessionRestoreHelper(_ helper: SessionRestoreHelper, didRestoreSessionForTab tab: Tab)
|
||||
}
|
||||
|
||||
class SessionRestoreHelper: TabContentScript {
|
||||
weak var delegate: SessionRestoreHelperDelegate?
|
||||
fileprivate weak var tab: Tab?
|
||||
|
||||
required init(tab: Tab) {
|
||||
self.tab = tab
|
||||
}
|
||||
|
||||
func scriptMessageHandlerName() -> String? {
|
||||
return "sessionRestoreHelper"
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage) {
|
||||
if let tab = tab, let params = message.body as? [String: AnyObject] {
|
||||
if params["name"] as! String == "didRestoreSession" {
|
||||
DispatchQueue.main.async {
|
||||
self.delegate?.sessionRestoreHelper(self, didRestoreSessionForTab: tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class func name() -> String {
|
||||
return "SessionRestoreHelper"
|
||||
}
|
||||
}
|
||||
73
mobile/ios/Client/Frontend/Browser/SimpleToast.swift
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/* 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
|
||||
|
||||
struct SimpleToastUX {
|
||||
static let ToastHeight = BottomToolbarHeight
|
||||
static let ToastAnimationDuration = 0.5
|
||||
static let ToastDefaultColor = UIColor(red: 10 / 255, green: 132 / 255, blue: 255.0 / 255, alpha: 1)
|
||||
static let ToastFont = UIFont.systemFont(ofSize: 15)
|
||||
static let ToastDismissAfter = DispatchTimeInterval.milliseconds(4500) // 4.5 seconds.
|
||||
static let ToastDelayBefore = DispatchTimeInterval.milliseconds(0) // 0 seconds
|
||||
static let BottomToolbarHeight = CGFloat(45)
|
||||
}
|
||||
|
||||
struct SimpleToast {
|
||||
|
||||
func showAlertWithText(_ text: String, bottomContainer: UIView) {
|
||||
let toast = self.createView()
|
||||
toast.text = text
|
||||
bottomContainer.addSubview(toast)
|
||||
toast.snp.makeConstraints { (make) in
|
||||
make.width.equalTo(bottomContainer)
|
||||
make.left.equalTo(bottomContainer)
|
||||
make.height.equalTo(SimpleToastUX.ToastHeight)
|
||||
make.bottom.equalTo(bottomContainer)
|
||||
}
|
||||
animate(toast)
|
||||
}
|
||||
|
||||
fileprivate func createView() -> UILabel {
|
||||
let toast = UILabel()
|
||||
toast.textColor = UIColor.white
|
||||
toast.backgroundColor = SimpleToastUX.ToastDefaultColor
|
||||
toast.font = SimpleToastUX.ToastFont
|
||||
toast.textAlignment = .center
|
||||
return toast
|
||||
}
|
||||
|
||||
fileprivate func dismiss(_ toast: UIView) {
|
||||
UIView.animate(withDuration: SimpleToastUX.ToastAnimationDuration,
|
||||
animations: {
|
||||
var frame = toast.frame
|
||||
frame.origin.y = frame.origin.y + SimpleToastUX.ToastHeight
|
||||
frame.size.height = 0
|
||||
toast.frame = frame
|
||||
},
|
||||
completion: { finished in
|
||||
toast.removeFromSuperview()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fileprivate func animate(_ toast: UIView) {
|
||||
UIView.animate(withDuration: SimpleToastUX.ToastAnimationDuration,
|
||||
animations: {
|
||||
var frame = toast.frame
|
||||
frame.origin.y = frame.origin.y - SimpleToastUX.ToastHeight
|
||||
frame.size.height = SimpleToastUX.ToastHeight
|
||||
toast.frame = frame
|
||||
},
|
||||
completion: { finished in
|
||||
let dispatchTime = DispatchTime.now() + SimpleToastUX.ToastDismissAfter
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: dispatchTime, execute: {
|
||||
self.dismiss(toast)
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
156
mobile/ios/Client/Frontend/Browser/SwipeAnimator.swift
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/* 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
|
||||
|
||||
struct SwipeAnimationParameters {
|
||||
let totalRotationInDegrees: Double
|
||||
let deleteThreshold: CGFloat
|
||||
let totalScale: CGFloat
|
||||
let totalAlpha: CGFloat
|
||||
let minExitVelocity: CGFloat
|
||||
let recenterAnimationDuration: TimeInterval
|
||||
}
|
||||
|
||||
private let DefaultParameters =
|
||||
SwipeAnimationParameters(
|
||||
totalRotationInDegrees: 10,
|
||||
deleteThreshold: 80,
|
||||
totalScale: 0.9,
|
||||
totalAlpha: 0,
|
||||
minExitVelocity: 800,
|
||||
recenterAnimationDuration: 0.15)
|
||||
|
||||
protocol SwipeAnimatorDelegate: class {
|
||||
func swipeAnimator(_ animator: SwipeAnimator, viewWillExitContainerBounds: UIView)
|
||||
}
|
||||
|
||||
class SwipeAnimator: NSObject {
|
||||
weak var delegate: SwipeAnimatorDelegate?
|
||||
weak var animatingView: UIView?
|
||||
|
||||
fileprivate var prevOffset: CGPoint?
|
||||
fileprivate let params: SwipeAnimationParameters
|
||||
|
||||
fileprivate var panGestureRecogniser: UIPanGestureRecognizer!
|
||||
|
||||
var containerCenter: CGPoint {
|
||||
guard let animatingView = self.animatingView else {
|
||||
return CGPoint.zero
|
||||
}
|
||||
return CGPoint(x: animatingView.frame.width / 2, y: animatingView.frame.height / 2)
|
||||
}
|
||||
|
||||
init(animatingView: UIView, params: SwipeAnimationParameters = DefaultParameters) {
|
||||
self.animatingView = animatingView
|
||||
self.params = params
|
||||
|
||||
super.init()
|
||||
|
||||
self.panGestureRecogniser = UIPanGestureRecognizer(target: self, action: #selector(SwipeAnimator.didPan(_:)))
|
||||
animatingView.addGestureRecognizer(self.panGestureRecogniser)
|
||||
self.panGestureRecogniser.delegate = self
|
||||
}
|
||||
|
||||
func cancelExistingGestures() {
|
||||
self.panGestureRecogniser.isEnabled = false
|
||||
self.panGestureRecogniser.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: Private Helpers
|
||||
extension SwipeAnimator {
|
||||
fileprivate func animateBackToCenter() {
|
||||
UIView.animate(withDuration: params.recenterAnimationDuration, animations: {
|
||||
self.animatingView?.transform = CGAffineTransform.identity
|
||||
self.animatingView?.alpha = 1
|
||||
})
|
||||
}
|
||||
|
||||
fileprivate func animateAwayWithVelocity(_ velocity: CGPoint, speed: CGFloat) {
|
||||
guard let animatingView = self.animatingView else {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate the edge to calculate distance from
|
||||
let translation = velocity.x >= 0 ? animatingView.frame.width : -animatingView.frame.width
|
||||
let timeStep = TimeInterval(abs(translation) / speed)
|
||||
self.delegate?.swipeAnimator(self, viewWillExitContainerBounds: animatingView)
|
||||
UIView.animate(withDuration: timeStep, animations: {
|
||||
animatingView.transform = self.transformForTranslation(translation)
|
||||
animatingView.alpha = self.alphaForDistanceFromCenter(abs(translation))
|
||||
}, completion: { finished in
|
||||
if finished {
|
||||
animatingView.alpha = 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fileprivate func transformForTranslation(_ translation: CGFloat) -> CGAffineTransform {
|
||||
let swipeWidth = animatingView?.frame.size.width ?? 1
|
||||
let totalRotationInRadians = CGFloat(params.totalRotationInDegrees / 180.0 * Double.pi)
|
||||
|
||||
// Determine rotation / scaling amounts by the distance to the edge
|
||||
let rotation = (translation / swipeWidth) * totalRotationInRadians
|
||||
let scale = 1 - (abs(translation) / swipeWidth) * (1 - params.totalScale)
|
||||
|
||||
let rotationTransform = CGAffineTransform(rotationAngle: rotation)
|
||||
let scaleTransform = CGAffineTransform(scaleX: scale, y: scale)
|
||||
let translateTransform = CGAffineTransform(translationX: translation, y: 0)
|
||||
return rotationTransform.concatenating(scaleTransform).concatenating(translateTransform)
|
||||
}
|
||||
|
||||
fileprivate func alphaForDistanceFromCenter(_ distance: CGFloat) -> CGFloat {
|
||||
let swipeWidth = animatingView?.frame.size.width ?? 1
|
||||
return 1 - (distance / swipeWidth) * (1 - params.totalAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: Selectors
|
||||
extension SwipeAnimator {
|
||||
@objc func didPan(_ recognizer: UIPanGestureRecognizer!) {
|
||||
let translation = recognizer.translation(in: animatingView)
|
||||
|
||||
switch recognizer.state {
|
||||
case .began:
|
||||
prevOffset = containerCenter
|
||||
case .changed:
|
||||
animatingView?.transform = transformForTranslation(translation.x)
|
||||
animatingView?.alpha = alphaForDistanceFromCenter(abs(translation.x))
|
||||
prevOffset = CGPoint(x: translation.x, y: 0)
|
||||
case .cancelled:
|
||||
animateBackToCenter()
|
||||
case .ended:
|
||||
let velocity = recognizer.velocity(in: animatingView)
|
||||
// Bounce back if the velocity is too low or if we have not reached the threshold yet
|
||||
let speed = max(abs(velocity.x), params.minExitVelocity)
|
||||
if speed < params.minExitVelocity || abs(prevOffset?.x ?? 0) < params.deleteThreshold {
|
||||
animateBackToCenter()
|
||||
} else {
|
||||
animateAwayWithVelocity(velocity, speed: speed)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func close(right: Bool) {
|
||||
let direction = CGFloat(right ? -1 : 1)
|
||||
animateAwayWithVelocity(CGPoint(x: -direction * params.minExitVelocity, y: 0), speed: direction * params.minExitVelocity)
|
||||
}
|
||||
|
||||
@discardableResult @objc func closeWithoutGesture() -> Bool {
|
||||
close(right: false)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension SwipeAnimator: UIGestureRecognizerDelegate {
|
||||
@objc func gestureRecognizerShouldBegin(_ recognizer: UIGestureRecognizer) -> Bool {
|
||||
let cellView = recognizer.view as UIView!
|
||||
let panGesture = recognizer as! UIPanGestureRecognizer
|
||||
let translation = panGesture.translation(in: cellView?.superview)
|
||||
return fabs(translation.x) > fabs(translation.y)
|
||||
}
|
||||
}
|
||||
598
mobile/ios/Client/Frontend/Browser/Tab.swift
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
/* 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 Storage
|
||||
import Shared
|
||||
import SwiftyJSON
|
||||
import XCGLogger
|
||||
|
||||
protocol TabContentScript {
|
||||
static func name() -> String
|
||||
func scriptMessageHandlerName() -> String?
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage)
|
||||
}
|
||||
|
||||
@objc
|
||||
protocol TabDelegate {
|
||||
func tab(_ tab: Tab, didAddSnackbar bar: SnackBar)
|
||||
func tab(_ tab: Tab, didRemoveSnackbar bar: SnackBar)
|
||||
func tab(_ tab: Tab, didSelectFindInPageForSelection selection: String)
|
||||
@objc optional func tab(_ tab: Tab, didCreateWebView webView: WKWebView)
|
||||
@objc optional func tab(_ tab: Tab, willDeleteWebView webView: WKWebView)
|
||||
}
|
||||
|
||||
@objc
|
||||
protocol URLChangeDelegate {
|
||||
func tab(_ tab: Tab, urlDidChangeTo url: URL)
|
||||
}
|
||||
|
||||
struct TabState {
|
||||
var isPrivate: Bool = false
|
||||
var desktopSite: Bool = false
|
||||
var isBookmarked: Bool = false
|
||||
var url: URL?
|
||||
var title: String?
|
||||
var favicon: Favicon?
|
||||
}
|
||||
|
||||
class Tab: NSObject {
|
||||
fileprivate var _isPrivate: Bool = false
|
||||
internal fileprivate(set) var isPrivate: Bool {
|
||||
get {
|
||||
return _isPrivate
|
||||
}
|
||||
set {
|
||||
if _isPrivate != newValue {
|
||||
_isPrivate = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tabState: TabState {
|
||||
return TabState(isPrivate: _isPrivate, desktopSite: desktopSite, isBookmarked: isBookmarked, url: url, title: displayTitle, favicon: displayFavicon)
|
||||
}
|
||||
|
||||
// PageMetadata is derived from the page content itself, and as such lags behind the
|
||||
// rest of the tab.
|
||||
var pageMetadata: PageMetadata?
|
||||
|
||||
var canonicalURL: URL? {
|
||||
if let string = pageMetadata?.siteURL,
|
||||
let siteURL = URL(string: string) {
|
||||
return siteURL
|
||||
}
|
||||
return self.url
|
||||
}
|
||||
|
||||
var userActivity: NSUserActivity? = nil
|
||||
|
||||
var webView: WKWebView?
|
||||
var tabDelegate: TabDelegate?
|
||||
weak var urlDidChangeDelegate: URLChangeDelegate? // TODO: generalize this.
|
||||
var bars = [SnackBar]()
|
||||
var favicons = [Favicon]()
|
||||
var lastExecutedTime: Timestamp?
|
||||
var sessionData: SessionData?
|
||||
fileprivate var lastRequest: URLRequest?
|
||||
var restoring: Bool = false
|
||||
var pendingScreenshot = false
|
||||
var url: URL?
|
||||
var mimeType: String?
|
||||
|
||||
fileprivate var _noImageMode = false
|
||||
|
||||
/// Returns true if this tab's URL is known, and it's longer than we want to store.
|
||||
var urlIsTooLong: Bool {
|
||||
guard let url = self.url else {
|
||||
return false
|
||||
}
|
||||
return url.absoluteString.lengthOfBytes(using: String.Encoding.utf8) > AppConstants.DB_URL_LENGTH_MAX
|
||||
}
|
||||
|
||||
// Use computed property so @available can be used to guard `noImageMode`.
|
||||
@available(iOS 11, *)
|
||||
var noImageMode: Bool {
|
||||
get { return _noImageMode }
|
||||
set {
|
||||
if newValue == _noImageMode {
|
||||
return
|
||||
}
|
||||
_noImageMode = newValue
|
||||
let helper = (contentBlocker as? ContentBlockerHelper)
|
||||
helper?.noImageMode(enabled: _noImageMode)
|
||||
}
|
||||
}
|
||||
|
||||
// There is no 'available macro' on props, we currently just need to store ownership.
|
||||
var contentBlocker: AnyObject?
|
||||
|
||||
/// The last title shown by this tab. Used by the tab tray to show titles for zombie tabs.
|
||||
var lastTitle: String?
|
||||
|
||||
/// Whether or not the desktop site was requested with the last request, reload or navigation. Note that this property needs to
|
||||
/// be managed by the web view's navigation delegate.
|
||||
var desktopSite: Bool = false
|
||||
var isBookmarked: Bool = false
|
||||
|
||||
var readerModeAvailableOrActive: Bool {
|
||||
if let readerMode = self.getContentScript(name: "ReaderMode") as? ReaderMode {
|
||||
return readerMode.state != .unavailable
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fileprivate(set) var screenshot: UIImage?
|
||||
var screenshotUUID: UUID?
|
||||
|
||||
// If this tab has been opened from another, its parent will point to the tab from which it was opened
|
||||
var parent: Tab?
|
||||
|
||||
fileprivate var contentScriptManager = TabContentScriptManager()
|
||||
|
||||
fileprivate var configuration: WKWebViewConfiguration?
|
||||
|
||||
/// Any time a tab tries to make requests to display a Javascript Alert and we are not the active
|
||||
/// tab instance, queue it for later until we become foregrounded.
|
||||
fileprivate var alertQueue = [JSAlertInfo]()
|
||||
|
||||
init(configuration: WKWebViewConfiguration, isPrivate: Bool = false) {
|
||||
self.configuration = configuration
|
||||
super.init()
|
||||
self.isPrivate = isPrivate
|
||||
|
||||
if #available(iOS 11, *) {
|
||||
if let appDelegate = UIApplication.shared.delegate as? AppDelegate, let profile = appDelegate.profile {
|
||||
contentBlocker = ContentBlockerHelper(tab: self, profile: profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class func toTab(_ tab: Tab) -> RemoteTab? {
|
||||
if let displayURL = tab.url?.displayURL, RemoteTab.shouldIncludeURL(displayURL) {
|
||||
let history = Array(tab.historyList.filter(RemoteTab.shouldIncludeURL).reversed())
|
||||
return RemoteTab(clientGUID: nil,
|
||||
URL: displayURL,
|
||||
title: tab.displayTitle,
|
||||
history: history,
|
||||
lastUsed: Date.now(),
|
||||
icon: nil)
|
||||
} else if let sessionData = tab.sessionData, !sessionData.urls.isEmpty {
|
||||
let history = Array(sessionData.urls.filter(RemoteTab.shouldIncludeURL).reversed())
|
||||
if let displayURL = history.first {
|
||||
return RemoteTab(clientGUID: nil,
|
||||
URL: displayURL,
|
||||
title: tab.displayTitle,
|
||||
history: history,
|
||||
lastUsed: sessionData.lastUsedTime,
|
||||
icon: nil)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
weak var navigationDelegate: WKNavigationDelegate? {
|
||||
didSet {
|
||||
if let webView = webView {
|
||||
webView.navigationDelegate = navigationDelegate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func createWebview() {
|
||||
if webView == nil {
|
||||
assert(configuration != nil, "Create webview can only be called once")
|
||||
configuration!.userContentController = WKUserContentController()
|
||||
configuration!.preferences = WKPreferences()
|
||||
configuration!.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
configuration!.allowsInlineMediaPlayback = true
|
||||
let webView = TabWebView(frame: CGRect.zero, configuration: configuration!)
|
||||
webView.delegate = self
|
||||
configuration = nil
|
||||
|
||||
webView.accessibilityLabel = NSLocalizedString("Web content", comment: "Accessibility label for the main web content view")
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.allowsLinkPreview = false
|
||||
|
||||
// Night mode enables this by toggling WKWebView.isOpaque, otherwise this has no effect.
|
||||
webView.backgroundColor = .black
|
||||
|
||||
// Turning off masking allows the web content to flow outside of the scrollView's frame
|
||||
// which allows the content appear beneath the toolbars in the BrowserViewController
|
||||
webView.scrollView.layer.masksToBounds = false
|
||||
webView.navigationDelegate = navigationDelegate
|
||||
|
||||
restore(webView)
|
||||
|
||||
self.webView = webView
|
||||
self.webView?.addObserver(self, forKeyPath: KVOConstants.URL.rawValue, options: .new, context: nil)
|
||||
tabDelegate?.tab?(self, didCreateWebView: webView)
|
||||
}
|
||||
}
|
||||
|
||||
func restore(_ webView: WKWebView) {
|
||||
// Pulls restored session data from a previous SavedTab to load into the Tab. If it's nil, a session restore
|
||||
// has already been triggered via custom URL, so we use the last request to trigger it again; otherwise,
|
||||
// we extract the information needed to restore the tabs and create a NSURLRequest with the custom session restore URL
|
||||
// to trigger the session restore via custom handlers
|
||||
if let sessionData = self.sessionData {
|
||||
restoring = true
|
||||
|
||||
var urls = [String]()
|
||||
for url in sessionData.urls {
|
||||
urls.append(url.absoluteString)
|
||||
}
|
||||
|
||||
let currentPage = sessionData.currentPage
|
||||
self.sessionData = nil
|
||||
var jsonDict = [String: AnyObject]()
|
||||
jsonDict["history"] = urls as AnyObject?
|
||||
jsonDict["currentPage"] = currentPage as AnyObject?
|
||||
guard let json = JSON(jsonDict).stringValue() else {
|
||||
return
|
||||
}
|
||||
let escapedJSON = json.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
|
||||
let restoreURL = URL(string: "\(WebServer.sharedInstance.base)/about/sessionrestore?history=\(escapedJSON)")
|
||||
lastRequest = PrivilegedRequest(url: restoreURL!) as URLRequest
|
||||
webView.load(lastRequest!)
|
||||
} else if let request = lastRequest {
|
||||
webView.load(request)
|
||||
} else {
|
||||
print("creating webview with no lastRequest and no session data: \(self.url?.description ?? "nil")")
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let webView = webView {
|
||||
webView.removeObserver(self, forKeyPath: KVOConstants.URL.rawValue)
|
||||
tabDelegate?.tab?(self, willDeleteWebView: webView)
|
||||
}
|
||||
}
|
||||
|
||||
var loading: Bool {
|
||||
return webView?.isLoading ?? false
|
||||
}
|
||||
|
||||
var estimatedProgress: Double {
|
||||
return webView?.estimatedProgress ?? 0
|
||||
}
|
||||
|
||||
var backList: [WKBackForwardListItem]? {
|
||||
return webView?.backForwardList.backList
|
||||
}
|
||||
|
||||
var forwardList: [WKBackForwardListItem]? {
|
||||
return webView?.backForwardList.forwardList
|
||||
}
|
||||
|
||||
var historyList: [URL] {
|
||||
func listToUrl(_ item: WKBackForwardListItem) -> URL { return item.url }
|
||||
var tabs = self.backList?.map(listToUrl) ?? [URL]()
|
||||
tabs.append(self.url!)
|
||||
return tabs
|
||||
}
|
||||
|
||||
var title: String? {
|
||||
return webView?.title
|
||||
}
|
||||
|
||||
var displayTitle: String {
|
||||
if let title = webView?.title {
|
||||
if !title.isEmpty {
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
// When picking a display title. Tabs with sessionData are pending a restore so show their old title.
|
||||
// To prevent flickering of the display title. If a tab is restoring make sure to use its lastTitle.
|
||||
if let url = self.url, url.isAboutHomeURL, sessionData == nil, !restoring {
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let lastTitle = lastTitle, !lastTitle.isEmpty else {
|
||||
return self.url?.displayURL?.absoluteString ?? ""
|
||||
}
|
||||
|
||||
return lastTitle
|
||||
}
|
||||
|
||||
var currentInitialURL: URL? {
|
||||
get {
|
||||
let initalURL = self.webView?.backForwardList.currentItem?.initialURL
|
||||
return initalURL
|
||||
}
|
||||
}
|
||||
|
||||
var displayFavicon: Favicon? {
|
||||
var width = 0
|
||||
var largest: Favicon?
|
||||
for icon in favicons where icon.width! > width {
|
||||
width = icon.width!
|
||||
largest = icon
|
||||
}
|
||||
return largest
|
||||
}
|
||||
|
||||
var canGoBack: Bool {
|
||||
return webView?.canGoBack ?? false
|
||||
}
|
||||
|
||||
var canGoForward: Bool {
|
||||
return webView?.canGoForward ?? false
|
||||
}
|
||||
|
||||
func goBack() {
|
||||
_ = webView?.goBack()
|
||||
}
|
||||
|
||||
func goForward() {
|
||||
_ = webView?.goForward()
|
||||
}
|
||||
|
||||
func goToBackForwardListItem(_ item: WKBackForwardListItem) {
|
||||
_ = webView?.go(to: item)
|
||||
}
|
||||
|
||||
@discardableResult func loadRequest(_ request: URLRequest) -> WKNavigation? {
|
||||
if let webView = webView {
|
||||
lastRequest = request
|
||||
return webView.load(request)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stop() {
|
||||
webView?.stopLoading()
|
||||
}
|
||||
|
||||
func reload() {
|
||||
let userAgent: String? = desktopSite ? UserAgent.desktopUserAgent() : nil
|
||||
if (userAgent ?? "") != webView?.customUserAgent,
|
||||
let currentItem = webView?.backForwardList.currentItem {
|
||||
webView?.customUserAgent = userAgent
|
||||
|
||||
// Reload the initial URL to avoid UA specific redirection
|
||||
loadRequest(PrivilegedRequest(url: currentItem.initialURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 60) as URLRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if let _ = webView?.reloadFromOrigin() {
|
||||
print("reloaded zombified tab from origin")
|
||||
return
|
||||
}
|
||||
|
||||
if let webView = self.webView {
|
||||
print("restoring webView from scratch")
|
||||
restore(webView)
|
||||
}
|
||||
}
|
||||
|
||||
func addContentScript(_ helper: TabContentScript, name: String) {
|
||||
contentScriptManager.addContentScript(helper, name: name, forTab: self)
|
||||
}
|
||||
|
||||
func getContentScript(name: String) -> TabContentScript? {
|
||||
return contentScriptManager.getContentScript(name)
|
||||
}
|
||||
|
||||
func hideContent(_ animated: Bool = false) {
|
||||
webView?.isUserInteractionEnabled = false
|
||||
if animated {
|
||||
UIView.animate(withDuration: 0.25, animations: { () -> Void in
|
||||
self.webView?.alpha = 0.0
|
||||
})
|
||||
} else {
|
||||
webView?.alpha = 0.0
|
||||
}
|
||||
}
|
||||
|
||||
func showContent(_ animated: Bool = false) {
|
||||
webView?.isUserInteractionEnabled = true
|
||||
if animated {
|
||||
UIView.animate(withDuration: 0.25, animations: { () -> Void in
|
||||
self.webView?.alpha = 1.0
|
||||
})
|
||||
} else {
|
||||
webView?.alpha = 1.0
|
||||
}
|
||||
}
|
||||
|
||||
func addSnackbar(_ bar: SnackBar) {
|
||||
bars.append(bar)
|
||||
tabDelegate?.tab(self, didAddSnackbar: bar)
|
||||
}
|
||||
|
||||
func removeSnackbar(_ bar: SnackBar) {
|
||||
if let index = bars.index(of: bar) {
|
||||
bars.remove(at: index)
|
||||
tabDelegate?.tab(self, didRemoveSnackbar: bar)
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllSnackbars() {
|
||||
// Enumerate backwards here because we'll remove items from the list as we go.
|
||||
for i in (0..<bars.count).reversed() {
|
||||
let bar = bars[i]
|
||||
removeSnackbar(bar)
|
||||
}
|
||||
}
|
||||
|
||||
func expireSnackbars() {
|
||||
// Enumerate backwards here because we may remove items from the list as we go.
|
||||
for i in (0..<bars.count).reversed() {
|
||||
let bar = bars[i]
|
||||
if !bar.shouldPersist(self) {
|
||||
removeSnackbar(bar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setScreenshot(_ screenshot: UIImage?, revUUID: Bool = true) {
|
||||
self.screenshot = screenshot
|
||||
if revUUID {
|
||||
self.screenshotUUID = UUID()
|
||||
}
|
||||
}
|
||||
|
||||
func toggleDesktopSite() {
|
||||
desktopSite = !desktopSite
|
||||
reload()
|
||||
}
|
||||
|
||||
func queueJavascriptAlertPrompt(_ alert: JSAlertInfo) {
|
||||
alertQueue.append(alert)
|
||||
}
|
||||
|
||||
func dequeueJavascriptAlertPrompt() -> JSAlertInfo? {
|
||||
guard !alertQueue.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return alertQueue.removeFirst()
|
||||
}
|
||||
|
||||
func cancelQueuedAlerts() {
|
||||
alertQueue.forEach { alert in
|
||||
alert.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
|
||||
guard let webView = object as? WKWebView, webView == self.webView,
|
||||
let path = keyPath, path == KVOConstants.URL.rawValue else {
|
||||
return assertionFailure("Unhandled KVO key: \(keyPath ?? "nil")")
|
||||
}
|
||||
guard let url = self.webView?.url else {
|
||||
return
|
||||
}
|
||||
|
||||
self.urlDidChangeDelegate?.tab(self, urlDidChangeTo: url)
|
||||
TabEvent.post(.didChangeURL(url), for: self)
|
||||
}
|
||||
|
||||
func isDescendentOf(_ ancestor: Tab) -> Bool {
|
||||
var tab = parent
|
||||
while tab != nil {
|
||||
if tab! == ancestor {
|
||||
return true
|
||||
}
|
||||
tab = tab?.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func setNightMode(_ enabled: Bool) {
|
||||
webView?.evaluateJavaScript("window.__firefox__.NightMode.setEnabled(\(enabled))", completionHandler: nil)
|
||||
// For WKWebView background color to take effect, isOpaque must be false, which is counter-intuitive. Default is true.
|
||||
// The color is previously set to black in the webview init
|
||||
webView?.isOpaque = !enabled
|
||||
}
|
||||
|
||||
func injectUserScriptWith(fileName: String, type: String = "js", injectionTime: WKUserScriptInjectionTime = .atDocumentEnd, mainFrameOnly: Bool = true) {
|
||||
guard let webView = self.webView else {
|
||||
return
|
||||
}
|
||||
if let path = Bundle.main.path(forResource: fileName, ofType: type),
|
||||
let source = try? String(contentsOfFile: path) {
|
||||
let userScript = WKUserScript(source: source, injectionTime: injectionTime, forMainFrameOnly: mainFrameOnly)
|
||||
webView.configuration.userContentController.addUserScript(userScript)
|
||||
}
|
||||
}
|
||||
|
||||
func observeURLChanges(delegate: URLChangeDelegate) {
|
||||
self.urlDidChangeDelegate = delegate
|
||||
}
|
||||
|
||||
func removeURLChangeObserver(delegate: URLChangeDelegate) {
|
||||
if let existing = self.urlDidChangeDelegate, existing === delegate {
|
||||
self.urlDidChangeDelegate = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Tab: TabWebViewDelegate {
|
||||
fileprivate func tabWebView(_ tabWebView: TabWebView, didSelectFindInPageForSelection selection: String) {
|
||||
tabDelegate?.tab(self, didSelectFindInPageForSelection: selection)
|
||||
}
|
||||
}
|
||||
|
||||
private class TabContentScriptManager: NSObject, WKScriptMessageHandler {
|
||||
fileprivate var helpers = [String: TabContentScript]()
|
||||
|
||||
@objc func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
for helper in helpers.values {
|
||||
if let scriptMessageHandlerName = helper.scriptMessageHandlerName() {
|
||||
if scriptMessageHandlerName == message.name {
|
||||
helper.userContentController(userContentController, didReceiveScriptMessage: message)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func addContentScript(_ helper: TabContentScript, name: String, forTab tab: Tab) {
|
||||
if let _ = helpers[name] {
|
||||
assertionFailure("Duplicate helper added: \(name)")
|
||||
}
|
||||
|
||||
helpers[name] = helper
|
||||
|
||||
// If this helper handles script messages, then get the handler name and register it. The Browser
|
||||
// receives all messages and then dispatches them to the right TabHelper.
|
||||
if let scriptMessageHandlerName = helper.scriptMessageHandlerName() {
|
||||
tab.webView?.configuration.userContentController.add(self, name: scriptMessageHandlerName)
|
||||
}
|
||||
}
|
||||
|
||||
func getContentScript(_ name: String) -> TabContentScript? {
|
||||
return helpers[name]
|
||||
}
|
||||
}
|
||||
|
||||
private protocol TabWebViewDelegate: class {
|
||||
func tabWebView(_ tabWebView: TabWebView, didSelectFindInPageForSelection selection: String)
|
||||
}
|
||||
|
||||
private class TabWebView: WKWebView, MenuHelperInterface {
|
||||
fileprivate weak var delegate: TabWebViewDelegate?
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
return super.canPerformAction(action, withSender: sender) || action == MenuHelper.SelectorFindInPage
|
||||
}
|
||||
|
||||
@objc func menuHelperFindInPage() {
|
||||
evaluateJavaScript("getSelection().toString()") { result, _ in
|
||||
let selection = result as? String ?? ""
|
||||
self.delegate?.tabWebView(self, didSelectFindInPageForSelection: selection)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
||||
// The find-in-page selection menu only appears if the webview is the first responder.
|
||||
becomeFirstResponder()
|
||||
|
||||
return super.hitTest(point, with: event)
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
// Temporary fix for Bug 1390871 - NSInvalidArgumentException: -[WKContentView menuHelperFindInPage]: unrecognized selector
|
||||
//
|
||||
// This class only exists to contain the swizzledMenuHelperFindInPage. This class is actually never
|
||||
// instantiated. It only serves as a placeholder for the method. When the method is called, self is
|
||||
// actually pointing to a WKContentView. Which is not public, but that is fine, we only need to know
|
||||
// that it is a UIView subclass to access its superview.
|
||||
//
|
||||
|
||||
class TabWebViewMenuHelper: UIView {
|
||||
@objc func swizzledMenuHelperFindInPage() {
|
||||
if let tabWebView = superview?.superview as? TabWebView {
|
||||
tabWebView.evaluateJavaScript("getSelection().toString()") { result, _ in
|
||||
let selection = result as? String ?? ""
|
||||
tabWebView.delegate?.tabWebView(tabWebView, didSelectFindInPageForSelection: selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
399
mobile/ios/Client/Frontend/Browser/TabLocationView.swift
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
/* 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 SnapKit
|
||||
import XCGLogger
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
protocol TabLocationViewDelegate {
|
||||
func tabLocationViewDidTapLocation(_ tabLocationView: TabLocationView)
|
||||
func tabLocationViewDidLongPressLocation(_ tabLocationView: TabLocationView)
|
||||
func tabLocationViewDidTapReaderMode(_ tabLocationView: TabLocationView)
|
||||
func tabLocationViewDidTapPageOptions(_ tabLocationView: TabLocationView, from button: UIButton)
|
||||
func tabLocationViewDidLongPressPageOptions(_ tabLocationVIew: TabLocationView)
|
||||
|
||||
/// - returns: whether the long-press was handled by the delegate; i.e. return `false` when the conditions for even starting handling long-press were not satisfied
|
||||
@discardableResult func tabLocationViewDidLongPressReaderMode(_ tabLocationView: TabLocationView) -> Bool
|
||||
func tabLocationViewLocationAccessibilityActions(_ tabLocationView: TabLocationView) -> [UIAccessibilityCustomAction]?
|
||||
}
|
||||
|
||||
struct TabLocationViewUX {
|
||||
static let HostFontColor = UIColor.black
|
||||
static let BaseURLFontColor = UIColor.gray
|
||||
static let LocationContentInset = 8
|
||||
static let URLBarPadding = 4
|
||||
|
||||
static let Themes: [String: Theme] = {
|
||||
var themes = [String: Theme]()
|
||||
var theme = Theme()
|
||||
theme.URLFontColor = UIColor.lightGray
|
||||
theme.textColor = UIColor(rgb: 0xf9f9fa)
|
||||
theme.highlightButtonColor = UIConstants.PrivateModePurple
|
||||
theme.buttonTintColor = UIColor(rgb: 0xADADb0)
|
||||
theme.backgroundColor = UIColor(rgb: 0x636369)
|
||||
themes[Theme.PrivateMode] = theme
|
||||
|
||||
theme = Theme()
|
||||
theme.textColor = UIColor(rgb: 0x27)
|
||||
theme.highlightButtonColor = UIColor(rgb: 0x00A2FE)
|
||||
theme.buttonTintColor = UIColor(rgb: 0x737373)
|
||||
theme.backgroundColor = .white
|
||||
themes[Theme.NormalMode] = theme
|
||||
|
||||
return themes
|
||||
}()
|
||||
}
|
||||
|
||||
class TabLocationView: UIView {
|
||||
var delegate: TabLocationViewDelegate?
|
||||
var longPressRecognizer: UILongPressGestureRecognizer!
|
||||
var tapRecognizer: UITapGestureRecognizer!
|
||||
|
||||
dynamic var baseURLFontColor: UIColor = TabLocationViewUX.BaseURLFontColor {
|
||||
didSet { updateTextWithURL() }
|
||||
}
|
||||
|
||||
var url: URL? {
|
||||
didSet {
|
||||
let wasHidden = lockImageView.isHidden
|
||||
lockImageView.isHidden = url?.scheme != "https"
|
||||
if wasHidden != lockImageView.isHidden {
|
||||
UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil)
|
||||
}
|
||||
updateTextWithURL()
|
||||
pageOptionsButton.isHidden = (url == nil)
|
||||
setNeedsUpdateConstraints()
|
||||
}
|
||||
}
|
||||
|
||||
var readerModeState: ReaderModeState {
|
||||
get {
|
||||
return readerModeButton.readerModeState
|
||||
}
|
||||
set (newReaderModeState) {
|
||||
if newReaderModeState != self.readerModeButton.readerModeState {
|
||||
let wasHidden = readerModeButton.isHidden
|
||||
self.readerModeButton.readerModeState = newReaderModeState
|
||||
readerModeButton.isHidden = (newReaderModeState == ReaderModeState.unavailable)
|
||||
separatorLine.isHidden = readerModeButton.isHidden
|
||||
if wasHidden != readerModeButton.isHidden {
|
||||
UIAccessibilityPostNotification(UIAccessibilityLayoutChangedNotification, nil)
|
||||
if !readerModeButton.isHidden {
|
||||
// Delay the Reader Mode accessibility announcement briefly to prevent interruptions.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
|
||||
UIAccessibilityPostNotification(UIAccessibilityAnnouncementNotification, Strings.ReaderModeAvailableVoiceOverAnnouncement)
|
||||
}
|
||||
}
|
||||
}
|
||||
UIView.animate(withDuration: 0.1, animations: { () -> Void in
|
||||
if newReaderModeState == ReaderModeState.unavailable {
|
||||
self.readerModeButton.alpha = 0.0
|
||||
} else {
|
||||
self.readerModeButton.alpha = 1.0
|
||||
}
|
||||
self.setNeedsUpdateConstraints()
|
||||
self.layoutIfNeeded()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lazy var placeholder: NSAttributedString = {
|
||||
let placeholderText = NSLocalizedString("Search or enter address", comment: "The text shown in the URL bar on about:home")
|
||||
return NSAttributedString(string: placeholderText, attributes: [NSForegroundColorAttributeName: UIColor.gray])
|
||||
}()
|
||||
|
||||
lazy var urlTextField: UITextField = {
|
||||
let urlTextField = DisplayTextField()
|
||||
|
||||
self.longPressRecognizer.delegate = self
|
||||
urlTextField.addGestureRecognizer(self.longPressRecognizer)
|
||||
self.tapRecognizer.delegate = self
|
||||
urlTextField.addGestureRecognizer(self.tapRecognizer)
|
||||
|
||||
// Prevent the field from compressing the toolbar buttons on the 4S in landscape.
|
||||
urlTextField.setContentCompressionResistancePriority(250, for: UILayoutConstraintAxis.horizontal)
|
||||
urlTextField.attributedPlaceholder = self.placeholder
|
||||
urlTextField.accessibilityIdentifier = "url"
|
||||
urlTextField.accessibilityActionsSource = self
|
||||
urlTextField.font = UIConstants.DefaultChromeFont
|
||||
urlTextField.backgroundColor = .clear
|
||||
return urlTextField
|
||||
}()
|
||||
|
||||
fileprivate lazy var lockImageView: UIImageView = {
|
||||
let lockImageView = UIImageView(image: UIImage.templateImageNamed("lock_verified"))
|
||||
lockImageView.isHidden = true
|
||||
lockImageView.tintColor = UIColor(rgb: 0x16DA00)
|
||||
lockImageView.isAccessibilityElement = true
|
||||
lockImageView.contentMode = UIViewContentMode.center
|
||||
lockImageView.accessibilityLabel = NSLocalizedString("Secure connection", comment: "Accessibility label for the lock icon, which is only present if the connection is secure")
|
||||
return lockImageView
|
||||
}()
|
||||
|
||||
fileprivate lazy var readerModeButton: ReaderModeButton = {
|
||||
let readerModeButton = ReaderModeButton(frame: CGRect.zero)
|
||||
readerModeButton.isHidden = true
|
||||
readerModeButton.addTarget(self, action: #selector(TabLocationView.SELtapReaderModeButton), for: .touchUpInside)
|
||||
readerModeButton.addGestureRecognizer(UILongPressGestureRecognizer(target: self, action: #selector(TabLocationView.SELlongPressReaderModeButton(_:))))
|
||||
readerModeButton.isAccessibilityElement = true
|
||||
readerModeButton.imageView?.contentMode = UIViewContentMode.scaleAspectFit
|
||||
readerModeButton.accessibilityLabel = NSLocalizedString("Reader View", comment: "Accessibility label for the Reader View button")
|
||||
readerModeButton.accessibilityIdentifier = "TabLocationView.readerModeButton"
|
||||
readerModeButton.accessibilityCustomActions = [UIAccessibilityCustomAction(name: NSLocalizedString("Add to Reading List", comment: "Accessibility label for action adding current page to reading list."), target: self, selector: #selector(TabLocationView.SELreaderModeCustomAction))]
|
||||
return readerModeButton
|
||||
}()
|
||||
|
||||
lazy var pageOptionsButton: ToolbarButton = {
|
||||
let pageOptionsButton = ToolbarButton(frame: CGRect.zero)
|
||||
pageOptionsButton.setImage(UIImage.templateImageNamed("menu-More-Options"), for: .normal)
|
||||
pageOptionsButton.isHidden = true
|
||||
pageOptionsButton.addTarget(self, action: #selector(TabLocationView.SELDidPressPageOptionsButton), for: .touchUpInside)
|
||||
pageOptionsButton.isAccessibilityElement = true
|
||||
pageOptionsButton.imageView?.contentMode = .center
|
||||
pageOptionsButton.accessibilityLabel = NSLocalizedString("Page Options Menu", comment: "Accessibility label for the Page Options menu button")
|
||||
pageOptionsButton.accessibilityIdentifier = "TabLocationView.pageOptionsButton"
|
||||
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(TabLocationView.SELDidLongPressPageOptionsButton))
|
||||
pageOptionsButton.addGestureRecognizer(longPressGesture)
|
||||
return pageOptionsButton
|
||||
}()
|
||||
|
||||
lazy var separatorLine: UIView = {
|
||||
let line = UIView()
|
||||
line.layer.cornerRadius = 2
|
||||
line.backgroundColor = UIColor(rgb: 0xE5E5E5)
|
||||
line.isHidden = true
|
||||
return line
|
||||
}()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(TabLocationView.SELlongPressLocation(_:)))
|
||||
tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(TabLocationView.SELtapLocation(_:)))
|
||||
|
||||
addSubview(urlTextField)
|
||||
addSubview(lockImageView)
|
||||
addSubview(readerModeButton)
|
||||
addSubview(pageOptionsButton)
|
||||
addSubview(separatorLine)
|
||||
|
||||
lockImageView.snp.makeConstraints { make in
|
||||
make.size.equalTo(24)
|
||||
make.centerY.equalTo(self)
|
||||
make.leading.equalTo(self).offset(9)
|
||||
}
|
||||
|
||||
pageOptionsButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self)
|
||||
make.trailing.equalTo(self)
|
||||
make.width.equalTo(44)
|
||||
make.height.equalTo(self)
|
||||
}
|
||||
|
||||
separatorLine.snp.makeConstraints { make in
|
||||
make.width.equalTo(1)
|
||||
make.height.equalTo(26)
|
||||
make.trailing.equalTo(pageOptionsButton.snp.leading)
|
||||
make.centerY.equalTo(self)
|
||||
}
|
||||
|
||||
readerModeButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self)
|
||||
make.trailing.equalTo(separatorLine.snp.leading).offset(-9)
|
||||
make.size.equalTo(24)
|
||||
}
|
||||
}
|
||||
|
||||
override var accessibilityElements: [Any]? {
|
||||
get {
|
||||
return [lockImageView, urlTextField, readerModeButton, pageOptionsButton].filter { !$0.isHidden }
|
||||
}
|
||||
set {
|
||||
super.accessibilityElements = newValue
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func updateConstraints() {
|
||||
urlTextField.snp.remakeConstraints { make in
|
||||
make.top.bottom.equalTo(self)
|
||||
|
||||
if lockImageView.isHidden {
|
||||
make.leading.equalTo(self).offset(TabLocationViewUX.LocationContentInset)
|
||||
} else {
|
||||
make.leading.equalTo(self.lockImageView.snp.trailing).offset(TabLocationViewUX.URLBarPadding)
|
||||
}
|
||||
|
||||
if readerModeButton.isHidden {
|
||||
make.trailing.equalTo(self.pageOptionsButton.snp.leading).offset(-TabLocationViewUX.URLBarPadding)
|
||||
} else {
|
||||
make.trailing.equalTo(self.readerModeButton.snp.leading).offset(-TabLocationViewUX.URLBarPadding)
|
||||
}
|
||||
}
|
||||
|
||||
super.updateConstraints()
|
||||
}
|
||||
|
||||
func SELtapReaderModeButton() {
|
||||
delegate?.tabLocationViewDidTapReaderMode(self)
|
||||
}
|
||||
|
||||
func SELlongPressReaderModeButton(_ recognizer: UILongPressGestureRecognizer) {
|
||||
if recognizer.state == UIGestureRecognizerState.began {
|
||||
delegate?.tabLocationViewDidLongPressReaderMode(self)
|
||||
}
|
||||
}
|
||||
|
||||
func SELDidPressPageOptionsButton(_ button: UIButton) {
|
||||
delegate?.tabLocationViewDidTapPageOptions(self, from: button)
|
||||
}
|
||||
|
||||
func SELDidLongPressPageOptionsButton(_ recognizer: UILongPressGestureRecognizer) {
|
||||
delegate?.tabLocationViewDidLongPressPageOptions(self)
|
||||
}
|
||||
|
||||
func SELlongPressLocation(_ recognizer: UITapGestureRecognizer) {
|
||||
if recognizer.state == UIGestureRecognizerState.began {
|
||||
delegate?.tabLocationViewDidLongPressLocation(self)
|
||||
}
|
||||
}
|
||||
|
||||
func SELtapLocation(_ recognizer: UITapGestureRecognizer) {
|
||||
delegate?.tabLocationViewDidTapLocation(self)
|
||||
}
|
||||
|
||||
func SELreaderModeCustomAction() -> Bool {
|
||||
return delegate?.tabLocationViewDidLongPressReaderMode(self) ?? false
|
||||
}
|
||||
|
||||
fileprivate func updateTextWithURL() {
|
||||
if let host = url?.host, AppConstants.MOZ_PUNYCODE {
|
||||
urlTextField.text = url?.absoluteString.replacingOccurrences(of: host, with: host.asciiHostToUTF8())
|
||||
} else {
|
||||
urlTextField.text = url?.absoluteString
|
||||
}
|
||||
// remove https:// (the scheme) from the url when displaying
|
||||
if let scheme = url?.scheme, let range = url?.absoluteString.range(of: "\(scheme)://") {
|
||||
urlTextField.text = url?.absoluteString.replacingCharacters(in: range, with: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TabLocationView: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
// If the longPressRecognizer is active, fail all other recognizers to avoid conflicts.
|
||||
return gestureRecognizer == longPressRecognizer
|
||||
}
|
||||
}
|
||||
|
||||
extension TabLocationView: AccessibilityActionsSource {
|
||||
func accessibilityCustomActionsForView(_ view: UIView) -> [UIAccessibilityCustomAction]? {
|
||||
if view === urlTextField {
|
||||
return delegate?.tabLocationViewLocationAccessibilityActions(self)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension TabLocationView: Themeable {
|
||||
func applyTheme(_ themeName: String) {
|
||||
guard let theme = TabLocationViewUX.Themes[themeName] else {
|
||||
log.error("Unable to apply unknown theme \(themeName)")
|
||||
return
|
||||
}
|
||||
let isPrivate = themeName == Theme.PrivateMode
|
||||
backgroundColor = theme.backgroundColor
|
||||
urlTextField.textColor = theme.textColor
|
||||
readerModeButton.selectedTintColor = theme.highlightButtonColor
|
||||
readerModeButton.unselectedTintColor = theme.buttonTintColor
|
||||
pageOptionsButton.selectedTintColor = theme.highlightButtonColor
|
||||
|
||||
pageOptionsButton.unselectedTintColor = isPrivate ? UIColor(rgb: 0xD2d2d4) : UIColor(rgb: 0x272727)
|
||||
pageOptionsButton.tintColor = pageOptionsButton.unselectedTintColor
|
||||
separatorLine.backgroundColor = isPrivate ? UIColor(rgb: 0x3f3f43) : UIColor(rgb: 0xE5E5E5)
|
||||
}
|
||||
}
|
||||
|
||||
class ReaderModeButton: UIButton {
|
||||
var selectedTintColor: UIColor?
|
||||
var unselectedTintColor: UIColor?
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
adjustsImageWhenHighlighted = false
|
||||
setImage(UIImage.templateImageNamed("reader"), for: .normal)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override var isSelected: Bool {
|
||||
didSet {
|
||||
self.tintColor = (isHighlighted || isSelected) ? selectedTintColor : unselectedTintColor
|
||||
}
|
||||
}
|
||||
|
||||
override open var isHighlighted: Bool {
|
||||
didSet {
|
||||
self.tintColor = (isHighlighted || isSelected) ? selectedTintColor : unselectedTintColor
|
||||
}
|
||||
}
|
||||
|
||||
override var tintColor: UIColor! {
|
||||
didSet {
|
||||
self.imageView?.tintColor = self.tintColor
|
||||
}
|
||||
}
|
||||
|
||||
var _readerModeState: ReaderModeState = ReaderModeState.unavailable
|
||||
|
||||
var readerModeState: ReaderModeState {
|
||||
get {
|
||||
return _readerModeState
|
||||
}
|
||||
set (newReaderModeState) {
|
||||
_readerModeState = newReaderModeState
|
||||
switch _readerModeState {
|
||||
case .available:
|
||||
self.isEnabled = true
|
||||
self.isSelected = false
|
||||
case .unavailable:
|
||||
self.isEnabled = false
|
||||
self.isSelected = false
|
||||
case .active:
|
||||
self.isEnabled = true
|
||||
self.isSelected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class DisplayTextField: UITextField {
|
||||
weak var accessibilityActionsSource: AccessibilityActionsSource?
|
||||
|
||||
override var accessibilityCustomActions: [UIAccessibilityCustomAction]? {
|
||||
get {
|
||||
return accessibilityActionsSource?.accessibilityCustomActionsForView(self)
|
||||
}
|
||||
set {
|
||||
super.accessibilityCustomActions = newValue
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate override var canBecomeFirstResponder: Bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
988
mobile/ios/Client/Frontend/Browser/TabManager.swift
Normal file
|
|
@ -0,0 +1,988 @@
|
|||
/* 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 Storage
|
||||
import Shared
|
||||
|
||||
protocol TabManagerDelegate: class {
|
||||
func tabManager(_ tabManager: TabManager, didSelectedTabChange selected: Tab?, previous: Tab?)
|
||||
func tabManager(_ tabManager: TabManager, willAddTab tab: Tab)
|
||||
func tabManager(_ tabManager: TabManager, didAddTab tab: Tab)
|
||||
func tabManager(_ tabManager: TabManager, willRemoveTab tab: Tab)
|
||||
func tabManager(_ tabManager: TabManager, didRemoveTab tab: Tab)
|
||||
|
||||
func tabManagerDidRestoreTabs(_ tabManager: TabManager)
|
||||
func tabManagerDidAddTabs(_ tabManager: TabManager)
|
||||
func tabManagerDidRemoveAllTabs(_ tabManager: TabManager, toast: ButtonToast?)
|
||||
}
|
||||
|
||||
protocol TabManagerStateDelegate: class {
|
||||
func tabManagerWillStoreTabs(_ tabs: [Tab])
|
||||
}
|
||||
|
||||
// We can't use a WeakList here because this is a protocol.
|
||||
class WeakTabManagerDelegate {
|
||||
weak var value: TabManagerDelegate?
|
||||
|
||||
init (value: TabManagerDelegate) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func get() -> TabManagerDelegate? {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// TabManager must extend NSObjectProtocol in order to implement WKNavigationDelegate
|
||||
class TabManager: NSObject {
|
||||
fileprivate var delegates = [WeakTabManagerDelegate]()
|
||||
fileprivate var tabEventHandlers = TabEventHandlers.default.handlers
|
||||
weak var stateDelegate: TabManagerStateDelegate?
|
||||
|
||||
func addDelegate(_ delegate: TabManagerDelegate) {
|
||||
assert(Thread.isMainThread)
|
||||
delegates.append(WeakTabManagerDelegate(value: delegate))
|
||||
}
|
||||
|
||||
func removeDelegate(_ delegate: TabManagerDelegate) {
|
||||
assert(Thread.isMainThread)
|
||||
for i in 0 ..< delegates.count {
|
||||
let del = delegates[i]
|
||||
if delegate === del.get() || del.get() == nil {
|
||||
delegates.remove(at: i)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate(set) var tabs = [Tab]()
|
||||
fileprivate var _selectedIndex = -1
|
||||
fileprivate let navDelegate: TabManagerNavDelegate
|
||||
fileprivate(set) var isRestoring = false
|
||||
|
||||
// A WKWebViewConfiguration used for normal tabs
|
||||
lazy fileprivate var configuration: WKWebViewConfiguration = {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.processPool = WKProcessPool()
|
||||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = !(self.prefs.boolForKey("blockPopups") ?? true)
|
||||
return configuration
|
||||
}()
|
||||
|
||||
// A WKWebViewConfiguration used for private mode tabs
|
||||
lazy fileprivate var privateConfiguration: WKWebViewConfiguration = {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.processPool = WKProcessPool()
|
||||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = !(self.prefs.boolForKey("blockPopups") ?? true)
|
||||
configuration.websiteDataStore = WKWebsiteDataStore.nonPersistent()
|
||||
return configuration
|
||||
}()
|
||||
|
||||
fileprivate let imageStore: DiskImageStore?
|
||||
|
||||
fileprivate let prefs: Prefs
|
||||
var selectedIndex: Int { return _selectedIndex }
|
||||
var tempTabs: [Tab]?
|
||||
|
||||
var normalTabs: [Tab] {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
return tabs.filter { !$0.isPrivate }
|
||||
}
|
||||
|
||||
var privateTabs: [Tab] {
|
||||
assert(Thread.isMainThread)
|
||||
return tabs.filter { $0.isPrivate }
|
||||
}
|
||||
|
||||
init(prefs: Prefs, imageStore: DiskImageStore?) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
self.prefs = prefs
|
||||
self.navDelegate = TabManagerNavDelegate()
|
||||
self.imageStore = imageStore
|
||||
super.init()
|
||||
|
||||
addNavigationDelegate(self)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(TabManager.prefsDidChange), name: UserDefaults.didChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
func addNavigationDelegate(_ delegate: WKNavigationDelegate) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
self.navDelegate.insert(delegate)
|
||||
}
|
||||
|
||||
var count: Int {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
return tabs.count
|
||||
}
|
||||
|
||||
var selectedTab: Tab? {
|
||||
assert(Thread.isMainThread)
|
||||
if !(0..<count ~= _selectedIndex) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tabs[_selectedIndex]
|
||||
}
|
||||
|
||||
subscript(index: Int) -> Tab? {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
if index >= tabs.count {
|
||||
return nil
|
||||
}
|
||||
return tabs[index]
|
||||
}
|
||||
|
||||
subscript(webView: WKWebView) -> Tab? {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
for tab in tabs where tab.webView === webView {
|
||||
return tab
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getTabFor(_ url: URL) -> Tab? {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
for tab in tabs {
|
||||
if tab.webView?.url == url {
|
||||
return tab
|
||||
}
|
||||
|
||||
// Also look for tabs that haven't been restored yet.
|
||||
if let sessionData = tab.sessionData,
|
||||
0..<sessionData.urls.count ~= sessionData.currentPage,
|
||||
sessionData.urls[sessionData.currentPage] == url {
|
||||
return tab
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func selectTab(_ tab: Tab?, previous: Tab? = nil) {
|
||||
assert(Thread.isMainThread)
|
||||
let previous = previous ?? selectedTab
|
||||
|
||||
if previous === tab {
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure to wipe the private tabs if the user has the pref turned on
|
||||
if shouldClearPrivateTabs(), !(tab?.isPrivate ?? false) {
|
||||
removeAllPrivateTabs()
|
||||
}
|
||||
|
||||
if let tab = tab {
|
||||
_selectedIndex = tabs.index(of: tab) ?? -1
|
||||
} else {
|
||||
_selectedIndex = -1
|
||||
}
|
||||
|
||||
preserveTabs()
|
||||
|
||||
assert(tab === selectedTab, "Expected tab is selected")
|
||||
selectedTab?.createWebview()
|
||||
|
||||
delegates.forEach { $0.get()?.tabManager(self, didSelectedTabChange: tab, previous: previous) }
|
||||
if let tab = previous {
|
||||
TabEvent.post(.didLoseFocus, for: tab)
|
||||
}
|
||||
if let tab = selectedTab {
|
||||
TabEvent.post(.didGainFocus, for: tab)
|
||||
}
|
||||
}
|
||||
|
||||
func shouldClearPrivateTabs() -> Bool {
|
||||
return prefs.boolForKey("settings.closePrivateTabs") ?? false
|
||||
}
|
||||
|
||||
//Called by other classes to signal that they are entering/exiting private mode
|
||||
//This is called by TabTrayVC when the private mode button is pressed and BEFORE we've switched to the new mode
|
||||
func willSwitchTabMode() {
|
||||
if shouldClearPrivateTabs() && (selectedTab?.isPrivate ?? false) {
|
||||
removeAllPrivateTabs()
|
||||
}
|
||||
}
|
||||
|
||||
func expireSnackbars() {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
for tab in tabs {
|
||||
tab.expireSnackbars()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult func addTab(_ request: URLRequest! = nil, configuration: WKWebViewConfiguration! = nil, afterTab: Tab? = nil, isPrivate: Bool) -> Tab {
|
||||
return self.addTab(request, configuration: configuration, afterTab: afterTab, flushToDisk: true, zombie: false, isPrivate: isPrivate)
|
||||
}
|
||||
|
||||
func addTabAndSelect(_ request: URLRequest! = nil, configuration: WKWebViewConfiguration! = nil, afterTab: Tab? = nil, isPrivate: Bool) -> Tab {
|
||||
let tab = addTab(request, configuration: configuration, afterTab: afterTab, isPrivate: isPrivate)
|
||||
selectTab(tab)
|
||||
return tab
|
||||
}
|
||||
|
||||
@discardableResult func addTabAndSelect(_ request: URLRequest! = nil, configuration: WKWebViewConfiguration! = nil, afterTab: Tab? = nil) -> Tab {
|
||||
let tab = addTab(request, configuration: configuration, afterTab: afterTab)
|
||||
selectTab(tab)
|
||||
return tab
|
||||
}
|
||||
|
||||
// This method is duplicated to hide the flushToDisk option from consumers.
|
||||
@discardableResult func addTab(_ request: URLRequest! = nil, configuration: WKWebViewConfiguration! = nil, afterTab: Tab? = nil) -> Tab {
|
||||
return self.addTab(request, configuration: configuration, afterTab: afterTab, flushToDisk: true, zombie: false)
|
||||
}
|
||||
|
||||
func addTabsForURLs(_ urls: [URL], zombie: Bool) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
if urls.isEmpty {
|
||||
return
|
||||
}
|
||||
// When bulk adding tabs don't notify delegates until we are done
|
||||
self.isRestoring = true
|
||||
var tab: Tab!
|
||||
for url in urls {
|
||||
tab = self.addTab(URLRequest(url: url), flushToDisk: false, zombie: zombie)
|
||||
}
|
||||
// Flush.
|
||||
storeChanges()
|
||||
// Select the most recent.
|
||||
self.selectTab(tab)
|
||||
self.isRestoring = false
|
||||
// Okay now notify that we bulk-loaded so we can adjust counts and animate changes.
|
||||
delegates.forEach { $0.get()?.tabManagerDidAddTabs(self) }
|
||||
}
|
||||
|
||||
fileprivate func addTab(_ request: URLRequest? = nil, configuration: WKWebViewConfiguration? = nil, afterTab: Tab? = nil, flushToDisk: Bool, zombie: Bool, isPrivate: Bool) -> Tab {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
// Take the given configuration. Or if it was nil, take our default configuration for the current browsing mode.
|
||||
let configuration: WKWebViewConfiguration = configuration ?? (isPrivate ? privateConfiguration : self.configuration)
|
||||
|
||||
let tab = Tab(configuration: configuration, isPrivate: isPrivate)
|
||||
configureTab(tab, request: request, afterTab: afterTab, flushToDisk: flushToDisk, zombie: zombie)
|
||||
return tab
|
||||
}
|
||||
|
||||
fileprivate func addTab(_ request: URLRequest? = nil, configuration: WKWebViewConfiguration? = nil, afterTab: Tab? = nil, flushToDisk: Bool, zombie: Bool) -> Tab {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
let tab = Tab(configuration: configuration ?? self.configuration)
|
||||
configureTab(tab, request: request, afterTab: afterTab, flushToDisk: flushToDisk, zombie: zombie)
|
||||
return tab
|
||||
}
|
||||
|
||||
func moveTab(isPrivate privateMode: Bool, fromIndex visibleFromIndex: Int, toIndex visibleToIndex: Int) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
let currentTabs = privateMode ? privateTabs : normalTabs
|
||||
let fromIndex = tabs.index(of: currentTabs[visibleFromIndex]) ?? tabs.count - 1
|
||||
let toIndex = tabs.index(of: currentTabs[visibleToIndex]) ?? tabs.count - 1
|
||||
|
||||
let previouslySelectedTab = selectedTab
|
||||
|
||||
tabs.insert(tabs.remove(at: fromIndex), at: toIndex)
|
||||
|
||||
if let previouslySelectedTab = previouslySelectedTab, let previousSelectedIndex = tabs.index(of: previouslySelectedTab) {
|
||||
_selectedIndex = previousSelectedIndex
|
||||
}
|
||||
|
||||
storeChanges()
|
||||
}
|
||||
|
||||
func configureTab(_ tab: Tab, request: URLRequest?, afterTab parent: Tab? = nil, flushToDisk: Bool, zombie: Bool) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
delegates.forEach { $0.get()?.tabManager(self, willAddTab: tab) }
|
||||
|
||||
if parent == nil || parent?.isPrivate != tab.isPrivate {
|
||||
tabs.append(tab)
|
||||
} else if let parent = parent, var insertIndex = tabs.index(of: parent) {
|
||||
insertIndex += 1
|
||||
while insertIndex < tabs.count && tabs[insertIndex].isDescendentOf(parent) {
|
||||
insertIndex += 1
|
||||
}
|
||||
tab.parent = parent
|
||||
tabs.insert(tab, at: insertIndex)
|
||||
}
|
||||
|
||||
delegates.forEach { $0.get()?.tabManager(self, didAddTab: tab) }
|
||||
|
||||
if !zombie {
|
||||
tab.createWebview()
|
||||
}
|
||||
tab.navigationDelegate = self.navDelegate
|
||||
|
||||
if let request = request {
|
||||
tab.loadRequest(request)
|
||||
} else {
|
||||
let newTabChoice = NewTabAccessors.getNewTabPage(prefs)
|
||||
switch newTabChoice {
|
||||
case .homePage:
|
||||
// We definitely have a homepage if we've got here
|
||||
// (so we can safely dereference it).
|
||||
let url = HomePageAccessors.getHomePage(prefs)!
|
||||
tab.loadRequest(URLRequest(url: url))
|
||||
case .blankPage:
|
||||
// Do nothing: we're already seeing a blank page.
|
||||
break
|
||||
default:
|
||||
// The common case, where the NewTabPage enum defines
|
||||
// one of the about:home pages.
|
||||
if let url = newTabChoice.url {
|
||||
tab.loadRequest(PrivilegedRequest(url: url) as URLRequest)
|
||||
tab.url = url
|
||||
}
|
||||
}
|
||||
}
|
||||
if flushToDisk {
|
||||
storeChanges()
|
||||
}
|
||||
}
|
||||
|
||||
// This method is duplicated to hide the flushToDisk option from consumers.
|
||||
func removeTab(_ tab: Tab) {
|
||||
self.removeTab(tab, flushToDisk: true, notify: true)
|
||||
hideNetworkActivitySpinner()
|
||||
}
|
||||
|
||||
/// - Parameter notify: if set to true, will call the delegate after the tab
|
||||
/// is removed.
|
||||
fileprivate func removeTab(_ tab: Tab, flushToDisk: Bool, notify: Bool) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
guard let removalIndex = tabs.index(where: { $0 === tab }) else {
|
||||
Sentry.shared.sendWithStacktrace(message: "Could not find index of tab to remove", tag: .tabManager, severity: .fatal, description: "Tab count: \(count)")
|
||||
return
|
||||
}
|
||||
|
||||
if tab.isPrivate {
|
||||
removeAllBrowsingDataForTab(tab)
|
||||
}
|
||||
|
||||
let oldSelectedTab = selectedTab
|
||||
|
||||
if notify {
|
||||
delegates.forEach { $0.get()?.tabManager(self, willRemoveTab: tab) }
|
||||
}
|
||||
|
||||
// The index of the tab in its respective tab grouping. Used to figure out which tab is next
|
||||
var tabIndex: Int = -1
|
||||
if let oldTab = oldSelectedTab {
|
||||
tabIndex = (tab.isPrivate ? privateTabs.index(of: oldTab) : normalTabs.index(of: oldTab)) ?? -1
|
||||
}
|
||||
|
||||
let prevCount = count
|
||||
tabs.remove(at: removalIndex)
|
||||
|
||||
let viableTabs: [Tab] = tab.isPrivate ? privateTabs : normalTabs
|
||||
|
||||
// Let's select the tab to be selected next.
|
||||
if let oldTab = oldSelectedTab, tab !== oldTab {
|
||||
// If it wasn't the selected tab we removed, then keep it like that.
|
||||
// It might have changed index, so we look it up again.
|
||||
_selectedIndex = tabs.index(of: oldTab) ?? -1
|
||||
} else if let newTab = viableTabs.reduce(viableTabs.first, { currentBestTab, tab2 in
|
||||
if let tab1 = currentBestTab, let time1 = tab1.lastExecutedTime {
|
||||
if let time2 = tab2.lastExecutedTime {
|
||||
return time1 <= time2 ? tab2 : tab1
|
||||
}
|
||||
return tab1
|
||||
} else {
|
||||
return tab2
|
||||
}
|
||||
}), tab !== newTab, newTab.lastExecutedTime != nil {
|
||||
// Next we look for the most recently loaded one. It might not exist, of course.
|
||||
_selectedIndex = tabs.index(of: newTab) ?? -1
|
||||
} else {
|
||||
// By now, we've just removed the selected one, and no previously loaded
|
||||
// tabs. So let's load the final one in the tab tray.
|
||||
if tabIndex == viableTabs.count {
|
||||
tabIndex -= 1
|
||||
}
|
||||
if tabIndex < viableTabs.count && !viableTabs.isEmpty {
|
||||
_selectedIndex = tabs.index(of: viableTabs[tabIndex]) ?? -1
|
||||
} else {
|
||||
_selectedIndex = -1
|
||||
}
|
||||
}
|
||||
|
||||
assert(count == prevCount - 1, "Make sure the tab count was actually removed")
|
||||
|
||||
// There's still some time between this and the webView being destroyed. We don't want to pick up any stray events.
|
||||
tab.webView?.navigationDelegate = nil
|
||||
|
||||
if notify {
|
||||
delegates.forEach { $0.get()?.tabManager(self, didRemoveTab: tab) }
|
||||
TabEvent.post(.didClose, for: tab)
|
||||
}
|
||||
|
||||
if !tab.isPrivate && viableTabs.isEmpty {
|
||||
addTab()
|
||||
}
|
||||
|
||||
// If the removed tab was selected, find the new tab to select.
|
||||
if selectedTab != nil {
|
||||
selectTab(selectedTab, previous: oldSelectedTab)
|
||||
} else {
|
||||
selectTab(tabs.last, previous: oldSelectedTab)
|
||||
}
|
||||
|
||||
if flushToDisk {
|
||||
storeChanges()
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes all private tabs from the manager without notifying delegates.
|
||||
private func removeAllPrivateTabs() {
|
||||
// reset the selectedTabIndex if we are on a private tab because we will be removing it.
|
||||
if selectedTab?.isPrivate ?? false {
|
||||
_selectedIndex = -1
|
||||
}
|
||||
tabs.forEach { tab in
|
||||
if tab.isPrivate {
|
||||
removeAllBrowsingDataForTab(tab)
|
||||
}
|
||||
}
|
||||
|
||||
tabs = tabs.filter { !$0.isPrivate }
|
||||
}
|
||||
|
||||
func removeAllBrowsingDataForTab(_ tab: Tab, completionHandler: @escaping () -> Void = {}) {
|
||||
let dataTypes = Set([WKWebsiteDataTypeCookies,
|
||||
WKWebsiteDataTypeLocalStorage,
|
||||
WKWebsiteDataTypeSessionStorage,
|
||||
WKWebsiteDataTypeWebSQLDatabases,
|
||||
WKWebsiteDataTypeIndexedDBDatabases])
|
||||
tab.webView?.configuration.websiteDataStore.removeData(ofTypes: dataTypes,
|
||||
modifiedSince: Date.distantPast,
|
||||
completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func removeTabsWithUndoToast(_ tabs: [Tab]) {
|
||||
tempTabs = tabs
|
||||
var tabsCopy = tabs
|
||||
|
||||
// Remove the current tab last to prevent switching tabs while removing tabs
|
||||
if let selectedTab = selectedTab {
|
||||
if let selectedIndex = tabsCopy.index(of: selectedTab) {
|
||||
let removed = tabsCopy.remove(at: selectedIndex)
|
||||
removeTabs(tabsCopy)
|
||||
removeTab(removed)
|
||||
} else {
|
||||
removeTabs(tabsCopy)
|
||||
}
|
||||
}
|
||||
for tab in tabs {
|
||||
tab.hideContent()
|
||||
}
|
||||
var toast: ButtonToast?
|
||||
if let numberOfTabs = tempTabs?.count, numberOfTabs > 0 {
|
||||
toast = ButtonToast(labelText: String.localizedStringWithFormat(Strings.TabsDeleteAllUndoTitle, numberOfTabs), buttonText: Strings.TabsDeleteAllUndoAction, completion: { buttonPressed in
|
||||
if buttonPressed {
|
||||
self.undoCloseTabs()
|
||||
self.storeChanges()
|
||||
for delegate in self.delegates {
|
||||
delegate.get()?.tabManagerDidAddTabs(self)
|
||||
}
|
||||
}
|
||||
self.eraseUndoCache()
|
||||
})
|
||||
}
|
||||
|
||||
delegates.forEach { $0.get()?.tabManagerDidRemoveAllTabs(self, toast: toast) }
|
||||
}
|
||||
|
||||
func undoCloseTabs() {
|
||||
guard let tempTabs = self.tempTabs, tempTabs.count > 0 else {
|
||||
return
|
||||
}
|
||||
let tabsCopy = normalTabs
|
||||
restoreTabs(tempTabs)
|
||||
self.isRestoring = true
|
||||
for tab in tempTabs {
|
||||
tab.showContent(true)
|
||||
}
|
||||
if !tempTabs[0].isPrivate {
|
||||
removeTabs(tabsCopy)
|
||||
}
|
||||
selectTab(tempTabs.first)
|
||||
self.isRestoring = false
|
||||
delegates.forEach { $0.get()?.tabManagerDidRestoreTabs(self) }
|
||||
self.tempTabs?.removeAll()
|
||||
tabs.first?.createWebview()
|
||||
}
|
||||
|
||||
func eraseUndoCache() {
|
||||
tempTabs?.removeAll()
|
||||
}
|
||||
|
||||
func removeTabs(_ tabs: [Tab]) {
|
||||
for tab in tabs {
|
||||
self.removeTab(tab, flushToDisk: false, notify: true)
|
||||
}
|
||||
storeChanges()
|
||||
}
|
||||
|
||||
func removeAll() {
|
||||
removeTabs(self.tabs)
|
||||
}
|
||||
|
||||
func getIndex(_ tab: Tab) -> Int? {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
for i in 0..<count where tabs[i] === tab {
|
||||
return i
|
||||
}
|
||||
|
||||
assertionFailure("Tab not in tabs list")
|
||||
return nil
|
||||
}
|
||||
|
||||
func getTabForURL(_ url: URL) -> Tab? {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
return tabs.filter { $0.webView?.url == url } .first
|
||||
}
|
||||
|
||||
func storeChanges() {
|
||||
stateDelegate?.tabManagerWillStoreTabs(normalTabs)
|
||||
|
||||
// Also save (full) tab state to disk.
|
||||
preserveTabs()
|
||||
}
|
||||
|
||||
func prefsDidChange() {
|
||||
DispatchQueue.main.async {
|
||||
let allowPopups = !(self.prefs.boolForKey("blockPopups") ?? true)
|
||||
// Each tab may have its own configuration, so we should tell each of them in turn.
|
||||
for tab in self.tabs {
|
||||
tab.webView?.configuration.preferences.javaScriptCanOpenWindowsAutomatically = allowPopups
|
||||
}
|
||||
// The default tab configurations also need to change.
|
||||
self.configuration.preferences.javaScriptCanOpenWindowsAutomatically = allowPopups
|
||||
self.privateConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = allowPopups
|
||||
}
|
||||
}
|
||||
|
||||
func resetProcessPool() {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
configuration.processPool = WKProcessPool()
|
||||
}
|
||||
}
|
||||
|
||||
class SavedTab: NSObject, NSCoding {
|
||||
let isSelected: Bool
|
||||
let title: String?
|
||||
let isPrivate: Bool
|
||||
var sessionData: SessionData?
|
||||
var screenshotUUID: UUID?
|
||||
var faviconURL: String?
|
||||
|
||||
var jsonDictionary: [String: AnyObject] {
|
||||
let title: String = self.title ?? "null"
|
||||
let faviconURL: String = self.faviconURL ?? "null"
|
||||
let uuid: String = self.screenshotUUID?.uuidString ?? "null"
|
||||
|
||||
var json: [String: AnyObject] = [
|
||||
"title": title as AnyObject,
|
||||
"isPrivate": String(self.isPrivate) as AnyObject,
|
||||
"isSelected": String(self.isSelected) as AnyObject,
|
||||
"faviconURL": faviconURL as AnyObject,
|
||||
"screenshotUUID": uuid as AnyObject
|
||||
]
|
||||
|
||||
if let sessionDataInfo = self.sessionData?.jsonDictionary {
|
||||
json["sessionData"] = sessionDataInfo as AnyObject?
|
||||
}
|
||||
|
||||
return json
|
||||
}
|
||||
|
||||
init?(tab: Tab, isSelected: Bool) {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
self.screenshotUUID = tab.screenshotUUID as UUID?
|
||||
self.isSelected = isSelected
|
||||
self.title = tab.displayTitle
|
||||
self.isPrivate = tab.isPrivate
|
||||
self.faviconURL = tab.displayFavicon?.url
|
||||
super.init()
|
||||
|
||||
if tab.sessionData == nil {
|
||||
let currentItem: WKBackForwardListItem! = tab.webView?.backForwardList.currentItem
|
||||
|
||||
// Freshly created web views won't have any history entries at all.
|
||||
// If we have no history, abort.
|
||||
if currentItem == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
let backList = tab.webView?.backForwardList.backList ?? []
|
||||
let forwardList = tab.webView?.backForwardList.forwardList ?? []
|
||||
let urls = (backList + [currentItem] + forwardList).map { $0.url }
|
||||
let currentPage = -forwardList.count
|
||||
self.sessionData = SessionData(currentPage: currentPage, urls: urls, lastUsedTime: tab.lastExecutedTime ?? Date.now())
|
||||
} else {
|
||||
self.sessionData = tab.sessionData
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
self.sessionData = coder.decodeObject(forKey: "sessionData") as? SessionData
|
||||
self.screenshotUUID = coder.decodeObject(forKey: "screenshotUUID") as? UUID
|
||||
self.isSelected = coder.decodeBool(forKey: "isSelected")
|
||||
self.title = coder.decodeObject(forKey: "title") as? String
|
||||
self.isPrivate = coder.decodeBool(forKey: "isPrivate")
|
||||
self.faviconURL = coder.decodeObject(forKey: "faviconURL") as? String
|
||||
}
|
||||
|
||||
func encode(with coder: NSCoder) {
|
||||
coder.encode(sessionData, forKey: "sessionData")
|
||||
coder.encode(screenshotUUID, forKey: "screenshotUUID")
|
||||
coder.encode(isSelected, forKey: "isSelected")
|
||||
coder.encode(title, forKey: "title")
|
||||
coder.encode(isPrivate, forKey: "isPrivate")
|
||||
coder.encode(faviconURL, forKey: "faviconURL")
|
||||
}
|
||||
}
|
||||
|
||||
extension TabManager {
|
||||
|
||||
static fileprivate func tabsStateArchivePath() -> String {
|
||||
let documentsPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0]
|
||||
return URL(fileURLWithPath: documentsPath).appendingPathComponent("tabsState.archive").path
|
||||
}
|
||||
|
||||
static func tabArchiveData() -> Data? {
|
||||
let tabStateArchivePath = tabsStateArchivePath()
|
||||
if FileManager.default.fileExists(atPath: tabStateArchivePath) {
|
||||
return (try? Data(contentsOf: URL(fileURLWithPath: tabStateArchivePath)))
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func tabsToRestore() -> [SavedTab]? {
|
||||
if let tabData = tabArchiveData() {
|
||||
let unarchiver = NSKeyedUnarchiver(forReadingWith: tabData)
|
||||
unarchiver.decodingFailurePolicy = .setErrorAndReturn
|
||||
guard let tabs = unarchiver.decodeObject(forKey: "tabs") as? [SavedTab] else {
|
||||
Sentry.shared.send(message: "Failed to restore tabs", tag: SentryTag.tabManager, severity: .error, description: "\(unarchiver.error ??? "nil")")
|
||||
return nil
|
||||
}
|
||||
return tabs
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func preserveTabsInternal() {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
guard !isRestoring else { return }
|
||||
|
||||
let path = TabManager.tabsStateArchivePath()
|
||||
var savedTabs = [SavedTab]()
|
||||
var savedUUIDs = Set<String>()
|
||||
for (tabIndex, tab) in tabs.enumerated() {
|
||||
if let savedTab = SavedTab(tab: tab, isSelected: tabIndex == selectedIndex) {
|
||||
savedTabs.append(savedTab)
|
||||
|
||||
if let screenshot = tab.screenshot,
|
||||
let screenshotUUID = tab.screenshotUUID {
|
||||
savedUUIDs.insert(screenshotUUID.uuidString)
|
||||
imageStore?.put(screenshotUUID.uuidString, image: screenshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any screenshots that are no longer associated with a tab.
|
||||
_ = imageStore?.clearExcluding(savedUUIDs)
|
||||
|
||||
let tabStateData = NSMutableData()
|
||||
let archiver = NSKeyedArchiver(forWritingWith: tabStateData)
|
||||
archiver.encode(savedTabs, forKey: "tabs")
|
||||
archiver.finishEncoding()
|
||||
tabStateData.write(toFile: path, atomically: true)
|
||||
}
|
||||
|
||||
func preserveTabs() {
|
||||
// This is wrapped in an Objective-C @try/@catch handler because NSKeyedArchiver may throw exceptions which Swift cannot handle
|
||||
_ = Try(withTry: { () -> Void in
|
||||
self.preserveTabsInternal()
|
||||
}) { (exception) -> Void in
|
||||
Sentry.shared.send(message: "Failed to preserve tabs", tag: SentryTag.tabManager, severity: .error, description: "\(exception ??? "nil")")
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func restoreTabsInternal() {
|
||||
guard var savedTabs = TabManager.tabsToRestore() else {
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure to wipe the private tabs if the user has the pref turned on
|
||||
if shouldClearPrivateTabs() {
|
||||
savedTabs = savedTabs.filter { !$0.isPrivate }
|
||||
}
|
||||
|
||||
var tabToSelect: Tab?
|
||||
for savedTab in savedTabs {
|
||||
// Provide an empty request to prevent a new tab from loading the home screen
|
||||
let tab = self.addTab(nil, configuration: nil, afterTab: nil, flushToDisk: false, zombie: true, isPrivate: savedTab.isPrivate)
|
||||
|
||||
// Since this is a restored tab, reset the URL to be loaded as that will be handled by the SessionRestoreHandler
|
||||
tab.url = nil
|
||||
|
||||
if let faviconURL = savedTab.faviconURL {
|
||||
let icon = Favicon(url: faviconURL, date: Date(), type: IconType.noneFound)
|
||||
icon.width = 1
|
||||
tab.favicons.append(icon)
|
||||
}
|
||||
|
||||
// Set the UUID for the tab, asynchronously fetch the UIImage, then store
|
||||
// the screenshot in the tab as long as long as a newer one hasn't been taken.
|
||||
if let screenshotUUID = savedTab.screenshotUUID,
|
||||
let imageStore = self.imageStore {
|
||||
tab.screenshotUUID = screenshotUUID
|
||||
imageStore.get(screenshotUUID.uuidString) >>== { screenshot in
|
||||
if tab.screenshotUUID == screenshotUUID {
|
||||
tab.setScreenshot(screenshot, revUUID: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if savedTab.isSelected {
|
||||
tabToSelect = tab
|
||||
}
|
||||
|
||||
tab.sessionData = savedTab.sessionData
|
||||
tab.lastTitle = savedTab.title
|
||||
}
|
||||
|
||||
if tabToSelect == nil {
|
||||
tabToSelect = tabs.first
|
||||
}
|
||||
|
||||
// Only tell our delegates that we restored tabs if we actually restored a tab(s)
|
||||
if savedTabs.count > 0 {
|
||||
for delegate in delegates {
|
||||
delegate.get()?.tabManagerDidRestoreTabs(self)
|
||||
}
|
||||
}
|
||||
|
||||
if let tab = tabToSelect {
|
||||
selectTab(tab)
|
||||
tab.createWebview()
|
||||
}
|
||||
}
|
||||
|
||||
func restoreTabs() {
|
||||
isRestoring = true
|
||||
|
||||
if count == 0 && !AppConstants.IsRunningTest && !DebugSettingsBundleOptions.skipSessionRestore {
|
||||
// This is wrapped in an Objective-C @try/@catch handler because NSKeyedUnarchiver may throw exceptions which Swift cannot handle
|
||||
_ = Try(
|
||||
withTry: { () -> Void in
|
||||
self.restoreTabsInternal()
|
||||
},
|
||||
catch: { exception in
|
||||
Sentry.shared.send(message: "Failed to restore tabs: ", tag: SentryTag.tabManager, severity: .error, description: "\(exception ??? "nil")")
|
||||
}
|
||||
)
|
||||
}
|
||||
isRestoring = false
|
||||
|
||||
// Always make sure there is a single normal tab.
|
||||
if normalTabs.isEmpty {
|
||||
let tab = addTab()
|
||||
if selectedTab == nil {
|
||||
selectTab(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func restoreTabs(_ savedTabs: [Tab]) {
|
||||
isRestoring = true
|
||||
for tab in savedTabs {
|
||||
tabs.append(tab)
|
||||
tab.navigationDelegate = self.navDelegate
|
||||
for delegate in delegates {
|
||||
delegate.get()?.tabManager(self, didAddTab: tab)
|
||||
}
|
||||
}
|
||||
isRestoring = false
|
||||
}
|
||||
}
|
||||
|
||||
extension TabManager: WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
UIApplication.shared.isNetworkActivityIndicatorVisible = true
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||||
let tab = self[webView]
|
||||
let isNightMode = NightModeAccessors.isNightMode(self.prefs)
|
||||
tab?.setNightMode(isNightMode)
|
||||
|
||||
if #available(iOS 11, *) {
|
||||
let isNoImageMode = self.prefs.boolForKey(PrefsKeys.KeyNoImageModeStatus) ?? false
|
||||
tab?.noImageMode = isNoImageMode
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
hideNetworkActivitySpinner()
|
||||
// only store changes if this is not an error page
|
||||
// as we current handle tab restore as error page redirects then this ensures that we don't
|
||||
// call storeChanges unnecessarily on startup
|
||||
if let url = webView.url {
|
||||
if !url.isErrorPageURL {
|
||||
storeChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
hideNetworkActivitySpinner()
|
||||
}
|
||||
|
||||
func hideNetworkActivitySpinner() {
|
||||
for tab in tabs {
|
||||
if let tabWebView = tab.webView {
|
||||
// If we find one tab loading, we don't hide the spinner
|
||||
if tabWebView.isLoading {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
UIApplication.shared.isNetworkActivityIndicatorVisible = false
|
||||
}
|
||||
|
||||
/// Called when the WKWebView's content process has gone away. If this happens for the currently selected tab
|
||||
/// then we immediately reload it.
|
||||
|
||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||
if let tab = selectedTab, tab.webView == webView {
|
||||
webView.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TabManager {
|
||||
class func tabRestorationDebugInfo() -> String {
|
||||
assert(Thread.isMainThread)
|
||||
|
||||
let tabs = TabManager.tabsToRestore()?.map { $0.jsonDictionary } ?? []
|
||||
do {
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: tabs, options: [.prettyPrinted])
|
||||
return String(data: jsonData, encoding: String.Encoding.utf8) ?? ""
|
||||
} catch _ {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WKNavigationDelegates must implement NSObjectProtocol
|
||||
class TabManagerNavDelegate: NSObject, WKNavigationDelegate {
|
||||
fileprivate var delegates = WeakList<WKNavigationDelegate>()
|
||||
|
||||
func insert(_ delegate: WKNavigationDelegate) {
|
||||
delegates.insert(delegate)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
|
||||
for delegate in delegates {
|
||||
delegate.webView?(webView, didCommit: navigation)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
for delegate in delegates {
|
||||
delegate.webView?(webView, didFail: navigation, withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
for delegate in delegates {
|
||||
delegate.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
for delegate in delegates {
|
||||
delegate.webView?(webView, didFinish: navigation)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
|
||||
let authenticatingDelegates = delegates.filter { wv in
|
||||
return wv.responds(to: #selector(WKNavigationDelegate.webView(_:didReceive:completionHandler:)))
|
||||
}
|
||||
|
||||
guard let firstAuthenticatingDelegate = authenticatingDelegates.first else {
|
||||
return completionHandler(URLSession.AuthChallengeDisposition.performDefaultHandling, nil)
|
||||
}
|
||||
|
||||
firstAuthenticatingDelegate.webView?(webView, didReceive: challenge) { (disposition, credential) in
|
||||
completionHandler(disposition, credential)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation!) {
|
||||
for delegate in delegates {
|
||||
delegate.webView?(webView, didReceiveServerRedirectForProvisionalNavigation: navigation)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||
for delegate in delegates {
|
||||
delegate.webView?(webView, didStartProvisionalNavigation: navigation)
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
var res = WKNavigationActionPolicy.allow
|
||||
for delegate in delegates {
|
||||
delegate.webView?(webView, decidePolicyFor: navigationAction, decisionHandler: { policy in
|
||||
if policy == .cancel {
|
||||
res = policy
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
decisionHandler(res)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView,
|
||||
decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
||||
var res = WKNavigationResponsePolicy.allow
|
||||
for delegate in delegates {
|
||||
delegate.webView?(webView, decidePolicyFor: navigationResponse, decisionHandler: { policy in
|
||||
if policy == .cancel {
|
||||
res = policy
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if res == .allow, let appDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||
let tab = appDelegate.browserViewController.tabManager[webView]
|
||||
tab?.mimeType = navigationResponse.response.mimeType
|
||||
}
|
||||
|
||||
decisionHandler(res)
|
||||
}
|
||||
}
|
||||
174
mobile/ios/Client/Frontend/Browser/TabPeekViewController.swift
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/* 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
|
||||
import Storage
|
||||
import ReadingList
|
||||
import WebKit
|
||||
|
||||
protocol TabPeekDelegate: class {
|
||||
func tabPeekDidAddBookmark(_ tab: Tab)
|
||||
@discardableResult func tabPeekDidAddToReadingList(_ tab: Tab) -> ReadingListClientRecord?
|
||||
func tabPeekRequestsPresentationOf(_ viewController: UIViewController)
|
||||
func tabPeekDidCloseTab(_ tab: Tab)
|
||||
}
|
||||
|
||||
class TabPeekViewController: UIViewController, WKNavigationDelegate {
|
||||
|
||||
fileprivate static let PreviewActionAddToBookmarks = NSLocalizedString("Add to Bookmarks", tableName: "3DTouchActions", comment: "Label for preview action on Tab Tray Tab to add current tab to Bookmarks")
|
||||
fileprivate static let PreviewActionAddToReadingList = NSLocalizedString("Add to Reading List", tableName: "3DTouchActions", comment: "Label for preview action on Tab Tray Tab to add current tab to Reading List")
|
||||
fileprivate static let PreviewActionCopyURL = NSLocalizedString("Copy URL", tableName: "3DTouchActions", comment: "Label for preview action on Tab Tray Tab to copy the URL of the current tab to clipboard")
|
||||
fileprivate static let PreviewActionCloseTab = NSLocalizedString("Close Tab", tableName: "3DTouchActions", comment: "Label for preview action on Tab Tray Tab to close the current tab")
|
||||
|
||||
weak var tab: Tab?
|
||||
|
||||
fileprivate weak var delegate: TabPeekDelegate?
|
||||
fileprivate var clientPicker: UINavigationController?
|
||||
fileprivate var isBookmarked: Bool = false
|
||||
fileprivate var isInReadingList: Bool = false
|
||||
fileprivate var hasRemoteClients: Bool = false
|
||||
fileprivate var ignoreURL: Bool = false
|
||||
|
||||
fileprivate var screenShot: UIImageView?
|
||||
fileprivate var previewAccessibilityLabel: String!
|
||||
|
||||
// Preview action items.
|
||||
lazy var previewActions: [UIPreviewActionItem] = {
|
||||
var actions = [UIPreviewActionItem]()
|
||||
|
||||
let urlIsTooLongToSave = self.tab?.urlIsTooLong ?? false
|
||||
if !self.ignoreURL && !urlIsTooLongToSave {
|
||||
if !self.isInReadingList {
|
||||
actions.append(UIPreviewAction(title: TabPeekViewController.PreviewActionAddToReadingList, style: .default) { previewAction, viewController in
|
||||
guard let tab = self.tab else { return }
|
||||
_ = self.delegate?.tabPeekDidAddToReadingList(tab)
|
||||
})
|
||||
}
|
||||
|
||||
if !self.isBookmarked {
|
||||
actions.append(UIPreviewAction(title: TabPeekViewController.PreviewActionAddToBookmarks, style: .default) { previewAction, viewController in
|
||||
guard let tab = self.tab else { return }
|
||||
self.delegate?.tabPeekDidAddBookmark(tab)
|
||||
})
|
||||
}
|
||||
if self.hasRemoteClients {
|
||||
actions.append(UIPreviewAction(title: Strings.SendToDeviceTitle, style: .default) { previewAction, viewController in
|
||||
guard let clientPicker = self.clientPicker else { return }
|
||||
self.delegate?.tabPeekRequestsPresentationOf(clientPicker)
|
||||
})
|
||||
}
|
||||
// only add the copy URL action if we don't already have 3 items in our list
|
||||
// as we are only allowed 4 in total and we always want to display close tab
|
||||
if actions.count < 3 {
|
||||
actions.append(UIPreviewAction(title: TabPeekViewController.PreviewActionCopyURL, style: .default) { previewAction, viewController in
|
||||
guard let url = self.tab?.canonicalURL else { return }
|
||||
UIPasteboard.general.url = url
|
||||
SimpleToast().showAlertWithText(Strings.AppMenuCopyURLConfirmMessage, bottomContainer: self.view)
|
||||
})
|
||||
}
|
||||
}
|
||||
actions.append(UIPreviewAction(title: TabPeekViewController.PreviewActionCloseTab, style: .destructive) { previewAction, viewController in
|
||||
guard let tab = self.tab else { return }
|
||||
self.delegate?.tabPeekDidCloseTab(tab)
|
||||
})
|
||||
|
||||
return actions
|
||||
}()
|
||||
|
||||
init(tab: Tab, delegate: TabPeekDelegate?) {
|
||||
self.tab = tab
|
||||
self.delegate = delegate
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
if let webViewAccessibilityLabel = tab?.webView?.accessibilityLabel {
|
||||
previewAccessibilityLabel = String(format: NSLocalizedString("Preview of %@", tableName: "3DTouchActions", comment: "Accessibility label, associated to the 3D Touch action on the current tab in the tab tray, used to display a larger preview of the tab."), webViewAccessibilityLabel)
|
||||
}
|
||||
// if there is no screenshot, load the URL in a web page
|
||||
// otherwise just show the screenshot
|
||||
setupWebView(tab?.webView)
|
||||
guard let screenshot = tab?.screenshot else { return }
|
||||
setupWithScreenshot(screenshot)
|
||||
}
|
||||
|
||||
fileprivate func setupWithScreenshot(_ screenshot: UIImage) {
|
||||
let imageView = UIImageView(image: screenshot)
|
||||
self.view.addSubview(imageView)
|
||||
|
||||
imageView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(self.view)
|
||||
}
|
||||
|
||||
screenShot = imageView
|
||||
screenShot?.accessibilityLabel = previewAccessibilityLabel
|
||||
}
|
||||
|
||||
fileprivate func setupWebView(_ webView: WKWebView?) {
|
||||
guard let webView = webView, let url = webView.url, !isIgnoredURL(url) else { return }
|
||||
let clonedWebView = WKWebView(frame: webView.frame, configuration: webView.configuration)
|
||||
clonedWebView.allowsLinkPreview = false
|
||||
webView.accessibilityLabel = previewAccessibilityLabel
|
||||
self.view.addSubview(clonedWebView)
|
||||
|
||||
clonedWebView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(self.view)
|
||||
}
|
||||
|
||||
clonedWebView.navigationDelegate = self
|
||||
|
||||
clonedWebView.load(URLRequest(url: url))
|
||||
}
|
||||
|
||||
func setState(withProfile browserProfile: BrowserProfile, clientPickerDelegate: ClientPickerViewControllerDelegate) {
|
||||
assert(Thread.current.isMainThread)
|
||||
|
||||
guard let tab = self.tab else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let displayURL = tab.url?.absoluteString, displayURL.characters.count > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let mainQueue = DispatchQueue.main
|
||||
browserProfile.bookmarks.modelFactory >>== {
|
||||
$0.isBookmarked(displayURL).uponQueue(mainQueue) {
|
||||
self.isBookmarked = $0.successValue ?? false
|
||||
}
|
||||
}
|
||||
|
||||
browserProfile.remoteClientsAndTabs.getClientGUIDs().uponQueue(mainQueue) {
|
||||
guard let clientGUIDs = $0.successValue else {
|
||||
return
|
||||
}
|
||||
|
||||
self.hasRemoteClients = !clientGUIDs.isEmpty
|
||||
let clientPickerController = ClientPickerViewController()
|
||||
clientPickerController.clientPickerDelegate = clientPickerDelegate
|
||||
clientPickerController.profile = browserProfile
|
||||
if let url = tab.url?.absoluteString {
|
||||
clientPickerController.shareItem = ShareItem(url: url, title: tab.title, favicon: nil)
|
||||
}
|
||||
|
||||
self.clientPicker = UINavigationController(rootViewController: clientPickerController)
|
||||
}
|
||||
|
||||
let result = browserProfile.readingList?.getRecordWithURL(displayURL).successValue!
|
||||
|
||||
self.isInReadingList = (result?.url.characters.count ?? 0) > 0
|
||||
self.ignoreURL = isIgnoredURL(displayURL)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
screenShot?.removeFromSuperview()
|
||||
screenShot = nil
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
/* 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
|
||||
|
||||
private struct PrintedPageUX {
|
||||
static let PageInsets = CGFloat(36.0)
|
||||
static let PageTextFont = DynamicFontHelper.defaultHelper.DefaultSmallFont
|
||||
static let PageMarginScale = CGFloat(0.5)
|
||||
}
|
||||
|
||||
class TabPrintPageRenderer: UIPrintPageRenderer {
|
||||
fileprivate weak var tab: Tab?
|
||||
let textAttributes = [NSFontAttributeName: PrintedPageUX.PageTextFont]
|
||||
let dateString: String
|
||||
|
||||
required init(tab: Tab) {
|
||||
self.tab = tab
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .short
|
||||
dateFormatter.timeStyle = .short
|
||||
self.dateString = dateFormatter.string(from: Date())
|
||||
|
||||
super.init()
|
||||
|
||||
self.footerHeight = PrintedPageUX.PageMarginScale * PrintedPageUX.PageInsets
|
||||
self.headerHeight = PrintedPageUX.PageMarginScale * PrintedPageUX.PageInsets
|
||||
|
||||
if let tab = self.tab {
|
||||
let formatter = tab.webView!.viewPrintFormatter()
|
||||
formatter.perPageContentInsets = UIEdgeInsets(top: PrintedPageUX.PageInsets, left: PrintedPageUX.PageInsets, bottom: PrintedPageUX.PageInsets, right: PrintedPageUX.PageInsets)
|
||||
addPrintFormatter(formatter, startingAtPageAt: 0)
|
||||
}
|
||||
}
|
||||
|
||||
override func drawFooterForPage(at pageIndex: Int, in headerRect: CGRect) {
|
||||
let headerInsets = UIEdgeInsets(top: headerRect.minY, left: PrintedPageUX.PageInsets, bottom: paperRect.maxY - headerRect.maxY, right: PrintedPageUX.PageInsets)
|
||||
let headerRect = UIEdgeInsetsInsetRect(paperRect, headerInsets)
|
||||
|
||||
// url on left
|
||||
self.drawTextAtPoint(tab!.url?.displayURL?.absoluteString ?? "", rect: headerRect, onLeft: true)
|
||||
|
||||
// page number on right
|
||||
let pageNumberString = "\(pageIndex + 1)"
|
||||
self.drawTextAtPoint(pageNumberString, rect: headerRect, onLeft: false)
|
||||
}
|
||||
|
||||
override func drawHeaderForPage(at pageIndex: Int, in headerRect: CGRect) {
|
||||
let headerInsets = UIEdgeInsets(top: headerRect.minY, left: PrintedPageUX.PageInsets, bottom: paperRect.maxY - headerRect.maxY, right: PrintedPageUX.PageInsets)
|
||||
let headerRect = UIEdgeInsetsInsetRect(paperRect, headerInsets)
|
||||
|
||||
// page title on left
|
||||
self.drawTextAtPoint(tab!.displayTitle, rect: headerRect, onLeft: true)
|
||||
|
||||
// date on right
|
||||
self.drawTextAtPoint(dateString, rect: headerRect, onLeft: false)
|
||||
}
|
||||
|
||||
func drawTextAtPoint(_ text: String, rect: CGRect, onLeft: Bool) {
|
||||
let size = text.size(attributes: textAttributes)
|
||||
let x, y: CGFloat
|
||||
if onLeft {
|
||||
x = rect.minX
|
||||
y = rect.midY - size.height / 2
|
||||
} else {
|
||||
x = rect.maxX - size.width
|
||||
y = rect.midY - size.height / 2
|
||||
}
|
||||
text.draw(at: CGPoint(x: x, y: y), withAttributes: textAttributes)
|
||||
}
|
||||
|
||||
}
|
||||
344
mobile/ios/Client/Frontend/Browser/TabScrollController.swift
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
/* 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 SnapKit
|
||||
|
||||
private let ToolbarBaseAnimationDuration: CGFloat = 0.2
|
||||
|
||||
class TabScrollingController: NSObject {
|
||||
enum ScrollDirection {
|
||||
case up
|
||||
case down
|
||||
}
|
||||
|
||||
enum ToolbarState {
|
||||
case collapsed
|
||||
case visible
|
||||
case animating
|
||||
}
|
||||
|
||||
weak var tab: Tab? {
|
||||
willSet {
|
||||
self.scrollView?.delegate = nil
|
||||
self.scrollView?.removeGestureRecognizer(panGesture)
|
||||
}
|
||||
|
||||
didSet {
|
||||
self.scrollView?.addGestureRecognizer(panGesture)
|
||||
scrollView?.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
// Constraint-based animation is causing PDF docs to flicker. This is used to bypass this animation.
|
||||
var isTabShowingPDF: Bool {
|
||||
return (tab?.mimeType ?? "") == MimeType.PDF.rawValue
|
||||
}
|
||||
|
||||
weak var header: UIView?
|
||||
weak var footer: UIView?
|
||||
weak var urlBar: URLBarView?
|
||||
weak var snackBars: UIView?
|
||||
weak var webViewContainerToolbar: UIView?
|
||||
|
||||
var footerBottomConstraint: Constraint?
|
||||
var headerTopConstraint: Constraint?
|
||||
var toolbarsShowing: Bool { return headerTopOffset == 0 }
|
||||
|
||||
fileprivate var isZoomedOut: Bool = false
|
||||
fileprivate var lastZoomedScale: CGFloat = 0
|
||||
fileprivate var isUserZoom: Bool = false
|
||||
|
||||
fileprivate var headerTopOffset: CGFloat = 0 {
|
||||
didSet {
|
||||
headerTopConstraint?.update(offset: headerTopOffset)
|
||||
header?.superview?.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var footerBottomOffset: CGFloat = 0 {
|
||||
didSet {
|
||||
footerBottomConstraint?.update(offset: footerBottomOffset)
|
||||
footer?.superview?.setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate lazy var panGesture: UIPanGestureRecognizer = {
|
||||
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(TabScrollingController.handlePan(_:)))
|
||||
panGesture.maximumNumberOfTouches = 1
|
||||
panGesture.delegate = self
|
||||
return panGesture
|
||||
}()
|
||||
|
||||
fileprivate var scrollView: UIScrollView? { return tab?.webView?.scrollView }
|
||||
fileprivate var contentOffset: CGPoint { return scrollView?.contentOffset ?? CGPoint.zero }
|
||||
fileprivate var contentSize: CGSize { return scrollView?.contentSize ?? CGSize.zero }
|
||||
fileprivate var scrollViewHeight: CGFloat { return scrollView?.frame.height ?? 0 }
|
||||
fileprivate var topScrollHeight: CGFloat { return header?.frame.height ?? 0 }
|
||||
fileprivate var bottomScrollHeight: CGFloat { return footer?.frame.height ?? 0 }
|
||||
fileprivate var snackBarsFrame: CGRect { return snackBars?.frame ?? CGRect.zero }
|
||||
|
||||
fileprivate var lastContentOffset: CGFloat = 0
|
||||
fileprivate var scrollDirection: ScrollDirection = .down
|
||||
fileprivate var toolbarState: ToolbarState = .visible
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func showToolbars(animated: Bool, completion: ((_ finished: Bool) -> Void)? = nil) {
|
||||
if toolbarState == .visible {
|
||||
completion?(true)
|
||||
return
|
||||
}
|
||||
toolbarState = .visible
|
||||
let durationRatio = abs(headerTopOffset / topScrollHeight)
|
||||
let actualDuration = TimeInterval(ToolbarBaseAnimationDuration * durationRatio)
|
||||
self.animateToolbarsWithOffsets(
|
||||
animated,
|
||||
duration: actualDuration,
|
||||
headerOffset: 0,
|
||||
footerOffset: 0,
|
||||
alpha: 1,
|
||||
completion: completion)
|
||||
}
|
||||
|
||||
func hideToolbars(animated: Bool, completion: ((_ finished: Bool) -> Void)? = nil) {
|
||||
if toolbarState == .collapsed {
|
||||
completion?(true)
|
||||
return
|
||||
}
|
||||
toolbarState = .collapsed
|
||||
let durationRatio = abs((topScrollHeight + headerTopOffset) / topScrollHeight)
|
||||
let actualDuration = TimeInterval(ToolbarBaseAnimationDuration * durationRatio)
|
||||
self.animateToolbarsWithOffsets(
|
||||
animated,
|
||||
duration: actualDuration,
|
||||
headerOffset: -topScrollHeight,
|
||||
footerOffset: bottomScrollHeight,
|
||||
alpha: 0,
|
||||
completion: completion)
|
||||
}
|
||||
|
||||
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
|
||||
if keyPath == "contentSize" {
|
||||
if !checkScrollHeightIsLargeEnoughForScrolling() && !toolbarsShowing {
|
||||
showToolbars(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateMinimumZoom() {
|
||||
guard let scrollView = scrollView else {
|
||||
return
|
||||
}
|
||||
self.isZoomedOut = roundNum(scrollView.zoomScale) == roundNum(scrollView.minimumZoomScale)
|
||||
self.lastZoomedScale = self.isZoomedOut ? 0 : scrollView.zoomScale
|
||||
}
|
||||
|
||||
func setMinimumZoom() {
|
||||
guard let scrollView = scrollView else {
|
||||
return
|
||||
}
|
||||
if self.isZoomedOut && roundNum(scrollView.zoomScale) != roundNum(scrollView.minimumZoomScale) {
|
||||
scrollView.zoomScale = scrollView.minimumZoomScale
|
||||
}
|
||||
}
|
||||
|
||||
func resetZoomState() {
|
||||
self.isZoomedOut = false
|
||||
self.lastZoomedScale = 0
|
||||
}
|
||||
|
||||
fileprivate func roundNum(_ num: CGFloat) -> CGFloat {
|
||||
return round(100 * num) / 100
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private extension TabScrollingController {
|
||||
func tabIsLoading() -> Bool {
|
||||
return tab?.loading ?? true
|
||||
}
|
||||
|
||||
func isBouncingAtBottom() -> Bool {
|
||||
guard let scrollView = scrollView else { return false }
|
||||
return scrollView.contentOffset.y > (scrollView.contentSize.height - scrollView.frame.size.height) && scrollView.contentSize.height > scrollView.frame.size.height
|
||||
}
|
||||
|
||||
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
if tabIsLoading() {
|
||||
return
|
||||
}
|
||||
|
||||
if let containerView = scrollView?.superview {
|
||||
let translation = gesture.translation(in: containerView)
|
||||
let delta = lastContentOffset - translation.y
|
||||
|
||||
if delta > 0 {
|
||||
scrollDirection = .down
|
||||
} else if delta < 0 {
|
||||
scrollDirection = .up
|
||||
}
|
||||
|
||||
lastContentOffset = translation.y
|
||||
if checkRubberbandingForDelta(delta) && checkScrollHeightIsLargeEnoughForScrolling() {
|
||||
let bottomIsNotRubberbanding = contentOffset.y + scrollViewHeight < contentSize.height
|
||||
let topIsRubberbanding = contentOffset.y <= 0
|
||||
if isTabShowingPDF || ((toolbarState != .collapsed || topIsRubberbanding) && bottomIsNotRubberbanding) {
|
||||
scrollWithDelta(delta)
|
||||
}
|
||||
|
||||
if headerTopOffset == -topScrollHeight && footerBottomOffset == bottomScrollHeight {
|
||||
toolbarState = .collapsed
|
||||
} else if headerTopOffset == 0 {
|
||||
toolbarState = .visible
|
||||
} else {
|
||||
toolbarState = .animating
|
||||
}
|
||||
}
|
||||
|
||||
if gesture.state == .ended || gesture.state == .cancelled {
|
||||
lastContentOffset = 0
|
||||
}
|
||||
|
||||
showOrHideWebViewContainerToolbar()
|
||||
}
|
||||
}
|
||||
|
||||
func checkRubberbandingForDelta(_ delta: CGFloat) -> Bool {
|
||||
return !((delta < 0 && contentOffset.y + scrollViewHeight > contentSize.height &&
|
||||
scrollViewHeight < contentSize.height) ||
|
||||
contentOffset.y < delta)
|
||||
}
|
||||
|
||||
func scrollWithDelta(_ delta: CGFloat) {
|
||||
if scrollViewHeight >= contentSize.height {
|
||||
return
|
||||
}
|
||||
|
||||
var updatedOffset = headerTopOffset - delta
|
||||
headerTopOffset = clamp(updatedOffset, min: -topScrollHeight, max: 0)
|
||||
if isHeaderDisplayedForGivenOffset(updatedOffset) {
|
||||
scrollView?.contentOffset = CGPoint(x: contentOffset.x, y: contentOffset.y - delta)
|
||||
}
|
||||
|
||||
updatedOffset = footerBottomOffset + delta
|
||||
footerBottomOffset = clamp(updatedOffset, min: 0, max: bottomScrollHeight)
|
||||
|
||||
let alpha = 1 - abs(headerTopOffset / topScrollHeight)
|
||||
urlBar?.updateAlphaForSubviews(alpha)
|
||||
}
|
||||
|
||||
func isHeaderDisplayedForGivenOffset(_ offset: CGFloat) -> Bool {
|
||||
return offset > -topScrollHeight && offset < 0
|
||||
}
|
||||
|
||||
func clamp(_ y: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat {
|
||||
if y >= max {
|
||||
return max
|
||||
} else if y <= min {
|
||||
return min
|
||||
}
|
||||
return y
|
||||
}
|
||||
|
||||
func animateToolbarsWithOffsets(_ animated: Bool, duration: TimeInterval, headerOffset: CGFloat, footerOffset: CGFloat, alpha: CGFloat, completion: ((_ finished: Bool) -> Void)?) {
|
||||
guard let scrollView = scrollView else { return }
|
||||
let initialContentOffset = scrollView.contentOffset
|
||||
|
||||
// If this function is used to fully animate the toolbar from hidden to shown, keep the page from scrolling by adjusting contentOffset,
|
||||
// Otherwise when the toolbar is hidden and a link navigated, showing the toolbar will scroll the page and
|
||||
// produce a ~50px page jumping effect in response to tap navigations.
|
||||
let isShownFromHidden = headerTopOffset == -topScrollHeight && headerOffset == 0
|
||||
|
||||
let animation: () -> Void = {
|
||||
if isShownFromHidden {
|
||||
scrollView.contentOffset = CGPoint(x: initialContentOffset.x, y: initialContentOffset.y + self.topScrollHeight)
|
||||
}
|
||||
self.headerTopOffset = headerOffset
|
||||
self.footerBottomOffset = footerOffset
|
||||
self.urlBar?.updateAlphaForSubviews(alpha)
|
||||
self.header?.superview?.layoutIfNeeded()
|
||||
}
|
||||
|
||||
if animated {
|
||||
UIView.animate(withDuration: duration, delay: 0, options: .allowUserInteraction, animations: animation, completion: completion)
|
||||
} else {
|
||||
animation()
|
||||
completion?(true)
|
||||
}
|
||||
}
|
||||
|
||||
func checkScrollHeightIsLargeEnoughForScrolling() -> Bool {
|
||||
return (UIScreen.main.bounds.size.height + 2 * UIConstants.ToolbarHeight) < scrollView?.contentSize.height ?? 0
|
||||
}
|
||||
|
||||
func showOrHideWebViewContainerToolbar() {
|
||||
if contentOffset.y >= webViewContainerToolbar?.frame.height ?? 0 {
|
||||
webViewContainerToolbar?.isHidden = true
|
||||
} else {
|
||||
webViewContainerToolbar?.isHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TabScrollingController: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
extension TabScrollingController: UIScrollViewDelegate {
|
||||
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
|
||||
if tabIsLoading() || isBouncingAtBottom() {
|
||||
return
|
||||
}
|
||||
|
||||
if (decelerate || (toolbarState == .animating && !decelerate)) && checkScrollHeightIsLargeEnoughForScrolling() {
|
||||
if scrollDirection == .up {
|
||||
showToolbars(animated: !isTabShowingPDF)
|
||||
} else if scrollDirection == .down {
|
||||
hideToolbars(animated: !isTabShowingPDF)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewDidZoom(_ scrollView: UIScrollView) {
|
||||
// Only mess with the zoom level if the user did not initate the zoom via a zoom gesture
|
||||
if self.isUserZoom {
|
||||
return
|
||||
}
|
||||
|
||||
//scrollViewDidZoom will be called multiple times when a rotation happens.
|
||||
// In that case ALWAYS reset to the minimum zoom level if the previous state was zoomed out (isZoomedOut=true)
|
||||
if isZoomedOut {
|
||||
scrollView.zoomScale = scrollView.minimumZoomScale
|
||||
} else if roundNum(scrollView.zoomScale) > roundNum(self.lastZoomedScale) && self.lastZoomedScale != 0 {
|
||||
//When we have manually zoomed in we want to preserve that scale.
|
||||
//But sometimes when we rotate a larger zoomScale is appled. In that case apply the lastZoomedScale
|
||||
scrollView.zoomScale = self.lastZoomedScale
|
||||
}
|
||||
}
|
||||
|
||||
func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) {
|
||||
self.isUserZoom = true
|
||||
}
|
||||
|
||||
func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
|
||||
self.isUserZoom = false
|
||||
showOrHideWebViewContainerToolbar()
|
||||
}
|
||||
|
||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
||||
showOrHideWebViewContainerToolbar()
|
||||
}
|
||||
|
||||
func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool {
|
||||
showToolbars(animated: true)
|
||||
webViewContainerToolbar?.isHidden = false
|
||||
return true
|
||||
}
|
||||
}
|
||||
315
mobile/ios/Client/Frontend/Browser/TabToolbar.swift
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
/* 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 SnapKit
|
||||
import Shared
|
||||
import XCGLogger
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
protocol TabToolbarProtocol: class {
|
||||
weak var tabToolbarDelegate: TabToolbarDelegate? { get set }
|
||||
var tabsButton: TabsButton { get }
|
||||
var menuButton: ToolbarButton { get }
|
||||
var forwardButton: ToolbarButton { get }
|
||||
var backButton: ToolbarButton { get }
|
||||
var stopReloadButton: ToolbarButton { get }
|
||||
var actionButtons: [Themeable & UIButton] { get }
|
||||
|
||||
func updateBackStatus(_ canGoBack: Bool)
|
||||
func updateForwardStatus(_ canGoForward: Bool)
|
||||
func updateReloadStatus(_ isLoading: Bool)
|
||||
func updatePageStatus(_ isWebPage: Bool)
|
||||
func updateTabCount(_ count: Int, animated: Bool)
|
||||
}
|
||||
|
||||
protocol TabToolbarDelegate: class {
|
||||
func tabToolbarDidPressBack(_ tabToolbar: TabToolbarProtocol, button: UIButton)
|
||||
func tabToolbarDidPressForward(_ tabToolbar: TabToolbarProtocol, button: UIButton)
|
||||
func tabToolbarDidLongPressBack(_ tabToolbar: TabToolbarProtocol, button: UIButton)
|
||||
func tabToolbarDidLongPressForward(_ tabToolbar: TabToolbarProtocol, button: UIButton)
|
||||
func tabToolbarDidPressReload(_ tabToolbar: TabToolbarProtocol, button: UIButton)
|
||||
func tabToolbarDidLongPressReload(_ tabToolbar: TabToolbarProtocol, button: UIButton)
|
||||
func tabToolbarDidPressStop(_ tabToolbar: TabToolbarProtocol, button: UIButton)
|
||||
func tabToolbarDidPressMenu(_ tabToolbar: TabToolbarProtocol, button: UIButton)
|
||||
func tabToolbarDidPressTabs(_ tabToolbar: TabToolbarProtocol, button: UIButton)
|
||||
func tabToolbarDidLongPressTabs(_ tabToolbar: TabToolbarProtocol, button: UIButton)
|
||||
}
|
||||
|
||||
@objc
|
||||
open class TabToolbarHelper: NSObject {
|
||||
let toolbar: TabToolbarProtocol
|
||||
|
||||
let ImageReload = UIImage.templateImageNamed("nav-refresh")
|
||||
let ImageStop = UIImage.templateImageNamed("nav-stop")
|
||||
|
||||
var loading: Bool = false {
|
||||
didSet {
|
||||
if loading {
|
||||
toolbar.stopReloadButton.setImage(ImageStop, for: .normal)
|
||||
toolbar.stopReloadButton.accessibilityLabel = NSLocalizedString("Stop", comment: "Accessibility Label for the tab toolbar Stop button")
|
||||
} else {
|
||||
toolbar.stopReloadButton.setImage(ImageReload, for: .normal)
|
||||
toolbar.stopReloadButton.accessibilityLabel = NSLocalizedString("Reload", comment: "Accessibility Label for the tab toolbar Reload button")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func setTheme(theme: String, forButtons buttons: [Themeable]) {
|
||||
buttons.forEach { $0.applyTheme(theme) }
|
||||
}
|
||||
|
||||
init(toolbar: TabToolbarProtocol) {
|
||||
self.toolbar = toolbar
|
||||
super.init()
|
||||
|
||||
toolbar.backButton.setImage(UIImage.templateImageNamed("nav-back"), for: .normal)
|
||||
toolbar.backButton.accessibilityLabel = NSLocalizedString("Back", comment: "Accessibility label for the Back button in the tab toolbar.")
|
||||
let longPressGestureBackButton = UILongPressGestureRecognizer(target: self, action: #selector(TabToolbarHelper.SELdidLongPressBack(_:)))
|
||||
toolbar.backButton.addGestureRecognizer(longPressGestureBackButton)
|
||||
toolbar.backButton.addTarget(self, action: #selector(TabToolbarHelper.SELdidClickBack), for: UIControlEvents.touchUpInside)
|
||||
|
||||
toolbar.forwardButton.setImage(UIImage.templateImageNamed("nav-forward"), for: .normal)
|
||||
toolbar.forwardButton.accessibilityLabel = NSLocalizedString("Forward", comment: "Accessibility Label for the tab toolbar Forward button")
|
||||
let longPressGestureForwardButton = UILongPressGestureRecognizer(target: self, action: #selector(TabToolbarHelper.SELdidLongPressForward(_:)))
|
||||
toolbar.forwardButton.addGestureRecognizer(longPressGestureForwardButton)
|
||||
toolbar.forwardButton.addTarget(self, action: #selector(TabToolbarHelper.SELdidClickForward), for: UIControlEvents.touchUpInside)
|
||||
|
||||
toolbar.stopReloadButton.setImage(UIImage.templateImageNamed("nav-refresh"), for: .normal)
|
||||
toolbar.stopReloadButton.accessibilityLabel = NSLocalizedString("Reload", comment: "Accessibility Label for the tab toolbar Reload button")
|
||||
let longPressGestureStopReloadButton = UILongPressGestureRecognizer(target: self, action: #selector(TabToolbarHelper.SELdidLongPressStopReload(_:)))
|
||||
toolbar.stopReloadButton.addGestureRecognizer(longPressGestureStopReloadButton)
|
||||
toolbar.stopReloadButton.addTarget(self, action: #selector(TabToolbarHelper.SELdidClickStopReload), for: UIControlEvents.touchUpInside)
|
||||
|
||||
toolbar.tabsButton.addTarget(self, action: #selector(TabToolbarHelper.SELdidClickTabs), for: .touchUpInside)
|
||||
let longPressGestureTabsButton = UILongPressGestureRecognizer(target: self, action: #selector(TabToolbarHelper.SELdidLongPressTabs(_:)))
|
||||
toolbar.tabsButton.addGestureRecognizer(longPressGestureTabsButton)
|
||||
|
||||
toolbar.menuButton.contentMode = UIViewContentMode.center
|
||||
toolbar.menuButton.setImage(UIImage.templateImageNamed("nav-menu"), for: .normal)
|
||||
toolbar.menuButton.accessibilityLabel = Strings.AppMenuButtonAccessibilityLabel
|
||||
toolbar.menuButton.addTarget(self, action: #selector(TabToolbarHelper.SELdidClickMenu), for: UIControlEvents.touchUpInside)
|
||||
toolbar.menuButton.accessibilityIdentifier = "TabToolbar.menuButton"
|
||||
setTheme(theme: Theme.NormalMode, forButtons: toolbar.actionButtons)
|
||||
}
|
||||
|
||||
func SELdidClickBack() {
|
||||
toolbar.tabToolbarDelegate?.tabToolbarDidPressBack(toolbar, button: toolbar.backButton)
|
||||
}
|
||||
|
||||
func SELdidLongPressBack(_ recognizer: UILongPressGestureRecognizer) {
|
||||
if recognizer.state == UIGestureRecognizerState.began {
|
||||
toolbar.tabToolbarDelegate?.tabToolbarDidLongPressBack(toolbar, button: toolbar.backButton)
|
||||
}
|
||||
}
|
||||
|
||||
func SELdidClickTabs() {
|
||||
toolbar.tabToolbarDelegate?.tabToolbarDidPressTabs(toolbar, button: toolbar.tabsButton)
|
||||
}
|
||||
|
||||
func SELdidLongPressTabs(_ recognizer: UILongPressGestureRecognizer) {
|
||||
toolbar.tabToolbarDelegate?.tabToolbarDidLongPressTabs(toolbar, button: toolbar.tabsButton)
|
||||
}
|
||||
|
||||
func SELdidClickForward() {
|
||||
toolbar.tabToolbarDelegate?.tabToolbarDidPressForward(toolbar, button: toolbar.forwardButton)
|
||||
}
|
||||
|
||||
func SELdidLongPressForward(_ recognizer: UILongPressGestureRecognizer) {
|
||||
if recognizer.state == UIGestureRecognizerState.began {
|
||||
toolbar.tabToolbarDelegate?.tabToolbarDidLongPressForward(toolbar, button: toolbar.forwardButton)
|
||||
}
|
||||
}
|
||||
|
||||
func SELdidClickMenu() {
|
||||
toolbar.tabToolbarDelegate?.tabToolbarDidPressMenu(toolbar, button: toolbar.menuButton)
|
||||
}
|
||||
|
||||
func SELdidClickStopReload() {
|
||||
if loading {
|
||||
toolbar.tabToolbarDelegate?.tabToolbarDidPressStop(toolbar, button: toolbar.stopReloadButton)
|
||||
} else {
|
||||
toolbar.tabToolbarDelegate?.tabToolbarDidPressReload(toolbar, button: toolbar.stopReloadButton)
|
||||
}
|
||||
}
|
||||
|
||||
func SELdidLongPressStopReload(_ recognizer: UILongPressGestureRecognizer) {
|
||||
if recognizer.state == UIGestureRecognizerState.began && !loading {
|
||||
toolbar.tabToolbarDelegate?.tabToolbarDidLongPressReload(toolbar, button: toolbar.stopReloadButton)
|
||||
}
|
||||
}
|
||||
|
||||
func updateReloadStatus(_ isLoading: Bool) {
|
||||
loading = isLoading
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarButton: UIButton {
|
||||
static let Themes: [String: Theme] = {
|
||||
var themes = [String: Theme]()
|
||||
var theme = Theme()
|
||||
theme.buttonTintColor = UIColor(rgb: 0xd2d2d4)
|
||||
theme.highlightButtonColor = UIColor(rgb: 0xAC39FF)
|
||||
theme.disabledButtonColor = UIColor.gray
|
||||
themes[Theme.PrivateMode] = theme
|
||||
|
||||
theme = Theme()
|
||||
theme.buttonTintColor = UIColor(rgb: 0x272727)
|
||||
theme.highlightButtonColor = UIColor(rgb: 0x00A2FE)
|
||||
theme.disabledButtonColor = UIColor.lightGray
|
||||
themes[Theme.NormalMode] = theme
|
||||
|
||||
return themes
|
||||
}()
|
||||
|
||||
var selectedTintColor: UIColor!
|
||||
var unselectedTintColor: UIColor!
|
||||
var disabledTintColor: UIColor!
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
adjustsImageWhenHighlighted = false
|
||||
selectedTintColor = tintColor
|
||||
unselectedTintColor = tintColor
|
||||
disabledTintColor = UIColor.gray
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override open var isHighlighted: Bool {
|
||||
didSet {
|
||||
self.tintColor = isHighlighted ? selectedTintColor : unselectedTintColor
|
||||
}
|
||||
}
|
||||
|
||||
override open var isEnabled: Bool {
|
||||
didSet {
|
||||
self.tintColor = isEnabled ? unselectedTintColor : disabledTintColor
|
||||
}
|
||||
}
|
||||
|
||||
override var tintColor: UIColor! {
|
||||
didSet {
|
||||
self.imageView?.tintColor = self.tintColor
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ToolbarButton: Themeable {
|
||||
func applyTheme(_ themeName: String) {
|
||||
guard let theme = ToolbarButton.Themes[themeName] else {
|
||||
log.error("Unable to apply unknown theme \(themeName)")
|
||||
return
|
||||
}
|
||||
selectedTintColor = theme.highlightButtonColor
|
||||
disabledTintColor = theme.disabledButtonColor
|
||||
unselectedTintColor = theme.buttonTintColor
|
||||
tintColor = isEnabled ? unselectedTintColor : disabledTintColor
|
||||
imageView?.tintColor = tintColor
|
||||
}
|
||||
}
|
||||
|
||||
class TabToolbar: Toolbar, TabToolbarProtocol {
|
||||
weak var tabToolbarDelegate: TabToolbarDelegate?
|
||||
|
||||
let tabsButton: TabsButton
|
||||
let menuButton: ToolbarButton
|
||||
let forwardButton: ToolbarButton
|
||||
let backButton: ToolbarButton
|
||||
let stopReloadButton: ToolbarButton
|
||||
let actionButtons: [Themeable & UIButton]
|
||||
|
||||
var helper: TabToolbarHelper?
|
||||
|
||||
static let Themes: [String: Theme] = {
|
||||
var themes = [String: Theme]()
|
||||
var theme = Theme()
|
||||
theme.backgroundColor = UIColor(rgb: 0x38383D)
|
||||
themes[Theme.PrivateMode] = theme
|
||||
|
||||
theme = Theme()
|
||||
theme.backgroundColor = UIConstants.AppBackgroundColor
|
||||
themes[Theme.NormalMode] = theme
|
||||
|
||||
return themes
|
||||
}()
|
||||
|
||||
// This has to be here since init() calls it
|
||||
fileprivate override init(frame: CGRect) {
|
||||
// And these have to be initialized in here or the compiler will get angry
|
||||
backButton = ToolbarButton()
|
||||
backButton.accessibilityIdentifier = "TabToolbar.backButton"
|
||||
forwardButton = ToolbarButton()
|
||||
forwardButton.accessibilityIdentifier = "TabToolbar.forwardButton"
|
||||
stopReloadButton = ToolbarButton()
|
||||
stopReloadButton.accessibilityIdentifier = "TabToolbar.stopReloadButton"
|
||||
tabsButton = TabsButton()
|
||||
tabsButton.accessibilityIdentifier = "TabToolbar.tabsButton"
|
||||
menuButton = ToolbarButton()
|
||||
menuButton.accessibilityIdentifier = "TabToolbar.menuButton"
|
||||
actionButtons = [backButton, forwardButton, stopReloadButton, tabsButton, menuButton]
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
helper = TabToolbarHelper(toolbar: self)
|
||||
addButtons(actionButtons)
|
||||
|
||||
accessibilityNavigationStyle = .combined
|
||||
accessibilityLabel = NSLocalizedString("Navigation Toolbar", comment: "Accessibility label for the navigation toolbar displayed at the bottom of the screen.")
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func updateBackStatus(_ canGoBack: Bool) {
|
||||
backButton.isEnabled = canGoBack
|
||||
}
|
||||
|
||||
func updateForwardStatus(_ canGoForward: Bool) {
|
||||
forwardButton.isEnabled = canGoForward
|
||||
}
|
||||
|
||||
func updateReloadStatus(_ isLoading: Bool) {
|
||||
helper?.updateReloadStatus(isLoading)
|
||||
}
|
||||
|
||||
func updatePageStatus(_ isWebPage: Bool) {
|
||||
stopReloadButton.isEnabled = isWebPage
|
||||
}
|
||||
|
||||
func updateTabCount(_ count: Int, animated: Bool) {
|
||||
tabsButton.updateTabCount(count, animated: animated)
|
||||
}
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
if let context = UIGraphicsGetCurrentContext() {
|
||||
drawLine(context, start: CGPoint(x: 0, y: 0), end: CGPoint(x: frame.width, y: 0))
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func drawLine(_ context: CGContext, start: CGPoint, end: CGPoint) {
|
||||
context.setStrokeColor(UIColor.black.withAlphaComponent(0.05).cgColor)
|
||||
context.setLineWidth(2)
|
||||
context.move(to: CGPoint(x: start.x, y: start.y))
|
||||
context.addLine(to: CGPoint(x: end.x, y: end.y))
|
||||
context.strokePath()
|
||||
}
|
||||
}
|
||||
|
||||
extension TabToolbar: Themeable {
|
||||
func applyTheme(_ themeName: String) {
|
||||
guard let theme = TabToolbar.Themes[themeName] else {
|
||||
log.error("Unable to apply unknown theme \(themeName)")
|
||||
return
|
||||
}
|
||||
backgroundColor = theme.backgroundColor!
|
||||
helper?.setTheme(theme: themeName, forButtons: actionButtons)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
class PrivateModeButton: ToggleButton {
|
||||
var light: Bool = false
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
self.accessibilityLabel = PrivateModeStrings.toggleAccessibilityLabel
|
||||
self.accessibilityHint = PrivateModeStrings.toggleAccessibilityHint
|
||||
let maskImage = UIImage(named: "smallPrivateMask")?.withRenderingMode(.alwaysTemplate)
|
||||
self.setImage(maskImage, for: UIControlState())
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func styleForMode(privateMode isPrivate: Bool) {
|
||||
self.tintColor = isPrivate ? UIColor(rgb: 0xf9f9fa) : UIColor(rgb: 0x272727)
|
||||
self.imageView?.tintColor = self.tintColor
|
||||
self.isSelected = isPrivate
|
||||
self.accessibilityValue = isPrivate ? PrivateModeStrings.toggleAccessibilityValueOn : PrivateModeStrings.toggleAccessibilityValueOff
|
||||
}
|
||||
}
|
||||
|
||||
extension UIButton {
|
||||
static func newTabButton() -> UIButton {
|
||||
let newTab = UIButton()
|
||||
newTab.setImage(UIImage.templateImageNamed("quick_action_new_tab"), for: .normal)
|
||||
newTab.accessibilityLabel = NSLocalizedString("New Tab", comment: "Accessibility label for the New Tab button in the tab toolbar.")
|
||||
return newTab
|
||||
}
|
||||
}
|
||||
|
||||
extension TabsButton {
|
||||
static func tabTrayButton() -> TabsButton {
|
||||
let tabsButton = TabsButton()
|
||||
tabsButton.countLabel.text = "0"
|
||||
tabsButton.accessibilityLabel = NSLocalizedString("Show Tabs", comment: "Accessibility Label for the tabs button in the tab toolbar")
|
||||
return tabsButton
|
||||
}
|
||||
}
|
||||
1061
mobile/ios/Client/Frontend/Browser/TabTrayController.swift
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/* 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
|
||||
|
||||
class ThirdPartySearchAlerts: UIAlertController {
|
||||
|
||||
/**
|
||||
Allows the keyboard to pop back up after an alertview.
|
||||
**/
|
||||
override var canBecomeFirstResponder: Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
Builds the Alert view that asks if the users wants to add a third party search engine.
|
||||
|
||||
- parameter okayCallback: Okay option handler.
|
||||
|
||||
- returns: UIAlertController for asking the user to add a search engine
|
||||
**/
|
||||
|
||||
static func addThirdPartySearchEngine(_ okayCallback: @escaping (UIAlertAction) -> Void) -> UIAlertController {
|
||||
let alert = ThirdPartySearchAlerts(
|
||||
title: Strings.ThirdPartySearchAddTitle,
|
||||
message: Strings.ThirdPartySearchAddMessage,
|
||||
preferredStyle: UIAlertControllerStyle.alert
|
||||
)
|
||||
|
||||
let noOption = UIAlertAction(
|
||||
title: Strings.ThirdPartySearchCancelButton,
|
||||
style: UIAlertActionStyle.cancel,
|
||||
handler: nil
|
||||
)
|
||||
|
||||
let okayOption = UIAlertAction(
|
||||
title: Strings.ThirdPartySearchOkayButton,
|
||||
style: UIAlertActionStyle.default,
|
||||
handler: okayCallback
|
||||
)
|
||||
|
||||
alert.addAction(okayOption)
|
||||
alert.addAction(noOption)
|
||||
|
||||
return alert
|
||||
}
|
||||
|
||||
/**
|
||||
Builds the Alert view that shows the user an error in case a search engine could not be added.
|
||||
|
||||
- returns: UIAlertController with an error dialog
|
||||
**/
|
||||
|
||||
static func failedToAddThirdPartySearch() -> UIAlertController {
|
||||
return searchAlertWithOK(title: Strings.ThirdPartySearchFailedTitle,
|
||||
message: Strings.ThirdPartySearchFailedMessage)
|
||||
}
|
||||
|
||||
static func incorrectCustomEngineForm() -> UIAlertController {
|
||||
return searchAlertWithOK(title: Strings.CustomEngineFormErrorTitle,
|
||||
message: Strings.CustomEngineFormErrorMessage)
|
||||
}
|
||||
|
||||
static func duplicateCustomEngine() -> UIAlertController {
|
||||
return searchAlertWithOK(title: Strings.CustomEngineDuplicateErrorTitle,
|
||||
message: Strings.CustomEngineDuplicateErrorMessage)
|
||||
}
|
||||
|
||||
private static func searchAlertWithOK(title: String, message: String) -> UIAlertController {
|
||||
let alert = ThirdPartySearchAlerts(
|
||||
title: title,
|
||||
message: message,
|
||||
preferredStyle: UIAlertControllerStyle.alert
|
||||
)
|
||||
|
||||
let okayOption = UIAlertAction(
|
||||
title: Strings.ThirdPartySearchOkayButton,
|
||||
style: UIAlertActionStyle.default,
|
||||
handler: nil
|
||||
)
|
||||
|
||||
alert.addAction(okayOption)
|
||||
return alert
|
||||
}
|
||||
|
||||
}
|
||||
113
mobile/ios/Client/Frontend/Browser/TopTabsLayout.swift
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/* 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
|
||||
|
||||
class TopTabsLayoutDelegate: NSObject, UICollectionViewDelegateFlowLayout {
|
||||
weak var tabSelectionDelegate: TabSelectionDelegate?
|
||||
let HeaderFooterWidth = TopTabsUX.SeparatorWidth + TopTabsUX.FaderPading
|
||||
|
||||
@objc func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat {
|
||||
return TopTabsUX.SeparatorWidth
|
||||
}
|
||||
|
||||
@objc func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
||||
return CGSize(width: TopTabsUX.TabWidth, height: collectionView.frame.height)
|
||||
}
|
||||
|
||||
@objc func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
|
||||
return UIEdgeInsets.zero
|
||||
}
|
||||
|
||||
@objc func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat {
|
||||
return TopTabsUX.SeparatorWidth
|
||||
}
|
||||
|
||||
@objc func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
||||
tabSelectionDelegate?.didSelectTabAtIndex(indexPath.row)
|
||||
}
|
||||
|
||||
@objc func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize {
|
||||
return CGSize(width: HeaderFooterWidth, height: 0)
|
||||
}
|
||||
|
||||
@objc func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
|
||||
return CGSize(width: HeaderFooterWidth, height: 0)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class TopTabsViewLayout: UICollectionViewFlowLayout {
|
||||
var decorationAttributeArr: [Int: UICollectionViewLayoutAttributes?] = [:]
|
||||
let separatorYOffset = TopTabsUX.SeparatorYOffset
|
||||
let separatorSize = TopTabsUX.SeparatorHeight
|
||||
let SeparatorZIndex = -2 ///Prevent the header/footer from appearing above the Tabs
|
||||
|
||||
override var collectionViewContentSize: CGSize {
|
||||
let tabsWidth = ((CGFloat(collectionView!.numberOfItems(inSection: 0))) * (TopTabsUX.TabWidth + TopTabsUX.SeparatorWidth)) - TopTabsUX.SeparatorWidth
|
||||
return CGSize(width: tabsWidth + (TopTabsUX.TopTabsBackgroundShadowWidth * 2), height: collectionView!.bounds.height)
|
||||
}
|
||||
|
||||
override func prepare() {
|
||||
super.prepare()
|
||||
self.minimumLineSpacing = TopTabsUX.SeparatorWidth
|
||||
scrollDirection = UICollectionViewScrollDirection.horizontal
|
||||
register(TopTabsSeparator.self, forDecorationViewOfKind: TopTabsSeparatorUX.Identifier)
|
||||
}
|
||||
|
||||
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
|
||||
decorationAttributeArr = [:]
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: layoutAttributesForElementsInRect
|
||||
override func layoutAttributesForDecorationView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
||||
guard indexPath.row < self.collectionView!.numberOfItems(inSection: 0) else {
|
||||
let separatorAttr = UICollectionViewLayoutAttributes(forDecorationViewOfKind: TopTabsSeparatorUX.Identifier, with: indexPath)
|
||||
separatorAttr.frame = CGRect.zero
|
||||
separatorAttr.zIndex = SeparatorZIndex
|
||||
return separatorAttr
|
||||
}
|
||||
|
||||
if let attr = self.decorationAttributeArr[indexPath.item] {
|
||||
return attr
|
||||
} else {
|
||||
// Compute the separator if it does not exist in the cache
|
||||
let separatorAttr = UICollectionViewLayoutAttributes(forDecorationViewOfKind: TopTabsSeparatorUX.Identifier, with: indexPath)
|
||||
let x = TopTabsUX.TopTabsBackgroundShadowWidth + ((CGFloat(indexPath.row) * (TopTabsUX.TabWidth + TopTabsUX.SeparatorWidth)) - TopTabsUX.SeparatorWidth)
|
||||
separatorAttr.frame = CGRect(x: x, y: separatorYOffset, width: TopTabsUX.SeparatorWidth, height: separatorSize)
|
||||
separatorAttr.zIndex = SeparatorZIndex
|
||||
return separatorAttr
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutAttributesForSupplementaryView(ofKind elementKind: String, at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
|
||||
let attributes = super.layoutAttributesForSupplementaryView(ofKind: elementKind, at: indexPath)
|
||||
attributes?.zIndex = SeparatorZIndex
|
||||
return attributes
|
||||
}
|
||||
|
||||
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
|
||||
var attributes = super.layoutAttributesForElements(in: rect)!
|
||||
|
||||
// Create attributes for the Tab Separator.
|
||||
for i in attributes {
|
||||
guard i.representedElementKind != UICollectionElementKindSectionHeader && i.representedElementKind != UICollectionElementKindSectionFooter else {
|
||||
i.zIndex = SeparatorZIndex
|
||||
continue
|
||||
}
|
||||
let sep = UICollectionViewLayoutAttributes(forDecorationViewOfKind: TopTabsSeparatorUX.Identifier, with: i.indexPath)
|
||||
sep.frame = CGRect(x: i.frame.origin.x - TopTabsUX.SeparatorWidth, y: separatorYOffset, width: TopTabsUX.SeparatorWidth, height: separatorSize)
|
||||
sep.zIndex = SeparatorZIndex
|
||||
i.zIndex = 10
|
||||
|
||||
// Only add the seperator if it will be shown.
|
||||
if i.indexPath.row != 0 && i.indexPath.row < self.collectionView!.numberOfItems(inSection: 0) {
|
||||
attributes.append(sep)
|
||||
decorationAttributeArr[i.indexPath.item] = sep
|
||||
}
|
||||
}
|
||||
|
||||
return attributes
|
||||
}
|
||||
}
|
||||
560
mobile/ios/Client/Frontend/Browser/TopTabsViewController.swift
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
/* 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 WebKit
|
||||
|
||||
struct TopTabsUX {
|
||||
static let TopTabsViewHeight: CGFloat = 44
|
||||
static let TopTabsBackgroundColor = UIColor(rgb: 0x2a2a2e)
|
||||
static let TopTabsBackgroundPadding: CGFloat = 35
|
||||
static let TopTabsBackgroundShadowWidth: CGFloat = 12
|
||||
static let TabWidth: CGFloat = 190
|
||||
static let CollectionViewPadding: CGFloat = 15
|
||||
static let FaderPading: CGFloat = 8
|
||||
static let SeparatorWidth: CGFloat = 1
|
||||
static let HighlightLineWidth: CGFloat = 3
|
||||
static let TabNudge: CGFloat = 1 // Nudge the favicon and close button by 1px
|
||||
static let TabTitleWidth: CGFloat = 110
|
||||
static let TabTitlePadding: CGFloat = 10
|
||||
static let AnimationSpeed: TimeInterval = 0.1
|
||||
static let SeparatorYOffset: CGFloat = 7
|
||||
static let SeparatorHeight: CGFloat = 32
|
||||
}
|
||||
|
||||
protocol TopTabsDelegate: class {
|
||||
func topTabsDidPressTabs()
|
||||
func topTabsDidPressNewTab(_ isPrivate: Bool)
|
||||
|
||||
func topTabsDidTogglePrivateMode()
|
||||
func topTabsDidChangeTab()
|
||||
}
|
||||
|
||||
protocol TopTabCellDelegate: class {
|
||||
func tabCellDidClose(_ cell: TopTabCell)
|
||||
}
|
||||
|
||||
class TopTabsViewController: UIViewController {
|
||||
let tabManager: TabManager
|
||||
weak var delegate: TopTabsDelegate?
|
||||
fileprivate var isPrivate = false
|
||||
let faviconNotification = NSNotification.Name(rawValue: FaviconManager.FaviconDidLoad)
|
||||
|
||||
lazy var collectionView: UICollectionView = {
|
||||
let collectionView = UICollectionView(frame: CGRect.zero, collectionViewLayout: TopTabsViewLayout())
|
||||
collectionView.register(TopTabCell.self, forCellWithReuseIdentifier: TopTabCell.Identifier)
|
||||
collectionView.showsVerticalScrollIndicator = false
|
||||
collectionView.showsHorizontalScrollIndicator = false
|
||||
collectionView.bounces = false
|
||||
collectionView.clipsToBounds = false
|
||||
collectionView.accessibilityIdentifier = "Top Tabs View"
|
||||
return collectionView
|
||||
}()
|
||||
|
||||
fileprivate lazy var tabsButton: TabsButton = {
|
||||
let tabsButton = TabsButton.tabTrayButton()
|
||||
tabsButton.addTarget(self, action: #selector(TopTabsViewController.tabsTrayTapped), for: UIControlEvents.touchUpInside)
|
||||
tabsButton.accessibilityIdentifier = "TopTabsViewController.tabsButton"
|
||||
return tabsButton
|
||||
}()
|
||||
|
||||
fileprivate lazy var newTab: UIButton = {
|
||||
let newTab = UIButton.newTabButton()
|
||||
newTab.addTarget(self, action: #selector(TopTabsViewController.newTabTapped), for: UIControlEvents.touchUpInside)
|
||||
return newTab
|
||||
}()
|
||||
|
||||
lazy var privateModeButton: PrivateModeButton = {
|
||||
let privateModeButton = PrivateModeButton()
|
||||
privateModeButton.light = true
|
||||
privateModeButton.addTarget(self, action: #selector(TopTabsViewController.togglePrivateModeTapped), for: UIControlEvents.touchUpInside)
|
||||
return privateModeButton
|
||||
}()
|
||||
|
||||
fileprivate lazy var tabLayoutDelegate: TopTabsLayoutDelegate = {
|
||||
let delegate = TopTabsLayoutDelegate()
|
||||
delegate.tabSelectionDelegate = self
|
||||
return delegate
|
||||
}()
|
||||
|
||||
fileprivate var tabsToDisplay: [Tab] {
|
||||
return self.isPrivate ? tabManager.privateTabs : tabManager.normalTabs
|
||||
}
|
||||
|
||||
// Handle animations.
|
||||
fileprivate var tabStore: [Tab] = [] //the actual datastore
|
||||
fileprivate var pendingUpdatesToTabs: [Tab] = [] //the datastore we are transitioning to
|
||||
fileprivate var needReloads: [Tab?] = [] // Tabs that need to be reloaded
|
||||
fileprivate var isUpdating = false
|
||||
fileprivate var pendingReloadData = false
|
||||
fileprivate var oldTabs: [Tab]? // The last state of the tabs before an animation
|
||||
fileprivate weak var oldSelectedTab: Tab? // Used to select the right tab when transitioning between private/normal tabs
|
||||
|
||||
init(tabManager: TabManager) {
|
||||
self.tabManager = tabManager
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
collectionView.dataSource = self
|
||||
collectionView.delegate = tabLayoutDelegate
|
||||
[UICollectionElementKindSectionHeader, UICollectionElementKindSectionFooter].forEach {
|
||||
collectionView.register(TopTabsHeaderFooter.self, forSupplementaryViewOfKind: $0, withReuseIdentifier: "HeaderFooter")
|
||||
}
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(TopTabsViewController.reloadFavicons(_:)), name: faviconNotification, object: nil)
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.tabManager.removeDelegate(self)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
if self.tabsToDisplay != self.tabStore {
|
||||
self.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tabManager.addDelegate(self)
|
||||
self.tabStore = self.tabsToDisplay
|
||||
|
||||
let topTabFader = TopTabFader()
|
||||
|
||||
view.addSubview(topTabFader)
|
||||
topTabFader.addSubview(collectionView)
|
||||
view.addSubview(tabsButton)
|
||||
view.addSubview(newTab)
|
||||
view.addSubview(privateModeButton)
|
||||
|
||||
newTab.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(view)
|
||||
make.trailing.equalTo(tabsButton.snp.leading).offset(-10)
|
||||
make.size.equalTo(view.snp.height)
|
||||
}
|
||||
tabsButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(view)
|
||||
make.trailing.equalTo(view).offset(-10)
|
||||
make.size.equalTo(view.snp.height)
|
||||
}
|
||||
privateModeButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(view)
|
||||
make.leading.equalTo(view).offset(10)
|
||||
make.size.equalTo(view.snp.height)
|
||||
}
|
||||
topTabFader.snp.makeConstraints { make in
|
||||
make.top.bottom.equalTo(view)
|
||||
make.leading.equalTo(privateModeButton.snp.trailing)
|
||||
make.trailing.equalTo(newTab.snp.leading)
|
||||
}
|
||||
collectionView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(topTabFader)
|
||||
}
|
||||
|
||||
view.backgroundColor = UIColor(rgb: 0x272727)
|
||||
tabsButton.applyTheme(Theme.NormalMode)
|
||||
if let currentTab = tabManager.selectedTab {
|
||||
applyTheme(currentTab.isPrivate ? Theme.PrivateMode : Theme.NormalMode)
|
||||
}
|
||||
updateTabCount(tabStore.count, animated: false)
|
||||
}
|
||||
|
||||
func switchForegroundStatus(isInForeground reveal: Bool) {
|
||||
// Called when the app leaves the foreground to make sure no information is inadvertently revealed
|
||||
if let cells = self.collectionView.visibleCells as? [TopTabCell] {
|
||||
let alpha: CGFloat = reveal ? 1 : 0
|
||||
for cell in cells {
|
||||
cell.titleText.alpha = alpha
|
||||
cell.favicon.alpha = alpha
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateTabCount(_ count: Int, animated: Bool = true) {
|
||||
self.tabsButton.updateTabCount(count, animated: animated)
|
||||
}
|
||||
|
||||
func tabsTrayTapped() {
|
||||
delegate?.topTabsDidPressTabs()
|
||||
}
|
||||
|
||||
func newTabTapped() {
|
||||
if pendingReloadData {
|
||||
return
|
||||
}
|
||||
self.delegate?.topTabsDidPressNewTab(self.isPrivate)
|
||||
LeanPlumClient.shared.track(event: .openedNewTab, withParameters: ["Source": "Add tab button in the URL Bar on iPad" as AnyObject])
|
||||
}
|
||||
|
||||
func togglePrivateModeTapped() {
|
||||
if isUpdating || pendingReloadData {
|
||||
return
|
||||
}
|
||||
let isPrivate = self.isPrivate
|
||||
delegate?.topTabsDidTogglePrivateMode()
|
||||
self.pendingReloadData = true // Stops animations from happening
|
||||
let oldSelectedTab = self.oldSelectedTab
|
||||
self.oldSelectedTab = tabManager.selectedTab
|
||||
self.privateModeButton.setSelected(!isPrivate, animated: true)
|
||||
|
||||
//if private tabs is empty and we are transitioning to it add a tab
|
||||
if tabManager.privateTabs.isEmpty && !isPrivate {
|
||||
tabManager.addTab(isPrivate: true)
|
||||
}
|
||||
|
||||
//get the tabs from which we will select which one to nominate for tribute (selection)
|
||||
//the isPrivate boolean still hasnt been flipped. (It'll be flipped in the BVC didSelectedTabChange method)
|
||||
let tabs = !isPrivate ? tabManager.privateTabs : tabManager.normalTabs
|
||||
if let tab = oldSelectedTab, tabs.index(of: tab) != nil {
|
||||
tabManager.selectTab(tab)
|
||||
} else {
|
||||
tabManager.selectTab(tabs.last)
|
||||
}
|
||||
}
|
||||
|
||||
func reloadFavicons(_ notification: Notification) {
|
||||
// Notifications might be called from a different thread. Make sure animations only happen on the main thread.
|
||||
DispatchQueue.main.async {
|
||||
if let tab = notification.object as? Tab, self.tabStore.index(of: tab) != nil {
|
||||
self.needReloads.append(tab)
|
||||
self.performTabUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scrollToCurrentTab(_ animated: Bool = true, centerCell: Bool = false) {
|
||||
assertIsMainThread("Only animate on the main thread")
|
||||
|
||||
guard let currentTab = tabManager.selectedTab, let index = tabStore.index(of: currentTab), !collectionView.frame.isEmpty else {
|
||||
return
|
||||
}
|
||||
if let frame = collectionView.layoutAttributesForItem(at: IndexPath(row: index, section: 0))?.frame {
|
||||
if centerCell {
|
||||
collectionView.scrollToItem(at: IndexPath(item: index, section: 0), at: .centeredHorizontally, animated: false)
|
||||
} else {
|
||||
// Padding is added to ensure the tab is completely visible (none of the tab is under the fader)
|
||||
let padFrame = frame.insetBy(dx: -(TopTabsUX.TopTabsBackgroundShadowWidth+TopTabsUX.FaderPading), dy: 0)
|
||||
if animated {
|
||||
UIView.animate(withDuration: TopTabsUX.AnimationSpeed, animations: {
|
||||
self.collectionView.scrollRectToVisible(padFrame, animated: true)
|
||||
})
|
||||
} else {
|
||||
collectionView.scrollRectToVisible(padFrame, animated: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TopTabsViewController: Themeable {
|
||||
func applyTheme(_ themeName: String) {
|
||||
tabsButton.applyTheme(themeName)
|
||||
tabsButton.titleBackgroundColor = view.backgroundColor ?? UIColor(rgb: 0x272727)
|
||||
tabsButton.textColor = UIColor(rgb: 0xb1b1b3)
|
||||
isPrivate = (themeName == Theme.PrivateMode)
|
||||
privateModeButton.styleForMode(privateMode: isPrivate)
|
||||
privateModeButton.tintColor = isPrivate ? UIColor(rgb: 0xf9f9fa) : UIColor(rgb: 0xb1b1b3)
|
||||
privateModeButton.imageView?.tintColor = privateModeButton.tintColor
|
||||
newTab.tintColor = UIColor(rgb: 0xb1b1b3)
|
||||
collectionView.backgroundColor = view.backgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
extension TopTabsViewController: TopTabCellDelegate {
|
||||
func tabCellDidClose(_ cell: TopTabCell) {
|
||||
// Trying to remove tabs while animating can lead to crashes as indexes change. If updates are happening don't allow tabs to be removed.
|
||||
guard let index = collectionView.indexPath(for: cell)?.item else {
|
||||
return
|
||||
}
|
||||
let tab = tabStore[index]
|
||||
if tabsToDisplay.index(of: tab) != nil {
|
||||
tabManager.removeTab(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TopTabsViewController: UICollectionViewDataSource {
|
||||
@objc func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
||||
let index = indexPath.item
|
||||
let tabCell = collectionView.dequeueReusableCell(withReuseIdentifier: TopTabCell.Identifier, for: indexPath) as! TopTabCell
|
||||
tabCell.delegate = self
|
||||
|
||||
let tab = tabStore[index]
|
||||
tabCell.style = tab.isPrivate ? .dark : .light
|
||||
tabCell.titleText.text = tab.displayTitle
|
||||
|
||||
if tab.displayTitle.isEmpty {
|
||||
if tab.webView?.url?.baseDomain?.contains("localhost") ?? true {
|
||||
tabCell.titleText.text = Strings.AppMenuNewTabTitleString
|
||||
} else {
|
||||
tabCell.titleText.text = tab.webView?.url?.absoluteDisplayString
|
||||
}
|
||||
tabCell.accessibilityLabel = tab.url?.aboutComponent ?? ""
|
||||
tabCell.closeButton.accessibilityLabel = String(format: Strings.TopSitesRemoveButtonAccessibilityLabel, tabCell.titleText.text ?? "")
|
||||
} else {
|
||||
tabCell.accessibilityLabel = tab.displayTitle
|
||||
tabCell.closeButton.accessibilityLabel = String(format: Strings.TopSitesRemoveButtonAccessibilityLabel, tab.displayTitle)
|
||||
}
|
||||
|
||||
tabCell.selectedTab = (tab == tabManager.selectedTab)
|
||||
if let siteURL = tab.url?.displayURL {
|
||||
tabCell.favicon.setIcon(tab.displayFavicon, forURL: siteURL, completed: { (color, url) in
|
||||
if siteURL == url {
|
||||
tabCell.favicon.image = tabCell.favicon.image?.createScaled(CGSize(width: 15, height: 15))
|
||||
tabCell.favicon.backgroundColor = color == .clear ? .white : color
|
||||
tabCell.favicon.contentMode = .center
|
||||
}
|
||||
})
|
||||
} else {
|
||||
tabCell.favicon.image = UIImage(named: "defaultFavicon")
|
||||
tabCell.favicon.contentMode = .scaleAspectFit
|
||||
tabCell.favicon.backgroundColor = .clear
|
||||
}
|
||||
|
||||
return tabCell
|
||||
}
|
||||
|
||||
@objc func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
return tabStore.count
|
||||
}
|
||||
|
||||
@objc func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView {
|
||||
let view = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "HeaderFooter", for: indexPath) as! TopTabsHeaderFooter
|
||||
view.arrangeLine(kind)
|
||||
return view
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension TopTabsViewController: TabSelectionDelegate {
|
||||
func didSelectTabAtIndex(_ index: Int) {
|
||||
let tab = tabStore[index]
|
||||
if tabsToDisplay.index(of: tab) != nil {
|
||||
tabManager.selectTab(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collection Diff (animations)
|
||||
extension TopTabsViewController {
|
||||
|
||||
struct TopTabChangeSet {
|
||||
let reloads: Set<IndexPath>
|
||||
let inserts: Set<IndexPath>
|
||||
let deletes: Set<IndexPath>
|
||||
|
||||
init(reloadArr: [IndexPath], insertArr: [IndexPath], deleteArr: [IndexPath]) {
|
||||
reloads = Set(reloadArr)
|
||||
inserts = Set(insertArr)
|
||||
deletes = Set(deleteArr)
|
||||
}
|
||||
|
||||
var all: [Set<IndexPath>] {
|
||||
return [inserts, reloads, deletes]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// create a TopTabChangeSet which is a snapshot of updates to perfrom on a collectionView
|
||||
func calculateDiffWith(_ oldTabs: [Tab], to newTabs: [Tab], and reloadTabs: [Tab?]) -> TopTabChangeSet {
|
||||
let inserts: [IndexPath] = newTabs.enumerated().flatMap { index, tab in
|
||||
if oldTabs.index(of: tab) == nil {
|
||||
return IndexPath(row: index, section: 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
let deletes: [IndexPath] = oldTabs.enumerated().flatMap { index, tab in
|
||||
if newTabs.index(of: tab) == nil {
|
||||
return IndexPath(row: index, section: 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create based on what is visibile but filter out tabs we are about to insert/delete.
|
||||
let reloads: [IndexPath] = reloadTabs.flatMap { tab in
|
||||
guard let tab = tab, newTabs.index(of: tab) != nil else {
|
||||
return nil
|
||||
}
|
||||
return IndexPath(row: newTabs.index(of: tab)!, section: 0)
|
||||
}.filter { return inserts.index(of: $0) == nil && deletes.index(of: $0) == nil }
|
||||
|
||||
return TopTabChangeSet(reloadArr: reloads, insertArr: inserts, deleteArr: deletes)
|
||||
}
|
||||
|
||||
func updateTabsFrom(_ oldTabs: [Tab]?, to newTabs: [Tab], on completion: (() -> Void)? = nil) {
|
||||
assertIsMainThread("Updates can only be performed from the main thread")
|
||||
guard let oldTabs = oldTabs, !self.isUpdating, !self.pendingReloadData else {
|
||||
return
|
||||
}
|
||||
|
||||
// Lets create our change set
|
||||
let update = self.calculateDiffWith(oldTabs, to: newTabs, and: needReloads)
|
||||
flushPendingChanges()
|
||||
|
||||
// If there are no changes. We have nothing to do
|
||||
if update.all.every({ $0.isEmpty }) {
|
||||
completion?()
|
||||
return
|
||||
}
|
||||
|
||||
// The actual update block. We update the dataStore right before we do the UI updates.
|
||||
let updateBlock = {
|
||||
self.tabStore = newTabs
|
||||
self.collectionView.deleteItems(at: Array(update.deletes))
|
||||
self.collectionView.insertItems(at: Array(update.inserts))
|
||||
self.collectionView.reloadItems(at: Array(update.reloads))
|
||||
}
|
||||
|
||||
//Lets lock any other updates from happening.
|
||||
self.isUpdating = true
|
||||
self.pendingUpdatesToTabs = newTabs // This var helps other mutations that might happen while updating.
|
||||
|
||||
// The actual update
|
||||
UIView.animate(withDuration: TopTabsUX.AnimationSpeed, animations: {
|
||||
self.collectionView.performBatchUpdates(updateBlock)
|
||||
}) { (_) in
|
||||
self.isUpdating = false
|
||||
self.pendingUpdatesToTabs = []
|
||||
// Sometimes there might be a pending reload. Lets do that.
|
||||
if self.pendingReloadData {
|
||||
return self.reloadData()
|
||||
}
|
||||
|
||||
// There can be pending animations. Run update again to clear them.
|
||||
let tabs = self.oldTabs ?? self.tabStore
|
||||
self.updateTabsFrom(tabs, to: self.tabsToDisplay, on: {
|
||||
if !update.inserts.isEmpty || !update.reloads.isEmpty {
|
||||
self.scrollToCurrentTab()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func flushPendingChanges() {
|
||||
oldTabs = nil
|
||||
needReloads.removeAll()
|
||||
}
|
||||
|
||||
fileprivate func reloadData() {
|
||||
assertIsMainThread("reloadData must only be called from main thread")
|
||||
|
||||
if self.isUpdating || self.collectionView.frame == CGRect.zero {
|
||||
self.pendingReloadData = true
|
||||
return
|
||||
}
|
||||
|
||||
isUpdating = true
|
||||
self.tabStore = self.tabsToDisplay
|
||||
self.newTab.isUserInteractionEnabled = false
|
||||
self.flushPendingChanges()
|
||||
UIView.animate(withDuration: TopTabsUX.AnimationSpeed, animations: {
|
||||
self.collectionView.reloadData()
|
||||
self.collectionView.collectionViewLayout.invalidateLayout()
|
||||
self.collectionView.layoutIfNeeded()
|
||||
self.scrollToCurrentTab(true, centerCell: true)
|
||||
}, completion: { (_) in
|
||||
self.isUpdating = false
|
||||
self.pendingReloadData = false
|
||||
self.performTabUpdates()
|
||||
self.newTab.isUserInteractionEnabled = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
extension TopTabsViewController: TabManagerDelegate {
|
||||
|
||||
// Because we don't know when we are about to transition to private mode
|
||||
// check to make sure that the tab we are trying to add is being added to the right tab group
|
||||
fileprivate func tabsMatchDisplayGroup(_ a: Tab?, b: Tab?) -> Bool {
|
||||
if let a = a, let b = b, a.isPrivate == b.isPrivate {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func performTabUpdates() {
|
||||
guard !isUpdating else {
|
||||
return
|
||||
}
|
||||
|
||||
let fromTabs = !self.pendingUpdatesToTabs.isEmpty ? self.pendingUpdatesToTabs : self.oldTabs
|
||||
self.oldTabs = fromTabs ?? self.tabStore
|
||||
if self.pendingReloadData && !isUpdating {
|
||||
self.reloadData()
|
||||
} else {
|
||||
self.updateTabsFrom(self.oldTabs, to: self.tabsToDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
// This helps make sure animations don't happen before the view is loaded.
|
||||
fileprivate var isRestoring: Bool {
|
||||
return self.tabManager.isRestoring || self.collectionView.frame == CGRect.zero
|
||||
}
|
||||
|
||||
func tabManager(_ tabManager: TabManager, didSelectedTabChange selected: Tab?, previous: Tab?) {
|
||||
if isRestoring {
|
||||
return
|
||||
}
|
||||
if !tabsMatchDisplayGroup(selected, b: previous) {
|
||||
self.reloadData()
|
||||
} else {
|
||||
self.needReloads.append(selected)
|
||||
self.needReloads.append(previous)
|
||||
performTabUpdates()
|
||||
delegate?.topTabsDidChangeTab()
|
||||
}
|
||||
}
|
||||
|
||||
func tabManager(_ tabManager: TabManager, willAddTab tab: Tab) {
|
||||
// We need to store the earliest oldTabs. So if one already exists use that.
|
||||
self.oldTabs = self.oldTabs ?? tabStore
|
||||
}
|
||||
|
||||
func tabManager(_ tabManager: TabManager, didAddTab tab: Tab) {
|
||||
if isRestoring || (tabManager.selectedTab != nil && !tabsMatchDisplayGroup(tab, b: tabManager.selectedTab)) {
|
||||
return
|
||||
}
|
||||
performTabUpdates()
|
||||
}
|
||||
|
||||
func tabManager(_ tabManager: TabManager, willRemoveTab tab: Tab) {
|
||||
// We need to store the earliest oldTabs. So if one already exists use that.
|
||||
self.oldTabs = self.oldTabs ?? tabStore
|
||||
}
|
||||
|
||||
func tabManager(_ tabManager: TabManager, didRemoveTab tab: Tab) {
|
||||
if isRestoring {
|
||||
return
|
||||
}
|
||||
// If we deleted the last private tab. We'll be switching back to normal browsing. Pause updates till then
|
||||
if self.tabsToDisplay.isEmpty {
|
||||
self.pendingReloadData = true
|
||||
return
|
||||
}
|
||||
|
||||
// dont want to hold a ref to a deleted tab
|
||||
if tab === oldSelectedTab {
|
||||
oldSelectedTab = nil
|
||||
}
|
||||
|
||||
performTabUpdates()
|
||||
}
|
||||
|
||||
func tabManagerDidRestoreTabs(_ tabManager: TabManager) {
|
||||
self.reloadData()
|
||||
}
|
||||
|
||||
func tabManagerDidAddTabs(_ tabManager: TabManager) {
|
||||
self.reloadData()
|
||||
}
|
||||
|
||||
func tabManagerDidRemoveAllTabs(_ tabManager: TabManager, toast: ButtonToast?) {
|
||||
self.reloadData()
|
||||
}
|
||||
}
|
||||
258
mobile/ios/Client/Frontend/Browser/TopTabsViews.swift
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
/* 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
|
||||
|
||||
struct TopTabsSeparatorUX {
|
||||
static let Identifier = "Separator"
|
||||
static let Color = UIColor(rgb: 0x3c3c3d)
|
||||
static let Width: CGFloat = 1
|
||||
}
|
||||
|
||||
class TopTabsSeparator: UICollectionReusableView {
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
self.backgroundColor = TopTabsSeparatorUX.Color
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
class TopTabsHeaderFooter: UICollectionReusableView {
|
||||
let line = UIView()
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
addSubview(line)
|
||||
line.backgroundColor = TopTabsSeparatorUX.Color
|
||||
}
|
||||
|
||||
func arrangeLine(_ kind: String) {
|
||||
line.snp.removeConstraints()
|
||||
switch kind {
|
||||
case UICollectionElementKindSectionHeader:
|
||||
line.snp.makeConstraints { make in
|
||||
make.trailing.equalTo(self)
|
||||
}
|
||||
case UICollectionElementKindSectionFooter:
|
||||
line.snp.makeConstraints { make in
|
||||
make.leading.equalTo(self)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
line.snp.makeConstraints { make in
|
||||
make.height.equalTo(TopTabsUX.SeparatorHeight)
|
||||
make.width.equalTo(TopTabsUX.SeparatorWidth)
|
||||
make.top.equalTo(self).offset(TopTabsUX.SeparatorYOffset)
|
||||
}
|
||||
}
|
||||
|
||||
override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
|
||||
layer.zPosition = CGFloat(layoutAttributes.zIndex)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
class TopTabCell: UICollectionViewCell {
|
||||
enum Style {
|
||||
case light
|
||||
case dark
|
||||
}
|
||||
|
||||
static let Identifier = "TopTabCellIdentifier"
|
||||
static let ShadowOffsetSize: CGFloat = 2 //The shadow is used to hide the tab separator
|
||||
|
||||
var style: Style = .light {
|
||||
didSet {
|
||||
if style != oldValue {
|
||||
applyStyle(style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var selectedTab = false {
|
||||
didSet {
|
||||
backgroundColor = selectedTab ? UIColor(rgb: 0xf9f9fa) : UIColor(rgb: 0x272727)
|
||||
titleText.textColor = selectedTab ? UIColor(rgb: 0x0c0c0d) : UIColor(rgb: 0xb1b1b3)
|
||||
highlightLine.isHidden = !selectedTab
|
||||
closeButton.tintColor = selectedTab ? UIColor(rgb: 0x272727) : UIColor(rgb: 0xb1b1b3)
|
||||
// restyle if we are in PBM
|
||||
if style == .dark && selectedTab {
|
||||
backgroundColor = UIColor(rgb: 0x38383D)
|
||||
titleText.textColor = UIColor(rgb: 0xf9f9fa)
|
||||
closeButton.tintColor = UIColor(rgb: 0xf9f9fa)
|
||||
}
|
||||
closeButton.backgroundColor = backgroundColor
|
||||
closeButton.layer.shadowColor = backgroundColor?.cgColor
|
||||
if selectedTab {
|
||||
drawShadow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let titleText: UILabel = {
|
||||
let titleText = UILabel()
|
||||
titleText.textAlignment = NSTextAlignment.left
|
||||
titleText.isUserInteractionEnabled = false
|
||||
titleText.numberOfLines = 1
|
||||
titleText.lineBreakMode = .byCharWrapping
|
||||
titleText.font = DynamicFontHelper.defaultHelper.DefaultSmallFont
|
||||
return titleText
|
||||
}()
|
||||
|
||||
let favicon: UIImageView = {
|
||||
let favicon = UIImageView()
|
||||
favicon.layer.cornerRadius = 2.0
|
||||
favicon.layer.masksToBounds = true
|
||||
return favicon
|
||||
}()
|
||||
|
||||
let closeButton: UIButton = {
|
||||
let closeButton = UIButton()
|
||||
closeButton.setImage(UIImage.templateImageNamed("menu-CloseTabs"), for: UIControlState())
|
||||
closeButton.tintColor = UIColor(rgb: 0xb1b1b3)
|
||||
closeButton.imageEdgeInsets = UIEdgeInsets(top: 15, left: TopTabsUX.TabTitlePadding, bottom: 15, right: TopTabsUX.TabTitlePadding)
|
||||
closeButton.layer.shadowOpacity = 0.8
|
||||
closeButton.layer.masksToBounds = false
|
||||
closeButton.layer.shadowOffset = CGSize(width: -TopTabsUX.TabTitlePadding, height: 0)
|
||||
return closeButton
|
||||
}()
|
||||
|
||||
let highlightLine: UIView = {
|
||||
let line = UIView()
|
||||
line.backgroundColor = UIColor(rgb: 0x0066DC)
|
||||
line.isHidden = true
|
||||
return line
|
||||
}()
|
||||
|
||||
weak var delegate: TopTabCellDelegate?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
closeButton.addTarget(self, action: #selector(TopTabCell.closeTab), for: UIControlEvents.touchUpInside)
|
||||
|
||||
contentView.addSubview(titleText)
|
||||
contentView.addSubview(closeButton)
|
||||
contentView.addSubview(favicon)
|
||||
contentView.addSubview(highlightLine)
|
||||
|
||||
favicon.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self).offset(TopTabsUX.TabNudge)
|
||||
make.size.equalTo(TabTrayControllerUX.FaviconSize)
|
||||
make.leading.equalTo(self).offset(TopTabsUX.TabTitlePadding)
|
||||
}
|
||||
titleText.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self)
|
||||
make.height.equalTo(self)
|
||||
make.trailing.equalTo(closeButton.snp.leading).offset(TopTabsUX.TabTitlePadding)
|
||||
make.leading.equalTo(favicon.snp.trailing).offset(TopTabsUX.TabTitlePadding)
|
||||
}
|
||||
closeButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self).offset(TopTabsUX.TabNudge)
|
||||
make.height.equalTo(self)
|
||||
make.width.equalTo(self.snp.height).offset(-TopTabsUX.TabTitlePadding)
|
||||
make.trailing.equalTo(self.snp.trailing)
|
||||
}
|
||||
highlightLine.snp.makeConstraints { make in
|
||||
make.top.equalTo(self)
|
||||
make.leading.equalTo(self).offset(-TopTabCell.ShadowOffsetSize)
|
||||
make.trailing.equalTo(self).offset(TopTabCell.ShadowOffsetSize)
|
||||
make.height.equalTo(TopTabsUX.HighlightLineWidth)
|
||||
}
|
||||
|
||||
self.clipsToBounds = false
|
||||
|
||||
applyStyle(style)
|
||||
}
|
||||
|
||||
fileprivate func applyStyle(_ style: Style) {
|
||||
switch style {
|
||||
case Style.light:
|
||||
titleText.textColor = UIColor.darkText
|
||||
backgroundColor = UIConstants.AppBackgroundColor
|
||||
highlightLine.backgroundColor = UIColor(rgb: 0x0066DC)
|
||||
case Style.dark:
|
||||
titleText.textColor = UIColor.lightText
|
||||
backgroundColor = UIColor(rgb: 0x38383D)
|
||||
highlightLine.backgroundColor = UIColor(rgb: 0x9400ff)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
self.layer.shadowOpacity = 0
|
||||
}
|
||||
|
||||
func closeTab() {
|
||||
delegate?.tabCellDidClose(self)
|
||||
}
|
||||
|
||||
// When a tab is selected the shadow prevents the tab separators from showing.
|
||||
func drawShadow() {
|
||||
self.layer.masksToBounds = false
|
||||
self.layer.shadowColor = backgroundColor?.cgColor
|
||||
self.layer.shadowOpacity = 1
|
||||
self.layer.shadowRadius = 0
|
||||
|
||||
self.layer.shadowPath = UIBezierPath(roundedRect: CGRect(x: 0, y: 0, width: self.frame.size.width + (TopTabCell.ShadowOffsetSize * 2), height: self.frame.size.height), cornerRadius: 0).cgPath
|
||||
self.layer.shadowOffset = CGSize(width: -TopTabCell.ShadowOffsetSize, height: 0)
|
||||
}
|
||||
|
||||
override func apply(_ layoutAttributes: UICollectionViewLayoutAttributes) {
|
||||
layer.zPosition = CGFloat(layoutAttributes.zIndex)
|
||||
}
|
||||
}
|
||||
|
||||
class TopTabFader: UIView {
|
||||
lazy var hMaskLayer: CAGradientLayer = {
|
||||
let innerColor: CGColor = UIColor(white: 1, alpha: 1.0).cgColor
|
||||
let outerColor: CGColor = UIColor(white: 1, alpha: 0.0).cgColor
|
||||
let hMaskLayer = CAGradientLayer()
|
||||
hMaskLayer.colors = [outerColor, innerColor, innerColor, outerColor]
|
||||
hMaskLayer.locations = [0.00, 0.005, 0.995, 1.0]
|
||||
hMaskLayer.startPoint = CGPoint(x: 0, y: 0.5)
|
||||
hMaskLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
|
||||
hMaskLayer.anchorPoint = CGPoint.zero
|
||||
return hMaskLayer
|
||||
}()
|
||||
|
||||
init() {
|
||||
super.init(frame: CGRect.zero)
|
||||
layer.mask = hMaskLayer
|
||||
}
|
||||
|
||||
internal override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
let widthA = NSNumber(value: Float(CGFloat(8) / frame.width))
|
||||
let widthB = NSNumber(value: Float(1 - CGFloat(8) / frame.width))
|
||||
|
||||
hMaskLayer.locations = [0.00, widthA, widthB, 1.0]
|
||||
hMaskLayer.frame = CGRect(x: 0, y: 0, width: frame.width, height: frame.height)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
class TopTabsViewLayoutAttributes: UICollectionViewLayoutAttributes {
|
||||
|
||||
override func isEqual(_ object: Any?) -> Bool {
|
||||
guard let object = object as? TopTabsViewLayoutAttributes else {
|
||||
return false
|
||||
}
|
||||
return super.isEqual(object)
|
||||
}
|
||||
}
|
||||
53
mobile/ios/Client/Frontend/Browser/URIFixup.swift
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/* 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
|
||||
|
||||
class URIFixup {
|
||||
static func getURL(_ entry: String) -> URL? {
|
||||
let trimmed = entry.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
guard let escaped = trimmed.addingPercentEncoding(withAllowedCharacters: CharacterSet.URLAllowedCharacterSet()) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Then check if the URL includes a scheme. This will handle
|
||||
// all valid requests starting with "http://", "about:", etc.
|
||||
// However, we ensure that the scheme is one that is listed in
|
||||
// the official URI scheme list, so that other such search phrases
|
||||
// like "filetype:" are recognised as searches rather than URLs.
|
||||
if let url = punycodedURL(escaped), url.schemeIsValid {
|
||||
return url
|
||||
}
|
||||
|
||||
// If there's no scheme, we're going to prepend "http://". First,
|
||||
// make sure there's at least one "." in the host. This means
|
||||
// we'll allow single-word searches (e.g., "foo") at the expense
|
||||
// of breaking single-word hosts without a scheme (e.g., "localhost").
|
||||
if trimmed.range(of: ".") == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if trimmed.range(of: " ") != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If there is a ".", prepend "http://" and try again. Since this
|
||||
// is strictly an "http://" URL, we also require a host.
|
||||
if let url = punycodedURL("http://\(escaped)"), url.host != nil {
|
||||
return url
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func punycodedURL(_ string: String) -> URL? {
|
||||
var components = URLComponents(string: string)
|
||||
if AppConstants.MOZ_PUNYCODE {
|
||||
let host = components?.host?.utf8HostToAscii()
|
||||
components?.host = host
|
||||
}
|
||||
return components?.url
|
||||
}
|
||||
}
|
||||
825
mobile/ios/Client/Frontend/Browser/URLBarView.swift
Normal file
|
|
@ -0,0 +1,825 @@
|
|||
/* 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 SnapKit
|
||||
import XCGLogger
|
||||
|
||||
private let log = Logger.browserLogger
|
||||
|
||||
struct URLBarViewUX {
|
||||
static let TextFieldBorderColor = UIColor(rgb: 0xBBBBBB)
|
||||
static let TextFieldActiveBorderColor = UIColor(rgb: 0xB0D5FB)
|
||||
static let LocationLeftPadding: CGFloat = 8
|
||||
static let Padding: CGFloat = 10
|
||||
static let LocationHeight: CGFloat = 40
|
||||
static let ButtonHeight: CGFloat = 44
|
||||
static let LocationContentOffset: CGFloat = 8
|
||||
static let TextFieldCornerRadius: CGFloat = 8
|
||||
static let TextFieldBorderWidth: CGFloat = 1
|
||||
static let TextFieldBorderWidthSelected: CGFloat = 4
|
||||
// offset from edge of tabs button
|
||||
static let ProgressTintColor = UIColor(rgb: 0x00dcfc)
|
||||
static let ProgressBarHeight: CGFloat = 3
|
||||
|
||||
static let TabsButtonRotationOffset: CGFloat = 1.5
|
||||
static let TabsButtonHeight: CGFloat = 18.0
|
||||
static let ToolbarButtonInsets = UIEdgeInsets(top: Padding, left: Padding, bottom: Padding, right: Padding)
|
||||
|
||||
static let Themes: [String: Theme] = {
|
||||
var themes = [String: Theme]()
|
||||
var theme = Theme()
|
||||
theme.borderColor = UIColor(rgb: 0x2D2D31)
|
||||
theme.backgroundColor = UIColor(rgb: 0x38383D)
|
||||
theme.activeBorderColor = UIColor(rgb: 0x4a4a4f)
|
||||
theme.tintColor = UIColor(rgb: 0xf9f9fa)
|
||||
theme.textColor = UIColor(rgb: 0xf9f9fa)
|
||||
theme.buttonTintColor = UIColor(rgb: 0xD2d2d4)
|
||||
theme.disabledButtonColor = UIColor.gray
|
||||
theme.highlightButtonColor = UIColor(rgb: 0xAC39FF)
|
||||
themes[Theme.PrivateMode] = theme
|
||||
|
||||
theme = Theme()
|
||||
theme.borderColor = UIColor(rgb: 0x737373).withAlphaComponent(0.3)
|
||||
theme.activeBorderColor = TextFieldActiveBorderColor
|
||||
theme.disabledButtonColor = UIColor.lightGray
|
||||
theme.highlightButtonColor = UIColor(rgb: 0x00A2FE)
|
||||
theme.tintColor = ProgressTintColor
|
||||
theme.textColor = UIColor(rgb: 0x272727)
|
||||
theme.backgroundColor = UIConstants.AppBackgroundColor
|
||||
theme.buttonTintColor = UIColor(rgb: 0x272727)
|
||||
themes[Theme.NormalMode] = theme
|
||||
|
||||
return themes
|
||||
}()
|
||||
}
|
||||
|
||||
protocol URLBarDelegate: class {
|
||||
func urlBarDidPressTabs(_ urlBar: URLBarView)
|
||||
func urlBarDidPressReaderMode(_ urlBar: URLBarView)
|
||||
/// - returns: whether the long-press was handled by the delegate; i.e. return `false` when the conditions for even starting handling long-press were not satisfied
|
||||
func urlBarDidLongPressReaderMode(_ urlBar: URLBarView) -> Bool
|
||||
func urlBarDidPressStop(_ urlBar: URLBarView)
|
||||
func urlBarDidPressReload(_ urlBar: URLBarView)
|
||||
func urlBarDidEnterOverlayMode(_ urlBar: URLBarView)
|
||||
func urlBarDidLeaveOverlayMode(_ urlBar: URLBarView)
|
||||
func urlBarDidLongPressLocation(_ urlBar: URLBarView)
|
||||
func urlBarDidPressQRButton(_ urlBar: URLBarView)
|
||||
func urlBarDidPressPageOptions(_ urlBar: URLBarView, from button: UIButton)
|
||||
func urlBarLocationAccessibilityActions(_ urlBar: URLBarView) -> [UIAccessibilityCustomAction]?
|
||||
func urlBarDidPressScrollToTop(_ urlBar: URLBarView)
|
||||
func urlBar(_ urlBar: URLBarView, didEnterText text: String)
|
||||
func urlBar(_ urlBar: URLBarView, didSubmitText text: String)
|
||||
// Returns either (search query, true) or (url, false).
|
||||
func urlBarDisplayTextForURL(_ url: URL?) -> (String?, Bool)
|
||||
func urlBarDidLongPressPageOptions(_ urlBar: URLBarView, from button: UIButton)
|
||||
}
|
||||
|
||||
class URLBarView: UIView {
|
||||
// Additional UIAppearance-configurable properties
|
||||
dynamic var locationBorderColor: UIColor = URLBarViewUX.TextFieldBorderColor {
|
||||
didSet {
|
||||
if !inOverlayMode {
|
||||
locationContainer.layer.borderColor = locationBorderColor.cgColor
|
||||
}
|
||||
}
|
||||
}
|
||||
dynamic var locationActiveBorderColor: UIColor = URLBarViewUX.TextFieldActiveBorderColor {
|
||||
didSet {
|
||||
if inOverlayMode {
|
||||
locationContainer.layer.borderColor = locationActiveBorderColor.cgColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
weak var delegate: URLBarDelegate?
|
||||
weak var tabToolbarDelegate: TabToolbarDelegate?
|
||||
var helper: TabToolbarHelper?
|
||||
var isTransitioning: Bool = false {
|
||||
didSet {
|
||||
if isTransitioning {
|
||||
// Cancel any pending/in-progress animations related to the progress bar
|
||||
self.progressBar.setProgress(1, animated: false)
|
||||
self.progressBar.alpha = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var currentTheme: String = Theme.NormalMode
|
||||
|
||||
var toolbarIsShowing = false
|
||||
var topTabsIsShowing = false
|
||||
|
||||
fileprivate var locationTextField: ToolbarTextField?
|
||||
|
||||
/// Overlay mode is the state where the lock/reader icons are hidden, the home panels are shown,
|
||||
/// and the Cancel button is visible (allowing the user to leave overlay mode). Overlay mode
|
||||
/// is *not* tied to the location text field's editing state; for instance, when selecting
|
||||
/// a panel, the first responder will be resigned, yet the overlay mode UI is still active.
|
||||
var inOverlayMode = false
|
||||
|
||||
lazy var locationView: TabLocationView = {
|
||||
let locationView = TabLocationView()
|
||||
locationView.translatesAutoresizingMaskIntoConstraints = false
|
||||
locationView.readerModeState = ReaderModeState.unavailable
|
||||
locationView.delegate = self
|
||||
return locationView
|
||||
}()
|
||||
|
||||
lazy var locationContainer: UIView = {
|
||||
let locationContainer = TabLocationContainerView()
|
||||
locationContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||
locationContainer.layer.shadowColor = self.locationBorderColor.cgColor
|
||||
locationContainer.layer.borderWidth = URLBarViewUX.TextFieldBorderWidth
|
||||
locationContainer.layer.borderColor = self.locationBorderColor.cgColor
|
||||
locationContainer.backgroundColor = .clear
|
||||
return locationContainer
|
||||
}()
|
||||
|
||||
let line = UIView()
|
||||
|
||||
lazy var tabsButton: TabsButton = {
|
||||
let tabsButton = TabsButton.tabTrayButton()
|
||||
tabsButton.accessibilityIdentifier = "URLBarView.tabsButton"
|
||||
return tabsButton
|
||||
}()
|
||||
|
||||
fileprivate lazy var progressBar: GradientProgressBar = {
|
||||
let progressBar = GradientProgressBar()
|
||||
progressBar.clipsToBounds = false
|
||||
return progressBar
|
||||
}()
|
||||
|
||||
fileprivate lazy var cancelButton: UIButton = {
|
||||
let cancelButton = InsetButton()
|
||||
cancelButton.setImage(UIImage.templateImageNamed("goBack"), for: .normal)
|
||||
cancelButton.addTarget(self, action: #selector(URLBarView.SELdidClickCancel), for: .touchUpInside)
|
||||
cancelButton.alpha = 0
|
||||
return cancelButton
|
||||
}()
|
||||
|
||||
fileprivate lazy var showQRScannerButton: InsetButton = {
|
||||
let button = InsetButton()
|
||||
button.setImage(UIImage.templateImageNamed("menu-ScanQRCode"), for: .normal)
|
||||
button.clipsToBounds = false
|
||||
button.addTarget(self, action: #selector(URLBarView.showQRScanner), for: .touchUpInside)
|
||||
button.setContentHuggingPriority(1000, for: UILayoutConstraintAxis.horizontal)
|
||||
button.setContentCompressionResistancePriority(1000, for: UILayoutConstraintAxis.horizontal)
|
||||
return button
|
||||
}()
|
||||
|
||||
fileprivate lazy var scrollToTopButton: UIButton = {
|
||||
let button = UIButton()
|
||||
button.addTarget(self, action: #selector(URLBarView.SELtappedScrollToTopArea), for: .touchUpInside)
|
||||
return button
|
||||
}()
|
||||
|
||||
var menuButton = ToolbarButton()
|
||||
var bookmarkButton = ToolbarButton()
|
||||
var forwardButton = ToolbarButton()
|
||||
var stopReloadButton = ToolbarButton()
|
||||
|
||||
var backButton: ToolbarButton = {
|
||||
let backButton = ToolbarButton()
|
||||
backButton.accessibilityIdentifier = "URLBarView.backButton"
|
||||
return backButton
|
||||
}()
|
||||
|
||||
lazy var actionButtons: [Themeable & UIButton] = [self.tabsButton, self.menuButton, self.forwardButton, self.backButton, self.stopReloadButton]
|
||||
|
||||
var currentURL: URL? {
|
||||
get {
|
||||
return locationView.url as URL?
|
||||
}
|
||||
|
||||
set(newURL) {
|
||||
locationView.url = newURL
|
||||
line.isHidden = newURL?.isAboutHomeURL ?? true
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
super.init(coder: aDecoder)
|
||||
commonInit()
|
||||
}
|
||||
|
||||
fileprivate func commonInit() {
|
||||
locationContainer.addSubview(locationView)
|
||||
|
||||
[scrollToTopButton, line, tabsButton, progressBar, cancelButton, showQRScannerButton].forEach { addSubview($0) }
|
||||
[menuButton, forwardButton, backButton, stopReloadButton, locationContainer].forEach { addSubview($0) }
|
||||
|
||||
helper = TabToolbarHelper(toolbar: self)
|
||||
setupConstraints()
|
||||
|
||||
// Make sure we hide any views that shouldn't be showing in non-overlay mode.
|
||||
updateViewsForOverlayModeAndToolbarChanges()
|
||||
}
|
||||
|
||||
fileprivate func setupConstraints() {
|
||||
|
||||
line.snp.makeConstraints { make in
|
||||
make.bottom.leading.trailing.equalTo(self)
|
||||
make.height.equalTo(1)
|
||||
}
|
||||
|
||||
scrollToTopButton.snp.makeConstraints { make in
|
||||
make.top.equalTo(self)
|
||||
make.left.right.equalTo(self.locationContainer)
|
||||
}
|
||||
|
||||
progressBar.snp.makeConstraints { make in
|
||||
make.top.equalTo(self.snp.bottom).inset(URLBarViewUX.ProgressBarHeight / 2)
|
||||
make.height.equalTo(URLBarViewUX.ProgressBarHeight)
|
||||
make.left.right.equalTo(self)
|
||||
}
|
||||
|
||||
locationView.snp.makeConstraints { make in
|
||||
make.edges.equalTo(self.locationContainer)
|
||||
}
|
||||
|
||||
cancelButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self.locationContainer)
|
||||
make.size.equalTo(URLBarViewUX.ButtonHeight)
|
||||
make.leading.equalTo(self)
|
||||
}
|
||||
|
||||
backButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self)
|
||||
make.leading.equalTo(self).offset(URLBarViewUX.Padding)
|
||||
make.size.equalTo(URLBarViewUX.ButtonHeight)
|
||||
}
|
||||
|
||||
forwardButton.snp.makeConstraints { make in
|
||||
make.left.equalTo(self.backButton.snp.right)
|
||||
make.centerY.equalTo(self)
|
||||
make.size.equalTo(URLBarViewUX.ButtonHeight)
|
||||
}
|
||||
|
||||
stopReloadButton.snp.makeConstraints { make in
|
||||
make.left.equalTo(self.forwardButton.snp.right)
|
||||
make.centerY.equalTo(self)
|
||||
make.size.equalTo(URLBarViewUX.ButtonHeight)
|
||||
}
|
||||
|
||||
menuButton.snp.makeConstraints { make in
|
||||
make.trailing.equalTo(self.snp.trailing).offset(-URLBarViewUX.Padding)
|
||||
make.centerY.equalTo(self)
|
||||
make.size.equalTo(URLBarViewUX.ButtonHeight)
|
||||
}
|
||||
|
||||
tabsButton.snp.makeConstraints { make in
|
||||
make.trailing.equalTo(self.menuButton.snp.leading)
|
||||
make.centerY.equalTo(self)
|
||||
make.size.equalTo(URLBarViewUX.ButtonHeight)
|
||||
}
|
||||
|
||||
showQRScannerButton.snp.makeConstraints { make in
|
||||
make.centerY.equalTo(self.locationContainer)
|
||||
make.trailing.equalTo(self)
|
||||
make.size.equalTo(URLBarViewUX.ButtonHeight)
|
||||
}
|
||||
}
|
||||
|
||||
override func updateConstraints() {
|
||||
super.updateConstraints()
|
||||
if inOverlayMode {
|
||||
// In overlay mode, we always show the location view full width
|
||||
self.locationContainer.layer.borderWidth = URLBarViewUX.TextFieldBorderWidthSelected
|
||||
self.locationContainer.snp.remakeConstraints { make in
|
||||
let height = URLBarViewUX.LocationHeight + (URLBarViewUX.TextFieldBorderWidthSelected * 2)
|
||||
make.height.equalTo(height)
|
||||
make.trailing.equalTo(self.showQRScannerButton.snp.leading)
|
||||
make.leading.equalTo(self.cancelButton.snp.trailing)
|
||||
make.centerY.equalTo(self)
|
||||
}
|
||||
self.locationView.snp.remakeConstraints { make in
|
||||
make.edges.equalTo(self.locationContainer).inset(UIEdgeInsets(equalInset: URLBarViewUX.TextFieldBorderWidthSelected))
|
||||
}
|
||||
self.locationTextField?.snp.remakeConstraints { make in
|
||||
make.edges.equalTo(self.locationView).inset(UIEdgeInsets(top: 0, left: URLBarViewUX.LocationLeftPadding, bottom: 0, right: URLBarViewUX.LocationLeftPadding))
|
||||
}
|
||||
} else {
|
||||
self.locationContainer.snp.remakeConstraints { make in
|
||||
if self.toolbarIsShowing {
|
||||
// If we are showing a toolbar, show the text field next to the forward button
|
||||
make.leading.equalTo(self.stopReloadButton.snp.trailing).offset(URLBarViewUX.Padding)
|
||||
if self.topTabsIsShowing {
|
||||
make.trailing.equalTo(self.menuButton.snp.leading).offset(-URLBarViewUX.Padding)
|
||||
} else {
|
||||
make.trailing.equalTo(self.tabsButton.snp.leading).offset(-URLBarViewUX.Padding)
|
||||
}
|
||||
|
||||
} else {
|
||||
// Otherwise, left align the location view
|
||||
make.leading.trailing.equalTo(self).inset(UIEdgeInsets(top: 0, left: URLBarViewUX.LocationLeftPadding-1, bottom: 0, right: URLBarViewUX.LocationLeftPadding-1))
|
||||
}
|
||||
|
||||
make.height.equalTo(URLBarViewUX.LocationHeight+2)
|
||||
make.centerY.equalTo(self)
|
||||
}
|
||||
self.locationContainer.layer.borderWidth = URLBarViewUX.TextFieldBorderWidth
|
||||
self.locationView.snp.remakeConstraints { make in
|
||||
make.edges.equalTo(self.locationContainer).inset(UIEdgeInsets(equalInset: URLBarViewUX.TextFieldBorderWidth))
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func showQRScanner() {
|
||||
self.delegate?.urlBarDidPressQRButton(self)
|
||||
}
|
||||
|
||||
func createLocationTextField() {
|
||||
guard locationTextField == nil else { return }
|
||||
|
||||
locationTextField = ToolbarTextField()
|
||||
|
||||
guard let locationTextField = locationTextField else { return }
|
||||
|
||||
locationTextField.translatesAutoresizingMaskIntoConstraints = false
|
||||
locationTextField.autocompleteDelegate = self
|
||||
locationTextField.keyboardType = UIKeyboardType.webSearch
|
||||
locationTextField.autocorrectionType = UITextAutocorrectionType.no
|
||||
locationTextField.autocapitalizationType = UITextAutocapitalizationType.none
|
||||
locationTextField.returnKeyType = UIReturnKeyType.go
|
||||
locationTextField.clearButtonMode = UITextFieldViewMode.whileEditing
|
||||
locationTextField.font = UIConstants.DefaultChromeFont
|
||||
locationTextField.accessibilityIdentifier = "address"
|
||||
locationTextField.accessibilityLabel = NSLocalizedString("Address and Search", comment: "Accessibility label for address and search field, both words (Address, Search) are therefore nouns.")
|
||||
locationTextField.attributedPlaceholder = self.locationView.placeholder
|
||||
locationContainer.addSubview(locationTextField)
|
||||
locationTextField.snp.remakeConstraints { make in
|
||||
make.edges.equalTo(self.locationView)
|
||||
}
|
||||
|
||||
locationTextField.applyTheme(currentTheme)
|
||||
}
|
||||
|
||||
func removeLocationTextField() {
|
||||
locationTextField?.removeFromSuperview()
|
||||
locationTextField = nil
|
||||
}
|
||||
|
||||
// Ideally we'd split this implementation in two, one URLBarView with a toolbar and one without
|
||||
// However, switching views dynamically at runtime is a difficult. For now, we just use one view
|
||||
// that can show in either mode.
|
||||
func setShowToolbar(_ shouldShow: Bool) {
|
||||
toolbarIsShowing = shouldShow
|
||||
setNeedsUpdateConstraints()
|
||||
// when we transition from portrait to landscape, calling this here causes
|
||||
// the constraints to be calculated too early and there are constraint errors
|
||||
if !toolbarIsShowing {
|
||||
updateConstraintsIfNeeded()
|
||||
}
|
||||
updateViewsForOverlayModeAndToolbarChanges()
|
||||
}
|
||||
|
||||
func updateAlphaForSubviews(_ alpha: CGFloat) {
|
||||
self.locationContainer.alpha = alpha
|
||||
self.alpha = alpha
|
||||
}
|
||||
|
||||
func updateProgressBar(_ progress: Float) {
|
||||
progressBar.alpha = 1
|
||||
progressBar.isHidden = false
|
||||
progressBar.setProgress(progress, animated: !isTransitioning)
|
||||
}
|
||||
|
||||
func hideProgressBar() {
|
||||
progressBar.isHidden = true
|
||||
progressBar.setProgress(0, animated: false)
|
||||
}
|
||||
|
||||
func updateReaderModeState(_ state: ReaderModeState) {
|
||||
locationView.readerModeState = state
|
||||
}
|
||||
|
||||
func setAutocompleteSuggestion(_ suggestion: String?) {
|
||||
locationTextField?.setAutocompleteSuggestion(suggestion)
|
||||
}
|
||||
|
||||
func setLocation(_ location: String?, search: Bool) {
|
||||
locationTextField?.text = location
|
||||
if search, let location = location, !location.isEmpty {
|
||||
// Not notifying when empty agrees with AutocompleteTextField.textDidChange.
|
||||
delegate?.urlBar(self, didEnterText: location)
|
||||
}
|
||||
}
|
||||
|
||||
func enterOverlayMode(_ locationText: String?, pasted: Bool, search: Bool) {
|
||||
createLocationTextField()
|
||||
|
||||
// Show the overlay mode UI, which includes hiding the locationView and replacing it
|
||||
// with the editable locationTextField.
|
||||
animateToOverlayState(overlayMode: true)
|
||||
|
||||
delegate?.urlBarDidEnterOverlayMode(self)
|
||||
|
||||
// Bug 1193755 Workaround - Calling becomeFirstResponder before the animation happens
|
||||
// won't take the initial frame of the label into consideration, which makes the label
|
||||
// look squished at the start of the animation and expand to be correct. As a workaround,
|
||||
// we becomeFirstResponder as the next event on UI thread, so the animation starts before we
|
||||
// set a first responder.
|
||||
if pasted {
|
||||
// Clear any existing text, focus the field, then set the actual pasted text.
|
||||
// This avoids highlighting all of the text.
|
||||
self.locationTextField?.text = ""
|
||||
DispatchQueue.main.async {
|
||||
self.locationTextField?.becomeFirstResponder()
|
||||
self.setLocation(locationText, search: search)
|
||||
}
|
||||
} else {
|
||||
// Copy the current URL to the editable text field, then activate it.
|
||||
self.setLocation(locationText, search: search)
|
||||
DispatchQueue.main.async {
|
||||
self.locationTextField?.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func leaveOverlayMode(didCancel cancel: Bool = false) {
|
||||
locationTextField?.resignFirstResponder()
|
||||
animateToOverlayState(overlayMode: false, didCancel: cancel)
|
||||
delegate?.urlBarDidLeaveOverlayMode(self)
|
||||
}
|
||||
|
||||
func prepareOverlayAnimation() {
|
||||
// Make sure everything is showing during the transition (we'll hide it afterwards).
|
||||
self.bringSubview(toFront: self.locationContainer)
|
||||
self.cancelButton.isHidden = false
|
||||
self.showQRScannerButton.isHidden = false
|
||||
self.progressBar.isHidden = false
|
||||
self.menuButton.isHidden = !self.toolbarIsShowing
|
||||
self.forwardButton.isHidden = !self.toolbarIsShowing
|
||||
self.backButton.isHidden = !self.toolbarIsShowing
|
||||
self.tabsButton.isHidden = !self.toolbarIsShowing || topTabsIsShowing
|
||||
self.stopReloadButton.isHidden = !self.toolbarIsShowing
|
||||
}
|
||||
|
||||
func transitionToOverlay(_ didCancel: Bool = false) {
|
||||
self.cancelButton.alpha = inOverlayMode ? 1 : 0
|
||||
self.showQRScannerButton.alpha = inOverlayMode ? 1 : 0
|
||||
self.progressBar.alpha = inOverlayMode || didCancel ? 0 : 1
|
||||
self.tabsButton.alpha = inOverlayMode ? 0 : 1
|
||||
self.menuButton.alpha = inOverlayMode ? 0 : 1
|
||||
self.forwardButton.alpha = inOverlayMode ? 0 : 1
|
||||
self.backButton.alpha = inOverlayMode ? 0 : 1
|
||||
self.stopReloadButton.alpha = inOverlayMode ? 0 : 1
|
||||
|
||||
let borderColor = inOverlayMode ? locationActiveBorderColor : locationBorderColor
|
||||
locationContainer.layer.borderColor = borderColor.cgColor
|
||||
|
||||
if inOverlayMode {
|
||||
self.line.isHidden = inOverlayMode
|
||||
// Make the editable text field span the entire URL bar, covering the lock and reader icons.
|
||||
self.locationTextField?.snp.remakeConstraints { make in
|
||||
make.edges.equalTo(self.locationView)
|
||||
}
|
||||
} else {
|
||||
// Shrink the editable text field back to the size of the location view before hiding it.
|
||||
self.locationTextField?.snp.remakeConstraints { make in
|
||||
make.edges.equalTo(self.locationView.urlTextField)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateViewsForOverlayModeAndToolbarChanges() {
|
||||
self.cancelButton.isHidden = !inOverlayMode
|
||||
self.showQRScannerButton.isHidden = !inOverlayMode
|
||||
self.progressBar.isHidden = inOverlayMode
|
||||
self.menuButton.isHidden = !self.toolbarIsShowing || inOverlayMode
|
||||
self.forwardButton.isHidden = !self.toolbarIsShowing || inOverlayMode
|
||||
self.backButton.isHidden = !self.toolbarIsShowing || inOverlayMode
|
||||
self.tabsButton.isHidden = !self.toolbarIsShowing || inOverlayMode || topTabsIsShowing
|
||||
self.stopReloadButton.isHidden = !self.toolbarIsShowing || inOverlayMode
|
||||
}
|
||||
|
||||
func animateToOverlayState(overlayMode overlay: Bool, didCancel cancel: Bool = false) {
|
||||
prepareOverlayAnimation()
|
||||
layoutIfNeeded()
|
||||
|
||||
inOverlayMode = overlay
|
||||
|
||||
if !overlay {
|
||||
removeLocationTextField()
|
||||
}
|
||||
|
||||
UIView.animate(withDuration: 0.3, delay: 0.0, usingSpringWithDamping: 0.85, initialSpringVelocity: 0.0, options: [], animations: { _ in
|
||||
self.transitionToOverlay(cancel)
|
||||
self.setNeedsUpdateConstraints()
|
||||
self.layoutIfNeeded()
|
||||
}, completion: { _ in
|
||||
self.updateViewsForOverlayModeAndToolbarChanges()
|
||||
})
|
||||
}
|
||||
|
||||
func SELdidClickAddTab() {
|
||||
delegate?.urlBarDidPressTabs(self)
|
||||
}
|
||||
|
||||
func SELdidClickCancel() {
|
||||
leaveOverlayMode(didCancel: true)
|
||||
}
|
||||
|
||||
func SELtappedScrollToTopArea() {
|
||||
delegate?.urlBarDidPressScrollToTop(self)
|
||||
}
|
||||
}
|
||||
|
||||
extension URLBarView: TabToolbarProtocol {
|
||||
|
||||
func updateBackStatus(_ canGoBack: Bool) {
|
||||
backButton.isEnabled = canGoBack
|
||||
}
|
||||
|
||||
func updateForwardStatus(_ canGoForward: Bool) {
|
||||
forwardButton.isEnabled = canGoForward
|
||||
}
|
||||
|
||||
func updateTabCount(_ count: Int, animated: Bool = true) {
|
||||
self.tabsButton.updateTabCount(count, animated: animated)
|
||||
}
|
||||
|
||||
func updateReloadStatus(_ isLoading: Bool) {
|
||||
helper?.updateReloadStatus(isLoading)
|
||||
if isLoading {
|
||||
stopReloadButton.setImage(helper?.ImageStop, for: .normal)
|
||||
} else {
|
||||
stopReloadButton.setImage(helper?.ImageReload, for: .normal)
|
||||
}
|
||||
}
|
||||
|
||||
func updatePageStatus(_ isWebPage: Bool) {
|
||||
stopReloadButton.isEnabled = isWebPage
|
||||
}
|
||||
|
||||
var access: [Any]? {
|
||||
get {
|
||||
if inOverlayMode {
|
||||
guard let locationTextField = locationTextField else { return nil }
|
||||
return [locationTextField, cancelButton]
|
||||
} else {
|
||||
if toolbarIsShowing {
|
||||
return [backButton, forwardButton, stopReloadButton, locationView, tabsButton, menuButton, progressBar]
|
||||
} else {
|
||||
return [locationView, progressBar]
|
||||
}
|
||||
}
|
||||
}
|
||||
set {
|
||||
super.accessibilityElements = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension URLBarView: TabLocationViewDelegate {
|
||||
func tabLocationViewDidLongPressReaderMode(_ tabLocationView: TabLocationView) -> Bool {
|
||||
return delegate?.urlBarDidLongPressReaderMode(self) ?? false
|
||||
}
|
||||
|
||||
func tabLocationViewDidTapLocation(_ tabLocationView: TabLocationView) {
|
||||
guard let (locationText, isSearchQuery) = delegate?.urlBarDisplayTextForURL(locationView.url as URL?) else { return }
|
||||
|
||||
var overlayText = locationText
|
||||
// Make sure to use the result from urlBarDisplayTextForURL as it is responsible for extracting out search terms when on a search page
|
||||
if let text = locationText, let url = URL(string: text), let host = url.host, AppConstants.MOZ_PUNYCODE {
|
||||
overlayText = url.absoluteString.replacingOccurrences(of: host, with: host.asciiHostToUTF8())
|
||||
}
|
||||
enterOverlayMode(overlayText, pasted: false, search: isSearchQuery)
|
||||
}
|
||||
|
||||
func tabLocationViewDidLongPressLocation(_ tabLocationView: TabLocationView) {
|
||||
delegate?.urlBarDidLongPressLocation(self)
|
||||
}
|
||||
|
||||
func tabLocationViewDidTapReload(_ tabLocationView: TabLocationView) {
|
||||
delegate?.urlBarDidPressReload(self)
|
||||
}
|
||||
|
||||
func tabLocationViewDidTapStop(_ tabLocationView: TabLocationView) {
|
||||
delegate?.urlBarDidPressStop(self)
|
||||
}
|
||||
|
||||
func tabLocationViewDidTapReaderMode(_ tabLocationView: TabLocationView) {
|
||||
delegate?.urlBarDidPressReaderMode(self)
|
||||
}
|
||||
|
||||
func tabLocationViewDidTapPageOptions(_ tabLocationView: TabLocationView, from button: UIButton) {
|
||||
delegate?.urlBarDidPressPageOptions(self, from: tabLocationView.pageOptionsButton)
|
||||
}
|
||||
|
||||
func tabLocationViewDidLongPressPageOptions(_ tabLocationView: TabLocationView) {
|
||||
delegate?.urlBarDidLongPressPageOptions(self, from: tabLocationView.pageOptionsButton)
|
||||
}
|
||||
|
||||
func tabLocationViewLocationAccessibilityActions(_ tabLocationView: TabLocationView) -> [UIAccessibilityCustomAction]? {
|
||||
return delegate?.urlBarLocationAccessibilityActions(self)
|
||||
}
|
||||
}
|
||||
|
||||
extension URLBarView: AutocompleteTextFieldDelegate {
|
||||
func autocompleteTextFieldShouldReturn(_ autocompleteTextField: AutocompleteTextField) -> Bool {
|
||||
guard let text = locationTextField?.text else { return true }
|
||||
if !text.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
delegate?.urlBar(self, didSubmitText: text)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func autocompleteTextField(_ autocompleteTextField: AutocompleteTextField, didEnterText text: String) {
|
||||
delegate?.urlBar(self, didEnterText: text)
|
||||
}
|
||||
|
||||
func autocompleteTextFieldDidBeginEditing(_ autocompleteTextField: AutocompleteTextField) {
|
||||
autocompleteTextField.highlightAll()
|
||||
}
|
||||
|
||||
func autocompleteTextFieldShouldClear(_ autocompleteTextField: AutocompleteTextField) -> Bool {
|
||||
delegate?.urlBar(self, didEnterText: "")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UIAppearance
|
||||
extension URLBarView {
|
||||
|
||||
dynamic var cancelTintColor: UIColor? {
|
||||
get { return cancelButton.tintColor }
|
||||
set { return cancelButton.tintColor = newValue }
|
||||
}
|
||||
|
||||
dynamic var showQRButtonTintColor: UIColor? {
|
||||
get { return showQRScannerButton.tintColor }
|
||||
set { return showQRScannerButton.tintColor = newValue }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension URLBarView: Themeable {
|
||||
|
||||
func applyTheme(_ themeName: String) {
|
||||
locationView.applyTheme(themeName)
|
||||
locationTextField?.applyTheme(themeName)
|
||||
|
||||
guard let theme = URLBarViewUX.Themes[themeName] else {
|
||||
fatalError("Theme not found")
|
||||
}
|
||||
|
||||
let isPrivate = themeName == Theme.PrivateMode
|
||||
|
||||
progressBar.setGradientColors(startColor: UIConstants.LoadingStartColor.color(isPBM: isPrivate), endColor: UIConstants.LoadingEndColor.color(isPBM: isPrivate))
|
||||
currentTheme = themeName
|
||||
locationBorderColor = theme.borderColor!
|
||||
locationActiveBorderColor = theme.activeBorderColor!
|
||||
cancelTintColor = theme.buttonTintColor
|
||||
showQRButtonTintColor = theme.buttonTintColor
|
||||
backgroundColor = theme.backgroundColor
|
||||
self.actionButtons.forEach { $0.applyTheme(themeName) }
|
||||
tabsButton.applyTheme(themeName)
|
||||
line.backgroundColor = UIConstants.URLBarDivider.color(isPBM: isPrivate)
|
||||
locationContainer.layer.shadowColor = self.locationBorderColor.cgColor
|
||||
}
|
||||
}
|
||||
|
||||
// We need a subclass so we can setup the shadows correctly
|
||||
// This subclass creates a strong shadow on the URLBar
|
||||
class TabLocationContainerView: UIView {
|
||||
|
||||
struct LocationContainerUX {
|
||||
static let CornerRadius: CGFloat = 4
|
||||
static let ShadowRadius: CGFloat = 2
|
||||
static let ShadowOpacity: Float = 1
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
let layer = self.layer
|
||||
layer.cornerRadius = LocationContainerUX.CornerRadius
|
||||
layer.shadowRadius = LocationContainerUX.ShadowRadius
|
||||
layer.shadowOpacity = LocationContainerUX.ShadowOpacity
|
||||
layer.masksToBounds = false
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
let layer = self.layer
|
||||
|
||||
layer.shadowOffset = CGSize(width: 0, height: 1)
|
||||
// the shadow appears 2px off from the view rect
|
||||
let shadowLength: CGFloat = 2
|
||||
let shadowPath = CGRect(x: shadowLength, y: shadowLength, width: layer.frame.width - (shadowLength * 2), height: layer.frame.height - (shadowLength * 2))
|
||||
layer.shadowPath = UIBezierPath(roundedRect: shadowPath, cornerRadius: layer.cornerRadius).cgPath
|
||||
super.layoutSubviews()
|
||||
}
|
||||
}
|
||||
|
||||
class ToolbarTextField: AutocompleteTextField {
|
||||
static let Themes: [String: Theme] = {
|
||||
var themes = [String: Theme]()
|
||||
var theme = Theme()
|
||||
theme.backgroundColor = UIColor(rgb: 0x636369)
|
||||
theme.textColor = UIColor.white
|
||||
theme.buttonTintColor = UIColor.white
|
||||
theme.highlightColor = UIConstants.PrivateModeInputHighlightColor
|
||||
themes[Theme.PrivateMode] = theme
|
||||
|
||||
theme = Theme()
|
||||
theme.backgroundColor = .white
|
||||
theme.textColor = UIColor(rgb: 0x272727)
|
||||
theme.highlightColor = AutocompleteTextFieldUX.HighlightColor
|
||||
themes[Theme.NormalMode] = theme
|
||||
|
||||
return themes
|
||||
}()
|
||||
|
||||
dynamic var clearButtonTintColor: UIColor? {
|
||||
didSet {
|
||||
// Clear previous tinted image that's cache and ask for a relayout
|
||||
tintedClearImage = nil
|
||||
setNeedsLayout()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var tintedClearImage: UIImage?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
// Since we're unable to change the tint color of the clear image, we need to iterate through the
|
||||
// subviews, find the clear button, and tint it ourselves. Thanks to Mikael Hellman for the tip:
|
||||
// http://stackoverflow.com/questions/27944781/how-to-change-the-tint-color-of-the-clear-button-on-a-uitextfield
|
||||
for view in subviews as [UIView] {
|
||||
if let button = view as? UIButton {
|
||||
if let image = button.image(for: UIControlState()) {
|
||||
if tintedClearImage == nil {
|
||||
tintedClearImage = tintImage(image, color: clearButtonTintColor)
|
||||
}
|
||||
|
||||
if button.imageView?.image != tintedClearImage {
|
||||
button.setImage(tintedClearImage, for: UIControlState())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func tintImage(_ image: UIImage, color: UIColor?) -> UIImage {
|
||||
guard let color = color else { return image }
|
||||
|
||||
let size = image.size
|
||||
|
||||
UIGraphicsBeginImageContextWithOptions(size, false, 2)
|
||||
let context = UIGraphicsGetCurrentContext()!
|
||||
image.draw(at: CGPoint.zero, blendMode: CGBlendMode.normal, alpha: 1.0)
|
||||
|
||||
context.setFillColor(color.cgColor)
|
||||
context.setBlendMode(CGBlendMode.sourceIn)
|
||||
context.setAlpha(1.0)
|
||||
|
||||
let rect = CGRect(
|
||||
x: CGPoint.zero.x,
|
||||
y: CGPoint.zero.y,
|
||||
width: image.size.width,
|
||||
height: image.size.height)
|
||||
context.fill(rect)
|
||||
let tintedImage = UIGraphicsGetImageFromCurrentImageContext()!
|
||||
UIGraphicsEndImageContext()
|
||||
|
||||
return tintedImage
|
||||
}
|
||||
}
|
||||
|
||||
extension ToolbarTextField: Themeable {
|
||||
func applyTheme(_ themeName: String) {
|
||||
guard let theme = ToolbarTextField.Themes[themeName] else {
|
||||
fatalError("Theme not found")
|
||||
}
|
||||
|
||||
backgroundColor = theme.backgroundColor
|
||||
textColor = theme.textColor
|
||||
clearButtonTintColor = theme.buttonTintColor
|
||||
highlightColor = theme.highlightColor!
|
||||
}
|
||||
}
|
||||