Dactyloidae iOS initial commit

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

View file

@ -0,0 +1,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")
}
}

View 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
}
}

View file

@ -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)
})
}
}
}

View file

@ -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
}
}

View file

@ -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)
}
}

View 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
}

View 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
}

File diff suppressed because it is too large Load diff

View 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),
]
}
}

View file

@ -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)
}
}
}

View 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)
}
}

View file

@ -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)
}
}
}
}

View file

@ -0,0 +1,108 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import 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
}
}
}

View 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"
}
}

View 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
}
}

View 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"
}
}

View 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)
}
}

View file

@ -0,0 +1,46 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import 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)
}
}
}

View 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"
}
}

View 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)
}
}

View 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"
}
}

View 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
})
}
}
}

View 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
}
}

View 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
}
}

View file

@ -0,0 +1,58 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import 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
}
}

View file

@ -0,0 +1,47 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import 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 }
}
}
}

View 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")
}
}

View 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)
}
}

View file

@ -0,0 +1,123 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import Shared
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()
}
}

View file

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import Shared
import 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)
}
}
}

View 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
}
}

View 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
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 785 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

View 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
}
}

View 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!
}
}

View 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)
}
}
}

View 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!
}
}
}

View 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
}
}

View 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()
}
}

View 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
}
}
}

View 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")
}
}

View file

@ -0,0 +1,30 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import Foundation
import 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=...&currentpage=... 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)
}
}
}

View file

@ -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"
}
}

View 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)
})
}
)
}
}

View 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)
}
}

View 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)
}
}
}
}

View 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
}
}

View 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)
}
}

View 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
}
}

View 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
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)
}
}

View 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
}
}

View 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)
}
}

View file

@ -0,0 +1,46 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import UIKit
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
}
}

File diff suppressed because it is too large Load diff

View 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
}
}

View 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
}
}

View 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()
}
}

View 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)
}
}

View 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
}
}

View 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!
}
}