mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-08-30 11:18:39 +09:00
598 lines
20 KiB
Swift
598 lines
20 KiB
Swift
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
import Foundation
|
|
import 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)
|
|
}
|
|
}
|
|
}
|
|
}
|