You can turn on the Userscripts iOS Safari extension in Settings. Read the docs.
diff --git a/README.md b/README.md
index 4ae9b024..dd835171 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,8 @@ To run Userscripts on macOS you should running macOS 12 or higher, along with Sa
**[App Store Link](https://itunes.apple.com/us/app/userscripts/id1463298887)**
+**[Development Progress](https://github.com/quoid/userscripts/projects/3)**
+
## Usage
It's recommend to read this documentation and, if you have time, watch the following video overviews to familiarize yourself with the app and extension.
@@ -168,7 +170,7 @@ Userscripts Safari currently supports the following userscript metadata:
## API
-Userscripts currently supports the following api methods. All methods are asynchronous unless otherwise noted. **All methods are accessible without regard to `@grant` when `@inject-into` has the `content` value.** Further, most methods do not require their respective prefix when calling. For example, `GM.setValue` can be called as `GM.setValue(...)` or with simply by `setValue(...)`.
+Userscripts currently supports the following api methods. All methods are asynchronous unless otherwise noted. **All methods are accessible without regard to `@grant` when `@inject-into` has the `content` value.**.
- `GM.addStyle(css)`
- `css: String`
@@ -195,6 +197,36 @@ Userscripts currently supports the following api methods. All methods are asynch
- `tabId: Int`
- `tabId` is **optional** and if omitted the tab that called `US.closeTab` will be closed
- on success returns a promise resolved with an object indicating success
+- `GM.setClipboard(data, type)`
+ - `data: String` - **required**
+ - `type: String` - **optional** and defaults to `text/plain`
+ - [read more here](https://developer.mozilla.org/en-US/docs/Web/API/ClipboardEvent/clipboardData)
+ - on success returns a promise resolved with a `Bool` indicating success
+- `GM_setClipboard(data, type)`
+ - "synchronous' version of `GM.setClipboard`
+ - the setClipboard function runs in the background script, requires a promise to send message from content script to background to facilitate writing to the clipboard, thus no real synchronous function available
+ - returns `undefined`
+- `GM.info` && `GM_info`
+ - is available without needing to add it to `@grant`
+ - an object containing information about the running userscript
+ - `scriptHandler: String` - returns `Userscripts`
+ - `version: String` - the version of Userscripts app
+ - `scriptMetaStr: String` - the metablock for the currently running script
+ - `script: Object` - contains data about the currently running script
+ - `description: String`
+ - `exclude-match: [String]`
+ - `excludes: [String]`
+ - `grant: [String]`
+ - `includes: [String]`
+ - `inject-into: String`
+ - `matches: [String]`
+ - `name: String`
+ - `namespace: String`
+ - `noframes: Bool`
+ - `require: [String]`
+ - `resources: [String]` - *currently not implemented*
+ - `run-at: String`
+ - `version: String` - *the userscript version value*
- `GM.xmlHttpRequest(details)`
- `details: Object`
- the `details` object accepts the following properties
diff --git a/etc/app_ios/src/css/_main.css b/etc/app_ios/src/css/_main.css
index d64503c5..e50249d1 100644
--- a/etc/app_ios/src/css/_main.css
+++ b/etc/app_ios/src/css/_main.css
@@ -98,7 +98,7 @@ a {
height: 1.5rem;
}
-.logo span {
+.logo > span {
margin-left: 0.5rem;
}
diff --git a/etc/app_ios/src/index.html b/etc/app_ios/src/index.html
index dcb82adf..c444e72b 100644
--- a/etc/app_ios/src/index.html
+++ b/etc/app_ios/src/index.html
@@ -13,7 +13,7 @@
You can turn on the Userscripts iOS Safari extension in Settings. Read the docs.
diff --git a/etc/app_ios/src/js/scripts.js b/etc/app_ios/src/js/scripts.js index 9e7f7ede..a2be1252 100644 --- a/etc/app_ios/src/js/scripts.js +++ b/etc/app_ios/src/js/scripts.js @@ -1,7 +1,13 @@ const directory = document.querySelector("#directory"); const button = document.querySelector("#set_directory"); +const version = document.querySelector("#version"); +const build = document.querySelector("#build"); const setDirectory = () => webkit.messageHandlers.controller.postMessage("SET_READ_LOCATION"); function printDirectory(location) { directory.innerText = location; } +function printVersion(v, b) { + version.innerText = v; + build.innerText = b; +} button.addEventListener("click", setDirectory); diff --git a/etc/assets.sketch b/etc/assets.sketch index 7ea34c54..0d440f58 100644 Binary files a/etc/assets.sketch and b/etc/assets.sketch differ diff --git a/extension/Userscripts Extension/Functions.swift b/extension/Userscripts Extension/Functions.swift index cb818718..3b2e80a0 100644 --- a/extension/Userscripts Extension/Functions.swift +++ b/extension/Userscripts Extension/Functions.swift @@ -12,7 +12,7 @@ func dateToMilliseconds(_ date: Date) -> Int { return Int(since1970 * 1000) } -func sanitize(_ str: String) -> String? { +func sanitize(_ str: String) -> String { // removes invalid filename characters from strings var sanitized = str if sanitized.first == "." { @@ -163,11 +163,15 @@ func isVersionNewer(_ oldVersion: String, _ newVersion: String) -> Bool { return false } +func isEncoded(_ str: String) -> Bool { + return str.removingPercentEncoding != str +} + // 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==\r?\n([\S\s]*?)\r?\n\/\/ ==\/UserScript==)([\S\s]*)|(\/\* ==UserStyle==\r?\n([\S\s]*?)\r?\n==\/UserStyle== \*\/)([\S\s]*))"# + 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) @@ -202,7 +206,7 @@ func parse(_ content: String) -> [String: Any]? { // this pattern checks for specific keys that won't have values let p2 = #"^(?:[ \t]*(?:\/\/)?[ \t]*@)(noframes)[ \t]*$"# // the individual meta string, ie. // @name File Name - let metaString = String(meta) + let metaString = String(meta).trimmingCharacters(in: .whitespaces) // force try b/c pattern is known to be valid regex let re = try! NSRegularExpression(pattern: p, options: []) let re2 = try! NSRegularExpression(pattern: p2, options: []) @@ -234,7 +238,7 @@ func parse(_ content: String) -> [String: Any]? { return [ "code": trimmedCode, "content": content, - "metablock": metablock, + "metablock": String(metablock), "metadata": metadata ] } @@ -448,9 +452,8 @@ func updateManifestRequired(_ optionalFilesArray: [[String: Any]] = []) -> Bool // populate array with entries for manifest var r = [String]() for resource in required { - if let sanitizedResourceName = sanitize(resource) { - r.append(sanitizedResourceName) - } + let sanitizedResourceName = sanitize(resource) + r.append(sanitizedResourceName) } // if there are values, write them to manifest @@ -708,7 +711,7 @@ func getRequiredCode(_ filename: String, _ resources: [String], _ fileType: Stri } // skip urls pointing to files of different types if resourceUrlPath.hasSuffix(fileType) { - guard let resourceFilename = sanitize(resourceUrlString) else {return false} + let resourceFilename = sanitize(resourceUrlString) let fileURL = directory.appendingPathComponent(resourceFilename) // only attempt to get resource if it does not yet exist if FileManager.default.fileExists(atPath: fileURL.path) {continue} @@ -785,7 +788,27 @@ func getRemoteFileContents(_ url: String) -> String? { urlChecked = urlChecked.replacingOccurrences(of: "http:", with: "https:") logText("\(url) is using insecure http, attempt to fetch remote content with https") } - guard let solidURL = URL(string: urlChecked) else {return nil} + // if the url is already encoded, decode it + if isEncoded(urlChecked) { + if let decodedUrl = urlChecked.removingPercentEncoding { + urlChecked = decodedUrl + } else { + err("getRemoteFileContents failed at (1), couldn't decode url, \(url)") + return nil + } + } + // encode all urls strings + if let encodedUrl = urlChecked.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { + urlChecked = encodedUrl + } else { + err("getRemoteFileContents failed at (2), couldn't percent encode url, \(url)") + return nil + } + // convert url string to url + guard let solidURL = URL(string: urlChecked) else { + err("getRemoteFileContents failed at (3), couldn't convert string to url, \(url)") + return nil + } var contents = "" // get remote file contents, synchronously let semaphore = DispatchSemaphore(value: 0) @@ -806,7 +829,7 @@ func getRemoteFileContents(_ url: String) -> String? { // if made it to this point and contents still an empty string, something went wrong with the request if contents.count < 1 { - logText("something went wrong while trying to fetch remote file contents \(url)") + logText("getRemoteFileContents failed at (4), contents empty, \(url)") return nil } logText("getRemoteFileContents for \(url) end") @@ -1092,8 +1115,10 @@ func getMatchedFiles(_ url: String) -> [String] { } // injection -func getCode(_ filenames: [String], _ isTop: Bool)-> [String: [String: [String: Any]]]? { - var allFiles = [String: [String: [String: Any]]]() +// func getCode(_ filenames: [String], _ isTop: Bool)-> [String: [String: [String: Any]]]? { +func getCode(_ filenames: [String], _ isTop: Bool)-> [String: Any]? { + //var allFiles = [String: [String: [String: Any]]]() + var allFiles = [String: Any]() var cssFiles = [String:[String:String]]() var jsFiles = [String: [String: [String: [String: Any]]]]() jsFiles["auto"] = ["document-start": [:], "document-end": [:], "document-idle": [:]] @@ -1141,6 +1166,71 @@ func getCode(_ filenames: [String], _ isTop: Bool)-> [String: [String: [String: // normalize weight var weight = metadata["weight"]?[0] ?? "1" weight = normalizeWeight(weight) + + // get inject-into and run-at values + // if either is missing, use default value + var injectInto = metadata["inject-into"]?[0] ?? "auto" + var runAt = metadata["run-at"]?[0] ?? "document-end" + let injectVals = ["auto", "content", "page"] + let runAtVals = ["context-menu", "document-start", "document-end", "document-idle"] + // 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 + grants = Array(Set(grants)) + + // set GM.info data + let description = metadata["description"]?[0] ?? "" + let excludes = metadata["exclude"] ?? [] + let excludeMatches = metadata["exclude-match"] ?? [] + 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, + "grant": grants, + "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.count > 1 ? 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 @@ -1149,7 +1239,7 @@ func getCode(_ filenames: [String], _ isTop: Bool)-> [String: [String: [String: // 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 sanitizedName = sanitize(require) let requiredFileURL = getRequireLocation().appendingPathComponent(filename).appendingPathComponent(sanitizedName) if let requiredContent = try? String(contentsOf: requiredFileURL, encoding: .utf8) { code = "\(requiredContent)\n\(code)" @@ -1159,28 +1249,15 @@ func getCode(_ filenames: [String], _ isTop: Bool)-> [String: [String: [String: } } - // attempt to get all @grant value - var grants = metadata["grant"] ?? [] - // remove duplicates, if any exist - grants = Array(Set(grants)) - if type == "css" { - cssFiles[filename] = ["code": code, "weight": weight] + cssFiles[filename] = ["code": code, "weight": weight, "name": name] } else if type == "js" { - var injectInto = metadata["inject-into"]?[0] ?? "auto" - var runAt = metadata["run-at"]?[0] ?? "document-end" - - let injectVals = ["auto", "content", "page"] - let runAtVals = ["context-menu", "document-start", "document-end", "document-idle"] - // if inject/runAt values are not valid, use default - if !injectVals.contains(injectInto) { - injectInto = "page" - } - if !runAtVals.contains(runAt) { - runAt = "document-end" - } - - let data = ["code": code, "weight": weight, "grant": grants] as [String : Any] + let data = [ + "code": code, + "weight": weight, + "scriptMetaStr": scriptMetaStr, + "scriptObject": scriptObject + ] as [String : Any] // add file data to appropriate dict if injectInto == "auto" && runAt == "document-start" { auto_docStart[filename] = data @@ -1201,15 +1278,14 @@ func getCode(_ filenames: [String], _ isTop: Bool)-> [String: [String: [String: } else if injectInto == "page" && runAt == "document-idle" { page_docIdle[filename] = data } - if runAt == "context-menu" && injectInto == "auto" { - auto_context_scripts[filename] = ["code": code, "name": name, "grant": grants] + auto_context_scripts[filename] = data } if runAt == "context-menu" && injectInto == "content" { - content_context_scripts[filename] = ["code": code, "name": name, "grant": grants] + content_context_scripts[filename] = data } if runAt == "context-menu" && injectInto == "page" { - page_context_scripts[filename] = ["code": code, "name": name, "grant": grants] + page_context_scripts[filename] = data } } } @@ -1233,7 +1309,13 @@ func getCode(_ filenames: [String], _ isTop: Bool)-> [String: [String: [String: // construct the returned dictionary allFiles["css"] = cssFiles allFiles["js"] = jsFiles - + + // add global values + let scriptHandler = "Userscripts" + let scriptHandlerVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" + allFiles["scriptHandler"] = scriptHandler + allFiles["scriptHandlerVersion"] = scriptHandlerVersion + return allFiles } @@ -1439,7 +1521,8 @@ func getInitData() -> [String: Any]? { var data:[String: Any] = manifest.settings data["blacklist"] = manifest.blacklist data["saveLocation"] = saveLocation.path - data["version"] = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + data["version"] = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" + data["build"] = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" return data } @@ -1458,12 +1541,14 @@ func saveFile(_ item: [String: Any],_ content: String) -> [String: Any] { } guard let parsed = parse(newContent), - let metadata = parsed["metadata"] as? [String: [String]], - let n = metadata["name"]?[0], - var name = sanitize(n) + let metadata = parsed["metadata"] as? [String: [String]] else { - return ["error": "failed to parse argument in save function"] + 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).\(type)" @@ -1759,12 +1844,12 @@ func installUserscript(_ content: String) -> [String: Any]? { guard let parsed = parse(content), let metadata = parsed["metadata"] as? [String: [String]], - let n = metadata["name"]?[0], - let name = sanitize(n) + let n = metadata["name"]?[0] else { err("installUserscript failed at (1)") return nil } + let name = sanitize(n) let filename = "\(name).js" let saved = saveFile(["filename": filename, "type": "js"], content) diff --git a/extension/Userscripts Extension/Resources/background.js b/extension/Userscripts Extension/Resources/background.js index a8bcc9c1..fd1fb056 100644 --- a/extension/Userscripts Extension/Resources/background.js +++ b/extension/Userscripts Extension/Resources/background.js @@ -91,8 +91,12 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => { } else if (name === "API_GET_VALUE") { const key = request.filename + "---" + request.key; browser.storage.local.get(key, item => { - if (Object.keys(item).length === 0 && request.defaultValue) { - sendResponse(request.defaultValue); + if (Object.keys(item).length === 0) { + if (request.defaultValue) { + sendResponse(request.defaultValue); + } else { + sendResponse(`undefined--${request.pid}`); + } } else { sendResponse(Object.values(item)[0]); } @@ -134,6 +138,9 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => { } })(); return true; + } else if (name === "API_SET_CLIPBOARD") { + const result = setClipboard(request.data, request.type); + sendResponse(result); } else if (name === "API_XHR_CS") { // https://jsonplaceholder.typicode.com/posts // get tab id and respond only to the content script that sent message @@ -143,14 +150,21 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => { const user = details.user || null; const password = details.password || null; let body = details.data || null; - if (body && details.binary) body = new Blob([body], {type: "text/plain"}); + if (body && details.binary) { + const len = body.length; + const arr = new Uint8Array(len); + for (let i = 0; i < len; i++) { + arr[i] = body.charCodeAt(i); + } + body = new Blob([arr], {type: "text/plain"}); + } const xhr = new XMLHttpRequest(); // push to global scoped array so it can be aborted xhrs.push({xhr: xhr, xhrId: request.xhrId}); xhr.withCredentials = (details.user && details.password); xhr.timeout = details.timeout || 0; if (details.overrideMimeType) xhr.overrideMimeType(details.overrideMimeType); - xhrAddListeners(xhr, tab, request.xhrId, details); + xhrAddListeners(xhr, tab, request.id, request.xhrId, details); xhr.open(method, details.url, true, user, password); xhr.responseType = details.responseType || ""; if (details.headers) { @@ -182,7 +196,7 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => { } }); -function xhrHandleEvent(e, xhr, tab, xhrId) { +function xhrHandleEvent(e, xhr, tab, id, xhrId) { const name = `RESP_API_XHR_BG_${e.type.toUpperCase()}`; const x = { readyState: xhr.readyState, @@ -197,33 +211,54 @@ function xhrHandleEvent(e, xhr, tab, xhrId) { }; // only include responseText when applicable if (["", "text"].includes(xhr.responseType)) x.responseText = xhr.responseText; - browser.tabs.sendMessage(tab, {name: name, xhrId: xhrId, response: x}); + // convert data if response is arraybuffer so sendMessage can pass it + if (xhr.responseType === "arraybuffer") { + const arr = Array.from(new Uint8Array(xhr.response)); + x.response = arr; + } + // convert data if response is blob so sendMessage can pass it + if (xhr.responseType === "blob") { + const reader = new FileReader(); + reader.readAsDataURL(xhr.response); + reader.onloadend = function() { + const base64data = reader.result; + x.response = { + data: base64data, + type: xhr.response.type + }; + browser.tabs.sendMessage(tab, {name: name, id: id, xhrId: xhrId, response: x}); + }; + } + // blob response will execute its own sendMessage call + if (xhr.responseType !== "blob") { + browser.tabs.sendMessage(tab, {name: name, id: id, xhrId: xhrId, response: x}); + } } -function xhrAddListeners(xhr, tab, xhrId, details) { +function xhrAddListeners(xhr, tab, id, xhrId, details) { if (details.onabort) { - xhr.addEventListener("abort", e => xhrHandleEvent(e, xhr, tab, xhrId)); + xhr.addEventListener("abort", e => xhrHandleEvent(e, xhr, tab, id, xhrId)); } if (details.onerror) { - xhr.addEventListener("error", e => xhrHandleEvent(e, xhr, tab, xhrId)); + xhr.addEventListener("error", e => xhrHandleEvent(e, xhr, tab, id, xhrId)); } if (details.onload) { - xhr.addEventListener("load", e => xhrHandleEvent(e, xhr, tab, xhrId)); + xhr.addEventListener("load", e => xhrHandleEvent(e, xhr, tab, id, xhrId)); } if (details.onloadend) { - xhr.addEventListener("loadend", e => xhrHandleEvent(e, xhr, tab, xhrId)); + xhr.addEventListener("loadend", e => xhrHandleEvent(e, xhr, tab, id, xhrId)); } if (details.onloadstart) { - xhr.addEventListener("loadstart", e => xhrHandleEvent(e, xhr, tab, xhrId)); + xhr.addEventListener("loadstart", e => xhrHandleEvent(e, xhr, tab, id, xhrId)); } if (details.onprogress) { - xhr.addEventListener("progress", e => xhrHandleEvent(e, xhr, tab, xhrId)); + xhr.addEventListener("progress", e => xhrHandleEvent(e, xhr, tab, id, xhrId)); } if (details.onreadystatechange) { - xhr.addEventListener("readystatechange", e => xhrHandleEvent(e, xhr, tab, xhrId)); + xhr.addEventListener("readystatechange", e => xhrHandleEvent(e, xhr, tab, id, xhrId)); } if (details.ontimeout) { - xhr.addEventListener("timeout", e => xhrHandleEvent(e, xhr, tab, xhrId)); + xhr.addEventListener("timeout", e => xhrHandleEvent(e, xhr, tab, id, xhrId)); } } @@ -302,6 +337,33 @@ async function getPlatform() { return response.platform; } +function setClipboard(data, type = "text/plain") { + // future enhancement? + // https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/write + // https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText + const onCopy = e => { + e.stopImmediatePropagation(); + e.preventDefault(); + e.clipboardData.setData(type, data); + document.removeEventListener("copy", onCopy, true); + }; + + const textarea = document.createElement("textarea"); + textarea.textContent = "You can turn on the Userscripts iOS Safari extension in Settings. Read the docs.
You can turn on the Userscripts iOS Safari extension in Settings. Read the docs.
Userscripts Safari Version {$settings.version}
You can review the documentation, report bugs and get more information about this extension by visiting the code repository.
If you enjoy using this extension, please consider leaving a review on the App Store or supporting the project.
+
Userscripts Safari Version {$settings.version} ({$settings.build})
You can review the documentation, report bugs and get more information about this extension by visiting the code repository.
If you enjoy using this extension, please consider leaving a review on the App Store or supporting the project.