Skip to content
Merged

V4.1 #231

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
a548578
bump versions and build
quoid Jan 22, 2022
275c741
Limit userscript description length on front end #207
quoid Jan 22, 2022
1a05fab
highlight search input text when focusin #199
quoid Jan 22, 2022
8f28bd0
show full path on hover #214
quoid Jan 23, 2022
d0930e0
attach page scoped javascript to head #184
quoid Jan 23, 2022
abcc3f2
allow whitespace after opening tags #218
quoid Jan 23, 2022
9413ea7
trim whitespace on meta lines #218
quoid Jan 23, 2022
a3153b1
improve saveFile error messages #218
quoid Jan 23, 2022
04bada4
sanitize func always returns a string
quoid Jan 23, 2022
7d28128
pass build number to settings modal
quoid Jan 23, 2022
6d0a797
basic unit test setup
quoid Jan 23, 2022
188eb70
pass version and build to ios app
quoid Jan 23, 2022
08181a6
add unique ids to promises, return undefined properly from getValue, …
quoid Jan 27, 2022
0bc95b6
GM.xmlHttpRequest improvements & fixes #212 #222
quoid Jan 30, 2022
b345f11
setClipboard functionality #198
quoid Jan 30, 2022
2e39790
remove extraneous eslint disable
quoid Jan 30, 2022
3f85f56
prefix api methods
quoid Jan 30, 2022
ae9cd82
bump build
quoid Feb 8, 2022
3182f79
encode url before getting remote contents #226
quoid Feb 8, 2022
e8d2b46
tests for encoded check and getRemoteFileContents
quoid Feb 8, 2022
2eb6b16
allow outgoing connections so tests can complete
quoid Feb 8, 2022
7a7f378
improve install prompt detection #208
quoid Feb 8, 2022
e002a65
allow user to cancel update checks #211
quoid Feb 8, 2022
c11e436
implement GM.info/GM_info #188
quoid Feb 10, 2022
484d885
readme update
quoid Feb 12, 2022
bbf7d66
fix fallback failure regression
quoid Feb 16, 2022
c15b14a
fix context-menu injection bug
quoid Feb 17, 2022
426dcf1
bump build
quoid Feb 17, 2022
8849baf
make GM.info/GM_info an object
quoid Feb 17, 2022
0ae7b44
build bump
quoid Feb 20, 2022
3f43c7b
GM.info, add more keys, rename some keys #229
quoid Feb 20, 2022
37d4c20
pass name to css userscripts #149
quoid Feb 21, 2022
63e77d4
update console message for injection #149
quoid Feb 21, 2022
609b414
readme update
quoid Feb 21, 2022
34a1d44
readme update
quoid Feb 21, 2022
a302997
update support link in settings modal
quoid Feb 22, 2022
c4654b2
build bump
quoid Feb 22, 2022
7974769
update app store screenshots
quoid Feb 25, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion etc/app_ios/src/css/_main.css
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ a {
height: 1.5rem;
}

.logo span {
.logo > span {
margin-left: 0.5rem;
}

Expand Down
2 changes: 1 addition & 1 deletion etc/app_ios/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<img class="icon" inline src="./img/icon.png" alt="Userscripts App Icon">
<div class="logo">
<img inline src="./img/logo.svg" alt="Userscripts Logo">
<span>v0.1</span>
<span><span id="version">v0.1</span>&nbsp;<span id="build">(0)</span></span>
</div>
<p>You can turn on the Userscripts iOS Safari extension in Settings. <a href="https://github.com/quoid/userscripts#userscripts-safari">Read the docs.</a></p>
<button id="set_directory">Set Userscripts Directory</button>
Expand Down
6 changes: 6 additions & 0 deletions etc/app_ios/src/js/scripts.js
Original file line number Diff line number Diff line change
@@ -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);
Binary file modified etc/assets.sketch
Binary file not shown.
175 changes: 130 additions & 45 deletions extension/Userscripts Extension/Functions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "." {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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: [])
Expand Down Expand Up @@ -234,7 +238,7 @@ func parse(_ content: String) -> [String: Any]? {
return [
"code": trimmedCode,
"content": content,
"metablock": metablock,
"metablock": String(metablock),
"metadata": metadata
]
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand Down Expand Up @@ -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": [:]]
Expand Down Expand Up @@ -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
Expand All @@ -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)"
Expand All @@ -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
Expand All @@ -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
}
}
}
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -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)"
Expand Down Expand Up @@ -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)
Expand Down
Loading