Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: quoid/userscripts
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: v5.0.0-beta.1
Choose a base ref
...
head repository: quoid/userscripts
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: v5.0.0-beta.2
Choose a head ref
  • 16 commits
  • 8 files changed
  • 2 contributors

Commits on Sep 8, 2024

  1. Copy the full SHA
    b85c4d9 View commit details

Commits on Sep 10, 2024

  1. Copy the full SHA
    fb71afa View commit details
  2. feat: asynchronized GM.xmlHttpRequest api

    `GM.xmlHttpRequest` return a promise resolved with response object
    ACTCD committed Sep 10, 2024
    Copy the full SHA
    6717dd6 View commit details
  3. Copy the full SHA
    5fb7df5 View commit details
  4. Copy the full SHA
    79cb875 View commit details
  5. Copy the full SHA
    013268e View commit details
  6. Copy the full SHA
    85baf1b View commit details
  7. Copy the full SHA
    6e7b83a View commit details

Commits on Sep 11, 2024

  1. Copy the full SHA
    16afc42 View commit details
  2. fix: avoid potential thread crashes of url

    Internal error in Foundation URL.host(percentEncoded:)
    ACTCD committed Sep 11, 2024
    Copy the full SHA
    b50a23e View commit details

Commits on Sep 13, 2024

  1. perf: process and transmit xhr done data only once

    ACTCD committed Sep 13, 2024
    Copy the full SHA
    3e0a290 View commit details
  2. docs: adjust description sentences

    Co-authored-by: Justin Wasack <[email protected]>
    ACTCD and quoid authored Sep 13, 2024
    Copy the full SHA
    fb7a28e View commit details

Commits on Sep 15, 2024

  1. refactor: improve jsdoc types and some adjustments

    ACTCD committed Sep 15, 2024
    Copy the full SHA
    e382a6c View commit details

Commits on Sep 16, 2024

  1. docs: add versioning documentation guidelines

    ACTCD committed Sep 16, 2024
    Copy the full SHA
    d1306ca View commit details

Commits on Sep 17, 2024

  1. Merge pull request #716 from quoid/api-xhr-improvements

    feat: `GM.xmlHttpRequest` returns promise and supports multiple data types
    ACTCD authored Sep 17, 2024
    Copy the full SHA
    4937f21 View commit details
  2. Merge pull request #714 from quoid/fix/avoid-potential-thread-crashes

    fix: avoid two potential thread crashes
    ACTCD authored Sep 17, 2024
    Copy the full SHA
    aee4b2e View commit details
Showing with 614 additions and 184 deletions.
  1. +24 −4 README.md
  2. +1 −1 jsconfig.json
  3. +26 −0 src/dev/jsconfig.json
  4. +166 −89 src/ext/background/main.js
  5. +294 −84 src/ext/content-scripts/api.js
  6. +81 −0 src/ext/types.d.ts
  7. +12 −3 xcode/Ext-Safari/Functions.swift
  8. +10 −3 xcode/Shared/UrlPolyfill.swift
28 changes: 24 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -187,13 +187,26 @@ Userscripts Safari currently supports the following userscript metadata:
- `@noframes`
- this key takes no value
- prevents code from being injected into nested frames
- `@grant`
- Imperative controls which special [`APIs`](#api) (if any) your script uses, one on each `@grant` line, only those API methods will be provided.
- If no `@grant` values are provided, `none` will be assumed.
- If you specify `none` and something else, `none` takes precedence.

**All userscripts need at least 1 `@match` or `@include` to run!**

## API

Userscripts currently supports the following api methods. All methods are asynchronous unless otherwise noted. Users must `@grant` these methods in order to use them in a userscript. When using API methods, it's only possible to inject into the content script scope due to security concerns.

> [!NOTE]
>
> The following API description applies to the latest development branch, you may need to check the documentation for the corresponding version. Please switch to the version you want to check via `Branches` or `Tags` at the top.
>
> For example, for the v4.x.x version of the App Store:
> https://github.com/quoid/userscripts/tree/release/4.x.x
For API type definitions, please refer to: [`types.d.ts`](https://github.com/userscriptsup/testscripts/blob/bfce18746cd6bcab0616727401fa7ab6ef4086ac/userscripts/types.d.ts)

- `GM.addStyle(css)`
- `css: String`
- returns a [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise), resolved if succeeds, rejected with error message if fails
@@ -258,8 +271,8 @@ Userscripts currently supports the following api methods. All methods are asynch
- `headers: Object` - optional
- `overrideMimeType: String` - optional
- `timeout: Int` - optional
- `binary: Bool` - optional
- `data: String` - optional
- `binary: Bool` - optional (Deprecated, use binary data objects such as `Blob`, `ArrayBuffer`, `TypedArray`, etc. instead.)
- `data: String | Blob | ArrayBuffer | TypedArray | DataView | FormData | URLSearchParams` - optional
- `responseType: String` - optional
- refer to [`XMLHttpRequests`](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest)
- event handlers:
@@ -281,10 +294,17 @@ Userscripts currently supports the following api methods. All methods are asynch
- `statusText`
- `timeout`
- `responseText` (when `responseType` is `text`)
- returns a custom [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) contains an additional property `abort`, resolved with the response object.
- usage:
- `const xhr = GM.xmlHttpRequest({...});`
- `xhr.abort();` to abort the request
- `const response = await xhr;`
- or just:
- `const response = await GM.xmlHttpRequest({...});`
- `GM_xmlhttpRequest(details)`
- Basically the same as `GM.xmlHttpRequest(details)`, except:
- returns an object with a single property, `abort`, which is a `Function`
- usage: `const foo = GM.xmlHttpRequest({...});` ... `foo.abort();` to abort the request
- `GM_xmlhttpRequest(details)`
- an alias for `GM.xmlHttpRequest`, works exactly the same

## Scripts Directory

2 changes: 1 addition & 1 deletion jsconfig.json
Original file line number Diff line number Diff line change
@@ -22,5 +22,5 @@
"skipLibCheck": true,
"sourceMap": true
},
"include": ["*.js", "src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
"include": ["*.d.ts", "*.js"]
}
26 changes: 26 additions & 0 deletions src/dev/jsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// https://code.visualstudio.com/docs/languages/jsconfig
// https://www.typescriptlang.org/docs/handbook/tsconfig-json.html
// https://www.typescriptlang.org/tsconfig

// https://www.typescriptlang.org/docs/handbook/type-checking-javascript-files.html
// https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html

{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",

"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,

"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"sourceMap": true
},
"include": ["**/*.d.ts", "**/*.js", "**/*.svelte"]
}
255 changes: 166 additions & 89 deletions src/ext/background/main.js
Original file line number Diff line number Diff line change
@@ -392,102 +392,179 @@ async function handleMessage(message, sender) {
return { status: "fulfilled", result };
}
case "API_XHR": {
// initializing an xhr instance
const xhr = new XMLHttpRequest();
// establish a long-lived port connection to content script
const port = browser.tabs.connect(sender.tab.id, {
name: message.xhrPortName,
});
// receive messages from content script and process them
port.onMessage.addListener((msg) => {
if (msg.name === "ABORT") xhr.abort();
if (msg.name === "DISCONNECT") port.disconnect();
});
// handle port disconnect and clean tasks
port.onDisconnect.addListener((p) => {
if (p?.error) {
console.error(
`port disconnected due to an error: ${p.error.message}`,
);
}
});
// parse details and set up for xhr instance
const details = message.details;
const method = details.method || "GET";
const user = details.user || null;
const password = details.password || null;
let body = details.data || null;
// deprecate once body supports more data types
// the `binary` key will no longer needed
if (typeof body === "string" && details.binary) {
body = new TextEncoder().encode(body);
}
// xhr instances automatically filter out unexpected user values
xhr.timeout = details.timeout;
xhr.responseType = details.responseType;
// record parsed values for subsequent use
const responseType = xhr.responseType;
// avoid unexpected behavior of legacy defaults such as parsing XML
if (responseType === "") xhr.responseType = "text";
// transfer to content script via arraybuffer and then parse to blob
if (responseType === "blob") xhr.responseType = "arraybuffer";
// transfer to content script via text and then parse to document
if (responseType === "document") xhr.responseType = "text";
// add required listeners and send result back to the content script
for (const e of message.events) {
if (!details[e]) continue;
xhr[e] = async (event) => {
// can not send xhr through postMessage
// construct new object to be sent as "response"
const x = {
contentType: undefined, // non-standard
readyState: xhr.readyState,
response: xhr.response,
responseHeaders: xhr.getAllResponseHeaders(),
responseType,
responseURL: xhr.responseURL,
status: xhr.status,
statusText: xhr.statusText,
timeout: xhr.timeout,
};
// get content-type when headers received
if (xhr.readyState >= xhr.HEADERS_RECEIVED) {
x.contentType = xhr.getResponseHeader("Content-Type");
try {
// initializing an xhr instance
const xhr = new XMLHttpRequest();
// establish a long-lived port connection to content script
const port = browser.tabs.connect(sender.tab.id, {
name: message.xhrPortName,
});
// receive messages from content script and process them
port.onMessage.addListener((msg) => {
if (msg.name === "ABORT") xhr.abort();
if (msg.name === "DISCONNECT") port.disconnect();
});
// handle port disconnect and clean tasks
port.onDisconnect.addListener((p) => {
if (p?.error) {
console.error(
`port disconnected due to an error: ${p.error.message}`,
);
}
// only process when xhr is complete and data exist
if (xhr.readyState === xhr.DONE && xhr.response !== null) {
// need to convert arraybuffer data to postMessage
});
// parse details and set up for xhr instance
/** @type {TypeExtMessages.XHRTransportableDetails} */
const details = message.details;
/** @type {Parameters<XMLHttpRequest["open"]>[0]} */
const method = details.method || "GET";
/** @type {Parameters<XMLHttpRequest["open"]>[1]} */
const url = details.url;
/** @type {Parameters<XMLHttpRequest["open"]>[3]} */
const user = details.user || null;
/** @type {Parameters<XMLHttpRequest["open"]>[4]} */
const password = details.password || null;
/** @type {Parameters<XMLHttpRequest["send"]>[0]} */
let body = null;
if (typeof details.data === "object") {
/** @type {TypeExtMessages.XHRProcessedData} */
const data = details.data;
if (typeof data.data === "string") {
if (data.type === "Text") {
// deprecate once body supports more data types
// the `binary` key will no longer needed
if (details.binary) {
const binaryString = data.data;
const view = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
view[i] = binaryString.charCodeAt(i);
}
body = view;
} else {
body = data.data;
}
}
if (data.type === "Document") {
body = data.data;
if (!("content-type" in details.headers)) {
details.headers["content-type"] = data.mime;
}
}
if (data.type === "URLSearchParams") {
body = new URLSearchParams(data.data);
}
}
if (Array.isArray(data.data)) {
if (
xhr.responseType === "arraybuffer" &&
xhr.response instanceof ArrayBuffer
data.type === "ArrayBuffer" ||
data.type === "ArrayBufferView"
) {
const buffer = xhr.response;
x.response = Array.from(new Uint8Array(buffer));
body = new Uint8Array(data.data);
}
if (data.type === "Blob") {
body = new Uint8Array(data.data);
if (!("content-type" in details.headers)) {
details.headers["content-type"] = data.mime;
}
}
if (data.type === "FormData") {
body = new FormData();
for (const [k, v] of data.data) {
if (typeof v === "string") {
body.append(k, v);
} else {
const view = new Uint8Array(v.data);
body.append(
k,
new File([view], v.name, {
type: v.mime,
lastModified: v.lastModified,
}),
);
}
}
}
}
port.postMessage({ name: e, event, response: x });
};
}
// if onloadend not set in xhr details
// onloadend event won't be passed to content script
// if that happens port DISCONNECT message won't be posted
// if details lacks onloadend attach listener
if (!details.onloadend) {
xhr.onloadend = (event) => {
port.postMessage({ name: "onloadend", event });
};
}
if (details.overrideMimeType) {
xhr.overrideMimeType(details.overrideMimeType);
}
xhr.open(method, details.url, true, user, password);
// must set headers after `xhr.open()`, but before `xhr.send()`
if (typeof details.headers === "object") {
for (const [key, val] of Object.entries(details.headers)) {
xhr.setRequestHeader(key, val);
}
// xhr instances automatically filter out unexpected user values
xhr.timeout = details.timeout;
xhr.responseType = details.responseType;
// record parsed values for subsequent use
const responseType = xhr.responseType;
// avoid unexpected behavior of legacy defaults such as parsing XML
if (responseType === "") xhr.responseType = "text";
// transfer to content script via arraybuffer and then parse to blob
if (responseType === "blob") xhr.responseType = "arraybuffer";
// transfer to content script via text and then parse to document
if (responseType === "document") xhr.responseType = "text";
// add required listeners and send result back to the content script
const handlers = details.hasHandlers || {};
for (const handler of Object.keys(handlers)) {
xhr[handler] = async () => {
// can not send xhr through postMessage
// construct new object to be sent as "response"
/** @type {TypeExtMessages.XHRTransportableResponse} */
const response = {
contentType: undefined, // non-standard
readyState: xhr.readyState,
response: xhr.response,
responseHeaders: xhr.getAllResponseHeaders(),
responseType,
responseURL: xhr.responseURL,
status: xhr.status,
statusText: xhr.statusText,
timeout: xhr.timeout,
};
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/response#value
if (xhr.readyState < xhr.DONE && xhr.responseType !== "text") {
response.response = null;
}
// get content-type when headers received
if (xhr.readyState >= xhr.HEADERS_RECEIVED) {
response.contentType = xhr.getResponseHeader("Content-Type");
}
// only process when xhr is complete and data exist
// note the status of the last `progress` event in Safari is DONE/4
// exclude this event to avoid unnecessary processing and transmission
if (
xhr.readyState === xhr.DONE &&
xhr.response !== null &&
handler !== "onprogress"
) {
// need to convert arraybuffer data to postMessage
if (
xhr.responseType === "arraybuffer" &&
xhr.response instanceof ArrayBuffer
) {
const buffer = xhr.response;
response.response = Array.from(new Uint8Array(buffer));
}
}
port.postMessage({ handler, response });
};
}
// if onloadend not set in xhr details
// onloadend event won't be passed to content script
// if that happens port DISCONNECT message won't be posted
// if details lacks onloadend attach listener
if (!handlers.onloadend) {
xhr.onloadend = () => {
port.postMessage({ handler: "onloadend" });
};
}
if (details.overrideMimeType) {
xhr.overrideMimeType(details.overrideMimeType);
}
xhr.open(method, url, true, user, password);
// must set headers after `xhr.open()`, but before `xhr.send()`
if (typeof details.headers === "object") {
for (const [key, val] of Object.entries(details.headers)) {
xhr.setRequestHeader(key, val);
}
}
xhr.send(body);
} catch (error) {
console.error(error);
}
xhr.send(body);
return { status: "fulfilled" };
}
case "REFRESH_DNR_RULES": {
Loading