From 962c4d81879e85362a5f878889d3aa869f4e6961 Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Sun, 3 Dec 2023 07:47:07 +0800 Subject: [PATCH 1/2] chore: xcode swift indent replace spaces to tabs --- xcode/App-Mac/AppDelegate.swift | 130 +- xcode/App-Mac/Functions.swift | 102 +- xcode/App-Mac/ViewController.swift | 134 +- xcode/App-Shared/USchemeHandler.swift | 100 +- xcode/App-Shared/ViewController.swift | 192 +- xcode/App-iOS/AppDelegate.swift | 18 +- xcode/App-iOS/Initialization.swift | 54 +- xcode/App-iOS/SceneDelegate.swift | 8 +- xcode/Ext-Safari/Functions.swift | 3582 ++++++++--------- .../SafariWebExtensionHandler.swift | 608 +-- xcode/Shared/Preferences.swift | 514 +-- xcode/Shared/UrlPolyfill.swift | 162 +- xcode/Shared/Utilities.swift | 52 +- xcode/Tests-Mac/UrlCodecTests.swift | 385 +- xcode/Tests-Mac/UserscriptsTests.swift | 382 +- xcode/Userscripts.xcodeproj/project.pbxproj | 1 + 16 files changed, 3213 insertions(+), 3211 deletions(-) diff --git a/xcode/App-Mac/AppDelegate.swift b/xcode/App-Mac/AppDelegate.swift index a834109c..ffdf0758 100644 --- a/xcode/App-Mac/AppDelegate.swift +++ b/xcode/App-Mac/AppDelegate.swift @@ -2,69 +2,69 @@ import Cocoa @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate { - - private var window: NSWindow! - private var windowForego = false - private var windowLoaded = false - - @IBOutlet weak var enbaleNativeLogger: NSMenuItem! - - func application(_ application: NSApplication, open urls: [URL]) { - // if open panel is already open, stop processing the URL scheme - if NSApplication.shared.keyWindow?.accessibilityIdentifier() == "open-panel" { return } - for url in urls { - if url.host == "changesavelocation" { - // avoid opening the panel repeatedly and playing unnecessary warning sounds - if NSApplication.shared.keyWindow?.identifier?.rawValue == "changeSaveLocation" { continue } - if windowLoaded { - let viewController = window.contentViewController as? ViewController - viewController?.changeSaveLocation(nil) - } else { - windowForego = true - schemeChangeSaveLocation() - } - } - } - } - - func applicationDidFinishLaunching(_ aNotification: Notification) { - // Initialize menu items - enbaleNativeLogger.state = Preferences.enableLogger ? .on : .off - if windowForego { return } - let storyboard = NSStoryboard(name: "View", bundle: Bundle.main) - let windowController = storyboard.instantiateInitialController() as! NSWindowController -// let viewController = windowController.contentViewController as! ViewController - window = windowController.window - window.setIsVisible(true) - windowLoaded = true - } - - func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { - return true - } - - func applicationWillTerminate(_ aNotification: Notification) { - // Insert code here to tear down your application - } - - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - @IBAction func enableLogger(_ sender: NSMenuItem) { - if sender.state == .on { - Preferences.enableLogger = false - sender.state = .off - } else { - Preferences.enableLogger = true - sender.state = .on - } - } - - @IBAction func applicationHelp(_ sender: NSMenuItem) { - if let url = URL(string: "https://github.com/quoid/userscripts") { - NSWorkspace.shared.open(url) - } - } - + + private var window: NSWindow! + private var windowForego = false + private var windowLoaded = false + + @IBOutlet weak var enbaleNativeLogger: NSMenuItem! + + func application(_ application: NSApplication, open urls: [URL]) { + // if open panel is already open, stop processing the URL scheme + if NSApplication.shared.keyWindow?.accessibilityIdentifier() == "open-panel" { return } + for url in urls { + if url.host == "changesavelocation" { + // avoid opening the panel repeatedly and playing unnecessary warning sounds + if NSApplication.shared.keyWindow?.identifier?.rawValue == "changeSaveLocation" { continue } + if windowLoaded { + let viewController = window.contentViewController as? ViewController + viewController?.changeSaveLocation(nil) + } else { + windowForego = true + schemeChangeSaveLocation() + } + } + } + } + + func applicationDidFinishLaunching(_ aNotification: Notification) { + // Initialize menu items + enbaleNativeLogger.state = Preferences.enableLogger ? .on : .off + if windowForego { return } + let storyboard = NSStoryboard(name: "View", bundle: Bundle.main) + let windowController = storyboard.instantiateInitialController() as! NSWindowController +// let viewController = windowController.contentViewController as! ViewController + window = windowController.window + window.setIsVisible(true) + windowLoaded = true + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + return true + } + + func applicationWillTerminate(_ aNotification: Notification) { + // Insert code here to tear down your application + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + @IBAction func enableLogger(_ sender: NSMenuItem) { + if sender.state == .on { + Preferences.enableLogger = false + sender.state = .off + } else { + Preferences.enableLogger = true + sender.state = .on + } + } + + @IBAction func applicationHelp(_ sender: NSMenuItem) { + if let url = URL(string: "https://github.com/quoid/userscripts") { + NSWorkspace.shared.open(url) + } + } + } diff --git a/xcode/App-Mac/Functions.swift b/xcode/App-Mac/Functions.swift index 650526e8..48770bfa 100644 --- a/xcode/App-Mac/Functions.swift +++ b/xcode/App-Mac/Functions.swift @@ -4,67 +4,67 @@ import SafariServices private let logger = USLogger(#fileID) func getSaveLocationURL() -> URL { - return Preferences.scriptsDirectoryUrl + return Preferences.scriptsDirectoryUrl } func setSaveLocationURL(url: URL) -> Bool { - guard FileManager.default.isWritableFile(atPath: url.path) else { - let alert = NSAlert() - alert.messageText = "Can not write to path. Choose a different path." - alert.runModal() - return false - } - Preferences.scriptsDirectoryUrl = url - return Preferences.scriptsDirectoryUrl == url + guard FileManager.default.isWritableFile(atPath: url.path) else { + let alert = NSAlert() + alert.messageText = "Can not write to path. Choose a different path." + alert.runModal() + return false + } + Preferences.scriptsDirectoryUrl = url + return Preferences.scriptsDirectoryUrl == url } func sendExtensionMessage(name: String, userInfo: [String : Any]? = nil, completion: ((Error?) -> Void)? = nil) { - SFSafariApplication.dispatchMessage( - withName: name, - toExtensionWithIdentifier: extIdentifier, - userInfo: userInfo - ) { error in // always be called - if error != nil { - debugPrint("Message attempted. Error info: \(String.init(describing: error))") - } - if let userHandle = completion { userHandle(error) } - } + SFSafariApplication.dispatchMessage( + withName: name, + toExtensionWithIdentifier: extIdentifier, + userInfo: userInfo + ) { error in // always be called + if error != nil { + debugPrint("Message attempted. Error info: \(String.init(describing: error))") + } + if let userHandle = completion { userHandle(error) } + } } func changeSaveLocationPanel(directoryURL: URL? = nil) -> NSOpenPanel { - let panel = NSOpenPanel() - panel.allowsMultipleSelection = false - panel.canChooseDirectories = true - panel.canCreateDirectories = true - panel.canChooseFiles = false - if directoryURL != nil { - panel.directoryURL = directoryURL - } - panel.title = "Change Save Location - Userscripts" - panel.identifier = NSUserInterfaceItemIdentifier("changeSaveLocation") - return panel + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = true + panel.canCreateDirectories = true + panel.canChooseFiles = false + if directoryURL != nil { + panel.directoryURL = directoryURL + } + panel.title = "Change Save Location - Userscripts" + panel.identifier = NSUserInterfaceItemIdentifier("changeSaveLocation") + return panel } func schemeChangeSaveLocation() { - let saveLocationURL = getSaveLocationURL() - let panel = changeSaveLocationPanel(directoryURL: saveLocationURL) - // shows the path selection panel - let response = panel.runModal() - // check if clicked open button and there is a valid result - guard response == .OK, let url: URL = panel.urls.first else { return } - // revoke implicitly starts security-scoped access - defer { url.stopAccessingSecurityScopedResource() } - // check if path has indeed changed - if url.absoluteString == saveLocationURL.absoluteString { return } - // try set new save location path to bookmark - guard setSaveLocationURL(url: url) else { return } - // use semaphore to ensure the async func executed before app exits - let semaphore = DispatchSemaphore(value: 0) - // notify browser extension of relevant updates - sendExtensionMessage( - name: "SAVE_LOCATION_CHANGED", - userInfo: ["saveLocation": url.absoluteString.removingPercentEncoding ?? url.absoluteString], - completion: { _ in semaphore.signal() } - ) - semaphore.wait() + let saveLocationURL = getSaveLocationURL() + let panel = changeSaveLocationPanel(directoryURL: saveLocationURL) + // shows the path selection panel + let response = panel.runModal() + // check if clicked open button and there is a valid result + guard response == .OK, let url: URL = panel.urls.first else { return } + // revoke implicitly starts security-scoped access + defer { url.stopAccessingSecurityScopedResource() } + // check if path has indeed changed + if url.absoluteString == saveLocationURL.absoluteString { return } + // try set new save location path to bookmark + guard setSaveLocationURL(url: url) else { return } + // use semaphore to ensure the async func executed before app exits + let semaphore = DispatchSemaphore(value: 0) + // notify browser extension of relevant updates + sendExtensionMessage( + name: "SAVE_LOCATION_CHANGED", + userInfo: ["saveLocation": url.absoluteString.removingPercentEncoding ?? url.absoluteString], + completion: { _ in semaphore.signal() } + ) + semaphore.wait() } diff --git a/xcode/App-Mac/ViewController.swift b/xcode/App-Mac/ViewController.swift index 494771e5..fc7598ee 100644 --- a/xcode/App-Mac/ViewController.swift +++ b/xcode/App-Mac/ViewController.swift @@ -4,79 +4,79 @@ private let logger = USLogger(#fileID) class ViewController: NSViewController { - @IBOutlet var appName: NSTextField! - @IBOutlet var saveLocation: NSTextField! - @IBOutlet weak var enabledText: NSTextField! - @IBOutlet weak var enabledIcon: NSView! + @IBOutlet var appName: NSTextField! + @IBOutlet var saveLocation: NSTextField! + @IBOutlet weak var enabledText: NSTextField! + @IBOutlet weak var enabledIcon: NSView! @IBOutlet weak var openButton: NSButton! - - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" - let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" - override func viewDidLoad() { - super.viewDidLoad() - self.appName.stringValue = "Userscripts Safari Version \(appVersion) (\(buildNumber))" - setExtensionState() - NotificationCenter.default.addObserver( - self, - selector: #selector(setExtensionState), - name: NSApplication.didBecomeActiveNotification, - object: nil - ) - let url = getSaveLocationURL() - self.saveLocation.stringValue = url.absoluteString - self.saveLocation.toolTip = url.absoluteString + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" + + override func viewDidLoad() { + super.viewDidLoad() + self.appName.stringValue = "Userscripts Safari Version \(appVersion) (\(buildNumber))" + setExtensionState() + NotificationCenter.default.addObserver( + self, + selector: #selector(setExtensionState), + name: NSApplication.didBecomeActiveNotification, + object: nil + ) + let url = getSaveLocationURL() + self.saveLocation.stringValue = url.absoluteString + self.saveLocation.toolTip = url.absoluteString if #available(macOS 13, *) { self.openButton.title = "Open Safari Settings" } - } + } - @objc func setExtensionState() { - SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extIdentifier) { (state, error) in - guard let state = state else { - self.enabledText.stringValue = "Safari Extension State Unknown" - if let error = error { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - } else { - logger?.error("\(#function, privacy: .public) - couldn't get safari extension state in containing app") - } - return - } - DispatchQueue.main.async { - self.enabledIcon.layer?.backgroundColor = state.isEnabled ? NSColor.green.cgColor : NSColor.red.cgColor - self.enabledText.stringValue = state.isEnabled ? "Safari Extension Enabled" : "Safari Extension Disabled" - } - } - } + @objc func setExtensionState() { + SFSafariExtensionManager.getStateOfSafariExtension(withIdentifier: extIdentifier) { (state, error) in + guard let state = state else { + self.enabledText.stringValue = "Safari Extension State Unknown" + if let error = error { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + } else { + logger?.error("\(#function, privacy: .public) - couldn't get safari extension state in containing app") + } + return + } + DispatchQueue.main.async { + self.enabledIcon.layer?.backgroundColor = state.isEnabled ? NSColor.green.cgColor : NSColor.red.cgColor + self.enabledText.stringValue = state.isEnabled ? "Safari Extension Enabled" : "Safari Extension Disabled" + } + } + } - @IBAction func changeSaveLocation(_ sender: AnyObject?) { - guard let window = self.view.window else { return } - let saveLocationURL = getSaveLocationURL() - let panel = changeSaveLocationPanel(directoryURL: saveLocationURL) - panel.beginSheetModal(for: window, completionHandler: { response in - // check if clicked open button and there is a valid result - guard response == .OK, let url: URL = panel.urls.first else { return } - // revoke implicitly starts security-scoped access - defer { url.stopAccessingSecurityScopedResource() } - // check if path has indeed changed - if url.absoluteString == saveLocationURL.absoluteString { return } - // try set new save location path to bookmark - guard setSaveLocationURL(url: url) else { return } - // update user interface text display - self.saveLocation.stringValue = url.absoluteString - self.saveLocation.toolTip = url.absoluteString - // notify browser extension of relevant updates - sendExtensionMessage( - name: "SAVE_LOCATION_CHANGED", - userInfo: [ - "saveLocation": url.absoluteString.removingPercentEncoding ?? url.absoluteString, - "returnApp": true - ] - ) - }) - } + @IBAction func changeSaveLocation(_ sender: AnyObject?) { + guard let window = self.view.window else { return } + let saveLocationURL = getSaveLocationURL() + let panel = changeSaveLocationPanel(directoryURL: saveLocationURL) + panel.beginSheetModal(for: window, completionHandler: { response in + // check if clicked open button and there is a valid result + guard response == .OK, let url: URL = panel.urls.first else { return } + // revoke implicitly starts security-scoped access + defer { url.stopAccessingSecurityScopedResource() } + // check if path has indeed changed + if url.absoluteString == saveLocationURL.absoluteString { return } + // try set new save location path to bookmark + guard setSaveLocationURL(url: url) else { return } + // update user interface text display + self.saveLocation.stringValue = url.absoluteString + self.saveLocation.toolTip = url.absoluteString + // notify browser extension of relevant updates + sendExtensionMessage( + name: "SAVE_LOCATION_CHANGED", + userInfo: [ + "saveLocation": url.absoluteString.removingPercentEncoding ?? url.absoluteString, + "returnApp": true + ] + ) + }) + } - @IBAction func openSafariExtensionPreferences(_ sender: AnyObject?) { - SFSafariApplication.showPreferencesForExtension(withIdentifier: extIdentifier) - } + @IBAction func openSafariExtensionPreferences(_ sender: AnyObject?) { + SFSafariApplication.showPreferencesForExtension(withIdentifier: extIdentifier) + } } diff --git a/xcode/App-Shared/USchemeHandler.swift b/xcode/App-Shared/USchemeHandler.swift index 99c8acaf..28fa301b 100644 --- a/xcode/App-Shared/USchemeHandler.swift +++ b/xcode/App-Shared/USchemeHandler.swift @@ -8,59 +8,59 @@ let AppWebViewEntryPage = "entry-app-webview.html" // https://developer.apple.com/documentation/webkit/wkurlschemehandler // https://developer.apple.com/documentation/webkit/wkurlschemetask class USchemeHandler: NSObject, WKURLSchemeHandler { - - func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { - // wrapper of didFailWithError - func failHandler(_ errmsg: String) { - logger?.error("\(#function) - \(errmsg, privacy: .public)") - // redirect to a customized error page -// DispatchQueue.main.async { -// webView.load(URLRequest(url: URL(string: "\(AppWebViewUrlScheme):///")!)) -// } - urlSchemeTask.didFailWithError(NSError(domain: "USchemeHandler", code: 0, userInfo: nil)) - } - // https://developer.apple.com/documentation/dispatch/dispatchqueue - DispatchQueue.global(qos: .userInteractive).async { - guard let url = urlSchemeTask.request.url else { - failHandler("failed to get request url") - return - } - guard url.scheme == AppWebViewUrlScheme else { - failHandler("unexpected url scheme: \(url)") - return - } - var name: String - if #available(macOS 13.0, iOS 16.0, *) { - name = url.path(percentEncoded: false) - } else { - name = url.path - } - if ["", "/"].contains(name) { - name = AppWebViewEntryPage - } - guard let file = Bundle.main.url(forResource: name, withExtension: nil, subdirectory: "dist") else { - failHandler("file not found: \(url)") - return - } - guard let data = try? Data(contentsOf: file) else { - failHandler("faild to get data from: \(url)") - return - } + func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { + // wrapper of didFailWithError + func failHandler(_ errmsg: String) { + logger?.error("\(#function) - \(errmsg, privacy: .public)") + // redirect to a customized error page +// DispatchQueue.main.async { +// webView.load(URLRequest(url: URL(string: "\(AppWebViewUrlScheme):///")!)) +// } + urlSchemeTask.didFailWithError(NSError(domain: "USchemeHandler", code: 0, userInfo: nil)) + } - // https://developer.apple.com/documentation/uniformtypeidentifiers - //let mime = UTType.init(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream" - let mime = UTTypeReference.init(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream" + // https://developer.apple.com/documentation/dispatch/dispatchqueue + DispatchQueue.global(qos: .userInteractive).async { + guard let url = urlSchemeTask.request.url else { + failHandler("failed to get request url") + return + } + guard url.scheme == AppWebViewUrlScheme else { + failHandler("unexpected url scheme: \(url)") + return + } + var name: String + if #available(macOS 13.0, iOS 16.0, *) { + name = url.path(percentEncoded: false) + } else { + name = url.path + } + if ["", "/"].contains(name) { + name = AppWebViewEntryPage + } + guard let file = Bundle.main.url(forResource: name, withExtension: nil, subdirectory: "dist") else { + failHandler("file not found: \(url)") + return + } + guard let data = try? Data(contentsOf: file) else { + failHandler("faild to get data from: \(url)") + return + } - let response = URLResponse(url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: nil) - urlSchemeTask.didReceive(response) - urlSchemeTask.didReceive(data) - urlSchemeTask.didFinish() - } - } + // https://developer.apple.com/documentation/uniformtypeidentifiers + // let mime = UTType.init(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream" + let mime = UTTypeReference.init(filenameExtension: file.pathExtension)?.preferredMIMEType ?? "application/octet-stream" - func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + let response = URLResponse(url: url, mimeType: mime, expectedContentLength: data.count, textEncodingName: nil) + urlSchemeTask.didReceive(response) + urlSchemeTask.didReceive(data) + urlSchemeTask.didFinish() + } + } + + func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) { + + } - } - } diff --git a/xcode/App-Shared/ViewController.swift b/xcode/App-Shared/ViewController.swift index a4dc6e4c..15430135 100644 --- a/xcode/App-Shared/ViewController.swift +++ b/xcode/App-Shared/ViewController.swift @@ -4,107 +4,107 @@ private let logger = USLogger(#fileID) class ViewController: UIViewController, WKNavigationDelegate, WKScriptMessageHandler, UIDocumentPickerDelegate { - @IBOutlet var webView: WKWebView! - - // https://developer.apple.com/documentation/uikit/uiviewcontroller/1621454-loadview - override func loadView() { - // https://developer.apple.com/documentation/webkit/wkwebviewconfiguration - let configuration = WKWebViewConfiguration() - // https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/2875766-seturlschemehandler - configuration.setURLSchemeHandler(USchemeHandler(), forURLScheme: AppWebViewUrlScheme) - // https://developer.apple.com/documentation/webkit/wkusercontentcontroller - configuration.userContentController.add(self, name: "controller") - // https://developer.apple.com/documentation/webkit/wkwebview - self.webView = WKWebView(frame: .zero, configuration: configuration) - // https://developer.apple.com/documentation/webkit/wknavigationdelegate - self.webView.navigationDelegate = self + @IBOutlet var webView: WKWebView! + + // https://developer.apple.com/documentation/uikit/uiviewcontroller/1621454-loadview + override func loadView() { + // https://developer.apple.com/documentation/webkit/wkwebviewconfiguration + let configuration = WKWebViewConfiguration() + // https://developer.apple.com/documentation/webkit/wkwebviewconfiguration/2875766-seturlschemehandler + configuration.setURLSchemeHandler(USchemeHandler(), forURLScheme: AppWebViewUrlScheme) + // https://developer.apple.com/documentation/webkit/wkusercontentcontroller + configuration.userContentController.add(self, name: "controller") + // https://developer.apple.com/documentation/webkit/wkwebview + self.webView = WKWebView(frame: .zero, configuration: configuration) + // https://developer.apple.com/documentation/webkit/wknavigationdelegate + self.webView.navigationDelegate = self #if DEBUG - // https://webkit.org/blog/13936/enabling-the-inspection-of-web-content-in-apps/ - if #available(macOS 13.3, iOS 16.4, tvOS 16.4, *) { - // https://developer.apple.com/documentation/webkit/wkwebview/4111163-inspectable/ - self.webView.isInspectable = true - } - logger?.debug("\(#function, privacy: .public) - DEBUG mode: isInspectable = true") + // https://webkit.org/blog/13936/enabling-the-inspection-of-web-content-in-apps/ + if #available(macOS 13.3, iOS 16.4, tvOS 16.4, *) { + // https://developer.apple.com/documentation/webkit/wkwebview/4111163-inspectable/ + self.webView.isInspectable = true + } + logger?.debug("\(#function, privacy: .public) - DEBUG mode: isInspectable = true") #endif - view = webView - self.webView.scrollView.isScrollEnabled = false - self.webView.isOpaque = false - let backgroundColor = UIColor.init(red: (47/255.0), green: (51/255.0), blue: (55/255.0), alpha: 1.0) - view.setValue(backgroundColor, forKey: "backgroundColor") - self.webView.backgroundColor = backgroundColor - } + view = webView + self.webView.scrollView.isScrollEnabled = false + self.webView.isOpaque = false + let backgroundColor = UIColor.init(red: (47/255.0), green: (51/255.0), blue: (55/255.0), alpha: 1.0) + view.setValue(backgroundColor, forKey: "backgroundColor") + self.webView.backgroundColor = backgroundColor + } - // https://developer.apple.com/documentation/uikit/uiviewcontroller/1621495-viewdidload - override func viewDidLoad() { - super.viewDidLoad() - webView.load(URLRequest(url: URL(string: "\(AppWebViewUrlScheme):///")!)) - } + // https://developer.apple.com/documentation/uikit/uiviewcontroller/1621495-viewdidload + override func viewDidLoad() { + super.viewDidLoad() + webView.load(URLRequest(url: URL(string: "\(AppWebViewUrlScheme):///")!)) + } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" - let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" - webView.evaluateJavaScript("APP.printVersion('v\(appVersion)', '(\(buildNumber))')") - webView.evaluateJavaScript("APP.printDirectory('\(getCurrentScriptsDirectoryString())')") - } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" + webView.evaluateJavaScript("APP.printVersion('v\(appVersion)', '(\(buildNumber))')") + webView.evaluateJavaScript("APP.printDirectory('\(getCurrentScriptsDirectoryString())')") + } - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - if navigationAction.navigationType == .linkActivated { - guard let url = navigationAction.request.url else { - decisionHandler(.allow) - return - } - // allow registration scheme - if url.scheme == AppWebViewUrlScheme { - decisionHandler(.allow) - return - } - // allow specified url prefixes -// let allowPrefixes = [ -// "https://github.com/quoid/userscripts" -// ] -// for prefix in allowPrefixes { -// if url.absoluteString.lowercased().hasPrefix(prefix) { -// decisionHandler(.allow) -// return -// } -// } - // open from external app like safari - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - decisionHandler(.cancel) - return - } - decisionHandler(.allow) - } - decisionHandler(.allow) - } + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if navigationAction.navigationType == .linkActivated { + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + // allow registration scheme + if url.scheme == AppWebViewUrlScheme { + decisionHandler(.allow) + return + } + // allow specified url prefixes +// let allowPrefixes = [ +// "https://github.com/quoid/userscripts" +// ] +// for prefix in allowPrefixes { +// if url.absoluteString.lowercased().hasPrefix(prefix) { +// decisionHandler(.allow) +// return +// } +// } + // open from external app like safari + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + decisionHandler(.cancel) + return + } + decisionHandler(.allow) + } + decisionHandler(.allow) + } - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard let name = message.body as? String else { - logger?.error("\(#function, privacy: .public) - Userscripts iOS received a message without a name") - return - } - if name == "CHANGE_DIRECTORY" { - // https://developer.apple.com/documentation/uikit/view_controllers/providing_access_to_directories - logger?.info("\(#function, privacy: .public) - Userscripts iOS has requested to set the readLocation") - let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder]) - documentPicker.delegate = self - documentPicker.directoryURL = getDocumentsDirectory() - present(documentPicker, animated: true, completion: nil) - } - if name == "OPEN_DIRECTORY" { - guard var components = URLComponents(url: Preferences.scriptsDirectoryUrl, resolvingAgainstBaseURL: true) else { - return - } - components.scheme = "shareddocuments" - if let url = components.url, UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) - } - } - } + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let name = message.body as? String else { + logger?.error("\(#function, privacy: .public) - Userscripts iOS received a message without a name") + return + } + if name == "CHANGE_DIRECTORY" { + // https://developer.apple.com/documentation/uikit/view_controllers/providing_access_to_directories + logger?.info("\(#function, privacy: .public) - Userscripts iOS has requested to set the readLocation") + let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder]) + documentPicker.delegate = self + documentPicker.directoryURL = getDocumentsDirectory() + present(documentPicker, animated: true, completion: nil) + } + if name == "OPEN_DIRECTORY" { + guard var components = URLComponents(url: Preferences.scriptsDirectoryUrl, resolvingAgainstBaseURL: true) else { + return + } + components.scheme = "shareddocuments" + if let url = components.url, UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + } - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { - Preferences.scriptsDirectoryUrl = url - webView.evaluateJavaScript("APP.printDirectory('\(getCurrentScriptsDirectoryString())')") - } + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { + Preferences.scriptsDirectoryUrl = url + webView.evaluateJavaScript("APP.printDirectory('\(getCurrentScriptsDirectoryString())')") + } } diff --git a/xcode/App-iOS/AppDelegate.swift b/xcode/App-iOS/AppDelegate.swift index 1857846c..9da1b4c6 100644 --- a/xcode/App-iOS/AppDelegate.swift +++ b/xcode/App-iOS/AppDelegate.swift @@ -12,16 +12,16 @@ import os @main class AppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? + var window: UIWindow? - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - initializeFirstStart() - return true - } + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + initializeFirstStart() + return true + } - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } } diff --git a/xcode/App-iOS/Initialization.swift b/xcode/App-iOS/Initialization.swift index ab6f6e9f..7585ff7d 100644 --- a/xcode/App-iOS/Initialization.swift +++ b/xcode/App-iOS/Initialization.swift @@ -3,7 +3,7 @@ import Foundation private let logger = USLogger(#fileID) private func createDemoScript() { - let demoScript = """ + let demoScript = """ // ==UserScript== // @name Demo user script // @description I am a demo user script that you can safely delete (add any files to this folder and I will no longer automatically generate) @@ -15,35 +15,35 @@ private func createDemoScript() { // ==/UserScript== (function () { - 'use strict'; - // here is your code + 'use strict'; + // here is your code })(); """ - let url = getDocumentsDirectory() - // get a list of non-hidden files - guard let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [], options: .skipsHiddenFiles) else { - logger?.error("\(#function, privacy: .public) - failed get contentsOfDirectory") - return - } - if contents.isEmpty { - let fileURL = url.appendingPathComponent("Demo_user_script.user.js") - do { - logger?.info("\(#function, privacy: .public) - try to write demo user script") - try demoScript.write(to: fileURL, atomically: true, encoding: .utf8) - } catch { - logger?.error("\(#function, privacy: .public) - failed to write demo user script") - } - } + let url = getDocumentsDirectory() + // get a list of non-hidden files + guard let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: [], options: .skipsHiddenFiles) else { + logger?.error("\(#function, privacy: .public) - failed get contentsOfDirectory") + return + } + if contents.isEmpty { + let fileURL = url.appendingPathComponent("Demo_user_script.user.js") + do { + logger?.info("\(#function, privacy: .public) - try to write demo user script") + try demoScript.write(to: fileURL, atomically: true, encoding: .utf8) + } catch { + logger?.error("\(#function, privacy: .public) - failed to write demo user script") + } + } } func initializeFirstStart() { - // set the scripts directory to the app document on first use - if isCurrentDefaultScriptsDirectory() { - logger?.info("\(#function, privacy: .public) - Initialize default directory") - Preferences.scriptsDirectoryUrl = getDocumentsDirectory() - } - // put a visible file to display the documents directory in files app - if isCurrentInitialScriptsDirectory() { - createDemoScript() - } + // set the scripts directory to the app document on first use + if isCurrentDefaultScriptsDirectory() { + logger?.info("\(#function, privacy: .public) - Initialize default directory") + Preferences.scriptsDirectoryUrl = getDocumentsDirectory() + } + // put a visible file to display the documents directory in files app + if isCurrentInitialScriptsDirectory() { + createDemoScript() + } } diff --git a/xcode/App-iOS/SceneDelegate.swift b/xcode/App-iOS/SceneDelegate.swift index 1d5426bd..e733d27e 100644 --- a/xcode/App-iOS/SceneDelegate.swift +++ b/xcode/App-iOS/SceneDelegate.swift @@ -10,10 +10,10 @@ import UIKit class SceneDelegate: UIResponder, UIWindowSceneDelegate { - var window: UIWindow? + var window: UIWindow? - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - guard let _ = (scene as? UIWindowScene) else { return } - } + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let _ = (scene as? UIWindowScene) else { return } + } } diff --git a/xcode/Ext-Safari/Functions.swift b/xcode/Ext-Safari/Functions.swift index 03d3397a..9ce1f86b 100644 --- a/xcode/Ext-Safari/Functions.swift +++ b/xcode/Ext-Safari/Functions.swift @@ -4,1954 +4,1954 @@ private let logger = USLogger(#fileID) // helpers func getRequireLocation() -> URL { - // simple helper in case required code save directory needs to change - return getDocumentsDirectory().appendingPathComponent("require") + // simple helper in case required code save directory needs to change + return getDocumentsDirectory().appendingPathComponent("require") } func dateToMilliseconds(_ date: Date) -> Int { - let since1970 = date.timeIntervalSince1970 - return Int(since1970 * 1000) + let since1970 = date.timeIntervalSince1970 + return Int(since1970 * 1000) } func sanitize(_ str: String) -> String { - // removes invalid filename characters from strings - var sanitized = str - if sanitized.first == "." { - sanitized = "%2" + str.dropFirst() - } - sanitized = sanitized.replacingOccurrences(of: "/", with: "%2F") - sanitized = sanitized.replacingOccurrences(of: ":", with: "%3A") - sanitized = sanitized.replacingOccurrences(of: "\\", with: "%5C") - return sanitized + // removes invalid filename characters from strings + var sanitized = str + if sanitized.first == "." { + sanitized = "%2" + str.dropFirst() + } + sanitized = sanitized.replacingOccurrences(of: "/", with: "%2F") + sanitized = sanitized.replacingOccurrences(of: ":", with: "%3A") + sanitized = sanitized.replacingOccurrences(of: "\\", with: "%5C") + return sanitized } func unsanitize(_ str: String) -> String { - var s = str - if s.hasPrefix("%2") && !s.hasPrefix("%2F") { - s = "." + s.dropFirst(2) - } - if s.removingPercentEncoding != s { - s = s.removingPercentEncoding ?? s - } - return s + var s = str + if s.hasPrefix("%2") && !s.hasPrefix("%2F") { + s = "." + s.dropFirst(2) + } + if s.removingPercentEncoding != s { + s = s.removingPercentEncoding ?? s + } + return s } func normalizeWeight(_ weight: String) -> String { - if let w = Int(weight) { - if w > 999 { - return "999" - } else if w < 1 { - return "1" - } else { - return weight - } - } else { - return "1" - } + if let w = Int(weight) { + if w > 999 { + return "999" + } else if w < 1 { + return "1" + } else { + return weight + } + } else { + return "1" + } } func getSaveLocation() -> URL? { #if os(iOS) - if isCurrentDefaultScriptsDirectory() { - logger?.info("\(#function, privacy: .public) - Uninitialized save location") - return nil - } + if isCurrentDefaultScriptsDirectory() { + logger?.info("\(#function, privacy: .public) - Uninitialized save location") + return nil + } #endif - let url = Preferences.scriptsDirectoryUrl - logger?.debug("\(#function, privacy: .public) - \(url, privacy: .public)") - return url + let url = Preferences.scriptsDirectoryUrl + logger?.debug("\(#function, privacy: .public) - \(url, privacy: .public)") + return url } func openSaveLocation() -> Bool { - #if os(macOS) - guard let saveLocation = getSaveLocation() else { - return false - } - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: saveLocation.path) - #endif - return true + #if os(macOS) + guard let saveLocation = getSaveLocation() else { + return false + } + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: saveLocation.path) + #endif + return true } func validateUrl(_ urlString: String) -> Bool { - guard - let parts = jsLikeURL(urlString), - let ptcl = parts["protocol"], - let path = parts["pathname"] - else { - logger?.error("\(#function, privacy: .public) - Invalid URL: \(urlString, privacy: .public)") - return false - } - if - (ptcl != "https:" && ptcl != "http:") - || (!path.hasSuffix(".css") && !path.hasSuffix(".js")) - { - return false - } - return true + guard + let parts = jsLikeURL(urlString), + let ptcl = parts["protocol"], + let path = parts["pathname"] + else { + logger?.error("\(#function, privacy: .public) - Invalid URL: \(urlString, privacy: .public)") + return false + } + if + (ptcl != "https:" && ptcl != "http:") + || (!path.hasSuffix(".css") && !path.hasSuffix(".js")) + { + return false + } + return true } func isVersionNewer(_ oldVersion: String, _ newVersion: String) -> Bool { - let oldVersions = oldVersion.components(separatedBy: ".") - let newVersions = newVersion.components(separatedBy: ".") - for (index, version) in newVersions.enumerated() { - let a = Int(version) ?? 0 - let oldVersionValue = oldVersions.indices.contains(index) ? oldVersions[index] : "0" - let b = Int(oldVersionValue) ?? 0 - if a > b { - return true - } - if a < b { - return false - } - } - return false + let oldVersions = oldVersion.components(separatedBy: ".") + let newVersions = newVersion.components(separatedBy: ".") + for (index, version) in newVersions.enumerated() { + let a = Int(version) ?? 0 + let oldVersionValue = oldVersions.indices.contains(index) ? oldVersions[index] : "0" + let b = Int(oldVersionValue) ?? 0 + if a > b { + return true + } + if a < b { + return false + } + } + return false } func isEncoded(_ str: String) -> Bool { - if let decoded = str.removingPercentEncoding { - return decoded != str - } - return false + if let decoded = str.removingPercentEncoding { + return decoded != str + } + return false } // parser func parse(_ content: String) -> [String: Any]? { - // returns structured data from content of file - // will fail to parse if metablock or required @name key missing - let pattern = #"(?:(\/\/ ==UserScript==[ \t]*?\r?\n([\S\s]*?)\r?\n\/\/ ==\/UserScript==)([\S\s]*)|(\/\* ==UserStyle==[ \t]*?\r?\n([\S\s]*?)\r?\n==\/UserStyle== \*\/)([\S\s]*))"# - // force try b/c pattern is known to be valid regex - let regex = try! NSRegularExpression(pattern: pattern, options: []) - let range = NSRange(location: 0, length: content.utf16.count) - // return nil/fail if metablock missing - guard let match = regex.firstMatch(in: content, options: [], range: range) else { - return nil - } - - // at this point the text content has passed initial validation, it contains valid metadata - // the metadata can be in userscript or userstyle format, need to check for this and adjust group numbers - // rather than being too strict, text content can precede the opening userscript tag, however it will be ignored - // adjust start index of file content while assigning group numbers to account for any text content preceding opening tag - let contentStartIndex = content.index(content.startIndex, offsetBy: match.range.lowerBound) - var g1, g2, g3:Int - if (content[contentStartIndex.. Bool { - let content = data - let url = getDocumentsDirectory().appendingPathComponent("manifest.json") - do { - let encoder = JSONEncoder() - encoder.outputFormatting = .prettyPrinted - let encoded = try encoder.encode(content) - let fileContent = String(decoding: encoded, as: UTF8.self) - try fileContent.write(to: url, atomically: false, encoding: .utf8) - return true - } catch { - logger?.error("\(#function, privacy: .public) - failed to update manifest: \(error.localizedDescription, privacy: .public)") - return false - } + let content = data + let url = getDocumentsDirectory().appendingPathComponent("manifest.json") + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let encoded = try encoder.encode(content) + let fileContent = String(decoding: encoded, as: UTF8.self) + try fileContent.write(to: url, atomically: false, encoding: .utf8) + return true + } catch { + logger?.error("\(#function, privacy: .public) - failed to update manifest: \(error.localizedDescription, privacy: .public)") + return false + } } func getManifest() -> Manifest { - let url = getDocumentsDirectory().appendingPathComponent("manifest.json") - if - let content = try? String(contentsOf: url, encoding: .utf8), - let data = content.data(using: .utf8), - let decoded = try? JSONDecoder().decode(Manifest.self, from: Data(data)) - { - return decoded - } else { - // manifest missing, improperly formatted or missing key - // create new manifest with default key/vals - let manifest = Manifest( - blacklist: [], - declarativeNetRequest: [], - disabled: [], - exclude: [:], - excludeMatch: [:], - include: [:], - match: [:], - require: [:], - settings: defaultSettings - ) - _ = updateManifest(with: manifest) - return manifest - } + let url = getDocumentsDirectory().appendingPathComponent("manifest.json") + if + let content = try? String(contentsOf: url, encoding: .utf8), + let data = content.data(using: .utf8), + let decoded = try? JSONDecoder().decode(Manifest.self, from: Data(data)) + { + return decoded + } else { + // manifest missing, improperly formatted or missing key + // create new manifest with default key/vals + let manifest = Manifest( + blacklist: [], + declarativeNetRequest: [], + disabled: [], + exclude: [:], + excludeMatch: [:], + include: [:], + match: [:], + require: [:], + settings: defaultSettings + ) + _ = updateManifest(with: manifest) + return manifest + } } func updateManifestMatches(_ optionalFilesArray: [[String: Any]] = []) -> Bool { - logger?.info("\(#function, privacy: .public) - started") - // only get all files if files were not provided - var files = [[String: Any]]() - if optionalFilesArray.isEmpty { - guard let getFiles = getAllFiles() else {return false} - files = getFiles - } else { - files = optionalFilesArray - } - var manifest = getManifest() - for file in files { - // can be force unwrapped because getAllFiles didn't return nil - let metadata = file["metadata"] as! [String: [String]] - let filename = file["filename"] as! String - // skip request type userscripts - let runAt = metadata["run-at"]?[0] ?? "document-end" - if runAt == "request" { - continue - } - // populate excludes & matches - var excludeMatched = [String]() - var matched = [String]() - var excluded = [String]() - var included = [String]() - if metadata["exclude-match"] != nil { - excludeMatched.append(contentsOf: metadata["exclude-match"]!) - } - if metadata["match"] != nil { - matched.append(contentsOf: metadata["match"]!) - } - if metadata["include"] != nil { - included.append(contentsOf: metadata["include"]!) - } - if metadata["exclude"] != nil { - excluded.append(contentsOf: metadata["exclude"]!) - } - // if in declarativeNetRequest array, remove it - if manifest.declarativeNetRequest.contains(filename) { - if let index = manifest.declarativeNetRequest.firstIndex(of: filename) { - manifest.declarativeNetRequest.remove(at: index) - } else { - logger?.error("\(#function, privacy: .public) - failed to remove \(filename, privacy: .public) from dNR array") - } - } - - // update manifest values - manifest.excludeMatch = updatePatternDict(filename, excludeMatched, manifest.excludeMatch) - manifest.match = updatePatternDict(filename, matched, manifest.match) - manifest.exclude = updatePatternDict(filename, excluded, manifest.exclude) - manifest.include = updatePatternDict(filename, included, manifest.include) - - if !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - failed to update manifest matches") - return false - } - } - logger?.info("\(#function, privacy: .public) - completed") - return true + logger?.info("\(#function, privacy: .public) - started") + // only get all files if files were not provided + var files = [[String: Any]]() + if optionalFilesArray.isEmpty { + guard let getFiles = getAllFiles() else {return false} + files = getFiles + } else { + files = optionalFilesArray + } + var manifest = getManifest() + for file in files { + // can be force unwrapped because getAllFiles didn't return nil + let metadata = file["metadata"] as! [String: [String]] + let filename = file["filename"] as! String + // skip request type userscripts + let runAt = metadata["run-at"]?[0] ?? "document-end" + if runAt == "request" { + continue + } + // populate excludes & matches + var excludeMatched = [String]() + var matched = [String]() + var excluded = [String]() + var included = [String]() + if metadata["exclude-match"] != nil { + excludeMatched.append(contentsOf: metadata["exclude-match"]!) + } + if metadata["match"] != nil { + matched.append(contentsOf: metadata["match"]!) + } + if metadata["include"] != nil { + included.append(contentsOf: metadata["include"]!) + } + if metadata["exclude"] != nil { + excluded.append(contentsOf: metadata["exclude"]!) + } + // if in declarativeNetRequest array, remove it + if manifest.declarativeNetRequest.contains(filename) { + if let index = manifest.declarativeNetRequest.firstIndex(of: filename) { + manifest.declarativeNetRequest.remove(at: index) + } else { + logger?.error("\(#function, privacy: .public) - failed to remove \(filename, privacy: .public) from dNR array") + } + } + + // update manifest values + manifest.excludeMatch = updatePatternDict(filename, excludeMatched, manifest.excludeMatch) + manifest.match = updatePatternDict(filename, matched, manifest.match) + manifest.exclude = updatePatternDict(filename, excluded, manifest.exclude) + manifest.include = updatePatternDict(filename, included, manifest.include) + + if !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - failed to update manifest matches") + return false + } + } + logger?.info("\(#function, privacy: .public) - completed") + return true } func updatePatternDict(_ filename: String, _ filePatterns: [String], _ manifestKeys: [String: [String]]) -> [String: [String]] { - // will hold the exclude/match patterns in manifest that have file name as value - var patternsInManifestForFile = [String]() - // new var from func argument, so it can be manipulated - var returnDictionary = manifestKeys - // patterns from manifest - let keys = returnDictionary.keys - // determine what patterns already have this filename as a value - for key in keys { - // key is an array of filenames - guard let filenames = returnDictionary[key] else { - logger?.error("\(#function, privacy: .public) - failed to get values for manifest key, \(key, privacy: .public)") - continue - } - for name in filenames { - // name is a single filename - // if name is same as filename, file already added for this pattern - // add it to patternsInManifestForFile for later comparison - if name == filename { - patternsInManifestForFile.append(key) - } - } - } - // patterns in file metadata and patterns in manifest that have filename as a value - // filename already present in manifest for these patterns, do nothing with these - // let common = filePatterns.filter{patternsInManifestForFile.contains($0)} - // patterns in file metadata, but don't have the filename as a value within the manifest - // these are the manifest patterns that the filename needs to be added to - let addFilenameTo = filePatterns.filter{!patternsInManifestForFile.contains($0)} - - // the patterns that have the filename as a value, but not present in file metadata - // ie. these are the manifest patterns we need to remove the filename from - let removeFilenameFrom = patternsInManifestForFile.filter{!filePatterns.contains($0)} - - // check if filename needs to be added or new key/val needs to be created - for pattern in addFilenameTo { - if returnDictionary[pattern] != nil { - returnDictionary[pattern]?.append(filename) - } else { - returnDictionary[pattern] = [filename] - } - } - - for pattern in removeFilenameFrom { - // get the index of the filename within the array - let ind = returnDictionary[pattern]?.firstIndex(of: filename) - // remove filename from array by index - returnDictionary[pattern]?.remove(at: ind!) - // if filename was the last item in array, remove the url pattern from dictionary - if returnDictionary[pattern]!.isEmpty { - returnDictionary.removeValue(forKey: pattern) - } - } - - return returnDictionary + // will hold the exclude/match patterns in manifest that have file name as value + var patternsInManifestForFile = [String]() + // new var from func argument, so it can be manipulated + var returnDictionary = manifestKeys + // patterns from manifest + let keys = returnDictionary.keys + // determine what patterns already have this filename as a value + for key in keys { + // key is an array of filenames + guard let filenames = returnDictionary[key] else { + logger?.error("\(#function, privacy: .public) - failed to get values for manifest key, \(key, privacy: .public)") + continue + } + for name in filenames { + // name is a single filename + // if name is same as filename, file already added for this pattern + // add it to patternsInManifestForFile for later comparison + if name == filename { + patternsInManifestForFile.append(key) + } + } + } + // patterns in file metadata and patterns in manifest that have filename as a value + // filename already present in manifest for these patterns, do nothing with these + // let common = filePatterns.filter{patternsInManifestForFile.contains($0)} + // patterns in file metadata, but don't have the filename as a value within the manifest + // these are the manifest patterns that the filename needs to be added to + let addFilenameTo = filePatterns.filter{!patternsInManifestForFile.contains($0)} + + // the patterns that have the filename as a value, but not present in file metadata + // ie. these are the manifest patterns we need to remove the filename from + let removeFilenameFrom = patternsInManifestForFile.filter{!filePatterns.contains($0)} + + // check if filename needs to be added or new key/val needs to be created + for pattern in addFilenameTo { + if returnDictionary[pattern] != nil { + returnDictionary[pattern]?.append(filename) + } else { + returnDictionary[pattern] = [filename] + } + } + + for pattern in removeFilenameFrom { + // get the index of the filename within the array + let ind = returnDictionary[pattern]?.firstIndex(of: filename) + // remove filename from array by index + returnDictionary[pattern]?.remove(at: ind!) + // if filename was the last item in array, remove the url pattern from dictionary + if returnDictionary[pattern]!.isEmpty { + returnDictionary.removeValue(forKey: pattern) + } + } + + return returnDictionary } func updateManifestRequired(_ optionalFilesArray: [[String: Any]] = []) -> Bool { - logger?.info("\(#function, privacy: .public) - started") - // only get all files if files were not provided - var files = [[String: Any]]() - if optionalFilesArray.isEmpty { - guard let getFiles = getAllFiles() else { - logger?.info("\(#function, privacy: .public) - count not get files") - return false - } - files = getFiles - } else { - files = optionalFilesArray - } - logger?.info("\(#function, privacy: .public) - will loop through \(files.count, privacy: .public)") - var manifest = getManifest() - for file in files { - // can be force unwrapped because getAllFiles didn't return nil - let filename = file["filename"] as! String - let metadata = file["metadata"] as! [String: [String]] - let type = file["type"] as! String - let required = metadata["require"] ?? [] - logger?.info("\(#function, privacy: .public) - begin \(filename, privacy: .public)") - // get required resources for file, if fail, skip updating manifest - if !getRequiredCode(filename, required, type) { - logger?.error("\(#function, privacy: .public) - couldn't fetch remote content for \(filename, privacy: .public)") - continue - } - - // create filenames from sanitized resource urls - // getRequiredCode does the same thing when saving to disk - // populate array with entries for manifest - var r = [String]() - for resource in required { - let sanitizedResourceName = sanitize(resource) - r.append(sanitizedResourceName) - } - - // if there are values, write them to manifest - // if failed to write to manifest, continue to next file & log error - if !r.isEmpty && r != manifest.require[filename] { - manifest.require[filename] = r - if !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - couldn't update manifest when getting required resources") - } - } - logger?.info("\(#function, privacy: .public) - end \(filename, privacy: .public)") - } - logger?.info("\(#function, privacy: .public) - completed") - return true + logger?.info("\(#function, privacy: .public) - started") + // only get all files if files were not provided + var files = [[String: Any]]() + if optionalFilesArray.isEmpty { + guard let getFiles = getAllFiles() else { + logger?.info("\(#function, privacy: .public) - count not get files") + return false + } + files = getFiles + } else { + files = optionalFilesArray + } + logger?.info("\(#function, privacy: .public) - will loop through \(files.count, privacy: .public)") + var manifest = getManifest() + for file in files { + // can be force unwrapped because getAllFiles didn't return nil + let filename = file["filename"] as! String + let metadata = file["metadata"] as! [String: [String]] + let type = file["type"] as! String + let required = metadata["require"] ?? [] + logger?.info("\(#function, privacy: .public) - begin \(filename, privacy: .public)") + // get required resources for file, if fail, skip updating manifest + if !getRequiredCode(filename, required, type) { + logger?.error("\(#function, privacy: .public) - couldn't fetch remote content for \(filename, privacy: .public)") + continue + } + + // create filenames from sanitized resource urls + // getRequiredCode does the same thing when saving to disk + // populate array with entries for manifest + var r = [String]() + for resource in required { + let sanitizedResourceName = sanitize(resource) + r.append(sanitizedResourceName) + } + + // if there are values, write them to manifest + // if failed to write to manifest, continue to next file & log error + if !r.isEmpty && r != manifest.require[filename] { + manifest.require[filename] = r + if !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - couldn't update manifest when getting required resources") + } + } + logger?.info("\(#function, privacy: .public) - end \(filename, privacy: .public)") + } + logger?.info("\(#function, privacy: .public) - completed") + return true } func updateManifestDeclarativeNetRequests(_ optionalFilesArray: [[String: Any]] = []) -> Bool { - logger?.info("\(#function, privacy: .public) - started") - var files = [[String: Any]]() - if optionalFilesArray.isEmpty { - guard let getFiles = getAllFiles() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return false - } - files = getFiles - } else { - files = optionalFilesArray - } - var manifest = getManifest() - for file in files { - // can be force unwrapped because getAllFiles didn't return nil - // and getAllFiles always returns the following - let metadata = file["metadata"] as! [String: [String]] - let filename = file["filename"] as! String - let runAt = metadata["run-at"]?[0] ?? "document-end" - // if not a request type, ignore - if runAt != "request" { - continue - } - var update = false - // if filename already in manifest - if !manifest.declarativeNetRequest.contains(filename) { - manifest.declarativeNetRequest.append(filename) - update = true - } - // if filename in another array remove it - for (pattern, filenames) in manifest.match { - for fn in filenames { - if fn == filename, let index = manifest.match[pattern]?.firstIndex(of: filename) { - manifest.match[pattern]?.remove(at: index) - update = true - } - } - } - for (pattern, filenames) in manifest.excludeMatch { - for fn in filenames { - if fn == filename, let index = manifest.excludeMatch[pattern]?.firstIndex(of: filename) { - manifest.excludeMatch[pattern]?.remove(at: index) - update = true - } - } - } - for (pattern, filenames) in manifest.include { - for fn in filenames { - if fn == filename, let index = manifest.include[pattern]?.firstIndex(of: filename) { - manifest.include[pattern]?.remove(at: index) - update = true - } - } - } - for (pattern, filenames) in manifest.exclude { - for fn in filenames { - if fn == filename, let index = manifest.exclude[pattern]?.firstIndex(of: filename) { - manifest.exclude[pattern]?.remove(at: index) - update = true - } - } - } - if update, !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return false - } - } - logger?.info("\(#function, privacy: .public) - completed") - return true + logger?.info("\(#function, privacy: .public) - started") + var files = [[String: Any]]() + if optionalFilesArray.isEmpty { + guard let getFiles = getAllFiles() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return false + } + files = getFiles + } else { + files = optionalFilesArray + } + var manifest = getManifest() + for file in files { + // can be force unwrapped because getAllFiles didn't return nil + // and getAllFiles always returns the following + let metadata = file["metadata"] as! [String: [String]] + let filename = file["filename"] as! String + let runAt = metadata["run-at"]?[0] ?? "document-end" + // if not a request type, ignore + if runAt != "request" { + continue + } + var update = false + // if filename already in manifest + if !manifest.declarativeNetRequest.contains(filename) { + manifest.declarativeNetRequest.append(filename) + update = true + } + // if filename in another array remove it + for (pattern, filenames) in manifest.match { + for fn in filenames { + if fn == filename, let index = manifest.match[pattern]?.firstIndex(of: filename) { + manifest.match[pattern]?.remove(at: index) + update = true + } + } + } + for (pattern, filenames) in manifest.excludeMatch { + for fn in filenames { + if fn == filename, let index = manifest.excludeMatch[pattern]?.firstIndex(of: filename) { + manifest.excludeMatch[pattern]?.remove(at: index) + update = true + } + } + } + for (pattern, filenames) in manifest.include { + for fn in filenames { + if fn == filename, let index = manifest.include[pattern]?.firstIndex(of: filename) { + manifest.include[pattern]?.remove(at: index) + update = true + } + } + } + for (pattern, filenames) in manifest.exclude { + for fn in filenames { + if fn == filename, let index = manifest.exclude[pattern]?.firstIndex(of: filename) { + manifest.exclude[pattern]?.remove(at: index) + update = true + } + } + } + if update, !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return false + } + } + logger?.info("\(#function, privacy: .public) - completed") + return true } func purgeManifest(_ optionalFilesArray: [[String: Any]] = []) -> Bool { - logger?.info("\(#function, privacy: .public) - started") - // purge all manifest keys of any stale entries - var update = false, manifest = getManifest(), allSaveLocationFilenames = [String]() - // only get all files if files were not provided - var allFiles = [[String: Any]]() - if optionalFilesArray.isEmpty { - // if getAllFiles fails to return, ignore and pass an empty array - let getFiles = getAllFiles() ?? [] - allFiles = getFiles - } else { - allFiles = optionalFilesArray - } - // populate array with filenames - for file in allFiles { - if let filename = file["filename"] as? String { - allSaveLocationFilenames.append(filename) - } - } - // loop through manifest keys, if no file exists for value, remove value from manifest - // if there are no more filenames in pattern, remove pattern from manifest - for (pattern, filenames) in manifest.match { - for filename in filenames { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.match[pattern]?.firstIndex(of: filename) { - manifest.match[pattern]?.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from match pattern - \(pattern, privacy: .public)") - } - } - } - if let length = manifest.match[pattern]?.count { - if length < 1, let ind = manifest.match.index(forKey: pattern) { - manifest.match.remove(at: ind) - logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) match pattern, removed from manifest") - } - } - } - for (pattern, filenames) in manifest.excludeMatch { - for filename in filenames { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.excludeMatch[pattern]?.firstIndex(of: filename) { - manifest.excludeMatch[pattern]?.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude-match pattern - \(pattern, privacy: .public)") - } - } - } - if let length = manifest.excludeMatch[pattern]?.count { - if length < 1, let ind = manifest.excludeMatch.index(forKey: pattern) { - manifest.excludeMatch.remove(at: ind) - logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude-match pattern, removed from manifest") - } - } - } - for (pattern, filenames) in manifest.exclude { - for filename in filenames { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.exclude[pattern]?.firstIndex(of: filename) { - manifest.exclude[pattern]?.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude pattern - \(pattern, privacy: .public)") - } - } - } - if let length = manifest.exclude[pattern]?.count { - if length < 1, let ind = manifest.exclude.index(forKey: pattern) { - manifest.exclude.remove(at: ind) - logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude pattern, removed from manifest") - } - } - } - for (pattern, filenames) in manifest.include { - for filename in filenames { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.include[pattern]?.firstIndex(of: filename) { - manifest.include[pattern]?.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude pattern - \(pattern, privacy: .public)") - } - } - } - if let length = manifest.include[pattern]?.count { - if length < 1, let ind = manifest.include.index(forKey: pattern) { - manifest.include.remove(at: ind) - logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude pattern, removed from manifest") - } - } - } - // loop through manifest required - for (filename, _) in manifest.require { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.require.index(forKey: filename) { - manifest.require.remove(at: index) - // remove associated resources - if !getRequiredCode(filename, [], (filename as NSString).pathExtension) { - logger?.error("\(#function, privacy: .public) - failed to remove required resources when purging \(filename, privacy: .public) from manifest required records") - } - update = true - logger?.info("\(#function, privacy: .public) - No more required resources for \(filename, privacy: .public), removed from manifest along with resource folder") - } - } - } - // loop through manifest disabled - for filename in manifest.disabled { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.disabled.firstIndex(of: filename) { - manifest.disabled.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from disabled") - } - } - } - // loop through manifest declarativeNetRequest - for filename in manifest.declarativeNetRequest { - if !allSaveLocationFilenames.contains(filename) { - if let index = manifest.declarativeNetRequest.firstIndex(of: filename) { - manifest.declarativeNetRequest.remove(at: index) - update = true - logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from dNR") - } - } - } - // remove obsolete settings - for setting in manifest.settings { - if !defaultSettings.keys.contains(setting.key) { - manifest.settings.removeValue(forKey: setting.key) - update = true - logger?.info("\(#function, privacy: .public) - Removed obsolete setting - \(setting.key, privacy: .public)") - } - } - if update, !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - failed to purge manifest") - return false - } - logger?.info("\(#function, privacy: .public) - completed") - return true + logger?.info("\(#function, privacy: .public) - started") + // purge all manifest keys of any stale entries + var update = false, manifest = getManifest(), allSaveLocationFilenames = [String]() + // only get all files if files were not provided + var allFiles = [[String: Any]]() + if optionalFilesArray.isEmpty { + // if getAllFiles fails to return, ignore and pass an empty array + let getFiles = getAllFiles() ?? [] + allFiles = getFiles + } else { + allFiles = optionalFilesArray + } + // populate array with filenames + for file in allFiles { + if let filename = file["filename"] as? String { + allSaveLocationFilenames.append(filename) + } + } + // loop through manifest keys, if no file exists for value, remove value from manifest + // if there are no more filenames in pattern, remove pattern from manifest + for (pattern, filenames) in manifest.match { + for filename in filenames { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.match[pattern]?.firstIndex(of: filename) { + manifest.match[pattern]?.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from match pattern - \(pattern, privacy: .public)") + } + } + } + if let length = manifest.match[pattern]?.count { + if length < 1, let ind = manifest.match.index(forKey: pattern) { + manifest.match.remove(at: ind) + logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) match pattern, removed from manifest") + } + } + } + for (pattern, filenames) in manifest.excludeMatch { + for filename in filenames { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.excludeMatch[pattern]?.firstIndex(of: filename) { + manifest.excludeMatch[pattern]?.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude-match pattern - \(pattern, privacy: .public)") + } + } + } + if let length = manifest.excludeMatch[pattern]?.count { + if length < 1, let ind = manifest.excludeMatch.index(forKey: pattern) { + manifest.excludeMatch.remove(at: ind) + logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude-match pattern, removed from manifest") + } + } + } + for (pattern, filenames) in manifest.exclude { + for filename in filenames { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.exclude[pattern]?.firstIndex(of: filename) { + manifest.exclude[pattern]?.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude pattern - \(pattern, privacy: .public)") + } + } + } + if let length = manifest.exclude[pattern]?.count { + if length < 1, let ind = manifest.exclude.index(forKey: pattern) { + manifest.exclude.remove(at: ind) + logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude pattern, removed from manifest") + } + } + } + for (pattern, filenames) in manifest.include { + for filename in filenames { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.include[pattern]?.firstIndex(of: filename) { + manifest.include[pattern]?.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from exclude pattern - \(pattern, privacy: .public)") + } + } + } + if let length = manifest.include[pattern]?.count { + if length < 1, let ind = manifest.include.index(forKey: pattern) { + manifest.include.remove(at: ind) + logger?.info("\(#function, privacy: .public) - No more files for \(pattern, privacy: .public) exclude pattern, removed from manifest") + } + } + } + // loop through manifest required + for (filename, _) in manifest.require { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.require.index(forKey: filename) { + manifest.require.remove(at: index) + // remove associated resources + if !getRequiredCode(filename, [], (filename as NSString).pathExtension) { + logger?.error("\(#function, privacy: .public) - failed to remove required resources when purging \(filename, privacy: .public) from manifest required records") + } + update = true + logger?.info("\(#function, privacy: .public) - No more required resources for \(filename, privacy: .public), removed from manifest along with resource folder") + } + } + } + // loop through manifest disabled + for filename in manifest.disabled { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.disabled.firstIndex(of: filename) { + manifest.disabled.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from disabled") + } + } + } + // loop through manifest declarativeNetRequest + for filename in manifest.declarativeNetRequest { + if !allSaveLocationFilenames.contains(filename) { + if let index = manifest.declarativeNetRequest.firstIndex(of: filename) { + manifest.declarativeNetRequest.remove(at: index) + update = true + logger?.info("\(#function, privacy: .public) - Could not find \(filename, privacy: .public) in save location, removed from dNR") + } + } + } + // remove obsolete settings + for setting in manifest.settings { + if !defaultSettings.keys.contains(setting.key) { + manifest.settings.removeValue(forKey: setting.key) + update = true + logger?.info("\(#function, privacy: .public) - Removed obsolete setting - \(setting.key, privacy: .public)") + } + } + if update, !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - failed to purge manifest") + return false + } + logger?.info("\(#function, privacy: .public) - completed") + return true } // settings func checkSettings() -> Bool { - // iterate over default settings and individually check if each present - // if missing add setting to manifest about to be returned - // missing keys will occur when new settings introduced - var manifest = getManifest() - var update = false - for (key, value) in defaultSettings { - if manifest.settings[key] == nil { - manifest.settings[key] = value - update = true - } - } - if update, !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - failed to update manifest settings") - return false - } - return true + // iterate over default settings and individually check if each present + // if missing add setting to manifest about to be returned + // missing keys will occur when new settings introduced + var manifest = getManifest() + var update = false + for (key, value) in defaultSettings { + if manifest.settings[key] == nil { + manifest.settings[key] = value + update = true + } + } + if update, !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - failed to update manifest settings") + return false + } + return true } func updateSettings(_ settings: [String: String]) -> Bool { - var manifest = getManifest() - manifest.settings = settings - if updateManifest(with: manifest) != true { - logger?.error("\(#function, privacy: .public) - failed to update settings") - return false - } - return true + var manifest = getManifest() + manifest.settings = settings + if updateManifest(with: manifest) != true { + logger?.error("\(#function, privacy: .public) - failed to update settings") + return false + } + return true } // files func getAllFiles(includeCode: Bool = false) -> [[String: Any]]? { - logger?.info("\(#function, privacy: .public) - started") - // returns all files of proper type with filenames, metadata & more - var files = [[String: Any]]() - let fm = FileManager.default - let manifest = getManifest() - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - // get all file urls within save location - guard let urls = try? fm.contentsOfDirectory(at: saveLocation, includingPropertiesForKeys: []) else { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return nil - } - for url in urls { - var fileData = [String: Any]() - // only read contents for css & js files - let filename = url.lastPathComponent - if (!filename.hasSuffix(".css") && !filename.hasSuffix(".js")) { - continue - } - // file will be skipped if metablock is missing - guard - let content = try? String(contentsOf: url, encoding: .utf8), - let dateMod = try? fm.attributesOfItem(atPath: url.path)[.modificationDate] as? Date, - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]], - let type = filename.split(separator: ".").last - else { - logger?.info("\(#function, privacy: .public) - ignoring \(filename, privacy: .public), file missing or metadata missing from file contents") - continue - } - fileData["canUpdate"] = false - fileData["content"] = content - fileData["disabled"] = manifest.disabled.contains(filename) - fileData["filename"] = filename - fileData["lastModified"] = dateToMilliseconds(dateMod) - fileData["metadata"] = metadata - // force unwrap name since parser ensure it exists - fileData["name"] = metadata["name"]![0] - fileData["type"] = "\(type)" - // add extra data if a request userscript - let runAt = metadata["run-at"]?[0] ?? "document-end" - if runAt == "request" { - fileData["request"] = true - } - if metadata["description"] != nil { - fileData["description"] = metadata["description"]![0] - } - if metadata["version"] != nil && metadata["updateURL"] != nil { - fileData["canUpdate"] = true - } - fileData["noframes"] = metadata["noframes"] != nil ? true : false - // if asked, also return the code string - if (includeCode) { - // can force unwrap because always returned from parser - fileData["code"] = parsed["code"] as! String - } - files.append(fileData) - } - logger?.info("\(#function, privacy: .public) - completed") - return files + logger?.info("\(#function, privacy: .public) - started") + // returns all files of proper type with filenames, metadata & more + var files = [[String: Any]]() + let fm = FileManager.default + let manifest = getManifest() + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + // get all file urls within save location + guard let urls = try? fm.contentsOfDirectory(at: saveLocation, includingPropertiesForKeys: []) else { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return nil + } + for url in urls { + var fileData = [String: Any]() + // only read contents for css & js files + let filename = url.lastPathComponent + if (!filename.hasSuffix(".css") && !filename.hasSuffix(".js")) { + continue + } + // file will be skipped if metablock is missing + guard + let content = try? String(contentsOf: url, encoding: .utf8), + let dateMod = try? fm.attributesOfItem(atPath: url.path)[.modificationDate] as? Date, + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]], + let type = filename.split(separator: ".").last + else { + logger?.info("\(#function, privacy: .public) - ignoring \(filename, privacy: .public), file missing or metadata missing from file contents") + continue + } + fileData["canUpdate"] = false + fileData["content"] = content + fileData["disabled"] = manifest.disabled.contains(filename) + fileData["filename"] = filename + fileData["lastModified"] = dateToMilliseconds(dateMod) + fileData["metadata"] = metadata + // force unwrap name since parser ensure it exists + fileData["name"] = metadata["name"]![0] + fileData["type"] = "\(type)" + // add extra data if a request userscript + let runAt = metadata["run-at"]?[0] ?? "document-end" + if runAt == "request" { + fileData["request"] = true + } + if metadata["description"] != nil { + fileData["description"] = metadata["description"]![0] + } + if metadata["version"] != nil && metadata["updateURL"] != nil { + fileData["canUpdate"] = true + } + fileData["noframes"] = metadata["noframes"] != nil ? true : false + // if asked, also return the code string + if (includeCode) { + // can force unwrap because always returned from parser + fileData["code"] = parsed["code"] as! String + } + files.append(fileData) + } + logger?.info("\(#function, privacy: .public) - completed") + return files } func getRequiredCode(_ filename: String, _ resources: [String], _ fileType: String) -> Bool { - let directory = getRequireLocation().appendingPathComponent(filename) - // if file requires no resource but directory exists, remove it - // this resource is not user-generated and can be downloaded again, so just remove it instead of moves to the trash - // also in ios, the volume “Data” has no trash and item can only be removed directly - if resources.isEmpty { - if FileManager.default.fileExists(atPath: directory.path) { - do { - try FileManager.default.removeItem(at: directory) - } catch { - logger?.error("\(#function, privacy: .public) - failed to remove directory: \(error.localizedDescription, privacy: .public)") - } - } - return true - } - // record URLs for subsequent processing - var resourceUrls = Set() - // loop through resource urls and attempt to fetch it - for resourceUrlString in resources { - // get the path of the url string - guard let resourceUrlPath = URLComponents(string: resourceUrlString)?.path else { - // if path can not be obtained, skip and log - logger?.info("\(#function, privacy: .public) - failed to get path on \(filename, privacy: .public) for \(resourceUrlString, privacy: .public)") - continue - } - // skip urls pointing to files of different types - if resourceUrlPath.hasSuffix(fileType) { - let resourceFilename = sanitize(resourceUrlString) - let fileURL = directory.appendingPathComponent(resourceFilename) - // insert url to resolve symlink into set - resourceUrls.insert(fileURL.standardizedFileURL) - // only attempt to get resource if it does not yet exist - if FileManager.default.fileExists(atPath: fileURL.path) {continue} - // get the remote file contents - guard let contents = getRemoteFileContents(resourceUrlString) else {continue} - // check if file specific folder exists at requires directory - if !FileManager.default.fileExists(atPath: directory.path) { - guard ((try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)) != nil) else { - logger?.info("\(#function, privacy: .public) - failed to create required code directory for \(filename, privacy: .public)") - return false - } - } - // finally write file to directory - guard ((try? contents.write(to: fileURL, atomically: false, encoding: .utf8)) != nil) else { - logger?.info("\(#function, privacy: .public) - failed to write content to file for \(filename, privacy: .public) from \(resourceUrlString, privacy: .public)") - return false - } - } - } - // cleanup downloaded files that are no longer required - do { - var downloadedUrls = Set() - // get all downloaded resources url - let fileUrls = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: []) - // insert url to resolve symlink into set - for url in fileUrls { downloadedUrls.insert(url.standardizedFileURL) } - // exclude currently required resources - let abandonedUrls = downloadedUrls.subtracting(resourceUrls) - // loop through abandoned urls and attempt to remove it - for abandonFileUrl in abandonedUrls { - do { - try FileManager.default.removeItem(at: abandonFileUrl) - logger?.info("\(#function, privacy: .public) - cleanup abandoned resource: \(unsanitize(abandonFileUrl.lastPathComponent), privacy: .public)") - } catch { - logger?.error("\(#function, privacy: .public) - failed to remove abandoned resource: \(error.localizedDescription, privacy: .public)") - } - } - } catch { - logger?.error("\(#function, privacy: .public) - failed to cleanup resources: \(error.localizedDescription, privacy: .public)") - } - return true + let directory = getRequireLocation().appendingPathComponent(filename) + // if file requires no resource but directory exists, remove it + // this resource is not user-generated and can be downloaded again, so just remove it instead of moves to the trash + // also in ios, the volume “Data” has no trash and item can only be removed directly + if resources.isEmpty { + if FileManager.default.fileExists(atPath: directory.path) { + do { + try FileManager.default.removeItem(at: directory) + } catch { + logger?.error("\(#function, privacy: .public) - failed to remove directory: \(error.localizedDescription, privacy: .public)") + } + } + return true + } + // record URLs for subsequent processing + var resourceUrls = Set() + // loop through resource urls and attempt to fetch it + for resourceUrlString in resources { + // get the path of the url string + guard let resourceUrlPath = URLComponents(string: resourceUrlString)?.path else { + // if path can not be obtained, skip and log + logger?.info("\(#function, privacy: .public) - failed to get path on \(filename, privacy: .public) for \(resourceUrlString, privacy: .public)") + continue + } + // skip urls pointing to files of different types + if resourceUrlPath.hasSuffix(fileType) { + let resourceFilename = sanitize(resourceUrlString) + let fileURL = directory.appendingPathComponent(resourceFilename) + // insert url to resolve symlink into set + resourceUrls.insert(fileURL.standardizedFileURL) + // only attempt to get resource if it does not yet exist + if FileManager.default.fileExists(atPath: fileURL.path) {continue} + // get the remote file contents + guard let contents = getRemoteFileContents(resourceUrlString) else {continue} + // check if file specific folder exists at requires directory + if !FileManager.default.fileExists(atPath: directory.path) { + guard ((try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)) != nil) else { + logger?.info("\(#function, privacy: .public) - failed to create required code directory for \(filename, privacy: .public)") + return false + } + } + // finally write file to directory + guard ((try? contents.write(to: fileURL, atomically: false, encoding: .utf8)) != nil) else { + logger?.info("\(#function, privacy: .public) - failed to write content to file for \(filename, privacy: .public) from \(resourceUrlString, privacy: .public)") + return false + } + } + } + // cleanup downloaded files that are no longer required + do { + var downloadedUrls = Set() + // get all downloaded resources url + let fileUrls = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: []) + // insert url to resolve symlink into set + for url in fileUrls { downloadedUrls.insert(url.standardizedFileURL) } + // exclude currently required resources + let abandonedUrls = downloadedUrls.subtracting(resourceUrls) + // loop through abandoned urls and attempt to remove it + for abandonFileUrl in abandonedUrls { + do { + try FileManager.default.removeItem(at: abandonFileUrl) + logger?.info("\(#function, privacy: .public) - cleanup abandoned resource: \(unsanitize(abandonFileUrl.lastPathComponent), privacy: .public)") + } catch { + logger?.error("\(#function, privacy: .public) - failed to remove abandoned resource: \(error.localizedDescription, privacy: .public)") + } + } + } catch { + logger?.error("\(#function, privacy: .public) - failed to cleanup resources: \(error.localizedDescription, privacy: .public)") + } + return true } func checkForRemoteUpdates(_ optionalFilesArray: [[String: Any]] = []) -> [[String: String]]? { - // only get all files if files were not provided - var files = [[String: Any]]() - if optionalFilesArray.isEmpty { - guard let getFiles = getAllFiles() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - files = getFiles - } else { - files = optionalFilesArray - } - - var hasUpdates = [[String: String]]() - for file in files { - // can be force unwrapped because getAllFiles didn't return nil - let filename = file["filename"] as! String - let canUpdate = file["canUpdate"] as! Bool - let metadata = file["metadata"] as! [String: [String]] - let type = file["type"] as! String - let name = metadata["name"]![0] - logger?.info("\(#function, privacy: .public) - Checking for remote updates for \(filename, privacy: .public)") - if canUpdate { - let currentVersion = metadata["version"]![0] - let updateUrl = metadata["updateURL"]![0] - // before fetching remote contents, ensure it points to a file of the same type - if !updateUrl.hasSuffix(type) {continue} - guard - let remoteFileContents = getRemoteFileContents(updateUrl), - let remoteFileContentsParsed = parse(remoteFileContents), - let remoteMetadata = remoteFileContentsParsed["metadata"] as? [String: [String]], - let remoteVersion = remoteMetadata["version"]?[0] - else { - logger?.error("\(#function, privacy: .public) - failed to parse remote file contents") - return nil - } - let remoteVersionNewer = isVersionNewer(currentVersion, remoteVersion) - if remoteVersionNewer { - hasUpdates.append(["name": name, "filename": filename, "type": type, "url": updateUrl]) - } - } - } - logger?.info("\(#function, privacy: .public) - Finished checking for remote updates for \(files.count, privacy: .public) files") - return hasUpdates + // only get all files if files were not provided + var files = [[String: Any]]() + if optionalFilesArray.isEmpty { + guard let getFiles = getAllFiles() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + files = getFiles + } else { + files = optionalFilesArray + } + + var hasUpdates = [[String: String]]() + for file in files { + // can be force unwrapped because getAllFiles didn't return nil + let filename = file["filename"] as! String + let canUpdate = file["canUpdate"] as! Bool + let metadata = file["metadata"] as! [String: [String]] + let type = file["type"] as! String + let name = metadata["name"]![0] + logger?.info("\(#function, privacy: .public) - Checking for remote updates for \(filename, privacy: .public)") + if canUpdate { + let currentVersion = metadata["version"]![0] + let updateUrl = metadata["updateURL"]![0] + // before fetching remote contents, ensure it points to a file of the same type + if !updateUrl.hasSuffix(type) {continue} + guard + let remoteFileContents = getRemoteFileContents(updateUrl), + let remoteFileContentsParsed = parse(remoteFileContents), + let remoteMetadata = remoteFileContentsParsed["metadata"] as? [String: [String]], + let remoteVersion = remoteMetadata["version"]?[0] + else { + logger?.error("\(#function, privacy: .public) - failed to parse remote file contents") + return nil + } + let remoteVersionNewer = isVersionNewer(currentVersion, remoteVersion) + if remoteVersionNewer { + hasUpdates.append(["name": name, "filename": filename, "type": type, "url": updateUrl]) + } + } + } + logger?.info("\(#function, privacy: .public) - Finished checking for remote updates for \(files.count, privacy: .public) files") + return hasUpdates } func getRemoteFileContents(_ url: String) -> String? { - logger?.info("\(#function, privacy: .public) - started for \(url, privacy: .public)") - // if url is http change to https - var urlChecked = url - if urlChecked.hasPrefix("http:") { - urlChecked = urlChecked.replacingOccurrences(of: "http:", with: "https:") - logger?.info("\(#function, privacy: .public) - \(url, privacy: .public) is using insecure http, attempt to fetch remote content with https") - } - // convert url string to url - guard let solidURL = fixedURL(string: urlChecked) else { - logger?.error("\(#function, privacy: .public) - failed at (1), invalid URL: \(url, privacy: .public)") - return nil - } - var contents = "" - // get remote file contents, synchronously - let semaphore = DispatchSemaphore(value: 0) - var task: URLSessionDataTask? - task = URLSession.shared.dataTask(with: solidURL) { data, response, error in - if let r = response as? HTTPURLResponse, data != nil, error == nil { - if r.statusCode == 200 { - contents = String(data: data!, encoding: .utf8) ?? "" - } else { - logger?.error("\(#function, privacy: .public) - http statusCode (\(r.statusCode, privacy: .public)): \(url, privacy: .public)") - } - } - if let error = error { - logger?.error("\(#function, privacy: .public) - task error: \(error.localizedDescription, privacy: .public) (\(url, privacy: .public))") - } - semaphore.signal() - } - task?.resume() - // wait 30 seconds before timing out - if semaphore.wait(timeout: .now() + 30) == .timedOut { - task?.cancel() - logger?.error("\(#function, privacy: .public) - 30 seconds timeout: \(url, privacy: .public)") - } - - // if made it to this point and contents still an empty string, something went wrong with the request - if contents.isEmpty { - logger?.error("\(#function, privacy: .public) - failed at (2), contents empty: \(url, privacy: .public)") - return nil - } - logger?.info("\(#function, privacy: .public) - completed for \(url, privacy: .public)") - return contents + logger?.info("\(#function, privacy: .public) - started for \(url, privacy: .public)") + // if url is http change to https + var urlChecked = url + if urlChecked.hasPrefix("http:") { + urlChecked = urlChecked.replacingOccurrences(of: "http:", with: "https:") + logger?.info("\(#function, privacy: .public) - \(url, privacy: .public) is using insecure http, attempt to fetch remote content with https") + } + // convert url string to url + guard let solidURL = fixedURL(string: urlChecked) else { + logger?.error("\(#function, privacy: .public) - failed at (1), invalid URL: \(url, privacy: .public)") + return nil + } + var contents = "" + // get remote file contents, synchronously + let semaphore = DispatchSemaphore(value: 0) + var task: URLSessionDataTask? + task = URLSession.shared.dataTask(with: solidURL) { data, response, error in + if let r = response as? HTTPURLResponse, data != nil, error == nil { + if r.statusCode == 200 { + contents = String(data: data!, encoding: .utf8) ?? "" + } else { + logger?.error("\(#function, privacy: .public) - http statusCode (\(r.statusCode, privacy: .public)): \(url, privacy: .public)") + } + } + if let error = error { + logger?.error("\(#function, privacy: .public) - task error: \(error.localizedDescription, privacy: .public) (\(url, privacy: .public))") + } + semaphore.signal() + } + task?.resume() + // wait 30 seconds before timing out + if semaphore.wait(timeout: .now() + 30) == .timedOut { + task?.cancel() + logger?.error("\(#function, privacy: .public) - 30 seconds timeout: \(url, privacy: .public)") + } + + // if made it to this point and contents still an empty string, something went wrong with the request + if contents.isEmpty { + logger?.error("\(#function, privacy: .public) - failed at (2), contents empty: \(url, privacy: .public)") + return nil + } + logger?.info("\(#function, privacy: .public) - completed for \(url, privacy: .public)") + return contents } func updateAllFiles(_ optionalFilesArray: [[String: Any]] = []) -> Bool { - // get names of all files with updates available - guard - let filesWithUpdates = checkForRemoteUpdates(optionalFilesArray), - let saveLocation = getSaveLocation() - else { - logger?.error("\(#function, privacy: .public) - failed to update files (1)") - return false - } - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - for file in filesWithUpdates { - // can be force unwrapped because checkForRemoteUpdates didn't return nil - let filename = file["filename"]! - let fileUrl = saveLocation.appendingPathComponent(filename) - guard - let content = try? String(contentsOf: fileUrl, encoding: .utf8), - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]], - let updateUrl = metadata["updateURL"]?[0] - else { - logger?.error("\(#function, privacy: .public) - failed to update files (2)") - continue - } - let downloadUrl = metadata["downloadURL"] != nil ? metadata["downloadURL"]![0] : updateUrl - guard - let remoteFileContents = getRemoteFileContents(downloadUrl), - ((try? remoteFileContents.write(to: fileUrl, atomically: false, encoding: .utf8)) != nil) - else { - logger?.error("\(#function, privacy: .public) - failed to update files (3)") - continue - } - logger?.info("\(#function, privacy: .public) - updated \(filename, privacy: .public) with contents fetched from \(downloadUrl, privacy: .public)") - } - return true + // get names of all files with updates available + guard + let filesWithUpdates = checkForRemoteUpdates(optionalFilesArray), + let saveLocation = getSaveLocation() + else { + logger?.error("\(#function, privacy: .public) - failed to update files (1)") + return false + } + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + for file in filesWithUpdates { + // can be force unwrapped because checkForRemoteUpdates didn't return nil + let filename = file["filename"]! + let fileUrl = saveLocation.appendingPathComponent(filename) + guard + let content = try? String(contentsOf: fileUrl, encoding: .utf8), + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]], + let updateUrl = metadata["updateURL"]?[0] + else { + logger?.error("\(#function, privacy: .public) - failed to update files (2)") + continue + } + let downloadUrl = metadata["downloadURL"] != nil ? metadata["downloadURL"]![0] : updateUrl + guard + let remoteFileContents = getRemoteFileContents(downloadUrl), + ((try? remoteFileContents.write(to: fileUrl, atomically: false, encoding: .utf8)) != nil) + else { + logger?.error("\(#function, privacy: .public) - failed to update files (3)") + continue + } + logger?.info("\(#function, privacy: .public) - updated \(filename, privacy: .public) with contents fetched from \(downloadUrl, privacy: .public)") + } + return true } func toggleFile(_ filename: String,_ action: String) -> Bool { - // if file doesn't exist return false - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return false - } - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - let path = saveLocation.appendingPathComponent(filename).path - if !FileManager.default.fileExists(atPath: path) { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return false - } - var manifest = getManifest() - // if file is already disabled - if action == "disable" && manifest.disabled.contains(filename) || action == "enabled" && !manifest.disabled.contains(filename) { - return true - } - // add filename to disabled array if disabling - if (action == "disable") {manifest.disabled.append(filename)} - // remove filename from disabled array if enabling - if (action == "enable") { - guard let index = manifest.disabled.firstIndex(of: filename) else { - logger?.error("\(#function, privacy: .public) - failed at (3)") - return false - } - manifest.disabled.remove(at: index) - } - if !updateManifest(with: manifest) { - logger?.error("\(#function, privacy: .public) - failed at (4)") - return false - } - return true + // if file doesn't exist return false + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return false + } + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + let path = saveLocation.appendingPathComponent(filename).path + if !FileManager.default.fileExists(atPath: path) { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return false + } + var manifest = getManifest() + // if file is already disabled + if action == "disable" && manifest.disabled.contains(filename) || action == "enabled" && !manifest.disabled.contains(filename) { + return true + } + // add filename to disabled array if disabling + if (action == "disable") {manifest.disabled.append(filename)} + // remove filename from disabled array if enabling + if (action == "enable") { + guard let index = manifest.disabled.firstIndex(of: filename) else { + logger?.error("\(#function, privacy: .public) - failed at (3)") + return false + } + manifest.disabled.remove(at: index) + } + if !updateManifest(with: manifest) { + logger?.error("\(#function, privacy: .public) - failed at (4)") + return false + } + return true } func checkDefaultDirectories() -> Bool { - let defaultSaveLocation = getDocumentsDirectory().appendingPathComponent("scripts") - let requireLocation = getRequireLocation() - let urls = [defaultSaveLocation, requireLocation] - for url in urls { - if !FileManager.default.fileExists(atPath: url.path) { - do { - try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) - } catch { - // could not create the save location directory, show error - logger?.error("\(#function, privacy: .public) - failed at (1) - \(url, privacy: .public) - \(error.localizedDescription, privacy: .public)") - return false - } - } - } - return true + let defaultSaveLocation = getDocumentsDirectory().appendingPathComponent("scripts") + let requireLocation = getRequireLocation() + let urls = [defaultSaveLocation, requireLocation] + for url in urls { + if !FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: false) + } catch { + // could not create the save location directory, show error + logger?.error("\(#function, privacy: .public) - failed at (1) - \(url, privacy: .public) - \(error.localizedDescription, privacy: .public)") + return false + } + } + } + return true } // matching func stringToRegex(_ stringPattern: String) -> NSRegularExpression? { - let pattern = #"[\.|\?|\^|\$|\+|\{|\}|\[|\]|\||\\(|\)|\/]"# - var patternReplace = "^\(stringPattern.replacingOccurrences(of: pattern, with: #"\\$0"#, options: .regularExpression))$" - patternReplace = patternReplace.replacingOccurrences(of: "*", with: ".*") - guard let regex = try? NSRegularExpression(pattern: patternReplace, options: .caseInsensitive) else { - return nil - } - return regex + let pattern = #"[\.|\?|\^|\$|\+|\{|\}|\[|\]|\||\\(|\)|\/]"# + var patternReplace = "^\(stringPattern.replacingOccurrences(of: pattern, with: #"\\$0"#, options: .regularExpression))$" + patternReplace = patternReplace.replacingOccurrences(of: "*", with: ".*") + guard let regex = try? NSRegularExpression(pattern: patternReplace, options: .caseInsensitive) else { + return nil + } + return regex } func match(_ url: String, _ matchPattern: String) -> Bool { - guard - let parts = jsLikeURL(url), - let ptcl = parts["protocol"], - let host = parts["hostname"], - var path = parts["pathname"] - else { - logger?.error("\(#function, privacy: .public) - invalid url \(url, privacy: .public)") - return false - } - - // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#path - // The value for the path matches against the string which is the URL path plus the URL query string - if let search = parts["search"], search.count > 0 { - path += search - } - - // matchPattern is the value from metatdata key @match or @exclude-match - if (matchPattern == "") { - return true - } - // currently only http/s supported - if (ptcl != "http:" && ptcl != "https:") { - return false - } - let partsPattern = #"^(http:|https:|\*:)\/\/((?:\*\.)?(?:[a-z0-9-]+\.)+(?:[a-z0-9]+)|\*\.[a-z]+|\*|[a-z0-9]+)(\/[^\s]*)$"# - let partsPatternReg = try! NSRegularExpression(pattern: partsPattern, options: .caseInsensitive) - let range = NSMakeRange(0, matchPattern.utf16.count) - guard let parts = partsPatternReg.firstMatch(in: matchPattern, options: [], range: range) else { - logger?.error("\(#function, privacy: .public) - malformed regex match pattern") - return false - } - // ensure url protocol matches pattern protocol - let protocolPattern = matchPattern[Range(parts.range(at: 1), in: matchPattern)!] - if (protocolPattern != "*:" && ptcl != protocolPattern) { - return false - } - // construct host regex from matchPattern - let matchPatternHost = matchPattern[Range(parts.range(at: 2), in: matchPattern)!] - var hostPattern = "^\(matchPatternHost.replacingOccurrences(of: ".", with: "\\."))$" - hostPattern = hostPattern.replacingOccurrences(of: "^*$", with: ".*") - hostPattern = hostPattern.replacingOccurrences(of: "*\\.", with: "(.*\\.)?") - guard let hostRegEx = try? NSRegularExpression(pattern: hostPattern, options: .caseInsensitive) else { - logger?.error("\(#function, privacy: .public) - invalid host regex") - return false - } - // construct path regex from matchPattern - let matchPatternPath = matchPattern[Range(parts.range(at: 3), in: matchPattern)!] - guard let pathRegEx = stringToRegex(String(matchPatternPath)) else { - logger?.error("\(#function, privacy: .public) - invalid path regex") - return false - } - guard - (hostRegEx.firstMatch(in: host, options: [], range: NSMakeRange(0, host.utf16.count)) != nil), - (pathRegEx.firstMatch(in: path, options: [], range: NSMakeRange(0, path.utf16.count)) != nil) - else { - return false - } - - return true + guard + let parts = jsLikeURL(url), + let ptcl = parts["protocol"], + let host = parts["hostname"], + var path = parts["pathname"] + else { + logger?.error("\(#function, privacy: .public) - invalid url \(url, privacy: .public)") + return false + } + + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#path + // The value for the path matches against the string which is the URL path plus the URL query string + if let search = parts["search"], search.count > 0 { + path += search + } + + // matchPattern is the value from metatdata key @match or @exclude-match + if (matchPattern == "") { + return true + } + // currently only http/s supported + if (ptcl != "http:" && ptcl != "https:") { + return false + } + let partsPattern = #"^(http:|https:|\*:)\/\/((?:\*\.)?(?:[a-z0-9-]+\.)+(?:[a-z0-9]+)|\*\.[a-z]+|\*|[a-z0-9]+)(\/[^\s]*)$"# + let partsPatternReg = try! NSRegularExpression(pattern: partsPattern, options: .caseInsensitive) + let range = NSMakeRange(0, matchPattern.utf16.count) + guard let parts = partsPatternReg.firstMatch(in: matchPattern, options: [], range: range) else { + logger?.error("\(#function, privacy: .public) - malformed regex match pattern") + return false + } + // ensure url protocol matches pattern protocol + let protocolPattern = matchPattern[Range(parts.range(at: 1), in: matchPattern)!] + if (protocolPattern != "*:" && ptcl != protocolPattern) { + return false + } + // construct host regex from matchPattern + let matchPatternHost = matchPattern[Range(parts.range(at: 2), in: matchPattern)!] + var hostPattern = "^\(matchPatternHost.replacingOccurrences(of: ".", with: "\\."))$" + hostPattern = hostPattern.replacingOccurrences(of: "^*$", with: ".*") + hostPattern = hostPattern.replacingOccurrences(of: "*\\.", with: "(.*\\.)?") + guard let hostRegEx = try? NSRegularExpression(pattern: hostPattern, options: .caseInsensitive) else { + logger?.error("\(#function, privacy: .public) - invalid host regex") + return false + } + // construct path regex from matchPattern + let matchPatternPath = matchPattern[Range(parts.range(at: 3), in: matchPattern)!] + guard let pathRegEx = stringToRegex(String(matchPatternPath)) else { + logger?.error("\(#function, privacy: .public) - invalid path regex") + return false + } + guard + (hostRegEx.firstMatch(in: host, options: [], range: NSMakeRange(0, host.utf16.count)) != nil), + (pathRegEx.firstMatch(in: path, options: [], range: NSMakeRange(0, path.utf16.count)) != nil) + else { + return false + } + + return true } func include(_ url: String,_ pattern: String) -> Bool { - var regex:NSRegularExpression - if pattern.hasPrefix("/") && pattern.hasSuffix("/") { - let p = String(pattern.dropFirst().dropLast()) - guard let exp = try? NSRegularExpression(pattern: p, options: .caseInsensitive) else { - logger?.error("\(#function, privacy: .public) - invalid regex") - return false - } - regex = exp - } else { - guard let exp = stringToRegex(pattern) else { - logger?.error("\(#function, privacy: .public) - coudn't convert string to regex") - return false - } - regex = exp - } - if (regex.firstMatch(in: url, options: [], range: NSMakeRange(0, url.utf16.count)) == nil) { - return false - } - return true + var regex:NSRegularExpression + if pattern.hasPrefix("/") && pattern.hasSuffix("/") { + let p = String(pattern.dropFirst().dropLast()) + guard let exp = try? NSRegularExpression(pattern: p, options: .caseInsensitive) else { + logger?.error("\(#function, privacy: .public) - invalid regex") + return false + } + regex = exp + } else { + guard let exp = stringToRegex(pattern) else { + logger?.error("\(#function, privacy: .public) - coudn't convert string to regex") + return false + } + regex = exp + } + if (regex.firstMatch(in: url, options: [], range: NSMakeRange(0, url.utf16.count)) == nil) { + return false + } + return true } func getMatchedFiles(_ url: String, _ optionalManifest: Manifest?, _ checkBlocklist: Bool) -> [String] { - logger?.info("\(#function, privacy: .public) - Getting matched files for \(url, privacy: .private(mask: .hash))") - // logger?.debug("\(#function, privacy: .public) - Getting matched files for \(url, privacy: .public)") - let manifest = optionalManifest ?? getManifest() - - // filenames that should not load for the passed url - // the manifest values from @exclude and @exclude-match populate this set - var excludedFilenames: Set = [] - // filenames that should load for the passed url - // the manifest values from @include and @match populate this set - var matchedFilenames: Set = [] - // all exclude-match patterns from manifest - let excludeMatchPatterns = manifest.excludeMatch.keys - // all match patterns from manifest - let matchPatterns = manifest.match.keys - // all include expressions from manifest - let includeExpressions = manifest.include.keys - // all exclude expressions from manifest - let excludeExpressions = manifest.exclude.keys - - // if url matches a pattern in blocklist, no injection for this url - if (checkBlocklist) { - for pattern in manifest.blacklist { - if match(url, pattern) { - // return empty array - return Array(matchedFilenames) - } - } - } - - // loop through all the @exclude-match patterns - // if any match passed url, push all filenames to excludedFilenames set - for pattern in excludeMatchPatterns { - if match(url, pattern) { - guard let filenames = manifest.excludeMatch[pattern] else { - logger?.error("\(#function, privacy: .public) - failed at (2) for \(pattern, privacy: .public)") - continue - } - excludedFilenames = excludedFilenames.union(filenames) - } - } - for exp in excludeExpressions { - if include(url, exp) { - guard let filenames = manifest.exclude[exp] else { - logger?.error("\(#function, privacy: .public) - failed at (3) for \(exp, privacy: .public)") - continue - } - excludedFilenames = excludedFilenames.union(filenames) - } - } - for pattern in matchPatterns { - if match(url, pattern) { - guard let filenames = manifest.match[pattern] else { - logger?.error("\(#function, privacy: .public) - failed at (4) for \(pattern, privacy: .public)") - continue - } - matchedFilenames = matchedFilenames.union(filenames) - } - } - for exp in includeExpressions { - if include(url, exp) { - guard let filenames = manifest.include[exp] else { - logger?.error("\(#function, privacy: .public) - failed at (5) for \(exp, privacy: .public)") - continue - } - matchedFilenames = matchedFilenames.union(filenames) - } - } - matchedFilenames = matchedFilenames.subtracting(excludedFilenames) - logger?.info("\(#function, privacy: .public) - Got \(matchedFilenames.count) matched files for \(url, privacy: .private(mask: .hash))") - // logger?.debug("\(#function, privacy: .public) - Got \(matchedFilenames.count) matched files for \(url, privacy: .public)") - return Array(matchedFilenames) + logger?.info("\(#function, privacy: .public) - Getting matched files for \(url, privacy: .private(mask: .hash))") + // logger?.debug("\(#function, privacy: .public) - Getting matched files for \(url, privacy: .public)") + let manifest = optionalManifest ?? getManifest() + + // filenames that should not load for the passed url + // the manifest values from @exclude and @exclude-match populate this set + var excludedFilenames: Set = [] + // filenames that should load for the passed url + // the manifest values from @include and @match populate this set + var matchedFilenames: Set = [] + // all exclude-match patterns from manifest + let excludeMatchPatterns = manifest.excludeMatch.keys + // all match patterns from manifest + let matchPatterns = manifest.match.keys + // all include expressions from manifest + let includeExpressions = manifest.include.keys + // all exclude expressions from manifest + let excludeExpressions = manifest.exclude.keys + + // if url matches a pattern in blocklist, no injection for this url + if (checkBlocklist) { + for pattern in manifest.blacklist { + if match(url, pattern) { + // return empty array + return Array(matchedFilenames) + } + } + } + + // loop through all the @exclude-match patterns + // if any match passed url, push all filenames to excludedFilenames set + for pattern in excludeMatchPatterns { + if match(url, pattern) { + guard let filenames = manifest.excludeMatch[pattern] else { + logger?.error("\(#function, privacy: .public) - failed at (2) for \(pattern, privacy: .public)") + continue + } + excludedFilenames = excludedFilenames.union(filenames) + } + } + for exp in excludeExpressions { + if include(url, exp) { + guard let filenames = manifest.exclude[exp] else { + logger?.error("\(#function, privacy: .public) - failed at (3) for \(exp, privacy: .public)") + continue + } + excludedFilenames = excludedFilenames.union(filenames) + } + } + for pattern in matchPatterns { + if match(url, pattern) { + guard let filenames = manifest.match[pattern] else { + logger?.error("\(#function, privacy: .public) - failed at (4) for \(pattern, privacy: .public)") + continue + } + matchedFilenames = matchedFilenames.union(filenames) + } + } + for exp in includeExpressions { + if include(url, exp) { + guard let filenames = manifest.include[exp] else { + logger?.error("\(#function, privacy: .public) - failed at (5) for \(exp, privacy: .public)") + continue + } + matchedFilenames = matchedFilenames.union(filenames) + } + } + matchedFilenames = matchedFilenames.subtracting(excludedFilenames) + logger?.info("\(#function, privacy: .public) - Got \(matchedFilenames.count) matched files for \(url, privacy: .private(mask: .hash))") + // logger?.debug("\(#function, privacy: .public) - Got \(matchedFilenames.count) matched files for \(url, privacy: .public)") + return Array(matchedFilenames) } // injection func getCode(_ filenames: [String], _ isTop: Bool)-> [String: Any]? { - var cssFiles = [Any]() - var jsFiles = [Any]() - var menuFiles = [Any]() - - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - - for filename in filenames { - guard - let contents = getFileContentsParsed(saveLocation.appendingPathComponent(filename)), - var code = contents["code"] as? String, - let type = filename.split(separator: ".").last - else { - // if guard fails, log error continue to next file - logger?.error("\(#function, privacy: .public) - failed at (2) for \(filename, privacy: .public)") - continue - } - // can force unwrap b/c getFileContentsParsed ensures metadata exists - let metadata = contents["metadata"] as! [String: [String]] - let name = metadata["name"]![0] - - // if metadata has noframes option and the url is not the top window, don't load - if (metadata["noframes"] != nil && !isTop) { - continue - } - - // get run-at values and set default if missing - // if type request, ignore - var runAt = metadata["run-at"]?[0] ?? "document-end" - if runAt == "request" { - continue - } - - // normalize weight - var weight = metadata["weight"]?[0] ?? "1" - weight = normalizeWeight(weight) - - // get inject-into and set default if missing - var injectInto = metadata["inject-into"]?[0] ?? "auto" - let injectVals: Set = ["auto", "content", "page"] - let runAtVals: Set = ["context-menu", "document-start", "document-end", "document-idle"] - let validGrants: Set = [ - "GM.info", - "GM_info", - "GM.addStyle", - "GM.openInTab", - "GM.closeTab", - "GM.setValue", - "GM.getValue", - "GM.deleteValue", - "GM.listValues", - "GM.setClipboard", - "GM.getTab", - "GM.saveTab", - "GM_xmlhttpRequest", - "GM.xmlHttpRequest" - ] - // if either is invalid use default value - if !injectVals.contains(injectInto) { - injectInto = "auto" - } - if !runAtVals.contains(runAt) { - runAt = "document-end" - } - - // attempt to get all @grant value - var grants = metadata["grant"] ?? [] - // remove duplicates, if any exist - if !grants.isEmpty { - grants = Array(Set(grants)) - } - - // filter out grant values that are not in validGrant set - grants = grants.filter{validGrants.contains($0)} - - // set GM.info data - let description = metadata["description"]?[0] ?? "" - let excludes = metadata["exclude"] ?? [] - let excludeMatches = metadata["exclude-match"] ?? [] - let icon = metadata["icon"]?[0] ?? "" - let includes = metadata["include"] ?? [] - let matches = metadata["match"] ?? [] - let requires = metadata["require"] ?? [] - let version = metadata["version"]?[0] ?? "" - let noframes = metadata["noframes"] != nil ? true : false - var scriptObject:[String: Any] = [ - "description": description, - "excludes": excludes, - "exclude-match": excludeMatches, - "filename": filename, - "grant": grants, - "icon": icon, - "includes": includes, - "inject-into": injectInto, - "matches": matches, - "name": name, - "noframes": noframes, - "namespace": "", - "resources": "", - "require": requires, - "run-at": runAt, - "version": version - ] - // certain metadata keys use a different key name then the actual key name - // for compatibility keeping this when applicable, although the rationale is not clear to me - // for unique keys passed to scriptObject, using the same key name that is present in actual userscript - // this key map is used to check for existence of keys in next loop - let keyMap = [ - "exclude": "excludes", - "include": "includes", - "match": "matches", - "resource": "resources", - ] - for metaline in metadata { - let key = keyMap[metaline.key] ?? metaline.key - if !scriptObject.keys.contains(key) { - let value = metaline.value - // metalines without values aren't included in parsed metadata object - // the only exception is @noframes - scriptObject[key] = !value.isEmpty ? value : value[0] - } - } - let scriptMetaStr = contents["metablock"] as? String ?? "??" - - // attempt to get require resource from disk - // if required resource is inaccessible, log error and continue - if let required = metadata["require"] { - // reverse required metadata - // if required is ["A", "B", "C"], C gets added above B which is above A, etc.. - // the reverse of that is desired - for require in required.reversed() { - let sanitizedName = sanitize(require) - let requiredFileURL = getRequireLocation().appendingPathComponent(filename).appendingPathComponent(sanitizedName) - if let requiredContent = try? String(contentsOf: requiredFileURL, encoding: .utf8) { - code = "\(requiredContent)\n\(code)" - } else { - logger?.error("\(#function, privacy: .public) - failed at (3) for \(require, privacy: .public)") - } - } - } - - if type == "css" { - cssFiles.append([ - "code": code, - "filename": filename, - "name": name, - "type": "css", - "weight": weight - ]) - } else if type == "js" { - if runAt == "context-menu" { - #if os(macOS) - menuFiles.append([ - "code": code, - "scriptMetaStr": scriptMetaStr, - "scriptObject": scriptObject, - "type": "js", - "weight": weight - ]) - #endif - } else { - jsFiles.append([ - "code": code, - "scriptMetaStr": scriptMetaStr, - "scriptObject": scriptObject, - "type": "js", - "weight": weight - ]) - } - } - } - let resp = [ - "files": ["css": cssFiles, "js": jsFiles, "menu": menuFiles], - "scriptHandler": "Userscripts", - "scriptHandlerVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" - ] as [String : Any] - return resp + var cssFiles = [Any]() + var jsFiles = [Any]() + var menuFiles = [Any]() + + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + + for filename in filenames { + guard + let contents = getFileContentsParsed(saveLocation.appendingPathComponent(filename)), + var code = contents["code"] as? String, + let type = filename.split(separator: ".").last + else { + // if guard fails, log error continue to next file + logger?.error("\(#function, privacy: .public) - failed at (2) for \(filename, privacy: .public)") + continue + } + // can force unwrap b/c getFileContentsParsed ensures metadata exists + let metadata = contents["metadata"] as! [String: [String]] + let name = metadata["name"]![0] + + // if metadata has noframes option and the url is not the top window, don't load + if (metadata["noframes"] != nil && !isTop) { + continue + } + + // get run-at values and set default if missing + // if type request, ignore + var runAt = metadata["run-at"]?[0] ?? "document-end" + if runAt == "request" { + continue + } + + // normalize weight + var weight = metadata["weight"]?[0] ?? "1" + weight = normalizeWeight(weight) + + // get inject-into and set default if missing + var injectInto = metadata["inject-into"]?[0] ?? "auto" + let injectVals: Set = ["auto", "content", "page"] + let runAtVals: Set = ["context-menu", "document-start", "document-end", "document-idle"] + let validGrants: Set = [ + "GM.info", + "GM_info", + "GM.addStyle", + "GM.openInTab", + "GM.closeTab", + "GM.setValue", + "GM.getValue", + "GM.deleteValue", + "GM.listValues", + "GM.setClipboard", + "GM.getTab", + "GM.saveTab", + "GM_xmlhttpRequest", + "GM.xmlHttpRequest" + ] + // if either is invalid use default value + if !injectVals.contains(injectInto) { + injectInto = "auto" + } + if !runAtVals.contains(runAt) { + runAt = "document-end" + } + + // attempt to get all @grant value + var grants = metadata["grant"] ?? [] + // remove duplicates, if any exist + if !grants.isEmpty { + grants = Array(Set(grants)) + } + + // filter out grant values that are not in validGrant set + grants = grants.filter{validGrants.contains($0)} + + // set GM.info data + let description = metadata["description"]?[0] ?? "" + let excludes = metadata["exclude"] ?? [] + let excludeMatches = metadata["exclude-match"] ?? [] + let icon = metadata["icon"]?[0] ?? "" + let includes = metadata["include"] ?? [] + let matches = metadata["match"] ?? [] + let requires = metadata["require"] ?? [] + let version = metadata["version"]?[0] ?? "" + let noframes = metadata["noframes"] != nil ? true : false + var scriptObject:[String: Any] = [ + "description": description, + "excludes": excludes, + "exclude-match": excludeMatches, + "filename": filename, + "grant": grants, + "icon": icon, + "includes": includes, + "inject-into": injectInto, + "matches": matches, + "name": name, + "noframes": noframes, + "namespace": "", + "resources": "", + "require": requires, + "run-at": runAt, + "version": version + ] + // certain metadata keys use a different key name then the actual key name + // for compatibility keeping this when applicable, although the rationale is not clear to me + // for unique keys passed to scriptObject, using the same key name that is present in actual userscript + // this key map is used to check for existence of keys in next loop + let keyMap = [ + "exclude": "excludes", + "include": "includes", + "match": "matches", + "resource": "resources", + ] + for metaline in metadata { + let key = keyMap[metaline.key] ?? metaline.key + if !scriptObject.keys.contains(key) { + let value = metaline.value + // metalines without values aren't included in parsed metadata object + // the only exception is @noframes + scriptObject[key] = !value.isEmpty ? value : value[0] + } + } + let scriptMetaStr = contents["metablock"] as? String ?? "??" + + // attempt to get require resource from disk + // if required resource is inaccessible, log error and continue + if let required = metadata["require"] { + // reverse required metadata + // if required is ["A", "B", "C"], C gets added above B which is above A, etc.. + // the reverse of that is desired + for require in required.reversed() { + let sanitizedName = sanitize(require) + let requiredFileURL = getRequireLocation().appendingPathComponent(filename).appendingPathComponent(sanitizedName) + if let requiredContent = try? String(contentsOf: requiredFileURL, encoding: .utf8) { + code = "\(requiredContent)\n\(code)" + } else { + logger?.error("\(#function, privacy: .public) - failed at (3) for \(require, privacy: .public)") + } + } + } + + if type == "css" { + cssFiles.append([ + "code": code, + "filename": filename, + "name": name, + "type": "css", + "weight": weight + ]) + } else if type == "js" { + if runAt == "context-menu" { + #if os(macOS) + menuFiles.append([ + "code": code, + "scriptMetaStr": scriptMetaStr, + "scriptObject": scriptObject, + "type": "js", + "weight": weight + ]) + #endif + } else { + jsFiles.append([ + "code": code, + "scriptMetaStr": scriptMetaStr, + "scriptObject": scriptObject, + "type": "js", + "weight": weight + ]) + } + } + } + let resp = [ + "files": ["css": cssFiles, "js": jsFiles, "menu": menuFiles], + "scriptHandler": "Userscripts", + "scriptHandlerVersion": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" + ] as [String : Any] + return resp } func getFileContentsParsed(_ url: URL) -> [String: Any]? { - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - // check that url is a valid path to a directory or single file - guard - FileManager.default.fileExists(atPath: url.path), - let content = try? String(contentsOf: url, encoding: .utf8), - let parsed = parse(content) - else { - return nil - } - return parsed + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + // check that url is a valid path to a directory or single file + guard + FileManager.default.fileExists(atPath: url.path), + let content = try? String(contentsOf: url, encoding: .utf8), + let parsed = parse(content) + else { + return nil + } + return parsed } func getInjectionFilenames(_ url: String) -> [String]? { - var filenames = [String]() - let manifest = getManifest() - let matched = getMatchedFiles(url, manifest, true) - guard let active = manifest.settings["active"] else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // if injection is disabled return empty array - if active != "true" { - return filenames - } - // filter out all disabled files - filenames = matched.filter{!manifest.disabled.contains($0)} - return filenames + var filenames = [String]() + let manifest = getManifest() + let matched = getMatchedFiles(url, manifest, true) + guard let active = manifest.settings["active"] else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // if injection is disabled return empty array + if active != "true" { + return filenames + } + // filter out all disabled files + filenames = matched.filter{!manifest.disabled.contains($0)} + return filenames } func getRequestScripts() -> [[String: String]]? { - var requestScripts = [[String: String]]() - // check the manifest to see if injection is enabled - let manifest = getManifest() - guard let active = manifest.settings["active"] else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // if not enabled, do not apply any net requests, ie. return empty array - if active != "true" { - return requestScripts - } - guard let files = getAllFiles(includeCode: true) else { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return nil - } - for file in files { - let isRequest = file["request"] as? Bool ?? false - // skip any non-request userscripts - if !isRequest { - continue - } - // can be force unwrapped because getAllFiles always returns these - let name = file["name"] as! String - let code = file["code"] as! String - let filename = file["filename"] as! String - - if !manifest.disabled.contains(filename) { - requestScripts.append(["name": name, "code": code]) - } - } - return requestScripts + var requestScripts = [[String: String]]() + // check the manifest to see if injection is enabled + let manifest = getManifest() + guard let active = manifest.settings["active"] else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // if not enabled, do not apply any net requests, ie. return empty array + if active != "true" { + return requestScripts + } + guard let files = getAllFiles(includeCode: true) else { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return nil + } + for file in files { + let isRequest = file["request"] as? Bool ?? false + // skip any non-request userscripts + if !isRequest { + continue + } + // can be force unwrapped because getAllFiles always returns these + let name = file["name"] as! String + let code = file["code"] as! String + let filename = file["filename"] as! String + + if !manifest.disabled.contains(filename) { + requestScripts.append(["name": name, "code": code]) + } + } + return requestScripts } func getContextMenuScripts() -> [String: Any]? { - var menuFilenames = [String]() - // check the manifest to see if injection is enabled - let manifest = getManifest() - guard let active = manifest.settings["active"] else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // if not enabled return empty array - if active != "true" { - return ["files": ["menu": []]] - } - // get all files at save location - guard let files = getAllFiles() else { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return nil - } - // loop through files and find @run-at context-menu script filenames - for file in files { - guard let filename = file["filename"] as? String else { - logger?.error("\(#function, privacy: .public) - failed at (3), couldn't get filename") - continue - } - guard let fileMetadata = file["metadata"] as? [String: [String]] else { - logger?.error("\(#function, privacy: .public) - failed at (4), couldn't get metadata for \(filename, privacy: .public)") - continue - } - let runAt = fileMetadata["run-at"]?[0] ?? "document-end" - if runAt != "context-menu" || manifest.disabled.contains(filename) { - continue - } - menuFilenames.append(filename) - } - // get and return script objects for all context-menu scripts - guard let scripts = getCode(menuFilenames, true) else { - logger?.error("\(#function, privacy: .public) - failed at (5)") - return nil - } - return scripts + var menuFilenames = [String]() + // check the manifest to see if injection is enabled + let manifest = getManifest() + guard let active = manifest.settings["active"] else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // if not enabled return empty array + if active != "true" { + return ["files": ["menu": []]] + } + // get all files at save location + guard let files = getAllFiles() else { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return nil + } + // loop through files and find @run-at context-menu script filenames + for file in files { + guard let filename = file["filename"] as? String else { + logger?.error("\(#function, privacy: .public) - failed at (3), couldn't get filename") + continue + } + guard let fileMetadata = file["metadata"] as? [String: [String]] else { + logger?.error("\(#function, privacy: .public) - failed at (4), couldn't get metadata for \(filename, privacy: .public)") + continue + } + let runAt = fileMetadata["run-at"]?[0] ?? "document-end" + if runAt != "context-menu" || manifest.disabled.contains(filename) { + continue + } + menuFilenames.append(filename) + } + // get and return script objects for all context-menu scripts + guard let scripts = getCode(menuFilenames, true) else { + logger?.error("\(#function, privacy: .public) - failed at (5)") + return nil + } + return scripts } // popup func getPopupMatches(_ url: String, _ subframeUrls: [String]) -> [[String: Any]]? { - var matches = [[String: Any]]() - // if the url doesn't start with http/s return empty array - if !url.starts(with: "http://") && !url.starts(with: "https://") { - return matches - } - // get all the files saved to manifest that match the passed url - let matched = getMatchedFiles(url, nil, false) - // get all the files at the save location - guard - let files = getAllFiles() - else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // filter out the files that are present in both files and matched - // force unwrap filename to string since getAllFiles always returns it - matches = files.filter{matched.contains($0["filename"] as! String)} - - // get the subframe url matches - var frameUrlsMatched = [[String: Any]]() - var frameUrlsMatches = [String]() - // filter out the top page url from the frame urls - let frameUrls = subframeUrls.filter{$0 != url} - // for each url just pushed to frameUrls, get all the files saved to manifest that match their url - for frameUrl in frameUrls { - let frameMatches = getMatchedFiles(frameUrl, nil, false) - for frameMatch in frameMatches { - // for the match against the frameUrl, see if it has @noframes - // if so, it should not be appended to frameUrlsMatches - // filter all files for the first one that matches the frameMatch filename - // can force unwrap filename b/c getAllFiles always returns it - let frameMatchMetadata = files.filter{$0["filename"] as! String == frameMatch}.first - // can force unwrap noframes b/c getAllFiles always returns it - let noFrames = frameMatchMetadata?["noframes"] as? Bool ?? false - if !matched.contains(frameMatch) && !noFrames { - frameUrlsMatches.append(frameMatch) - } - } - } - - // filter out the files that are present in both files and frameUrlsMatches - // force unwrap filename to string since getAllFiles always returns it - frameUrlsMatched = files.filter{frameUrlsMatches.contains($0["filename"] as! String)} - // loop through frameUrlsMatched and add subframe key/val - for (index, var frameUrlsMatch) in frameUrlsMatched.enumerated() { - frameUrlsMatch["subframe"] = true - frameUrlsMatched[index] = frameUrlsMatch - } - // add frameUrlsMatched to matches array - matches.append(contentsOf: frameUrlsMatched) - return matches + var matches = [[String: Any]]() + // if the url doesn't start with http/s return empty array + if !url.starts(with: "http://") && !url.starts(with: "https://") { + return matches + } + // get all the files saved to manifest that match the passed url + let matched = getMatchedFiles(url, nil, false) + // get all the files at the save location + guard + let files = getAllFiles() + else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // filter out the files that are present in both files and matched + // force unwrap filename to string since getAllFiles always returns it + matches = files.filter{matched.contains($0["filename"] as! String)} + + // get the subframe url matches + var frameUrlsMatched = [[String: Any]]() + var frameUrlsMatches = [String]() + // filter out the top page url from the frame urls + let frameUrls = subframeUrls.filter{$0 != url} + // for each url just pushed to frameUrls, get all the files saved to manifest that match their url + for frameUrl in frameUrls { + let frameMatches = getMatchedFiles(frameUrl, nil, false) + for frameMatch in frameMatches { + // for the match against the frameUrl, see if it has @noframes + // if so, it should not be appended to frameUrlsMatches + // filter all files for the first one that matches the frameMatch filename + // can force unwrap filename b/c getAllFiles always returns it + let frameMatchMetadata = files.filter{$0["filename"] as! String == frameMatch}.first + // can force unwrap noframes b/c getAllFiles always returns it + let noFrames = frameMatchMetadata?["noframes"] as? Bool ?? false + if !matched.contains(frameMatch) && !noFrames { + frameUrlsMatches.append(frameMatch) + } + } + } + + // filter out the files that are present in both files and frameUrlsMatches + // force unwrap filename to string since getAllFiles always returns it + frameUrlsMatched = files.filter{frameUrlsMatches.contains($0["filename"] as! String)} + // loop through frameUrlsMatched and add subframe key/val + for (index, var frameUrlsMatch) in frameUrlsMatched.enumerated() { + frameUrlsMatch["subframe"] = true + frameUrlsMatched[index] = frameUrlsMatch + } + // add frameUrlsMatched to matches array + matches.append(contentsOf: frameUrlsMatched) + return matches } func popupUpdateAll() -> Bool { - guard - let files = getAllFiles(), - updateAllFiles(files), - updateManifestMatches(files), - updateManifestRequired(files), - purgeManifest(files) - else { - return false - } - return true + guard + let files = getAllFiles(), + updateAllFiles(files), + updateManifestMatches(files), + updateManifestRequired(files), + purgeManifest(files) + else { + return false + } + return true } func getPopupBadgeCount(_ url: String, _ subframeUrls: [String]) -> Int? { - if !url.starts(with: "http://") && !url.starts(with: "https://") { - return 0 - } - let manifest = getManifest() - guard - var matches = getPopupMatches(url, subframeUrls) - else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - for pattern in manifest.blacklist { - if match(url, pattern) { - return 0 - } - } - matches = matches.filter{!manifest.disabled.contains($0["filename"] as! String)} - return matches.count + if !url.starts(with: "http://") && !url.starts(with: "https://") { + return 0 + } + let manifest = getManifest() + guard + var matches = getPopupMatches(url, subframeUrls) + else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + for pattern in manifest.blacklist { + if match(url, pattern) { + return 0 + } + } + matches = matches.filter{!manifest.disabled.contains($0["filename"] as! String)} + return matches.count } func popupUpdateSingle(_ filename: String, _ url: String, _ subframeUrls: [String]) -> [[String: Any]]? { - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - let fileUrl = saveLocation.appendingPathComponent(filename) - guard - let content = try? String(contentsOf: fileUrl, encoding: .utf8), - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]], - let updateUrl = metadata["updateURL"]?[0] - else { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return nil - } - let downloadUrl = metadata["downloadURL"] != nil ? metadata["downloadURL"]![0] : updateUrl - guard - let remoteFileContents = getRemoteFileContents(downloadUrl), - ((try? remoteFileContents.write(to: fileUrl, atomically: false, encoding: .utf8)) != nil) - else { - logger?.error("\(#function, privacy: .public) - failed at (3)") - return nil - } - guard - let files = getAllFiles(), - updateManifestMatches(files), - updateManifestRequired(files), - purgeManifest(files), - let matches = getPopupMatches(url, subframeUrls) - else { - logger?.error("\(#function, privacy: .public) - failed at (4)") - return nil - } - return matches + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + let fileUrl = saveLocation.appendingPathComponent(filename) + guard + let content = try? String(contentsOf: fileUrl, encoding: .utf8), + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]], + let updateUrl = metadata["updateURL"]?[0] + else { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return nil + } + let downloadUrl = metadata["downloadURL"] != nil ? metadata["downloadURL"]![0] : updateUrl + guard + let remoteFileContents = getRemoteFileContents(downloadUrl), + ((try? remoteFileContents.write(to: fileUrl, atomically: false, encoding: .utf8)) != nil) + else { + logger?.error("\(#function, privacy: .public) - failed at (3)") + return nil + } + guard + let files = getAllFiles(), + updateManifestMatches(files), + updateManifestRequired(files), + purgeManifest(files), + let matches = getPopupMatches(url, subframeUrls) + else { + logger?.error("\(#function, privacy: .public) - failed at (4)") + return nil + } + return matches } // page func getInitData() -> [String: Any]? { - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return nil - } - return [ - "saveLocation": saveLocation.path, - "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??", - "build": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" - ] + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return nil + } + return [ + "saveLocation": saveLocation.path, + "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??", + "build": Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" + ] } func getLegacyData() -> [String: Any]? { - let manifest = getManifest() - var data:[String: Any] = manifest.settings - data["blacklist"] = manifest.blacklist - return data + let manifest = getManifest() + var data:[String: Any] = manifest.settings + data["blacklist"] = manifest.blacklist + return data } func saveFile(_ item: [String: Any],_ content: String) -> [String: Any] { - var response = [String: Any]() - let newContent = content - guard let saveLocation = getSaveLocation() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return ["error": "failed to get save location when attempting to save"] - } - guard - let oldFilename = item["filename"] as? String, - let type = item["type"] as? String - else { - return ["error": "invalid argument in save function"] - } - guard - let parsed = parse(newContent), - let metadata = parsed["metadata"] as? [String: [String]] - else { - return ["error": "failed to parse metadata"] - } - guard let n = metadata["name"]?[0] else { - return ["error": "@name not found in metadata"] - } - var name = sanitize(n) - - // construct new file name - let newFilename = "\(name).user.\(type)" - - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - guard - let allFilesUrls = try? FileManager.default.contentsOfDirectory(at: saveLocation, includingPropertiesForKeys: []) - else { - return ["error": "failed to read save urls in save function"] - } - - // validate file before save - var allFilenames:[String] = [] // stores the indv filenames for later comparison - // old and new filenames are equal, overwriting and can skip - if oldFilename.lowercased() != newFilename.lowercased() { - // loop through all the file urls in the save location and save filename to var - for fileUrl in allFilesUrls { - // skip file if it is not of the proper type - let filename = fileUrl.lastPathComponent - if (!filename.hasSuffix(type)) { - continue - } - // if file is of the proper type, add it to the allFilenames array - allFilenames.append(filename.lowercased()) - } - } - - if allFilenames.contains(newFilename.lowercased()) { - return ["error": "filename already taken"] - } - - if newFilename.count > 250 { - return ["error": "filename too long"] - } - - // file passed validation - - // attempt to save to disk - let newFileUrl = saveLocation.appendingPathComponent(newFilename) - do { - try newContent.write(to: newFileUrl, atomically: false, encoding: .utf8) - } catch { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return ["error": "failed to write file to disk"] - } - - // saved to disk successfully - - // get the file last modified date - guard - let dateMod = try? FileManager.default.attributesOfItem(atPath: newFileUrl.path)[.modificationDate] as? Date - else { - logger?.error("\(#function, privacy: .public) - failed at (3)") - return ["error": "failed to read modified date in save function"] - } - - // remove old file and manifest records for old file if they exist - if oldFilename.lowercased() != newFilename.lowercased() { - // if user changed the filename, remove file with old filename - let oldFileUrl = saveLocation.appendingPathComponent(oldFilename) - // however, when creating a new file, if user changes the temp given name by app... - // oldFilename (the temp name in activeItem) and newFilename (@name in file contents) will differ - // the file with oldFilename will not be on the filesystem and can not be deleted - // for that edge case, using try? rather than try(!) to allow failures - try? FileManager.default.trashItem(at: oldFileUrl, resultingItemURL: nil) - } - - // update manifest for new file and purge anything from old file - guard - let allFiles = getAllFiles(), - updateManifestMatches(allFiles), - updateManifestRequired(allFiles), - updateManifestDeclarativeNetRequests(allFiles), - purgeManifest(allFiles) - else { - logger?.error("\(#function, privacy: .public) - failed at (4)") - return ["error": "file save but manifest couldn't be updated"] - } - - // un-santized name - name = unsanitize(name) - - // build response dict - response["canUpdate"] = false - response["content"] = newContent - response["filename"] = newFilename - response["lastModified"] = dateToMilliseconds(dateMod) - response["name"] = name - if metadata["description"] != nil { - response["description"] = metadata["description"]![0] - } - if metadata["version"] != nil && metadata["updateURL"] != nil { - response["canUpdate"] = true - } - // if a request "type" userscript add key/val - let runAt = metadata["run-at"]?[0] ?? "document-end" - if runAt == "request" { - response["request"] = true - } - - return response + var response = [String: Any]() + let newContent = content + guard let saveLocation = getSaveLocation() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return ["error": "failed to get save location when attempting to save"] + } + guard + let oldFilename = item["filename"] as? String, + let type = item["type"] as? String + else { + return ["error": "invalid argument in save function"] + } + guard + let parsed = parse(newContent), + let metadata = parsed["metadata"] as? [String: [String]] + else { + return ["error": "failed to parse metadata"] + } + guard let n = metadata["name"]?[0] else { + return ["error": "@name not found in metadata"] + } + var name = sanitize(n) + + // construct new file name + let newFilename = "\(name).user.\(type)" + + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + guard + let allFilesUrls = try? FileManager.default.contentsOfDirectory(at: saveLocation, includingPropertiesForKeys: []) + else { + return ["error": "failed to read save urls in save function"] + } + + // validate file before save + var allFilenames:[String] = [] // stores the indv filenames for later comparison + // old and new filenames are equal, overwriting and can skip + if oldFilename.lowercased() != newFilename.lowercased() { + // loop through all the file urls in the save location and save filename to var + for fileUrl in allFilesUrls { + // skip file if it is not of the proper type + let filename = fileUrl.lastPathComponent + if (!filename.hasSuffix(type)) { + continue + } + // if file is of the proper type, add it to the allFilenames array + allFilenames.append(filename.lowercased()) + } + } + + if allFilenames.contains(newFilename.lowercased()) { + return ["error": "filename already taken"] + } + + if newFilename.count > 250 { + return ["error": "filename too long"] + } + + // file passed validation + + // attempt to save to disk + let newFileUrl = saveLocation.appendingPathComponent(newFilename) + do { + try newContent.write(to: newFileUrl, atomically: false, encoding: .utf8) + } catch { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return ["error": "failed to write file to disk"] + } + + // saved to disk successfully + + // get the file last modified date + guard + let dateMod = try? FileManager.default.attributesOfItem(atPath: newFileUrl.path)[.modificationDate] as? Date + else { + logger?.error("\(#function, privacy: .public) - failed at (3)") + return ["error": "failed to read modified date in save function"] + } + + // remove old file and manifest records for old file if they exist + if oldFilename.lowercased() != newFilename.lowercased() { + // if user changed the filename, remove file with old filename + let oldFileUrl = saveLocation.appendingPathComponent(oldFilename) + // however, when creating a new file, if user changes the temp given name by app... + // oldFilename (the temp name in activeItem) and newFilename (@name in file contents) will differ + // the file with oldFilename will not be on the filesystem and can not be deleted + // for that edge case, using try? rather than try(!) to allow failures + try? FileManager.default.trashItem(at: oldFileUrl, resultingItemURL: nil) + } + + // update manifest for new file and purge anything from old file + guard + let allFiles = getAllFiles(), + updateManifestMatches(allFiles), + updateManifestRequired(allFiles), + updateManifestDeclarativeNetRequests(allFiles), + purgeManifest(allFiles) + else { + logger?.error("\(#function, privacy: .public) - failed at (4)") + return ["error": "file save but manifest couldn't be updated"] + } + + // un-santized name + name = unsanitize(name) + + // build response dict + response["canUpdate"] = false + response["content"] = newContent + response["filename"] = newFilename + response["lastModified"] = dateToMilliseconds(dateMod) + response["name"] = name + if metadata["description"] != nil { + response["description"] = metadata["description"]![0] + } + if metadata["version"] != nil && metadata["updateURL"] != nil { + response["canUpdate"] = true + } + // if a request "type" userscript add key/val + let runAt = metadata["run-at"]?[0] ?? "document-end" + if runAt == "request" { + response["request"] = true + } + + return response } func trashFile(_ item: [String: Any]) -> Bool { - guard - let saveLocation = getSaveLocation(), - let filename = item["filename"] as? String - else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return false - } - // security scope - let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() - defer { - if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} - } - let url = saveLocation.appendingPathComponent(filename) - // if file is already removed from path, assume it was removed by user and return true - if (FileManager.default.fileExists(atPath: url.path)) { - do { - try FileManager.default.trashItem(at: url, resultingItemURL: nil) - } catch { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - return false - } - } - // update manifest - guard updateManifestMatches(), updateManifestRequired(), purgeManifest() else { - logger?.error("\(#function, privacy: .public) - failed at (2)") - return false - } - return true; + guard + let saveLocation = getSaveLocation(), + let filename = item["filename"] as? String + else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return false + } + // security scope + let didStartAccessing = saveLocation.startAccessingSecurityScopedResource() + defer { + if didStartAccessing {saveLocation.stopAccessingSecurityScopedResource()} + } + let url = saveLocation.appendingPathComponent(filename) + // if file is already removed from path, assume it was removed by user and return true + if (FileManager.default.fileExists(atPath: url.path)) { + do { + try FileManager.default.trashItem(at: url, resultingItemURL: nil) + } catch { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + return false + } + } + // update manifest + guard updateManifestMatches(), updateManifestRequired(), purgeManifest() else { + logger?.error("\(#function, privacy: .public) - failed at (2)") + return false + } + return true; } func getFileRemoteUpdate(_ content: String) -> [String: String] { - guard - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]] - else { - // can't parse editor contents - return ["error": "Update failed, metadata missing"] - } - // editor contents missing version value - guard let version = metadata["version"]?[0] else { - return ["error": "Update failed, version value required"] - } - // editor contents missing updateURL - guard let updateURL = metadata["updateURL"]?[0] else { - return ["error": "Update failed, update url required"] - } - // set download url - let downloadURL = (metadata["downloadURL"] != nil) ? metadata["downloadURL"]![0] : updateURL - // basic url validation - guard validateUrl(updateURL) else { - return ["error": "Update failed, invalid updateURL"] - } - guard validateUrl(downloadURL) else { - return ["error": "Update failed, invalid downloadURL"] - } - // get the remote file contents for checking version - guard var remoteContent = getRemoteFileContents(updateURL) else { - return ["error": "Update failed, updateURL unreachable"] - } - // parse remote file contents - guard - let remoteParsed = parse(remoteContent), - let remoteMetadata = remoteParsed["metadata"] as? [String: [String]], - let remoteVersion = remoteMetadata["version"]?[0] - else { - // can't parse editor contents - return ["error": "Update failed, couldn't parse remote file contents"] - } - // check if update is needed - if version >= remoteVersion { - return ["info": "No updates found"] - } - // at this point it is known an update is available, get new code from downloadURL - // is there's a specific downloadURL overwrite remoteContents with code from downloadURL - if updateURL != downloadURL { - guard let remoteDownloadContent = getRemoteFileContents(downloadURL) else { - return ["error": "Update failed, downloadURL unreachable"] - } - remoteContent = remoteDownloadContent - } - return ["content": remoteContent] + guard + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]] + else { + // can't parse editor contents + return ["error": "Update failed, metadata missing"] + } + // editor contents missing version value + guard let version = metadata["version"]?[0] else { + return ["error": "Update failed, version value required"] + } + // editor contents missing updateURL + guard let updateURL = metadata["updateURL"]?[0] else { + return ["error": "Update failed, update url required"] + } + // set download url + let downloadURL = (metadata["downloadURL"] != nil) ? metadata["downloadURL"]![0] : updateURL + // basic url validation + guard validateUrl(updateURL) else { + return ["error": "Update failed, invalid updateURL"] + } + guard validateUrl(downloadURL) else { + return ["error": "Update failed, invalid downloadURL"] + } + // get the remote file contents for checking version + guard var remoteContent = getRemoteFileContents(updateURL) else { + return ["error": "Update failed, updateURL unreachable"] + } + // parse remote file contents + guard + let remoteParsed = parse(remoteContent), + let remoteMetadata = remoteParsed["metadata"] as? [String: [String]], + let remoteVersion = remoteMetadata["version"]?[0] + else { + // can't parse editor contents + return ["error": "Update failed, couldn't parse remote file contents"] + } + // check if update is needed + if version >= remoteVersion { + return ["info": "No updates found"] + } + // at this point it is known an update is available, get new code from downloadURL + // is there's a specific downloadURL overwrite remoteContents with code from downloadURL + if updateURL != downloadURL { + guard let remoteDownloadContent = getRemoteFileContents(downloadURL) else { + return ["error": "Update failed, downloadURL unreachable"] + } + remoteContent = remoteDownloadContent + } + return ["content": remoteContent] } // background func nativeChecks() -> [String: String] { - logger?.info("\(#function, privacy: .public) - started") - #if os(iOS) - // check the save location is set - guard (getSaveLocation() != nil) else { - logger?.error("\(#function, privacy: .public) - save location unset (iOS)") - return [ - "error": "Native checks error (0)", - "saveLocation": "unset", - "scheme": Bundle.main.infoDictionary?["US_URL_SCHEME"] as! String - ] - } - #endif - // check the default directories - guard checkDefaultDirectories() else { - logger?.error("\(#function, privacy: .public) - checkDefaultDirectories failed") - return ["error": "Native checks error (1)"] - } - // check the settings - guard checkSettings() else { - logger?.error("\(#function, privacy: .public) - checkSettings failed") - return ["error": "Native checks error (2)"] - } - // get all files to pass as arguments to function below - guard let allFiles = getAllFiles() else { - logger?.error("\(#function, privacy: .public) - getAllFiles failed") - return ["error": "Native checks error (3)"] - } - // purge the manifest of old records - guard purgeManifest(allFiles) else { - logger?.error("\(#function, privacy: .public) - purgeManifest failed") - return ["error": "Native checks error (4)"] - } - // update matches in manifest - guard updateManifestMatches(allFiles) else { - logger?.error("\(#function, privacy: .public) - updateManifestMatches failed") - return ["error": "Native checks error (5)"] - } - // update the required resources - guard updateManifestRequired(allFiles) else { - logger?.error("\(#function, privacy: .public) - updateManifestRequired failed") - return ["error": "Native checks error (6)"] - } - // update declarativeNetRequest - guard updateManifestDeclarativeNetRequests(allFiles) else { - logger?.error("\(#function, privacy: .public) - updateManifestDeclarativeNetRequests failed") - return ["error": "Native checks error (7)"] - } - // pass some info in response - logger?.info("\(#function, privacy: .public) - completed") - return ["success": "Native checks complete"] + logger?.info("\(#function, privacy: .public) - started") + #if os(iOS) + // check the save location is set + guard (getSaveLocation() != nil) else { + logger?.error("\(#function, privacy: .public) - save location unset (iOS)") + return [ + "error": "Native checks error (0)", + "saveLocation": "unset", + "scheme": Bundle.main.infoDictionary?["US_URL_SCHEME"] as! String + ] + } + #endif + // check the default directories + guard checkDefaultDirectories() else { + logger?.error("\(#function, privacy: .public) - checkDefaultDirectories failed") + return ["error": "Native checks error (1)"] + } + // check the settings + guard checkSettings() else { + logger?.error("\(#function, privacy: .public) - checkSettings failed") + return ["error": "Native checks error (2)"] + } + // get all files to pass as arguments to function below + guard let allFiles = getAllFiles() else { + logger?.error("\(#function, privacy: .public) - getAllFiles failed") + return ["error": "Native checks error (3)"] + } + // purge the manifest of old records + guard purgeManifest(allFiles) else { + logger?.error("\(#function, privacy: .public) - purgeManifest failed") + return ["error": "Native checks error (4)"] + } + // update matches in manifest + guard updateManifestMatches(allFiles) else { + logger?.error("\(#function, privacy: .public) - updateManifestMatches failed") + return ["error": "Native checks error (5)"] + } + // update the required resources + guard updateManifestRequired(allFiles) else { + logger?.error("\(#function, privacy: .public) - updateManifestRequired failed") + return ["error": "Native checks error (6)"] + } + // update declarativeNetRequest + guard updateManifestDeclarativeNetRequests(allFiles) else { + logger?.error("\(#function, privacy: .public) - updateManifestDeclarativeNetRequests failed") + return ["error": "Native checks error (7)"] + } + // pass some info in response + logger?.info("\(#function, privacy: .public) - completed") + return ["success": "Native checks complete"] } // userscript install func installCheck(_ content: String) -> [String: Any] { - // this func checks a userscript's metadata to determine if it's already installed - - guard let files = getAllFiles() else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return ["error": "installCheck failed at (1)"] - } - - guard - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]], - let newName = metadata["name"]?[0] - else { - return ["error": "userscript metadata is invalid"] - } - - // loop through all files nad get their names and filenames - // we will check the new name/filename to see if this is a unique userscript - // or if it will overwrite an existing userscript - var names = [String]() - for file in files { - // can be force unwrapped because getAllFiles didn't return nil - let name = file["name"] as! String - - // populate array - names.append(name) - } - - var directive = "" - #if os(macOS) - directive = "Click" - #elseif os(iOS) - directive = "Tap" - #endif - - if names.contains(newName) { - return [ - "success": "\(directive) to re-install", - "metadata": metadata, - "installed": true - ] - } - - return [ - "success": "\(directive) to install", - "metadata": metadata, - "installed": false - ]; + // this func checks a userscript's metadata to determine if it's already installed + + guard let files = getAllFiles() else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return ["error": "installCheck failed at (1)"] + } + + guard + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]], + let newName = metadata["name"]?[0] + else { + return ["error": "userscript metadata is invalid"] + } + + // loop through all files nad get their names and filenames + // we will check the new name/filename to see if this is a unique userscript + // or if it will overwrite an existing userscript + var names = [String]() + for file in files { + // can be force unwrapped because getAllFiles didn't return nil + let name = file["name"] as! String + + // populate array + names.append(name) + } + + var directive = "" + #if os(macOS) + directive = "Click" + #elseif os(iOS) + directive = "Tap" + #endif + + if names.contains(newName) { + return [ + "success": "\(directive) to re-install", + "metadata": metadata, + "installed": true + ] + } + + return [ + "success": "\(directive) to install", + "metadata": metadata, + "installed": false + ]; } func installUserscript(_ content: String) -> [String: Any] { - guard - let parsed = parse(content), - let metadata = parsed["metadata"] as? [String: [String]], - let n = metadata["name"]?[0] - else { - logger?.error("\(#function, privacy: .public) - failed at (1)") - return ["error": "installUserscript failed at (1)"] - } - let name = sanitize(n) - let filename = "\(name).user.js" - - let saved = saveFile(["filename": filename, "type": "js"], content) - return saved + guard + let parsed = parse(content), + let metadata = parsed["metadata"] as? [String: [String]], + let n = metadata["name"]?[0] + else { + logger?.error("\(#function, privacy: .public) - failed at (1)") + return ["error": "installUserscript failed at (1)"] + } + let name = sanitize(n) + let filename = "\(name).user.js" + + let saved = saveFile(["filename": filename, "type": "js"], content) + return saved } diff --git a/xcode/Ext-Safari/SafariWebExtensionHandler.swift b/xcode/Ext-Safari/SafariWebExtensionHandler.swift index 2386ab40..ac5cec13 100644 --- a/xcode/Ext-Safari/SafariWebExtensionHandler.swift +++ b/xcode/Ext-Safari/SafariWebExtensionHandler.swift @@ -1,308 +1,308 @@ import SafariServices class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { - func beginRequest(with context: NSExtensionContext) { - let logger = USLogger(#fileID) - let item = context.inputItems[0] as? NSExtensionItem - let message = item?.userInfo?[SFExtensionMessageKey] as? [String: Any] - // if message received without name, ignore - guard let name = message?["name"] as? String else { - logger?.error("\(#function, privacy: .public) - could not get message name from web extension") - return - } - logger?.info("\(#function, privacy: .public) - Got message with name: \(name, privacy: .public)") - // got a valid message, construct response based on message received - let response = NSExtensionItem() - // send standard error when there's an issue parsing inbound message - var inBoundError = false - // these if/else if statement are formatted so that they can be neatly collapsed in Xcode - // typically the "else if" would be on the same line as the preceding statements close bracket - // ie. } else if { - if name == "OPEN_APP" { - if let scheme = Bundle.main.infoDictionary?["US_URL_SCHEME"], - let url = URL(string: "\(scheme):") { - #if os(macOS) - NSWorkspace.shared.open(url) - #endif - } - } - else if name == "NATIVE_CHECKS" { - let result = nativeChecks() - response.userInfo = [SFExtensionMessageKey: result] - } - else if name == "REQ_PLATFORM" { - let platform = getPlatform() - response.userInfo = [SFExtensionMessageKey: ["platform": platform]] - } - else if name == "REQ_USERSCRIPTS" { - if let url = message?["url"] as? String, let isTop = message?["isTop"] as? Bool { - if - let matches = getInjectionFilenames(url), - let code = getCode(matches, isTop) - { - response.userInfo = [SFExtensionMessageKey: code] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "REQ_USERSCRIPTS failed"]] - } - } else { - inBoundError = true - } - } - else if name == "REQ_REQUESTS" { - if let requestScripts = getRequestScripts() { - response.userInfo = [SFExtensionMessageKey: requestScripts] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get requestScripts"]] - } - } - else if name == "REQ_CONTEXT_MENU_SCRIPTS" { - if let contextMenuScripts = getContextMenuScripts() { - response.userInfo = [SFExtensionMessageKey: contextMenuScripts] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get contextMenuScripts"]] - } - } - else if name == "POPUP_BADGE_COUNT" { - if let url = message?["url"] as? String, let frameUrls = message?["frameUrls"] as? [String] { - if let matches = getPopupBadgeCount(url, frameUrls) { - response.userInfo = [SFExtensionMessageKey: ["count": matches]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to update badge count"]] - } - } else { - inBoundError = true - } - } - else if name == "POPUP_MATCHES"{ - if let url = message?["url"] as? String, let frameUrls = message?["frameUrls"] as? [String] { - if let matches = getPopupMatches(url, frameUrls) { - response.userInfo = [SFExtensionMessageKey: ["matches": matches]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get matches"]] - } - } else { - inBoundError = true - } - } - else if name == "POPUP_UPDATES" { - if let updates = checkForRemoteUpdates() { - response.userInfo = [SFExtensionMessageKey: ["updates": updates]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get updates"]] - } - } - else if name == "POPUP_UPDATE_ALL" { - if popupUpdateAll(), let updates = checkForRemoteUpdates() { - response.userInfo = [SFExtensionMessageKey: ["updates": updates]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to run update sequence"]] - } - } - else if name == "POPUP_UPDATE_SINGLE" { - if - let filename = message?["filename"] as? String, - let url = message?["url"] as? String, - let frameUrls = message?["frameUrls"] as? [String] - { - if let matches = popupUpdateSingle(filename, url, frameUrls) { - response.userInfo = [SFExtensionMessageKey: ["items": matches]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to update file"]] - } - } else { - inBoundError = true - } - } - else if name == "POPUP_CHECK_UPDATES" { - if let updates = checkForRemoteUpdates() { - response.userInfo = [SFExtensionMessageKey: ["updates": updates]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to check for updates"]] - } - } - else if name == "TOGGLE_EXTENSION" { - if let active = message?["active"] as? String, ["true", "false"].contains(active) { - var manifest = getManifest() - manifest.settings["active"] = active - if updateManifest(with: manifest) { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to update injection state"]] - } - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "missing or wrong message content"]] - } - } - else if name == "TOGGLE_ITEM" { - // the current status of the item to toggle comes in as 0 || 1 - if - let item = message?["item"] as? [String: Any], - let filename = item["filename"] as? String, - let current = item["disabled"] as? Int - { - let action = current == 0 ? "disable" : "enable" - if toggleFile(filename, action) { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to toggle file"]] - } - } else { - inBoundError = true - } - } - else if name == "OPEN_SAVE_LOCATION" { - #if os(macOS) - if openSaveLocation() { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } - #elseif os(iOS) - if let files = getAllFiles() { - response.userInfo = [SFExtensionMessageKey: ["items": files]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get all files"]] - } - #endif - } - else if name == "CHANGE_SAVE_LOCATION" { - #if os(macOS) - if let scheme = Bundle.main.infoDictionary?["US_URL_SCHEME"], - let url = URL(string: "\(scheme)://changesavelocation") { - NSWorkspace.shared.open(url) - } - #endif - } - else if name == "POPUP_INSTALL_CHECK" { - if let content = message?["content"] as? String { - response.userInfo = [SFExtensionMessageKey: installCheck(content)] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get script content"]] - } - } - else if name == "POPUP_INSTALL_SCRIPT" { - if let content = message?["content"] as? String { - response.userInfo = [SFExtensionMessageKey: installUserscript(content)] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get script content (2)"]] - } - } - else if name == "PAGE_INIT_DATA" { - #if os(macOS) - if let initData = getInitData(), checkDefaultDirectories() { - response.userInfo = [SFExtensionMessageKey: initData] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get init data"]] - } - #endif - } - else if name == "PAGE_LEGACY_IMPORT" { - #if os(macOS) - if let settings = getLegacyData() { - response.userInfo = [SFExtensionMessageKey: settings] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get legacy data"]] - } - #endif - } - else if name == "PAGE_ALL_FILES" { - #if os(macOS) - if let files = getAllFiles() { - response.userInfo = [SFExtensionMessageKey: files] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to get all files"]] - } - #endif - } - else if name == "PAGE_SAVE" { - #if os(macOS) - if - let item = message?["item"] as? [String: Any], - let content = message?["content"] as? String - { - let saveResponse = saveFile(item, content) - response.userInfo = [SFExtensionMessageKey: saveResponse] - } else { - inBoundError = true - } - #endif - } - else if name == "PAGE_TRASH" { - #if os(macOS) - if let item = message?["item"] as? [String: Any] { - if trashFile(item) { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "failed to trash file"]] - } - } else { - inBoundError = true - } - #endif - } - else if name == "PAGE_UPDATE" { - #if os(macOS) - if let content = message?["content"] as? String { - let updateResponse = getFileRemoteUpdate(content) - response.userInfo = [SFExtensionMessageKey: updateResponse] - } else { - inBoundError = true - } - #endif - } - else if name == "CANCEL_REQUESTS" { - URLSession.shared.getAllTasks { tasks in - for task in tasks { - task.cancel() - } - } - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } - else if name == "PAGE_NEW_REMOTE" { - #if os(macOS) - if let url = message?["url"] as? String { - if !validateUrl(url) { - response.userInfo = [SFExtensionMessageKey: ["error": "Failed to get remote content, invalid url"]] - } else if let content = getRemoteFileContents(url) { - response.userInfo = [SFExtensionMessageKey: content] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "Failed to get remote content"]] - } - } else { - inBoundError = true - } - #endif - } - else if name == "PAGE_UPDATE_SETTINGS" { - #if os(macOS) - if let settings = message?["settings"] as? [String: String] { - if updateSettings(settings) { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } else { - response.userInfo = [SFExtensionMessageKey: ["error": "Failed to save settings to disk"]] - } - } else { - inBoundError = true - } - #endif - } - else if name == "PAGE_UPDATE_BLACKLIST" { - #if os(macOS) - if let blacklist = message?["blacklist"] as? [String] { - var manifest = getManifest() - manifest.blacklist = blacklist - if !updateManifest(with: manifest) { - response.userInfo = [SFExtensionMessageKey: ["error": "Failed to save blacklist to disk"]] - } else { - response.userInfo = [SFExtensionMessageKey: ["success": true]] - } - } else { - inBoundError = true - } - #endif - } - // send inBoundError if found - if inBoundError { - response.userInfo = [SFExtensionMessageKey: ["error": "Failed to parse inbound message"]] - } - context.completeRequest(returningItems: [response], completionHandler: nil) - } + func beginRequest(with context: NSExtensionContext) { + let logger = USLogger(#fileID) + let item = context.inputItems[0] as? NSExtensionItem + let message = item?.userInfo?[SFExtensionMessageKey] as? [String: Any] + // if message received without name, ignore + guard let name = message?["name"] as? String else { + logger?.error("\(#function, privacy: .public) - could not get message name from web extension") + return + } + logger?.info("\(#function, privacy: .public) - Got message with name: \(name, privacy: .public)") + // got a valid message, construct response based on message received + let response = NSExtensionItem() + // send standard error when there's an issue parsing inbound message + var inBoundError = false + // these if/else if statement are formatted so that they can be neatly collapsed in Xcode + // typically the "else if" would be on the same line as the preceding statements close bracket + // ie. } else if { + if name == "OPEN_APP" { + if let scheme = Bundle.main.infoDictionary?["US_URL_SCHEME"], + let url = URL(string: "\(scheme):") { + #if os(macOS) + NSWorkspace.shared.open(url) + #endif + } + } + else if name == "NATIVE_CHECKS" { + let result = nativeChecks() + response.userInfo = [SFExtensionMessageKey: result] + } + else if name == "REQ_PLATFORM" { + let platform = getPlatform() + response.userInfo = [SFExtensionMessageKey: ["platform": platform]] + } + else if name == "REQ_USERSCRIPTS" { + if let url = message?["url"] as? String, let isTop = message?["isTop"] as? Bool { + if + let matches = getInjectionFilenames(url), + let code = getCode(matches, isTop) + { + response.userInfo = [SFExtensionMessageKey: code] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "REQ_USERSCRIPTS failed"]] + } + } else { + inBoundError = true + } + } + else if name == "REQ_REQUESTS" { + if let requestScripts = getRequestScripts() { + response.userInfo = [SFExtensionMessageKey: requestScripts] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get requestScripts"]] + } + } + else if name == "REQ_CONTEXT_MENU_SCRIPTS" { + if let contextMenuScripts = getContextMenuScripts() { + response.userInfo = [SFExtensionMessageKey: contextMenuScripts] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get contextMenuScripts"]] + } + } + else if name == "POPUP_BADGE_COUNT" { + if let url = message?["url"] as? String, let frameUrls = message?["frameUrls"] as? [String] { + if let matches = getPopupBadgeCount(url, frameUrls) { + response.userInfo = [SFExtensionMessageKey: ["count": matches]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to update badge count"]] + } + } else { + inBoundError = true + } + } + else if name == "POPUP_MATCHES"{ + if let url = message?["url"] as? String, let frameUrls = message?["frameUrls"] as? [String] { + if let matches = getPopupMatches(url, frameUrls) { + response.userInfo = [SFExtensionMessageKey: ["matches": matches]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get matches"]] + } + } else { + inBoundError = true + } + } + else if name == "POPUP_UPDATES" { + if let updates = checkForRemoteUpdates() { + response.userInfo = [SFExtensionMessageKey: ["updates": updates]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get updates"]] + } + } + else if name == "POPUP_UPDATE_ALL" { + if popupUpdateAll(), let updates = checkForRemoteUpdates() { + response.userInfo = [SFExtensionMessageKey: ["updates": updates]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to run update sequence"]] + } + } + else if name == "POPUP_UPDATE_SINGLE" { + if + let filename = message?["filename"] as? String, + let url = message?["url"] as? String, + let frameUrls = message?["frameUrls"] as? [String] + { + if let matches = popupUpdateSingle(filename, url, frameUrls) { + response.userInfo = [SFExtensionMessageKey: ["items": matches]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to update file"]] + } + } else { + inBoundError = true + } + } + else if name == "POPUP_CHECK_UPDATES" { + if let updates = checkForRemoteUpdates() { + response.userInfo = [SFExtensionMessageKey: ["updates": updates]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to check for updates"]] + } + } + else if name == "TOGGLE_EXTENSION" { + if let active = message?["active"] as? String, ["true", "false"].contains(active) { + var manifest = getManifest() + manifest.settings["active"] = active + if updateManifest(with: manifest) { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to update injection state"]] + } + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "missing or wrong message content"]] + } + } + else if name == "TOGGLE_ITEM" { + // the current status of the item to toggle comes in as 0 || 1 + if + let item = message?["item"] as? [String: Any], + let filename = item["filename"] as? String, + let current = item["disabled"] as? Int + { + let action = current == 0 ? "disable" : "enable" + if toggleFile(filename, action) { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to toggle file"]] + } + } else { + inBoundError = true + } + } + else if name == "OPEN_SAVE_LOCATION" { + #if os(macOS) + if openSaveLocation() { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } + #elseif os(iOS) + if let files = getAllFiles() { + response.userInfo = [SFExtensionMessageKey: ["items": files]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get all files"]] + } + #endif + } + else if name == "CHANGE_SAVE_LOCATION" { + #if os(macOS) + if let scheme = Bundle.main.infoDictionary?["US_URL_SCHEME"], + let url = URL(string: "\(scheme)://changesavelocation") { + NSWorkspace.shared.open(url) + } + #endif + } + else if name == "POPUP_INSTALL_CHECK" { + if let content = message?["content"] as? String { + response.userInfo = [SFExtensionMessageKey: installCheck(content)] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get script content"]] + } + } + else if name == "POPUP_INSTALL_SCRIPT" { + if let content = message?["content"] as? String { + response.userInfo = [SFExtensionMessageKey: installUserscript(content)] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get script content (2)"]] + } + } + else if name == "PAGE_INIT_DATA" { + #if os(macOS) + if let initData = getInitData(), checkDefaultDirectories() { + response.userInfo = [SFExtensionMessageKey: initData] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get init data"]] + } + #endif + } + else if name == "PAGE_LEGACY_IMPORT" { + #if os(macOS) + if let settings = getLegacyData() { + response.userInfo = [SFExtensionMessageKey: settings] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get legacy data"]] + } + #endif + } + else if name == "PAGE_ALL_FILES" { + #if os(macOS) + if let files = getAllFiles() { + response.userInfo = [SFExtensionMessageKey: files] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to get all files"]] + } + #endif + } + else if name == "PAGE_SAVE" { + #if os(macOS) + if + let item = message?["item"] as? [String: Any], + let content = message?["content"] as? String + { + let saveResponse = saveFile(item, content) + response.userInfo = [SFExtensionMessageKey: saveResponse] + } else { + inBoundError = true + } + #endif + } + else if name == "PAGE_TRASH" { + #if os(macOS) + if let item = message?["item"] as? [String: Any] { + if trashFile(item) { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "failed to trash file"]] + } + } else { + inBoundError = true + } + #endif + } + else if name == "PAGE_UPDATE" { + #if os(macOS) + if let content = message?["content"] as? String { + let updateResponse = getFileRemoteUpdate(content) + response.userInfo = [SFExtensionMessageKey: updateResponse] + } else { + inBoundError = true + } + #endif + } + else if name == "CANCEL_REQUESTS" { + URLSession.shared.getAllTasks { tasks in + for task in tasks { + task.cancel() + } + } + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } + else if name == "PAGE_NEW_REMOTE" { + #if os(macOS) + if let url = message?["url"] as? String { + if !validateUrl(url) { + response.userInfo = [SFExtensionMessageKey: ["error": "Failed to get remote content, invalid url"]] + } else if let content = getRemoteFileContents(url) { + response.userInfo = [SFExtensionMessageKey: content] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "Failed to get remote content"]] + } + } else { + inBoundError = true + } + #endif + } + else if name == "PAGE_UPDATE_SETTINGS" { + #if os(macOS) + if let settings = message?["settings"] as? [String: String] { + if updateSettings(settings) { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } else { + response.userInfo = [SFExtensionMessageKey: ["error": "Failed to save settings to disk"]] + } + } else { + inBoundError = true + } + #endif + } + else if name == "PAGE_UPDATE_BLACKLIST" { + #if os(macOS) + if let blacklist = message?["blacklist"] as? [String] { + var manifest = getManifest() + manifest.blacklist = blacklist + if !updateManifest(with: manifest) { + response.userInfo = [SFExtensionMessageKey: ["error": "Failed to save blacklist to disk"]] + } else { + response.userInfo = [SFExtensionMessageKey: ["success": true]] + } + } else { + inBoundError = true + } + #endif + } + // send inBoundError if found + if inBoundError { + response.userInfo = [SFExtensionMessageKey: ["error": "Failed to parse inbound message"]] + } + context.completeRequest(returningItems: [response], completionHandler: nil) + } } diff --git a/xcode/Shared/Preferences.swift b/xcode/Shared/Preferences.swift index 377ccd3e..f486cede 100644 --- a/xcode/Shared/Preferences.swift +++ b/xcode/Shared/Preferences.swift @@ -8,64 +8,64 @@ let bundleIdentifier = Bundle.main.bundleIdentifier! let extIdentifier = Bundle.main.infoDictionary?["US_EXT_IDENTIFIER"] as! String func getDefaultScriptsDirectoryUrl() -> URL { - var url: URL - if #available(macOS 13.0, iOS 16.0, *) { - url = getDocumentsDirectory().appending(path: "scripts") - } else { - url = getDocumentsDirectory().appendingPathComponent("scripts") - } - // if not in safari extension environment, replace with extension path - if bundleIdentifier != extIdentifier { - var path: String - if #available(macOS 13.0, iOS 16.0, *) { - path = url.path(percentEncoded: false) - } else { - path = url.path.removingPercentEncoding ?? url.path - } - let truePath = path.replacingOccurrences(of: bundleIdentifier, with: extIdentifier) - url = URL(fileURLWithPath: truePath, isDirectory: true) - } - return url + var url: URL + if #available(macOS 13.0, iOS 16.0, *) { + url = getDocumentsDirectory().appending(path: "scripts") + } else { + url = getDocumentsDirectory().appendingPathComponent("scripts") + } + // if not in safari extension environment, replace with extension path + if bundleIdentifier != extIdentifier { + var path: String + if #available(macOS 13.0, iOS 16.0, *) { + path = url.path(percentEncoded: false) + } else { + path = url.path.removingPercentEncoding ?? url.path + } + let truePath = path.replacingOccurrences(of: bundleIdentifier, with: extIdentifier) + url = URL(fileURLWithPath: truePath, isDirectory: true) + } + return url } func isCurrentDefaultScriptsDirectory() -> Bool { - return Preferences.scriptsDirectoryUrl == getDefaultScriptsDirectoryUrl() + return Preferences.scriptsDirectoryUrl == getDefaultScriptsDirectoryUrl() } func isCurrentInitialScriptsDirectory() -> Bool { - let url = Preferences.scriptsDirectoryUrl.standardizedFileURL - return url == getDocumentsDirectory().standardizedFileURL + let url = Preferences.scriptsDirectoryUrl.standardizedFileURL + return url == getDocumentsDirectory().standardizedFileURL } func getCurrentScriptsDirectoryString() -> String { - let url = Preferences.scriptsDirectoryUrl.standardizedFileURL - if url == getDocumentsDirectory().standardizedFileURL { - return "Userscripts App Documents" - } - if #available(macOS 13.0, iOS 16.0, *) { - return url.path(percentEncoded: false) - } else { - return url.path.removingPercentEncoding ?? url.path - } + let url = Preferences.scriptsDirectoryUrl.standardizedFileURL + if url == getDocumentsDirectory().standardizedFileURL { + return "Userscripts App Documents" + } + if #available(macOS 13.0, iOS 16.0, *) { + return url.path(percentEncoded: false) + } else { + return url.path.removingPercentEncoding ?? url.path + } } // https://docs.swift.org/swift-book/documentation/the-swift-programming-language/properties#Property-Wrappers // https://docs.swift.org/swift-book/documentation/the-swift-programming-language/generics/ @propertyWrapper private struct SharedUserDefaults { - private let key: String - private let defaultValue: T + private let key: String + private let defaultValue: T - init(wrappedValue: T, _ key: String) { - self.key = key - self.defaultValue = wrappedValue - SDefaults?.register(defaults: [key: wrappedValue]) - } + init(wrappedValue: T, _ key: String) { + self.key = key + self.defaultValue = wrappedValue + SDefaults?.register(defaults: [key: wrappedValue]) + } - var wrappedValue: T { - get { SDefaults?.object(forKey: key) as? T ?? defaultValue } - set { SDefaults?.set(newValue, forKey: key) } - } + var wrappedValue: T { + get { SDefaults?.object(forKey: key) as? T ?? defaultValue } + set { SDefaults?.set(newValue, forKey: key) } + } } #if os(macOS) @@ -84,149 +84,149 @@ private struct SharedUserDefaults { */ @propertyWrapper private struct SecurityScopedBookmark { - private let key: String - private let keySetter: String - private let keyTransfer: String - private let keySecurityScoped: String - private let defaultValue: URL - - init(wrappedValue: URL, _ key: String) { - self.key = key - self.keySetter = key + "/SetterId" - self.keyTransfer = key + "/Transfer" - self.keySecurityScoped = key + "/SecurityScoped/" + bundleIdentifier - self.defaultValue = wrappedValue - } + private let key: String + private let keySetter: String + private let keyTransfer: String + private let keySecurityScoped: String + private let defaultValue: URL - private func createBookmark(_ url: URL, _ withSecurityScope: Bool) -> Data? { - do { - return try url.bookmarkData( - options: withSecurityScope ? .withSecurityScope : [], - includingResourceValuesForKeys: nil, - relativeTo: nil - ) - } catch { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - } - return nil - } + init(wrappedValue: URL, _ key: String) { + self.key = key + self.keySetter = key + "/SetterId" + self.keyTransfer = key + "/Transfer" + self.keySecurityScoped = key + "/SecurityScoped/" + bundleIdentifier + self.defaultValue = wrappedValue + } - private func resolvBookmark(_ data: Data, _ withSecurityScope: Bool, _ isStale: inout Bool) -> URL? { - do { - return try URL( - resolvingBookmarkData: data, - options: withSecurityScope ? .withSecurityScope : [], - relativeTo: nil, - bookmarkDataIsStale: &isStale - ) - } catch { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - } - return nil - } + private func createBookmark(_ url: URL, _ withSecurityScope: Bool) -> Data? { + do { + return try url.bookmarkData( + options: withSecurityScope ? .withSecurityScope : [], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + } catch { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + } + return nil + } - private func updateBookmark() { - guard let setterIdentifier = SDefaults?.string(forKey: keySetter) else { - logger?.debug("\(#function, privacy: .public) - setterId not exist: \(key, privacy: .public)") - return - } - guard bundleIdentifier != setterIdentifier else { return } // update only in non-setter environment - guard let data = SDefaults?.data(forKey: keyTransfer) else { // no need to update due empty data - logger?.debug("\(#function, privacy: .public) - no update: \(key, privacy: .public)") - return - } - var isStale = false // no need to renew since will remove anyway - guard let url = resolvBookmark(data, false, &isStale) else { // get URL bookmark with an implicit security scope - removeBookmark() - return - } - defer { url.stopAccessingSecurityScopedResource() } // revoke implicitly starts security-scoped access - if let data = createBookmark(url, true) { // set URL bookmark with an explicit security scope - logger?.info("\(#function, privacy: .public) - update bookmark: \(key, privacy: .public) \(url, privacy: .public)") - SDefaults?.removeObject(forKey: keyTransfer) - SDefaults?.set(data, forKey: keySecurityScoped) - } - } - - private func removeBookmark() { - logger?.info("\(#function, privacy: .public) - remove invalid bookmark: \(key, privacy: .public)") - SDefaults?.removeObject(forKey: keyTransfer) - SDefaults?.removeObject(forKey: keySecurityScoped) - if let setterIdentifier = SDefaults?.string(forKey: keySetter) { - SDefaults?.removeObject(forKey: key + "/SecurityScoped/" + setterIdentifier) - } - } - - // NOTE: This function will be deleted after several public versions of v4.5.0 - private func legacyBookmarkImporter() { - guard key == "ScriptsDirectoryUrlBookmarkData" else { return } - guard bundleIdentifier == extIdentifier else { return } - func legacyCleanup() { - logger?.debug("\(#function, privacy: .public) - cleanup legacy bookmark data") - SDefaults?.removeObject(forKey: "hostSelectedSaveLocation") // shared - UserDefaults.standard.removeObject(forKey: "saveLocation") // ext - UserDefaults.standard.removeObject(forKey: "userSaveLocation") // ext - } - guard let data = UserDefaults.standard.data(forKey: "userSaveLocation") else { // get from old key - logger?.debug("\(#function, privacy: .public) - legacy bookmark not exist") - return - } - guard SDefaults?.string(forKey: keySetter) == nil else { // already a new key - return legacyCleanup() - } - logger?.debug("\(#function, privacy: .public) - Import legacy bookmark data") - var isStale = false // no need to renew since will remove anyway - guard let url = resolvBookmark(data, true, &isStale) else { // get URL bookmark with an explicit security scope - return legacyCleanup() - } - guard url.startAccessingSecurityScopedResource() else { - return legacyCleanup() - } - defer { url.stopAccessingSecurityScopedResource() } - guard let transferData = createBookmark(url, false) else { - return legacyCleanup() - } - SDefaults?.set(bundleIdentifier, forKey: keySetter) - SDefaults?.set(transferData, forKey: keyTransfer) // set URL bookmark with an implicit security scope - SDefaults?.set(data, forKey: keySecurityScoped) // set URL bookmark with an explicit security scope - legacyCleanup() - } + private func resolvBookmark(_ data: Data, _ withSecurityScope: Bool, _ isStale: inout Bool) -> URL? { + do { + return try URL( + resolvingBookmarkData: data, + options: withSecurityScope ? .withSecurityScope : [], + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) + } catch { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + } + return nil + } - var wrappedValue: URL { - get { - legacyBookmarkImporter() - updateBookmark() - logger?.info("\(#function, privacy: .public) - try get bookmark: \(key, privacy: .public)") - guard let data = SDefaults?.data(forKey: keySecurityScoped) else { - logger?.debug("\(#function, privacy: .public) - bookmark not exist: \(key, privacy: .public)") - return defaultValue - } - var isStale = false - guard let url = resolvBookmark(data, true, &isStale) else { // get URL bookmark with an explicit security scope - removeBookmark() // remove data that cannot be resolved - return defaultValue - } - if isStale, url.startAccessingSecurityScopedResource() { // renew URL bookmark - defer { url.stopAccessingSecurityScopedResource() } - if let data = createBookmark(url, true) { // set URL bookmark with an explicit security scope - logger?.info("\(#function, privacy: .public) - renew bookmark: \(key, privacy: .public) \(url, privacy: .public)") - SDefaults?.set(data, forKey: keySecurityScoped) - } - } - return url - } - set(url) { - let k = key // key cannot be log directly with error: Escaping autoclosure captures mutating 'self' parameter - logger?.info("\(#function, privacy: .public) - try set bookmark: \(k, privacy: .public) \(url, privacy: .public)") - guard let tdata = createBookmark(url, false), let sdata = createBookmark(url, true) else { - logger?.info("\(#function, privacy: .public) - failed create bookmark: \(k, privacy: .public) \(url, privacy: .public)") - return - } - SDefaults?.set(bundleIdentifier, forKey: keySetter) - SDefaults?.set(tdata, forKey: keyTransfer) // set URL bookmark with an implicit security scope - SDefaults?.set(sdata, forKey: keySecurityScoped) // set URL bookmark with an explicit security scope - } - } + private func updateBookmark() { + guard let setterIdentifier = SDefaults?.string(forKey: keySetter) else { + logger?.debug("\(#function, privacy: .public) - setterId not exist: \(key, privacy: .public)") + return + } + guard bundleIdentifier != setterIdentifier else { return } // update only in non-setter environment + guard let data = SDefaults?.data(forKey: keyTransfer) else { // no need to update due empty data + logger?.debug("\(#function, privacy: .public) - no update: \(key, privacy: .public)") + return + } + var isStale = false // no need to renew since will remove anyway + guard let url = resolvBookmark(data, false, &isStale) else { // get URL bookmark with an implicit security scope + removeBookmark() + return + } + defer { url.stopAccessingSecurityScopedResource() } // revoke implicitly starts security-scoped access + if let data = createBookmark(url, true) { // set URL bookmark with an explicit security scope + logger?.info("\(#function, privacy: .public) - update bookmark: \(key, privacy: .public) \(url, privacy: .public)") + SDefaults?.removeObject(forKey: keyTransfer) + SDefaults?.set(data, forKey: keySecurityScoped) + } + } + + private func removeBookmark() { + logger?.info("\(#function, privacy: .public) - remove invalid bookmark: \(key, privacy: .public)") + SDefaults?.removeObject(forKey: keyTransfer) + SDefaults?.removeObject(forKey: keySecurityScoped) + if let setterIdentifier = SDefaults?.string(forKey: keySetter) { + SDefaults?.removeObject(forKey: key + "/SecurityScoped/" + setterIdentifier) + } + } + + // NOTE: This function will be deleted after several public versions of v4.5.0 + private func legacyBookmarkImporter() { + guard key == "ScriptsDirectoryUrlBookmarkData" else { return } + guard bundleIdentifier == extIdentifier else { return } + func legacyCleanup() { + logger?.debug("\(#function, privacy: .public) - cleanup legacy bookmark data") + SDefaults?.removeObject(forKey: "hostSelectedSaveLocation") // shared + UserDefaults.standard.removeObject(forKey: "saveLocation") // ext + UserDefaults.standard.removeObject(forKey: "userSaveLocation") // ext + } + guard let data = UserDefaults.standard.data(forKey: "userSaveLocation") else { // get from old key + logger?.debug("\(#function, privacy: .public) - legacy bookmark not exist") + return + } + guard SDefaults?.string(forKey: keySetter) == nil else { // already a new key + return legacyCleanup() + } + logger?.debug("\(#function, privacy: .public) - Import legacy bookmark data") + var isStale = false // no need to renew since will remove anyway + guard let url = resolvBookmark(data, true, &isStale) else { // get URL bookmark with an explicit security scope + return legacyCleanup() + } + guard url.startAccessingSecurityScopedResource() else { + return legacyCleanup() + } + defer { url.stopAccessingSecurityScopedResource() } + guard let transferData = createBookmark(url, false) else { + return legacyCleanup() + } + SDefaults?.set(bundleIdentifier, forKey: keySetter) + SDefaults?.set(transferData, forKey: keyTransfer) // set URL bookmark with an implicit security scope + SDefaults?.set(data, forKey: keySecurityScoped) // set URL bookmark with an explicit security scope + legacyCleanup() + } + + var wrappedValue: URL { + get { + legacyBookmarkImporter() + updateBookmark() + logger?.info("\(#function, privacy: .public) - try get bookmark: \(key, privacy: .public)") + guard let data = SDefaults?.data(forKey: keySecurityScoped) else { + logger?.debug("\(#function, privacy: .public) - bookmark not exist: \(key, privacy: .public)") + return defaultValue + } + var isStale = false + guard let url = resolvBookmark(data, true, &isStale) else { // get URL bookmark with an explicit security scope + removeBookmark() // remove data that cannot be resolved + return defaultValue + } + if isStale, url.startAccessingSecurityScopedResource() { // renew URL bookmark + defer { url.stopAccessingSecurityScopedResource() } + if let data = createBookmark(url, true) { // set URL bookmark with an explicit security scope + logger?.info("\(#function, privacy: .public) - renew bookmark: \(key, privacy: .public) \(url, privacy: .public)") + SDefaults?.set(data, forKey: keySecurityScoped) + } + } + return url + } + set(url) { + let k = key // key cannot be log directly with error: Escaping autoclosure captures mutating 'self' parameter + logger?.info("\(#function, privacy: .public) - try set bookmark: \(k, privacy: .public) \(url, privacy: .public)") + guard let tdata = createBookmark(url, false), let sdata = createBookmark(url, true) else { + logger?.info("\(#function, privacy: .public) - failed create bookmark: \(k, privacy: .public) \(url, privacy: .public)") + return + } + SDefaults?.set(bundleIdentifier, forKey: keySetter) + SDefaults?.set(tdata, forKey: keyTransfer) // set URL bookmark with an implicit security scope + SDefaults?.set(sdata, forKey: keySecurityScoped) // set URL bookmark with an explicit security scope + } + } } #elseif os(iOS) /* https://developer.apple.com/documentation/uikit/view_controllers/providing_access_to_directories#3331285 @@ -236,91 +236,91 @@ private struct SecurityScopedBookmark { */ @propertyWrapper private struct SecurityScopedBookmark { - private let key: String - private let defaultValue: URL - - init(wrappedValue: URL, _ key: String) { - self.key = key - self.defaultValue = wrappedValue - } - - private func createBookmark(_ url: URL) -> Data? { - do { - return try url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil) - } catch { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - } - return nil - } + private let key: String + private let defaultValue: URL + + init(wrappedValue: URL, _ key: String) { + self.key = key + self.defaultValue = wrappedValue + } + + private func createBookmark(_ url: URL) -> Data? { + do { + return try url.bookmarkData(options: .minimalBookmark, includingResourceValuesForKeys: nil, relativeTo: nil) + } catch { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + } + return nil + } + + private func resolvBookmark(_ data: Data, _ isStale: inout Bool) -> URL? { + do { + return try URL(resolvingBookmarkData: data, bookmarkDataIsStale: &isStale) + } catch { + logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") + } + return nil + } - private func resolvBookmark(_ data: Data, _ isStale: inout Bool) -> URL? { - do { - return try URL(resolvingBookmarkData: data, bookmarkDataIsStale: &isStale) - } catch { - logger?.error("\(#function, privacy: .public) - \(error.localizedDescription, privacy: .public)") - } - return nil - } - - // NOTE: This function will be deleted after several public versions of v4.5.0 - private func legacyBookmarkImporter() { - guard let data = SDefaults?.data(forKey: "iosReadLocation") else { // get from old key - logger?.debug("\(#function, privacy: .public) - legacy bookmark not exist") - return - } - logger?.debug("\(#function, privacy: .public) - Import legacy bookmark data") - SDefaults?.set(data, forKey: key) - SDefaults?.removeObject(forKey: "iosReadLocation") - } + // NOTE: This function will be deleted after several public versions of v4.5.0 + private func legacyBookmarkImporter() { + guard let data = SDefaults?.data(forKey: "iosReadLocation") else { // get from old key + logger?.debug("\(#function, privacy: .public) - legacy bookmark not exist") + return + } + logger?.debug("\(#function, privacy: .public) - Import legacy bookmark data") + SDefaults?.set(data, forKey: key) + SDefaults?.removeObject(forKey: "iosReadLocation") + } - var wrappedValue: URL { - get { - legacyBookmarkImporter() - logger?.info("\(#function, privacy: .public) - try get bookmark: \(key, privacy: .public)") - guard let data = SDefaults?.data(forKey: key) else { - logger?.debug("\(#function, privacy: .public) - bookmark not exist: \(key, privacy: .public)") - return defaultValue - } - var isStale = false - guard let url = resolvBookmark(data, &isStale) else { // get security-scoped URL - SDefaults?.removeObject(forKey: key) // remove data that cannot be resolved - return defaultValue - } - if isStale, url.startAccessingSecurityScopedResource() { // renew URL bookmark - defer { url.stopAccessingSecurityScopedResource() } - if let data = createBookmark(url) { // set security-scoped URL - logger?.info("\(#function, privacy: .public) - renew bookmark: \(key, privacy: .public) \(url, privacy: .public)") - SDefaults?.set(data, forKey: key) - } - } - return url - } - set(url) { - let k = key // key cannot be log directly with error: Escaping autoclosure captures mutating 'self' parameter - logger?.info("\(#function, privacy: .public) - try set bookmark: \(k, privacy: .public) \(url, privacy: .public)") - let didStartAccessing = url.startAccessingSecurityScopedResource() - defer { - if didStartAccessing { url.stopAccessingSecurityScopedResource() } - } - guard let data = createBookmark(url) else { - logger?.info("\(#function, privacy: .public) - failed create bookmark: \(k, privacy: .public) \(url, privacy: .public)") - return - } - SDefaults?.set(data, forKey: key) // set security-scoped URL - } - } + var wrappedValue: URL { + get { + legacyBookmarkImporter() + logger?.info("\(#function, privacy: .public) - try get bookmark: \(key, privacy: .public)") + guard let data = SDefaults?.data(forKey: key) else { + logger?.debug("\(#function, privacy: .public) - bookmark not exist: \(key, privacy: .public)") + return defaultValue + } + var isStale = false + guard let url = resolvBookmark(data, &isStale) else { // get security-scoped URL + SDefaults?.removeObject(forKey: key) // remove data that cannot be resolved + return defaultValue + } + if isStale, url.startAccessingSecurityScopedResource() { // renew URL bookmark + defer { url.stopAccessingSecurityScopedResource() } + if let data = createBookmark(url) { // set security-scoped URL + logger?.info("\(#function, privacy: .public) - renew bookmark: \(key, privacy: .public) \(url, privacy: .public)") + SDefaults?.set(data, forKey: key) + } + } + return url + } + set(url) { + let k = key // key cannot be log directly with error: Escaping autoclosure captures mutating 'self' parameter + logger?.info("\(#function, privacy: .public) - try set bookmark: \(k, privacy: .public) \(url, privacy: .public)") + let didStartAccessing = url.startAccessingSecurityScopedResource() + defer { + if didStartAccessing { url.stopAccessingSecurityScopedResource() } + } + guard let data = createBookmark(url) else { + logger?.info("\(#function, privacy: .public) - failed create bookmark: \(k, privacy: .public) \(url, privacy: .public)") + return + } + SDefaults?.set(data, forKey: key) // set security-scoped URL + } + } } #endif // Define shared preferences, default values ​​determine data type struct Preferences { /* Example preference, can be get or set with: Preferences.propertyName - @SharedUserDefaults("SharedUserDefaultsKeyName") - static var propertyName = "DefaultValue" // String + @SharedUserDefaults("SharedUserDefaultsKeyName") + static var propertyName = "DefaultValue" // String */ - @SecurityScopedBookmark("ScriptsDirectoryUrlBookmarkData") - static var scriptsDirectoryUrl = getDefaultScriptsDirectoryUrl() + @SecurityScopedBookmark("ScriptsDirectoryUrlBookmarkData") + static var scriptsDirectoryUrl = getDefaultScriptsDirectoryUrl() - @SharedUserDefaults("EnableLogger") - static var enableLogger = false + @SharedUserDefaults("EnableLogger") + static var enableLogger = false } diff --git a/xcode/Shared/UrlPolyfill.swift b/xcode/Shared/UrlPolyfill.swift index 96cebed5..dc76e2b3 100644 --- a/xcode/Shared/UrlPolyfill.swift +++ b/xcode/Shared/UrlPolyfill.swift @@ -1,96 +1,96 @@ import Foundation extension CharacterSet { - // https://developer.apple.com/documentation/foundation/characterset#2902136 - public static let urlAllowed_ = CharacterSet(charactersIn: "#") - .union(.urlFragmentAllowed) - .union(.urlHostAllowed) - .union(.urlPasswordAllowed) - .union(.urlPathAllowed) - .union(.urlQueryAllowed) - .union(.urlUserAllowed) + // https://developer.apple.com/documentation/foundation/characterset#2902136 + public static let urlAllowed_ = CharacterSet(charactersIn: "#") + .union(.urlFragmentAllowed) + .union(.urlHostAllowed) + .union(.urlPasswordAllowed) + .union(.urlPathAllowed) + .union(.urlQueryAllowed) + .union(.urlUserAllowed) } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI func encodeURI(_ uri: String) -> String { - // https://developer.apple.com/documentation/foundation/characterset#2902136 -// var urlAllowed = CharacterSet(charactersIn: "#") -// urlAllowed.formUnion(.urlFragmentAllowed) -// urlAllowed.formUnion(.urlHostAllowed) -// urlAllowed.formUnion(.urlPasswordAllowed) -// urlAllowed.formUnion(.urlPathAllowed) -// urlAllowed.formUnion(.urlQueryAllowed) -// urlAllowed.formUnion(.urlUserAllowed) - return uri.addingPercentEncoding(withAllowedCharacters: .urlAllowed_) ?? uri + // https://developer.apple.com/documentation/foundation/characterset#2902136 +// var urlAllowed = CharacterSet(charactersIn: "#") +// urlAllowed.formUnion(.urlFragmentAllowed) +// urlAllowed.formUnion(.urlHostAllowed) +// urlAllowed.formUnion(.urlPasswordAllowed) +// urlAllowed.formUnion(.urlPathAllowed) +// urlAllowed.formUnion(.urlQueryAllowed) +// urlAllowed.formUnion(.urlUserAllowed) + return uri.addingPercentEncoding(withAllowedCharacters: .urlAllowed_) ?? uri } func fixedURL(string urlString: String) -> URL? { - let rawUrlString = urlString.removingPercentEncoding ?? urlString - var url: URL? - if #available(macOS 14.0, iOS 17.0, *) { - url = URL(string: rawUrlString, encodingInvalidCharacters: true) - } else { - url = URL(string: encodeURI(rawUrlString)) - } - return url + let rawUrlString = urlString.removingPercentEncoding ?? urlString + var url: URL? + if #available(macOS 14.0, iOS 17.0, *) { + url = URL(string: rawUrlString, encodingInvalidCharacters: true) + } else { + url = URL(string: encodeURI(rawUrlString)) + } + return url } // https://developer.mozilla.org/en-US/docs/Web/API/URL func jsLikeURL(_ urlString: String, baseString: String? = nil) -> [String: String]? { - var _url: URL? - if let baseString = baseString { - guard let baseUrl = fixedURL(string: baseString) else { - return nil - } - _url = URL(string: urlString, relativeTo: baseUrl) - } else { - _url = fixedURL(string: urlString) - } - guard let url = _url else { - return nil - } - - guard let scheme = url.scheme else { - return nil - } - var port = (url.port == nil) ? "" : String(url.port!) - if (scheme == "http" && port == "80") { port = "" } - if (scheme == "https" && port == "443") { port = "" } - if #available(macOS 13.0, iOS 16.0, *) { - let hostname = url.host(percentEncoded: true) ?? "" - let host = (port == "") ? hostname : "\(hostname):\(port)" - let query = url.query(percentEncoded: true) ?? "" - let fragment = url.fragment(percentEncoded: true) ?? "" - return [ - "hash": fragment == "" ? "" : "#\(fragment)", - "host": host, - "hostname": hostname, - // "href": url.absoluteString, - "origin": "\(scheme)://\(host)", - "password": url.password(percentEncoded: true) ?? "", - "pathname": url.path(percentEncoded: true), - "port": port, - "protocol": "\(scheme):", - "search": query == "" ? "" : "?\(query)", - "username": url.user(percentEncoded: true) ?? "" - ] - } else { - let hostname = url.host ?? "" - let host = (port == "") ? hostname : "\(hostname):\(port)" - let query = url.query ?? "" - let fragment = url.fragment ?? "" - return [ - "hash": fragment == "" ? "" : "#\(fragment)", - "host": host, - "hostname": hostname, - // "href": url.absoluteString, - "origin": "\(scheme)://\(host)", - "password": url.password ?? "", - "pathname": url.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? url.path, - "port": port, - "protocol": "\(scheme):", - "search": query == "" ? "" : "?\(query)", - "username": url.user?.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? "" - ] - } + var _url: URL? + if let baseString = baseString { + guard let baseUrl = fixedURL(string: baseString) else { + return nil + } + _url = URL(string: urlString, relativeTo: baseUrl) + } else { + _url = fixedURL(string: urlString) + } + guard let url = _url else { + return nil + } + + guard let scheme = url.scheme else { + return nil + } + var port = (url.port == nil) ? "" : String(url.port!) + if (scheme == "http" && port == "80") { port = "" } + if (scheme == "https" && port == "443") { port = "" } + if #available(macOS 13.0, iOS 16.0, *) { + let hostname = url.host(percentEncoded: true) ?? "" + let host = (port == "") ? hostname : "\(hostname):\(port)" + let query = url.query(percentEncoded: true) ?? "" + let fragment = url.fragment(percentEncoded: true) ?? "" + return [ + "hash": fragment == "" ? "" : "#\(fragment)", + "host": host, + "hostname": hostname, + // "href": url.absoluteString, + "origin": "\(scheme)://\(host)", + "password": url.password(percentEncoded: true) ?? "", + "pathname": url.path(percentEncoded: true), + "port": port, + "protocol": "\(scheme):", + "search": query == "" ? "" : "?\(query)", + "username": url.user(percentEncoded: true) ?? "" + ] + } else { + let hostname = url.host ?? "" + let host = (port == "") ? hostname : "\(hostname):\(port)" + let query = url.query ?? "" + let fragment = url.fragment ?? "" + return [ + "hash": fragment == "" ? "" : "#\(fragment)", + "host": host, + "hostname": hostname, + // "href": url.absoluteString, + "origin": "\(scheme)://\(host)", + "password": url.password ?? "", + "pathname": url.path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? url.path, + "port": port, + "protocol": "\(scheme):", + "search": query == "" ? "" : "?\(query)", + "username": url.user?.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? "" + ] + } } diff --git a/xcode/Shared/Utilities.swift b/xcode/Shared/Utilities.swift index d5c55cc9..c64c2fc6 100644 --- a/xcode/Shared/Utilities.swift +++ b/xcode/Shared/Utilities.swift @@ -2,42 +2,42 @@ import SwiftUI import os.log func USLogger(_ category: String) -> Logger? { - let subsystem = Bundle.main.bundleIdentifier! + let subsystem = Bundle.main.bundleIdentifier! #if DEBUG // Always enable logger in DEBUG builds - return Logger(subsystem: subsystem, category: category) + return Logger(subsystem: subsystem, category: category) #else - if Preferences.enableLogger { - return Logger(subsystem: subsystem, category: category) - } - return nil + if Preferences.enableLogger { + return Logger(subsystem: subsystem, category: category) + } + return nil #endif } func getDocumentsDirectory() -> URL { - let fm = FileManager.default - let paths = fm.urls(for: .documentDirectory, in: .userDomainMask) - let documentsDirectory = paths[0] - return documentsDirectory + let fm = FileManager.default + let paths = fm.urls(for: .documentDirectory, in: .userDomainMask) + let documentsDirectory = paths[0] + return documentsDirectory } func directoryExists(path: String) -> Bool { - var isDirectory = ObjCBool(true) - let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) - let inTrash = path.contains(".Trash") ? false : true - return exists && inTrash && isDirectory.boolValue + var isDirectory = ObjCBool(true) + let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) + let inTrash = path.contains(".Trash") ? false : true + return exists && inTrash && isDirectory.boolValue } func getPlatform() -> String { - var platform:String - #if os(iOS) - if UIDevice.current.userInterfaceIdiom == .pad { - platform = "ipados" - } - else { - platform = "ios" - } - #elseif os(macOS) - platform = "macos" - #endif - return platform + var platform:String +#if os(iOS) + if UIDevice.current.userInterfaceIdiom == .pad { + platform = "ipados" + } + else { + platform = "ios" + } +#elseif os(macOS) + platform = "macos" +#endif + return platform } diff --git a/xcode/Tests-Mac/UrlCodecTests.swift b/xcode/Tests-Mac/UrlCodecTests.swift index a11ac25a..0188cd00 100644 --- a/xcode/Tests-Mac/UrlCodecTests.swift +++ b/xcode/Tests-Mac/UrlCodecTests.swift @@ -2,197 +2,198 @@ import XCTest final class UrlCodecTests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - - func testEncodeURI() throws { - - func check(_ urlString: String, _ res: String) -> Bool { - let url = encodeURI(urlString) - if (url != res) { - print(urlString, url) - return false - } - return true - } - - XCTAssert(check("http://user:password@host:80/path?q=1#id", - "http://user:password@host:80/path?q=1#id")) - - XCTAssert(check("https://用户名:密码@中.文:80/path/中文/?a=中文#中文", - "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@%E4%B8%AD.%E6%96%87:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD%E6%96%87#%E4%B8%AD%E6%96%87")) - - XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", - "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@%E4%B8%AD.%E6%96%87:80/path/%E4%B8%AD%25E6%2596%2587/?a=%E4%B8%AD&b=%25E6%2596%2587&c#%E4%B8%AD%E6%96%87")) - - } - - func testJsLikeURL() throws { - - func diffPrint(_ dA: Dictionary?, _ dB: Dictionary) { - guard let dA = dA else { - return print("dA is nil") - } - for k in dA.keys { - if dA[k] != dB[k] { - print(k, dA[k] ?? "nil", dB[k] ?? "nil") - } - } - } - - func check(_ urlString: String, _ res: Dictionary) -> Bool { - let url = jsLikeURL(urlString) - if (url != res) { - print(urlString) - diffPrint(url, res) - return false - } - return true - } - - /** javascript get res - url = new URL("http://user:password@host:80/path?q=1#id") - res = {} - for(let k of ["hash","host","hostname","href","origin","password","pathname","port","protocol","search","username"]){ - res[k] = url[k]; - } - JSON.stringify(res) - */ - - XCTAssert(check("http://user:password@host:80/path?q=1#id", [ - "hash": "#id", - "host": "host", - "hostname": "host", - // "href": "http://user:password@host/path?q=1#id", - "origin": "http://host", - "password": "password", - "pathname": "/path", - "port": "", - "protocol": "http:", - "search": "?q=1", - "username": "user" - ])) - - XCTAssert(check("http://host.test:8080/path?#id", [ - "hash": "#id", - "host": "host.test:8080", - "hostname": "host.test", - // "href": "http://host.test:8080/path?#id", - "origin": "http://host.test:8080", - "password": "", - "pathname": "/path", - "port": "8080", - "protocol": "http:", - "search": "", - "username": "" - ])) - - XCTAssert(check("http://host.test:8080/path?", [ - "hash": "", - "host": "host.test:8080", - "hostname": "host.test", - // "href": "http://host.test:8080/path?", - "origin": "http://host.test:8080", - "password": "", - "pathname": "/path", - "port": "8080", - "protocol": "http:", - "search": "", - "username": "" - ])) - - XCTAssert(check("http://host.test/path#id", [ - "hash": "#id", - "host": "host.test", - "hostname": "host.test", - // "href": "http://host.test/path#id", - "origin": "http://host.test", - "password": "", - "pathname": "/path", - "port": "", - "protocol": "http:", - "search": "", - "username": "" - ])) - - XCTAssert(check("http://host.test/path", [ - "hash": "", - "host": "host.test", - "hostname": "host.test", - // "href": "http://host.test/path", - "origin": "http://host.test", - "password": "", - "pathname": "/path", - "port": "", - "protocol": "http:", - "search": "", - "username": "" - ])) - - XCTAssert(check("http://host.test/", [ - "hash": "", - "host": "host.test", - "hostname": "host.test", - // "href": "http://host.test/", - "origin": "http://host.test", - "password": "", - "pathname": "/", - "port": "", - "protocol": "http:", - "search": "", - "username": "" - ])) - - XCTAssert(check("https://用户名:密码@host.test:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ - "hash": "#%E4%B8%AD%E6%96%87", - "host": "host.test:80", - "hostname": "host.test", - // "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@host.test:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", - "origin": "https://host.test:80", - "password": "%E5%AF%86%E7%A0%81", - "pathname": "/path/%E4%B8%AD%E6%96%87/", - "port": "80", - "protocol": "https:", - "search": "?a=%E4%B8%AD&b=%E6%96%87&c", - "username": "%E7%94%A8%E6%88%B7%E5%90%8D" - ])) - - if #available(macOS 14.0, iOS 17.0, *) { - XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ - "hash": "#%E4%B8%AD%E6%96%87", - "host": "xn--fiq.xn--7dv:80", - "hostname": "xn--fiq.xn--7dv", - // "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@xn--fiq.xn--7dv:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", - "origin": "https://xn--fiq.xn--7dv:80", - "password": "%E5%AF%86%E7%A0%81", - "pathname": "/path/%E4%B8%AD%E6%96%87/", - "port": "80", - "protocol": "https:", - "search": "?a=%E4%B8%AD&b=%E6%96%87&c", - "username": "%E7%94%A8%E6%88%B7%E5%90%8D" - ])) - } - - } // testJsLikeURL() -> END + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + self.measure { + // Put the code you want to measure the time of here. + } + } + + func testEncodeURI() throws { + + func check(_ urlString: String, _ res: String) -> Bool { + let url = encodeURI(urlString) + if (url != res) { + print(urlString, url) + return false + } + return true + } + + XCTAssert(check("http://user:password@host:80/path?q=1#id", + "http://user:password@host:80/path?q=1#id")) + + XCTAssert(check("https://用户名:密码@中.文:80/path/中文/?a=中文#中文", + "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@%E4%B8%AD.%E6%96%87:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD%E6%96%87#%E4%B8%AD%E6%96%87")) + + XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", + "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@%E4%B8%AD.%E6%96%87:80/path/%E4%B8%AD%25E6%2596%2587/?a=%E4%B8%AD&b=%25E6%2596%2587&c#%E4%B8%AD%E6%96%87")) + + } + + func testJsLikeURL() throws { + + func diffPrint(_ dA: Dictionary?, _ dB: Dictionary) { + guard let dA = dA else { + return print("dA is nil") + } + for k in dA.keys { + if dA[k] != dB[k] { + print(k, dA[k] ?? "nil", dB[k] ?? "nil") + } + } + } + + func check(_ urlString: String, _ res: Dictionary) -> Bool { + let url = jsLikeURL(urlString) + if (url != res) { + print(urlString) + diffPrint(url, res) + return false + } + return true + } + + /** javascript get res + + url = new URL("http://user:password@host:80/path?q=1#id") + res = {} + for(let k of ["hash","host","hostname","href","origin","password","pathname","port","protocol","search","username"]){ + res[k] = url[k]; + } + JSON.stringify(res) + */ + + XCTAssert(check("http://user:password@host:80/path?q=1#id", [ + "hash": "#id", + "host": "host", + "hostname": "host", + // "href": "http://user:password@host/path?q=1#id", + "origin": "http://host", + "password": "password", + "pathname": "/path", + "port": "", + "protocol": "http:", + "search": "?q=1", + "username": "user" + ])) + + XCTAssert(check("http://host.test:8080/path?#id", [ + "hash": "#id", + "host": "host.test:8080", + "hostname": "host.test", + // "href": "http://host.test:8080/path?#id", + "origin": "http://host.test:8080", + "password": "", + "pathname": "/path", + "port": "8080", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test:8080/path?", [ + "hash": "", + "host": "host.test:8080", + "hostname": "host.test", + // "href": "http://host.test:8080/path?", + "origin": "http://host.test:8080", + "password": "", + "pathname": "/path", + "port": "8080", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test/path#id", [ + "hash": "#id", + "host": "host.test", + "hostname": "host.test", + // "href": "http://host.test/path#id", + "origin": "http://host.test", + "password": "", + "pathname": "/path", + "port": "", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test/path", [ + "hash": "", + "host": "host.test", + "hostname": "host.test", + // "href": "http://host.test/path", + "origin": "http://host.test", + "password": "", + "pathname": "/path", + "port": "", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("http://host.test/", [ + "hash": "", + "host": "host.test", + "hostname": "host.test", + // "href": "http://host.test/", + "origin": "http://host.test", + "password": "", + "pathname": "/", + "port": "", + "protocol": "http:", + "search": "", + "username": "" + ])) + + XCTAssert(check("https://用户名:密码@host.test:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ + "hash": "#%E4%B8%AD%E6%96%87", + "host": "host.test:80", + "hostname": "host.test", + // "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@host.test:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", + "origin": "https://host.test:80", + "password": "%E5%AF%86%E7%A0%81", + "pathname": "/path/%E4%B8%AD%E6%96%87/", + "port": "80", + "protocol": "https:", + "search": "?a=%E4%B8%AD&b=%E6%96%87&c", + "username": "%E7%94%A8%E6%88%B7%E5%90%8D" + ])) + + if #available(macOS 14.0, iOS 17.0, *) { + XCTAssert(check("https://用户名:密码@中.文:80/path/中%E6%96%87/?a=中&b=%E6%96%87&c#中文", [ + "hash": "#%E4%B8%AD%E6%96%87", + "host": "xn--fiq.xn--7dv:80", + "hostname": "xn--fiq.xn--7dv", + // "href": "https://%E7%94%A8%E6%88%B7%E5%90%8D:%E5%AF%86%E7%A0%81@xn--fiq.xn--7dv:80/path/%E4%B8%AD%E6%96%87/?a=%E4%B8%AD&b=%E6%96%87&c#%E4%B8%AD%E6%96%87", + "origin": "https://xn--fiq.xn--7dv:80", + "password": "%E5%AF%86%E7%A0%81", + "pathname": "/path/%E4%B8%AD%E6%96%87/", + "port": "80", + "protocol": "https:", + "search": "?a=%E4%B8%AD&b=%E6%96%87&c", + "username": "%E7%94%A8%E6%88%B7%E5%90%8D" + ])) + } + + } // testJsLikeURL() -> END } diff --git a/xcode/Tests-Mac/UserscriptsTests.swift b/xcode/Tests-Mac/UserscriptsTests.swift index 24d70009..451f9e99 100644 --- a/xcode/Tests-Mac/UserscriptsTests.swift +++ b/xcode/Tests-Mac/UserscriptsTests.swift @@ -11,196 +11,196 @@ import XCTest class UserscriptsTests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - } - - func testStringSanitization() throws { - // given - let strs = [ - "String", - "https://something.com/?foo=12", - "I have backslashes \\\\", - ".....ok", - ":Akneh.,><>dkie:lm", - "..解锁B站大会员番剧、", - "解锁B站大会员番剧、B站视频解析下载;全网VIP视频免费破解去广告;全网音乐直接下载;油管、Facebook等国外视频解析下载;网盘搜索引擎破解无限下载等", - "5CLksm3AAbb2F2F2f----___--+87363&^#%o%3O3", - "Example Userscript Name" - ] - - // when - var result = [String]() - for str in strs { - let sanitizedString = sanitize(str) - let unsanitizedString = unsanitize(sanitizedString) - result.append(unsanitizedString) - } - - // then - XCTAssert(result.elementsEqual(strs)) - } - - func testEncodedCheck() throws { - let urls = [ - "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect%20外链跳转.user.js", - "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", - "https://cdn.frankerfacez.com/static/ffz_injector.user.js", - "http://www.k21p.com/example.user.js", // add http protocol - "https://example.test/%%%test.user.js", // removingPercentEncoding -> nil - "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js" - ] - var result = [String]() - for url in urls { - if isEncoded(url) { - result.append(url) - } - } - // 2 urls already percent encoded - XCTAssert(result.count == 2) - } - - func testGetRemoteFileContents() throws { - var urls:[String] = [ - "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect%20外链跳转.user.js", - "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js", - "https://update.greasyfork.org/scripts/460897/1277476/gbCookies.js#sha256-Sv+EuBerch8z/6LvAU0m/ufvjmqB1Q/kbQrX7zAvOPk=", - "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", - "https://cdn.frankerfacez.com/static/ffz_injector.user.js", - "http://www.k21p.com/example.user.js" // add http protocol - ] - if #available(macOS 14.0, iOS 17.0, *) { - urls += [ - "https://☁️.com/", // IDN / Non-Punycode-encoded domain name - ] - } - for url in urls { - if getRemoteFileContents(url) == nil { - print(#function, url) - XCTAssert(false) - } - } - } - - func testFileRemoteUpdate() throws { - let urls = [ - "https://www.k21p.com/example.user.js", - "https://www.k21p.com/example.user.js?foo=bar", // query string - "http://www.k21p.com/example.user.js", // http protocol - "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js", // non latin chars - "https://www.k21p.com/example.user.jsx" // should fail - - ] - var result = [Int]() - for url in urls { - let content = """ - // ==UserScript== - // @name test - // @match *://*/* - // @version 0.1 - // @updateURL http://www.k21p.com/example.user.js - // @downloadURL \(url) - // ==/UserScript== - """; - let response = getFileRemoteUpdate(content) - if !response.keys.contains("error") { - result.append(1) - } - } - XCTAssert(result.count == (urls.count - 1)) - } - - func testMatching() throws { - let patternDict = [ - "*://*/*": [ - "https://www.bing.com/", - "https://example.org/foo/bar.html", - "https://a.org/some/path/", - "https://☁️.com/" - ], - "*://*.mozilla.org/*": [ - "http://mozilla.org/", - "https://mozilla.org/", - "https://b.mozilla.org/path/" - ], - "*://www.google.com/*": [ - "https://www.google.com/://aa", - "https://www.google.com/preferences?prev=https://www.google.com/", - "https://www.google.com/preferences?prev=", - "https://www.google.com/" - ], - "*://localhost/*": [ - "http://localhost:8000/", - "https://localhost:3000/foo.html" - ], - "http://127.0.0.1/*": [ - "http://127.0.0.1/", - "http://127.0.0.1/foo/bar.html" - ], - "*://*.example.com/*?a=1*": [ - "http://example.com/?a=1", - "https://www.example.com/index?a=1&b=2" - ] - ] - let patternDictFails = [ - "https://www.example.com/*": [ - "file://www.example.com/", - "ftp://www.example.com/", - "ws://www.example.com/", - "http://www.example.com/" - ], - "http://www.example.com/index.html": [ - "http://www.example.com/", - "https://www.example.com/index.html" - ], - "*://localhost/*": [ - "https://localhost.com/", - "ftp://localhost:8080/" - ], - "https://www.example*/*": [ - "https://www.example.com/" - ], - "*://*.example.com/*?a=1*": [ - "http://example.com/", - "https://www.example.com/?a=2" - ] - ] - for (pattern, urls) in patternDict { - for url in urls { - if !match(url, pattern) { - print(#function, "patternDict", url, pattern) - XCTAssert(false) - } - } - } - for (pattern, urls) in patternDictFails { - for url in urls { - if match(url, pattern) { - print(#function, "patternDictFails", url, pattern) - XCTAssert(false) - } - } - } - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - measure { - // Put the code you want to measure the time of here. - } - } + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + + func testExample() throws { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + // Any test you write for XCTest can be annotated as throws and async. + // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. + // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. + } + + func testStringSanitization() throws { + // given + let strs = [ + "String", + "https://something.com/?foo=12", + "I have backslashes \\\\", + ".....ok", + ":Akneh.,><>dkie:lm", + "..解锁B站大会员番剧、", + "解锁B站大会员番剧、B站视频解析下载;全网VIP视频免费破解去广告;全网音乐直接下载;油管、Facebook等国外视频解析下载;网盘搜索引擎破解无限下载等", + "5CLksm3AAbb2F2F2f----___--+87363&^#%o%3O3", + "Example Userscript Name" + ] + + // when + var result = [String]() + for str in strs { + let sanitizedString = sanitize(str) + let unsanitizedString = unsanitize(sanitizedString) + result.append(unsanitizedString) + } + + // then + XCTAssert(result.elementsEqual(strs)) + } + + func testEncodedCheck() throws { + let urls = [ + "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect%20外链跳转.user.js", + "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", + "https://cdn.frankerfacez.com/static/ffz_injector.user.js", + "http://www.k21p.com/example.user.js", // add http protocol + "https://example.test/%%%test.user.js", // removingPercentEncoding -> nil + "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js" + ] + var result = [String]() + for url in urls { + if isEncoded(url) { + result.append(url) + } + } + // 2 urls already percent encoded + XCTAssert(result.count == 2) + } + + func testGetRemoteFileContents() throws { + var urls:[String] = [ + "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect%20外链跳转.user.js", + "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js", + "https://update.greasyfork.org/scripts/460897/1277476/gbCookies.js#sha256-Sv+EuBerch8z/6LvAU0m/ufvjmqB1Q/kbQrX7zAvOPk=", + "https://raw.githubusercontent.com/Anarios/return-youtube-dislike/main/Extensions/UserScript/Return%20Youtube%20Dislike.user.js", + "https://cdn.frankerfacez.com/static/ffz_injector.user.js", + "http://www.k21p.com/example.user.js" // add http protocol + ] + if #available(macOS 14.0, iOS 17.0, *) { + urls += [ + "https://☁️.com/", // IDN / Non-Punycode-encoded domain name + ] + } + for url in urls { + if getRemoteFileContents(url) == nil { + print(#function, url) + XCTAssert(false) + } + } + } + + func testFileRemoteUpdate() throws { + let urls = [ + "https://www.k21p.com/example.user.js", + "https://www.k21p.com/example.user.js?foo=bar", // query string + "http://www.k21p.com/example.user.js", // http protocol + "https://greasyfork.org/scripts/416338-redirect-外链跳转/code/redirect 外链跳转.user.js", // non latin chars + "https://www.k21p.com/example.user.jsx" // should fail + + ] + var result = [Int]() + for url in urls { + let content = """ + // ==UserScript== + // @name test + // @match *://*/* + // @version 0.1 + // @updateURL http://www.k21p.com/example.user.js + // @downloadURL \(url) + // ==/UserScript== + """; + let response = getFileRemoteUpdate(content) + if !response.keys.contains("error") { + result.append(1) + } + } + XCTAssert(result.count == (urls.count - 1)) + } + + func testMatching() throws { + let patternDict = [ + "*://*/*": [ + "https://www.bing.com/", + "https://example.org/foo/bar.html", + "https://a.org/some/path/", + "https://☁️.com/" + ], + "*://*.mozilla.org/*": [ + "http://mozilla.org/", + "https://mozilla.org/", + "https://b.mozilla.org/path/" + ], + "*://www.google.com/*": [ + "https://www.google.com/://aa", + "https://www.google.com/preferences?prev=https://www.google.com/", + "https://www.google.com/preferences?prev=", + "https://www.google.com/" + ], + "*://localhost/*": [ + "http://localhost:8000/", + "https://localhost:3000/foo.html" + ], + "http://127.0.0.1/*": [ + "http://127.0.0.1/", + "http://127.0.0.1/foo/bar.html" + ], + "*://*.example.com/*?a=1*": [ + "http://example.com/?a=1", + "https://www.example.com/index?a=1&b=2" + ] + ] + let patternDictFails = [ + "https://www.example.com/*": [ + "file://www.example.com/", + "ftp://www.example.com/", + "ws://www.example.com/", + "http://www.example.com/" + ], + "http://www.example.com/index.html": [ + "http://www.example.com/", + "https://www.example.com/index.html" + ], + "*://localhost/*": [ + "https://localhost.com/", + "ftp://localhost:8080/" + ], + "https://www.example*/*": [ + "https://www.example.com/" + ], + "*://*.example.com/*?a=1*": [ + "http://example.com/", + "https://www.example.com/?a=2" + ] + ] + for (pattern, urls) in patternDict { + for url in urls { + if !match(url, pattern) { + print(#function, "patternDict", url, pattern) + XCTAssert(false) + } + } + } + for (pattern, urls) in patternDictFails { + for url in urls { + if match(url, pattern) { + print(#function, "patternDictFails", url, pattern) + XCTAssert(false) + } + } + } + } + + func testPerformanceExample() throws { + // This is an example of a performance test case. + measure { + // Put the code you want to measure the time of here. + } + } } diff --git a/xcode/Userscripts.xcodeproj/project.pbxproj b/xcode/Userscripts.xcodeproj/project.pbxproj index 5ddbcdad..f5944419 100644 --- a/xcode/Userscripts.xcodeproj/project.pbxproj +++ b/xcode/Userscripts.xcodeproj/project.pbxproj @@ -248,6 +248,7 @@ 4A57B9ED227235CD008A9763 /* Products */, ); sourceTree = ""; + usesTabs = 1; }; 4A57B9ED227235CD008A9763 /* Products */ = { isa = PBXGroup; From 2f950e0d7fd74aeb79270cc3c7f1c0a7d597a07b Mon Sep 17 00:00:00 2001 From: ACTCD <101378590+ACTCD@users.noreply.github.com> Date: Sun, 3 Dec 2023 07:53:53 +0800 Subject: [PATCH 2/2] chore: add ignore to `.git-blame-ignore-revs` --- .git-blame-ignore-revs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 5e1b7211..cc156834 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -4,3 +4,6 @@ # Prettier 3.0.3 acc35fbec38d72968b735826c6807793a2054aed + +# Xcode swift indent replace spaces to tabs +962c4d81879e85362a5f878889d3aa869f4e6961