diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index 017e071b308..0e02079a190 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -1057,6 +1057,8 @@ CA225DF72795D1A80024C104 /* MainFrameAtDocumentEndSandboxed.js in Resources */ = {isa = PBXBuildFile; fileRef = CA225DF52795D1A80024C104 /* MainFrameAtDocumentEndSandboxed.js */; }; CA225DF92795D7690024C104 /* WKContentWorld+Sandbox.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA225DF82795D7690024C104 /* WKContentWorld+Sandbox.swift */; }; CA29F2F3273DAEA100C391C3 /* PlaylistOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA29F2F2273DAEA100C391C3 /* PlaylistOnboardingView.swift */; }; + CA2CA1B127F4B50300B25646 /* ReadyState.js in Resources */ = {isa = PBXBuildFile; fileRef = CA2CA1B027F4B50300B25646 /* ReadyState.js */; }; + CA2CA1CF27F4B93400B25646 /* ReadyStateScriptHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2CA1CE27F4B93400B25646 /* ReadyStateScriptHelper.swift */; }; CA2EE0A527349E970089C75F /* disconnect-entitylist.json in Resources */ = {isa = PBXBuildFile; fileRef = CA2EE09927349E970089C75F /* disconnect-entitylist.json */; }; CA2EE0A727349F760089C75F /* OnboardingAdblockDisconnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA2EE0A627349F760089C75F /* OnboardingAdblockDisconnect.swift */; }; CA439A5925E6F29D00FE9150 /* VideoPlayerInfoBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA439A5825E6F29D00FE9150 /* VideoPlayerInfoBar.swift */; }; @@ -3014,6 +3016,8 @@ CA225DF52795D1A80024C104 /* MainFrameAtDocumentEndSandboxed.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = MainFrameAtDocumentEndSandboxed.js; sourceTree = ""; }; CA225DF82795D7690024C104 /* WKContentWorld+Sandbox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WKContentWorld+Sandbox.swift"; sourceTree = ""; }; CA29F2F2273DAEA100C391C3 /* PlaylistOnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistOnboardingView.swift; sourceTree = ""; }; + CA2CA1B027F4B50300B25646 /* ReadyState.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = ReadyState.js; sourceTree = ""; }; + CA2CA1CE27F4B93400B25646 /* ReadyStateScriptHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadyStateScriptHelper.swift; sourceTree = ""; }; CA2EE09927349E970089C75F /* disconnect-entitylist.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "disconnect-entitylist.json"; sourceTree = ""; }; CA2EE0A627349F760089C75F /* OnboardingAdblockDisconnect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingAdblockDisconnect.swift; sourceTree = ""; }; CA439A5825E6F29D00FE9150 /* VideoPlayerInfoBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerInfoBar.swift; sourceTree = ""; }; @@ -4833,6 +4837,7 @@ D31F95E81AC226CB005C9F3B /* ScreenshotHelper.swift */, F35B8D2C1D6383E9008E3D61 /* SessionRestoreHelper.swift */, 5E4845C122DE3DF800372022 /* WindowRenderHelperScript.swift */, + CA2CA1CE27F4B93400B25646 /* ReadyStateScriptHelper.swift */, ); path = Helpers; sourceTree = ""; @@ -6064,6 +6069,7 @@ 5EC2C0E425C0B321005EA984 /* PlaylistDetector.js */, 5EC0017D260129AC005DDE4A /* PlaylistSwizzler.js */, CA55048F269DED8F00C19917 /* MediaBackgrounding.js */, + CA2CA1B027F4B50300B25646 /* ReadyState.js */, ); path = UserScripts; sourceTree = ""; @@ -7732,6 +7738,7 @@ CA225DF22795CECE0024C104 /* MainFrame.js in Resources */, 4481F23626CBD27B00658EAC /* GlobalSignRootCA_E46.cer in Resources */, 4422D55221BFFB7E00BF1855 /* make_unicode_casefold.py in Resources */, + CA2CA1B127F4B50300B25646 /* ReadyState.js in Resources */, 5E4E078324A0E4D700B01720 /* YoutubeAdblock.js in Resources */, 445ABC962731DDFF0089710D /* NewYorkMedium-Bold.otf in Resources */, E4B7B7791A793CF20022C5E0 /* FiraSans-Light.ttf in Resources */, @@ -8440,6 +8447,7 @@ 27FD2CAB2146C31C00A5A779 /* RequestDesktopSiteActivity.swift in Sources */, 3BCE6D3C1CEB9E4D0080928C /* ThirdPartySearchAlerts.swift in Sources */, 4422D55A21BFFB7F00BF1855 /* regexp.cc in Sources */, + CA2CA1CF27F4B93400B25646 /* ReadyStateScriptHelper.swift in Sources */, 0A7B5D722269E7AD00AADF22 /* BookmarkEditMode.swift in Sources */, 4422D4DC21BFFB7600BF1855 /* block_builder.cc in Sources */, 27201EFE24539B5500C19DD1 /* NewTabPageBackground.swift in Sources */, diff --git a/Client/Frontend/Browser/BrowserViewController.swift b/Client/Frontend/Browser/BrowserViewController.swift index 5ba8343c4eb..937ac7a6e73 100644 --- a/Client/Frontend/Browser/BrowserViewController.swift +++ b/Client/Frontend/Browser/BrowserViewController.swift @@ -2046,6 +2046,7 @@ extension BrowserViewController: TabDelegate { tab.addContentScript(RewardsReporting(rewards: rewards, tab: tab), name: RewardsReporting.name(), contentWorld: .page) tab.addContentScript(AdsMediaReporting(rewards: rewards, tab: tab), name: AdsMediaReporting.name(), contentWorld: .defaultClient) + tab.addContentScript(ReadyStateScriptHelper(tab: tab), name: ReadyStateScriptHelper.name(), contentWorld: .page) } func tab(_ tab: Tab, willDeleteWebView webView: WKWebView) { diff --git a/Client/Frontend/Browser/Helpers/ReadyStateScriptHelper.swift b/Client/Frontend/Browser/Helpers/ReadyStateScriptHelper.swift new file mode 100644 index 00000000000..c472bc4faf2 --- /dev/null +++ b/Client/Frontend/Browser/Helpers/ReadyStateScriptHelper.swift @@ -0,0 +1,82 @@ +// Copyright 2022 The Brave Authors. All rights reserved. +// 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 + +private let log = Logger.browserLogger + +struct ReadyState: Codable { + let securityToken: String + let state: State + + enum State: String, Codable { + // Page State + case loading + case interactive + case complete + case loaded + + // History State + case pushstate + case replacestate + case popstate + } + + public static func from(message: WKScriptMessage) -> ReadyState? { + if !JSONSerialization.isValidJSONObject(message.body) { + return nil + } + + do { + let data = try JSONSerialization.data(withJSONObject: message.body, options: []) + return try JSONDecoder().decode(ReadyState.self, from: data) + } catch { + log.error("Error Decoding ReadyState: \(error)") + } + + return nil + } + + private enum CodingKeys: String, CodingKey { + case securityToken = "securitytoken" + case state + } +} + +class ReadyStateScriptHelper: TabContentScript { + private weak var tab: Tab? + private var debounceTimer: Timer? + + required init(tab: Tab) { + self.tab = tab + } + + class func name() -> String { + return "ReadyStateScriptHelper" + } + + func scriptMessageHandlerName() -> String? { + return "ReadyState_\(UserScriptManager.messageHandlerTokenString)" + } + + func userContentController(_ userContentController: WKUserContentController, didReceiveScriptMessage message: WKScriptMessage, replyHandler: (Any?, String?) -> Void) { + + defer { replyHandler(nil, nil) } + + guard let readyState = ReadyState.from(message: message) else { + log.error("Invalid Ready State") + return + } + + guard readyState.securityToken == UserScriptManager.securityTokenString else { + log.error("Invalid or Missing security token") + return + } + + tab?.onPageReadyStateChanged?(readyState.state) + } +} diff --git a/Client/Frontend/Browser/Tab.swift b/Client/Frontend/Browser/Tab.swift index f4d8d0975ea..c4b771464e6 100644 --- a/Client/Frontend/Browser/Tab.swift +++ b/Client/Frontend/Browser/Tab.swift @@ -195,6 +195,8 @@ class Tab: NSObject { var screenshotUUID: UUID? { didSet { TabMO.saveScreenshotUUID(screenshotUUID, tabId: id) } } + + var onPageReadyStateChanged: ((ReadyState.State) -> Void)? // If this tab has been opened from another, its parent will point to the tab from which it was opened var parent: Tab? diff --git a/Client/Frontend/Browser/TabManager.swift b/Client/Frontend/Browser/TabManager.swift index 8da5eb93147..4e24946e443 100644 --- a/Client/Frontend/Browser/TabManager.swift +++ b/Client/Frontend/Browser/TabManager.swift @@ -491,7 +491,27 @@ class TabManager: NSObject { if flushToDisk && !zombie && !isPrivate { saveTab(tab, saveOrder: true) } - + + // When the state of the page changes, we debounce a call to save the screenshots and tab information + // This fixes pages that have dynamic URL via changing history + // as well as regular pages that load DOM normally. + var debounce_timer: Timer? + tab.onPageReadyStateChanged = { [weak self] state in + guard let self = self else { return } + + debounce_timer?.invalidate() + debounce_timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: false) { _ in + debounce_timer?.invalidate() + + if state == .complete || state == .loaded || state == .pushstate || state == .popstate { + // Saving Tab Private Mode - not supported yet. + if !tab.isPrivate { + self.preserveScreenshots() + self.saveTab(tab) + } + } + } + } } func indexOfWebView(_ webView: WKWebView) -> UInt? { diff --git a/Client/Frontend/Browser/UserScriptManager.swift b/Client/Frontend/Browser/UserScriptManager.swift index 43583d66ea2..9685dbf9728 100644 --- a/Client/Frontend/Browser/UserScriptManager.swift +++ b/Client/Frontend/Browser/UserScriptManager.swift @@ -363,6 +363,31 @@ class UserScriptManager { forMainFrameOnly: true, in: .defaultClient) }() + + private let ReadyStateScript: WKUserScript? = { + guard let path = Bundle.main.path(forResource: "ReadyState", ofType: "js"), let source = try? String(contentsOfFile: path) else { + log.error("Failed to load ReadyState.js") + return nil + } + + var alteredSource = source + let token = UserScriptManager.securityTokenString + + let replacements = [ + "$": token, + "$": "ReadyState_\(messageHandlerTokenString)", + ] + + replacements.forEach({ + alteredSource = alteredSource.replacingOccurrences(of: $0.key, with: $0.value, options: .literal) + }) + + return WKUserScript.create( + source: alteredSource, + injectionTime: .atDocumentStart, + forMainFrameOnly: true, + in: .page) + }() private func reloadUserScripts() { tab?.webView?.configuration.userContentController.do { @@ -403,6 +428,10 @@ class UserScriptManager { if isNightModeEnabled, let script = NightModeScript { $0.addUserScript(script) } + + if let script = ReadyStateScript { + $0.addUserScript(script) + } if let domainUserScript = domainUserScript, let script = domainUserScript.script { $0.addUserScript(script) diff --git a/Client/Frontend/UserContent/UserScripts/ReadyState.js b/Client/Frontend/UserContent/UserScripts/ReadyState.js new file mode 100644 index 00000000000..26cb184a670 --- /dev/null +++ b/Client/Frontend/UserContent/UserScripts/ReadyState.js @@ -0,0 +1,77 @@ +// Copyright 2021 The Brave Authors. All rights reserved. +// 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/. + +(function() { + // Listen for document ready state + document.addEventListener('readystatechange', (event) => { + window.webkit.messageHandlers.$.postMessage({ + "securitytoken": "$", + "state": document.readyState + }); + }); + + // Listen for document load state + window.addEventListener('load', (event) => { + window.webkit.messageHandlers.$.postMessage({ + "securitytoken": "$", + "state": "loaded" + }); + }); + + // Listen for history popped + window.addEventListener('popstate', (event) => { + if (event.state) { + // Run on the browser's next run-loop + setTimeout(() => { + window.webkit.messageHandlers.$.postMessage({ + "securitytoken": "$", + "state": "popstate" + }); + }, 0); + } + }); + + // Listen for history pushed + const pushState = History.prototype.pushState; + History.prototype.pushState = function(state, unused, url) { + pushState.call(this, state, unused, url); + + if (state) { + // Run on the browser's next run-loop + setTimeout(() => { + window.webkit.messageHandlers.$.postMessage({ + "securitytoken": "$", + "state": "pushstate" + }); + }, 0); + } + }; + + // Listen for history replaced + const replaceState = History.prototype.replaceState; + History.prototype.replaceState = function(state, unused, url) { + replaceState.call(this, state, unused, url); + + if (state) { + // Run on the browser's next run-loop + setTimeout(() => { + window.webkit.messageHandlers.$.postMessage({ + "securitytoken": "$", + "state": "replacestate" + }); + }, 0); + } + }; + + // Hide the pushState trampoline + History.prototype.pushState.toString = function() { + return "function () { [native code] }"; + }; + + // Hide the replaceState trampoline + History.prototype.replaceState.toString = function() { + return "function () { [native code] }"; + }; +})();