// // SafariExtensionHandler.swift // Subscribe to Feed // // Created by Daniel Jalkut on 6/11/18. // Copyright © 2018 Ranchero Software. All rights reserved. // import SafariServices import os final class SafariExtensionHandler: SFSafariExtensionHandler { // Safari App Extensions don't support any reasonable means of detecting whether a // specific Safari page was loaded with the benefit of the extension's injected // JavaScript. For this reason a condition can easily be reached where the toolbar // icon is active for a page, but the expected supporting code is not loaded into // the page. To detect this and disable our icon, we use a kind of "ping" trick // to verify whether our code is installed. // I tried to use a NSMapTable from String to the closure directly, but Swift // complains that the object has to be a class type. typealias ValidationHandler = @Sendable (Bool, String) -> Void final class ValidationWrapper: Sendable { let validationHandler: ValidationHandler init(validationHandler: @escaping ValidationHandler) { self.validationHandler = validationHandler } } // Maps from UUID to a validation wrapper private static let _gPingPongMap: OSAllocatedUnfairLock> = OSAllocatedUnfairLock(initialState: [String: ValidationWrapper]()) static var gPingPongMap: [String: ValidationWrapper] { get { _gPingPongMap.withLock { $0 } } set { _gPingPongMap.withLock { $0 = newValue } } } static let validationQueue = DispatchQueue(label: "Toolbar Validation") // Bottleneck for calling through to a validation handler we have saved, and removing it from the list. static func callValidationHandler(forHandlerID handlerID: String, withShouldValidate shouldValidate: Bool) { if let validationWrapper = gPingPongMap[handlerID] { validationWrapper.validationHandler(shouldValidate, "") gPingPongMap.removeValue(forKey: handlerID) } } override func messageReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String : Any]?) { if (messageName == "subscribeToFeed") { if var feedURLString = userInfo?["url"] as? String { var openInDefaultBrowser = false // Ask for the user's choice for whether to open the feed URL using whatever the system // configured default is, or to always hard-code it to have NetNewsWire handle it itself. if let appGroupID = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as? String { if let groupDefaults = UserDefaults(suiteName: appGroupID) { openInDefaultBrowser = groupDefaults.bool(forKey: "subscribeToFeedsInDefaultBrowser") } } if openInDefaultBrowser == false { feedURLString = feedURLString.replacingOccurrences(of: "feed:", with: "x-netnewswire-feed:") } if let feedURL = URL(string: feedURLString) { NSWorkspace.shared.open(feedURL) } } } else if (messageName == "pong") { if let validationIDString = userInfo?["validationID"] as? String { // Should we validate the button? let shouldValidate = userInfo?["shouldValidate"] as? Bool ?? false SafariExtensionHandler.callValidationHandler(forHandlerID: validationIDString, withShouldValidate:shouldValidate) } } } override func toolbarItemClicked(in window: SFSafariWindow) { window.getActiveTab { (activeTab) in activeTab?.getActivePage(completionHandler: { (activePage) in activePage?.dispatchMessageToScript(withName: "toolbarButtonClicked", userInfo: nil) }) } } override func validateToolbarItem(in window: SFSafariWindow, validationHandler: @escaping (Bool, String) -> Void) { let uniqueValidationID = NSUUID().uuidString SafariExtensionHandler.validationQueue.sync { // Save it right away to eliminate any doubt of whether the handler gets deallocated while // we are waiting for a callback from the getActiveTab or getActivatePage methods below. let validationWrapper = ValidationWrapper(validationHandler: validationHandler) SafariExtensionHandler.gPingPongMap[uniqueValidationID] = validationWrapper // To avoid problems with validation handlers dispatched after we've, for example, // switched to a new tab, we aggressively clear out the map of any pending validations, // and focus only on the newest validation request we've been asked for. for thisValidationID in SafariExtensionHandler.gPingPongMap.keys { if thisValidationID != uniqueValidationID { // Default to valid ... we'll know soon enough whether the latest state // is actually still valid or not... SafariExtensionHandler.callValidationHandler(forHandlerID: thisValidationID, withShouldValidate: true); } } // See comments above where gPingPongMap is declared. Upon being asked to validate the // toolbar icon for a specific page, we save the validationHandler and postpone calling // it until we have either received a response from our installed JavaScript, or until // a timeout period has elapsed window.getActiveTab { (activeTab) in guard let activeTab = activeTab else { SafariExtensionHandler.callValidationHandler(forHandlerID: uniqueValidationID, withShouldValidate:false); return } activeTab.getActivePage { (activePage) in guard let activePage = activePage else { SafariExtensionHandler.callValidationHandler(forHandlerID: uniqueValidationID, withShouldValidate:false); return } activePage.getPropertiesWithCompletionHandler { (pageProperties) in if let isActive = pageProperties?.isActive { if isActive { // Capture the uniqueValidationID to ensure it doesn't change out from under us on a future call activePage.dispatchMessageToScript(withName: "ping", userInfo: ["validationID": uniqueValidationID]) let pongTimeoutInNanoseconds = Int(Double(NSEC_PER_SEC) * 0.5) let timeoutDeadline = DispatchTime.now() + DispatchTimeInterval.nanoseconds(pongTimeoutInNanoseconds) DispatchQueue.main.asyncAfter(deadline: timeoutDeadline, execute: { [timedOutValidationID = uniqueValidationID] in SafariExtensionHandler.callValidationHandler(forHandlerID: timedOutValidationID, withShouldValidate:false) }) } } } } } } } }