From 6d30f1f9df6df8994f7909e178ac3483c3815e94 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:37:02 +0200 Subject: [PATCH 01/24] Create build-local-app.yml --- .github/workflows/build-local-app.yml | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/build-local-app.yml diff --git a/.github/workflows/build-local-app.yml b/.github/workflows/build-local-app.yml new file mode 100644 index 00000000..8724e961 --- /dev/null +++ b/.github/workflows/build-local-app.yml @@ -0,0 +1,36 @@ +name: Build local macOS app + +on: + workflow_dispatch: + +jobs: + build: + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - run: npm ci + + - run: npm run build:mac + + - name: Build Xcode app + run: | + xcodebuild \ + -project xcode/Userscripts.xcodeproj \ + -scheme Mac \ + -configuration Debug \ + -derivedDataPath build \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO + + - name: Upload app + uses: actions/upload-artifact@v4 + with: + name: Userscripts.app + path: build/Build/Products/Debug/Userscripts.app From d2781ac44df2f395e57ffd9fcd0c57301ab93fad Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:39:55 +0200 Subject: [PATCH 02/24] Update build-local-app.yml --- .github/workflows/build-local-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-local-app.yml b/.github/workflows/build-local-app.yml index 8724e961..95b58425 100644 --- a/.github/workflows/build-local-app.yml +++ b/.github/workflows/build-local-app.yml @@ -16,7 +16,7 @@ jobs: - run: npm ci - - run: npm run build:mac + - run: npm run build:mac-safari-15 - name: Build Xcode app run: | From 1a7e8e28250ac4c3901c68945c585a2e19f2234f Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:44:41 +0200 Subject: [PATCH 03/24] Update build-local-app.yml --- .github/workflows/build-local-app.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build-local-app.yml b/.github/workflows/build-local-app.yml index 95b58425..d01ad094 100644 --- a/.github/workflows/build-local-app.yml +++ b/.github/workflows/build-local-app.yml @@ -9,6 +9,9 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true - uses: actions/setup-node@v4 with: @@ -16,6 +19,9 @@ jobs: - run: npm ci + - name: Add fallback tag + run: git tag v4.8.6 || true + - run: npm run build:mac-safari-15 - name: Build Xcode app From ac98a74cd86a13c7d729c836471233391f64e419 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:49:57 +0200 Subject: [PATCH 04/24] Update build-local-app.yml --- .github/workflows/build-local-app.yml | 31 +++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-local-app.yml b/.github/workflows/build-local-app.yml index d01ad094..66fb57f2 100644 --- a/.github/workflows/build-local-app.yml +++ b/.github/workflows/build-local-app.yml @@ -1,4 +1,4 @@ -name: Build local macOS app +nname: Build local macOS app on: workflow_dispatch: @@ -17,12 +17,18 @@ jobs: with: node-version: 22 - - run: npm ci + - name: Install dependencies + run: npm ci - name: Add fallback tag run: git tag v4.8.6 || true - - run: npm run build:mac-safari-15 + - name: Build web assets + run: npm run build:mac-safari-15 + + - name: List Xcode schemes + run: | + xcodebuild -list -project xcode/Userscripts.xcodeproj - name: Build Xcode app run: | @@ -35,8 +41,21 @@ jobs: CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO - - name: Upload app + - name: Show build output + run: | + echo "===== BUILD =====" + find build -maxdepth 6 | sort + + echo + echo "===== APPS =====" + find . -name "*.app" + + echo + echo "===== APPEX =====" + find . -name "*.appex" + + - name: Upload build directory uses: actions/upload-artifact@v4 with: - name: Userscripts.app - path: build/Build/Products/Debug/Userscripts.app + name: BuildProducts + path: build From 9f896b846a209e11be56657882801a7e312ccb4f Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:51:44 +0200 Subject: [PATCH 05/24] Update build-local-app.yml --- .github/workflows/build-local-app.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-local-app.yml b/.github/workflows/build-local-app.yml index 66fb57f2..534794d9 100644 --- a/.github/workflows/build-local-app.yml +++ b/.github/workflows/build-local-app.yml @@ -1,4 +1,4 @@ -nname: Build local macOS app +name: Build local macOS app on: workflow_dispatch: From b024fc1ad414ca381437e733261dc130627f5610 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:59:38 +0200 Subject: [PATCH 06/24] Update build-local-app.yml --- .github/workflows/build-local-app.yml | 25 +++++-------------------- 1 file changed, 5 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build-local-app.yml b/.github/workflows/build-local-app.yml index 534794d9..bce28fdd 100644 --- a/.github/workflows/build-local-app.yml +++ b/.github/workflows/build-local-app.yml @@ -26,10 +26,6 @@ jobs: - name: Build web assets run: npm run build:mac-safari-15 - - name: List Xcode schemes - run: | - xcodebuild -list -project xcode/Userscripts.xcodeproj - - name: Build Xcode app run: | xcodebuild \ @@ -41,21 +37,10 @@ jobs: CODE_SIGNING_REQUIRED=NO \ CODE_SIGNING_ALLOWED=NO - - name: Show build output - run: | - echo "===== BUILD =====" - find build -maxdepth 6 | sort - - echo - echo "===== APPS =====" - find . -name "*.app" - - echo - echo "===== APPEX =====" - find . -name "*.appex" - - - name: Upload build directory + - name: Upload build products uses: actions/upload-artifact@v4 with: - name: BuildProducts - path: build + name: Userscripts-build + path: | + build/Build/Products/Debug/Userscripts-Debug.app + build/Build/Products/Debug/Userscripts-Mac-Safari-Extension.appex From 68d0c2b691c077fb39868675db2ae5331ba04d94 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:13:58 +0200 Subject: [PATCH 07/24] Update build-local-app.yml --- .github/workflows/build-local-app.yml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-local-app.yml b/.github/workflows/build-local-app.yml index bce28fdd..17241241 100644 --- a/.github/workflows/build-local-app.yml +++ b/.github/workflows/build-local-app.yml @@ -28,14 +28,16 @@ jobs: - name: Build Xcode app run: | - xcodebuild \ - -project xcode/Userscripts.xcodeproj \ - -scheme Mac \ - -configuration Debug \ - -derivedDataPath build \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO + xcodebuild \ + -project xcode/Userscripts.xcodeproj \ + -scheme Mac \ + -configuration Debug \ + -derivedDataPath build \ + ARCHS=x86_64 \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGN_IDENTITY="" \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGNING_ALLOWED=NO - name: Upload build products uses: actions/upload-artifact@v4 From 0da9f5d913c264f6bf5dd85ddcc18c768767543a Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 08:20:06 +0200 Subject: [PATCH 08/24] Update build-local-app.yml --- .github/workflows/build-local-app.yml | 38 ++++++++++++++------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-local-app.yml b/.github/workflows/build-local-app.yml index 17241241..e8959ae3 100644 --- a/.github/workflows/build-local-app.yml +++ b/.github/workflows/build-local-app.yml @@ -1,11 +1,11 @@ -name: Build local macOS app +name: Build local macOS Intel app on: workflow_dispatch: jobs: build: - runs-on: macos-15 + runs-on: macos-15-intel steps: - uses: actions/checkout@v4 @@ -28,21 +28,23 @@ jobs: - name: Build Xcode app run: | - xcodebuild \ - -project xcode/Userscripts.xcodeproj \ - -scheme Mac \ - -configuration Debug \ - -derivedDataPath build \ - ARCHS=x86_64 \ - ONLY_ACTIVE_ARCH=NO \ - CODE_SIGN_IDENTITY="" \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGNING_ALLOWED=NO - - - name: Upload build products + xcodebuild \ + -project xcode/Userscripts.xcodeproj \ + -scheme Mac \ + -configuration Debug \ + -derivedDataPath build \ + -destination 'platform=macOS,arch=x86_64' \ + ARCHS=x86_64 \ + ONLY_ACTIVE_ARCH=NO \ + clean build + + - name: Package app + run: | + cd build/Build/Products/Debug + ditto -c -k --sequesterRsrc --keepParent Userscripts-Debug.app Userscripts-Debug-macOS-Intel.zip + + - name: Upload app uses: actions/upload-artifact@v4 with: - name: Userscripts-build - path: | - build/Build/Products/Debug/Userscripts-Debug.app - build/Build/Products/Debug/Userscripts-Mac-Safari-Extension.appex + name: Userscripts-Debug-macOS-Intel + path: build/Build/Products/Debug/Userscripts-Debug-macOS-Intel.zip From 40f00baafe2c957eb29bbecffba889c312e0940c Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:32:04 +0200 Subject: [PATCH 09/24] Create build-macos-intel.yml --- .github/workflows/build-macos-intel.yml | 51 +++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/build-macos-intel.yml diff --git a/.github/workflows/build-macos-intel.yml b/.github/workflows/build-macos-intel.yml new file mode 100644 index 00000000..063d26f2 --- /dev/null +++ b/.github/workflows/build-macos-intel.yml @@ -0,0 +1,51 @@ +name: Build macOS Intel app + +on: + workflow_dispatch: + +jobs: + build: + runs-on: macos-15-intel + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - run: | + if ! git describe --tags --abbrev=0 >/dev/null 2>&1; then + git tag v4.8.6 + fi + + - run: npm run build:mac-safari-15 + + - run: | + xcodebuild \ + -project xcode/Userscripts.xcodeproj \ + -scheme Mac \ + -configuration Debug \ + -derivedDataPath build \ + -destination 'platform=macOS,arch=x86_64' \ + ARCHS=x86_64 \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + CODE_SIGN_IDENTITY='' \ + clean build + + - run: | + cd build/Build/Products/Debug + ditto -c -k --sequesterRsrc --keepParent Userscripts-Debug.app Userscripts-Debug-macOS-Intel-unsigned.zip + + - uses: actions/upload-artifact@v4 + with: + name: Userscripts-Debug-macOS-Intel-unsigned + path: build/Build/Products/Debug/Userscripts-Debug-macOS-Intel-unsigned.zip From 81ccb62bfa77c003205c858b3a50091051328f9d Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:49:56 +0200 Subject: [PATCH 10/24] Update build-macos-intel.yml --- .github/workflows/build-macos-intel.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-macos-intel.yml b/.github/workflows/build-macos-intel.yml index 063d26f2..1161fe2c 100644 --- a/.github/workflows/build-macos-intel.yml +++ b/.github/workflows/build-macos-intel.yml @@ -1,4 +1,4 @@ -name: Build macOS Intel app +name: Userscripts-Debug-macOS-Intel-adhoc on: workflow_dispatch: @@ -36,9 +36,9 @@ jobs: -destination 'platform=macOS,arch=x86_64' \ ARCHS=x86_64 \ ONLY_ACTIVE_ARCH=NO \ - CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_ALLOWED=YES \ CODE_SIGNING_REQUIRED=NO \ - CODE_SIGN_IDENTITY='' \ + CODE_SIGN_IDENTITY="-" \ clean build - run: | From d53f9c8f8a72bf300f87efa5a0cb695249f268c5 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:52:22 +0200 Subject: [PATCH 11/24] Update build-macos-intel.yml --- .github/workflows/build-macos-intel.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-macos-intel.yml b/.github/workflows/build-macos-intel.yml index 1161fe2c..b5dfb4cc 100644 --- a/.github/workflows/build-macos-intel.yml +++ b/.github/workflows/build-macos-intel.yml @@ -1,4 +1,4 @@ -name: Userscripts-Debug-macOS-Intel-adhoc +name: Build macOS Intel app on: workflow_dispatch: From 622304b522ca86161481f0e69cf5e6e07b3395b6 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:53:13 +0200 Subject: [PATCH 12/24] Update build-macos-intel.yml --- .github/workflows/build-macos-intel.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-macos-intel.yml b/.github/workflows/build-macos-intel.yml index b5dfb4cc..1161fe2c 100644 --- a/.github/workflows/build-macos-intel.yml +++ b/.github/workflows/build-macos-intel.yml @@ -1,4 +1,4 @@ -name: Build macOS Intel app +name: Userscripts-Debug-macOS-Intel-adhoc on: workflow_dispatch: From 28fcecd57b51685851ff1cfb4fc0af31b8b89829 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:36:17 +0200 Subject: [PATCH 13/24] Update entry-userscripts.js --- src/ext/content-scripts/entry-userscripts.js | 284 ++++++++++++++++--- 1 file changed, 245 insertions(+), 39 deletions(-) diff --git a/src/ext/content-scripts/entry-userscripts.js b/src/ext/content-scripts/entry-userscripts.js index 362bfacb..0baa8317 100644 --- a/src/ext/content-scripts/entry-userscripts.js +++ b/src/ext/content-scripts/entry-userscripts.js @@ -1,5 +1,4 @@ import USAPI from "./api.js"; -import { colors } from "@shared/colors.js"; // code received from background page will be stored in this variable // code referenced again when strict CSPs block initial injection attempt @@ -17,6 +16,224 @@ function randomLabel() { return a[Math.floor(r * a.length)] + r.toString().slice(5, 6); } + + +function pageGrantBridgeEventName(id, type) { + return `__userscripts_page_grant_bridge_${id}_${type}__`; +} + +function serializableXhrResponse(response) { + if (!response || typeof response !== "object") return response; + const result = {}; + for (const key of [ + "readyState", + "responseHeaders", + "responseText", + "responseType", + "responseURL", + "finalUrl", + "status", + "statusText", + ]) { + if (key in response) result[key] = response[key]; + } + if ("response" in response) { + const value = response.response; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value == null + ) { + result.response = value; + } else { + result.response = response.responseText ?? undefined; + } + } + return result; +} + +function installPageGrantBridge(userscript, grants) { + const filename = userscript.scriptObject.filename; + const bridgeId = `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; + const requestEvent = pageGrantBridgeEventName(bridgeId, "request"); + const responseEvent = pageGrantBridgeEventName(bridgeId, "response"); + const abortEvent = pageGrantBridgeEventName(bridgeId, "abort"); + const xhrControls = new Map(); + + const respond = (id, payload) => { + document.dispatchEvent( + new CustomEvent(responseEvent, { + detail: { id, ...payload }, + }), + ); + }; + + const handleRequest = async (event) => { + const detail = event.detail; + if (!detail || detail.bridgeId !== bridgeId || !detail.id) return; + const { id, method, args = [] } = detail; + try { + if (method === "GM_xmlhttpRequest") { + const details = { ...(args[0] || {}) }; + for (const handler of [ + "onreadystatechange", + "onloadstart", + "onprogress", + "onabort", + "onerror", + "onload", + "ontimeout", + "onloadend", + ]) { + details[handler] = (response) => { + respond(id, { + type: "xhr-event", + handler, + response: serializableXhrResponse(response), + }); + }; + } + const control = USAPI.GM_xmlhttpRequest(details); + xhrControls.set(id, control); + return; + } + + if (method === "GM_addStyle" || method === "addStyle") { + const result = USAPI.addStyle(String(args[0] || "")); + respond(id, { type: "result", result }); + return; + } + + if (method === "GM_setValue" || method === "setValue") { + const result = await USAPI.setValue.bind({ US_filename: filename })( + args[0], + args[1], + ); + respond(id, { type: "result", result }); + return; + } + + if (method === "GM_getValue" || method === "getValue") { + const result = await USAPI.getValue.bind({ US_filename: filename })( + args[0], + args[1], + ); + respond(id, { type: "result", result }); + return; + } + + if (method === "GM_deleteValue" || method === "deleteValue") { + const result = await USAPI.deleteValue.bind({ US_filename: filename })( + args[0], + ); + respond(id, { type: "result", result }); + return; + } + + if (method === "GM_listValues" || method === "listValues") { + const result = await USAPI.listValues.bind({ US_filename: filename })(); + respond(id, { type: "result", result }); + return; + } + + respond(id, { + type: "error", + error: `Unsupported bridged grant: ${method}`, + }); + } catch (error) { + respond(id, { + type: "error", + error: String(error?.message || error), + }); + } + }; + + const handleAbort = (event) => { + const detail = event.detail; + if (!detail || detail.bridgeId !== bridgeId || !detail.id) return; + const control = xhrControls.get(detail.id); + if (control && typeof control.abort === "function") control.abort(); + xhrControls.delete(detail.id); + }; + + document.addEventListener(requestEvent, handleRequest); + document.addEventListener(abortEvent, handleAbort); + + userscript.pageGrantBridge = { + bridgeId, + requestEvent, + responseEvent, + abortEvent, + grants: [...grants], + }; +} + +function getPageGrantClientPreamble(userscript) { + const bridge = userscript.pageGrantBridge; + if (!bridge) return ""; + const info = userscript.apis?.GM?.info || userscript.apis?.GM_info || {}; + return `(() => {\n` + + `const __US_BRIDGE_ID__ = ${JSON.stringify(bridge.bridgeId)};\n` + + `const __US_REQUEST_EVENT__ = ${JSON.stringify(bridge.requestEvent)};\n` + + `const __US_RESPONSE_EVENT__ = ${JSON.stringify(bridge.responseEvent)};\n` + + `const __US_ABORT_EVENT__ = ${JSON.stringify(bridge.abortEvent)};\n` + + `const GM_info = ${JSON.stringify(info)};\n` + + `const GM = { info: GM_info };\n` + + `const __US_randomId = () => Date.now().toString(36) + '_' + Math.random().toString(36).slice(2);\n` + + `const __US_callGrant = (method, args = []) => new Promise((resolve, reject) => {\n` + + ` const id = __US_randomId();\n` + + ` const onResponse = (event) => {\n` + + ` const detail = event.detail || {};\n` + + ` if (detail.id !== id) return;\n` + + ` if (detail.type === 'result') { document.removeEventListener(__US_RESPONSE_EVENT__, onResponse); resolve(detail.result); }\n` + + ` if (detail.type === 'error') { document.removeEventListener(__US_RESPONSE_EVENT__, onResponse); reject(new Error(detail.error || 'Userscripts grant bridge error')); }\n` + + ` };\n` + + ` document.addEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + + ` document.dispatchEvent(new CustomEvent(__US_REQUEST_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id, method, args } }));\n` + + `});\n` + + `function GM_xmlhttpRequest(details) {\n` + + ` const id = __US_randomId();\n` + + ` const callbacks = {};\n` + + ` const payload = { ...(details || {}) };\n` + + ` for (const key of ['onreadystatechange','onloadstart','onprogress','onabort','onerror','onload','ontimeout','onloadend']) {\n` + + ` if (typeof payload[key] === 'function') { callbacks[key] = payload[key]; delete payload[key]; }\n` + + ` }\n` + + ` const onResponse = (event) => {\n` + + ` const detail = event.detail || {};\n` + + ` if (detail.id !== id) return;\n` + + ` if (detail.type === 'xhr-event') {\n` + + ` const cb = callbacks[detail.handler];\n` + + ` if (typeof cb === 'function') cb(detail.response);\n` + + ` if (['onloadend','onabort','onerror','ontimeout'].includes(detail.handler)) document.removeEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + + ` return;\n` + + ` }\n` + + ` if (detail.type === 'error') {\n` + + ` document.removeEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + + ` if (typeof callbacks.onerror === 'function') callbacks.onerror({ error: detail.error });\n` + + ` }\n` + + ` };\n` + + ` document.addEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + + ` document.dispatchEvent(new CustomEvent(__US_REQUEST_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id, method: 'GM_xmlhttpRequest', args: [payload] } }));\n` + + ` return { abort() { document.dispatchEvent(new CustomEvent(__US_ABORT_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id } })); } };\n` + + `}\n` + + `const GM_addStyle = (css) => __US_callGrant('GM_addStyle', [css]);\n` + + `const GM_setValue = (key, value) => __US_callGrant('GM_setValue', [key, value]);\n` + + `const GM_getValue = (key, defaultValue) => __US_callGrant('GM_getValue', [key, defaultValue]);\n` + + `const GM_deleteValue = (key) => __US_callGrant('GM_deleteValue', [key]);\n` + + `const GM_listValues = () => __US_callGrant('GM_listValues');\n` + + `GM.xmlHttpRequest = (details) => new Promise((resolve, reject) => {\n` + + ` GM_xmlhttpRequest({ ...(details || {}), onloadend: resolve, onerror: reject, ontimeout: reject, onabort: reject });\n` + + `});\n` + + `GM.addStyle = GM_addStyle; GM.setValue = GM_setValue; GM.getValue = GM_getValue; GM.deleteValue = GM_deleteValue; GM.listValues = GM_listValues;\n` + + `try {\n`; +} + +function getPageGrantClientPostamble(userscript) { + if (!userscript.pageGrantBridge) return ""; + return `\n} catch (error) { console.error('Userscripts page grant bridge script error:', error); }\n})();`; +} + function triageJS(userscript) { const runAt = userscript.scriptObject["run-at"]; if (runAt === "document-start") { @@ -49,16 +266,11 @@ function triageJS(userscript) { function injectJS(userscript) { const filename = userscript.scriptObject.filename; const name = userscript.scriptObject.name; - const code = `\ -(async () => { - try { -// ===UserScript===start=== -${userscript.code} -// ===UserScript====end==== - } catch (error) { - console.error(\`${filename.replaceAll("`", "\\`")}\`, error); - } -})(); //# sourceURL=${filename.replace(/[\s"']/g, "-") + usTag}`; + const pageGrantPreamble = getPageGrantClientPreamble(userscript); + const pageGrantPostamble = getPageGrantClientPostamble(userscript); + const code = `${pageGrantPreamble}${userscript.code}${pageGrantPostamble} //# sourceURL=${ + filename.replace(/\s/g, "-") + usTag + }`; let injectInto = userscript.scriptObject["inject-into"]; // change scope to content since strict CSP event detected if (injectInto === "auto" && (userscript.fallback || cspFallbackAttempted)) { @@ -67,13 +279,13 @@ ${userscript.code} } const world = injectInto === "content" ? "content" : "page"; if (window.self === window.top) { - console.info(`Injecting: ${name} %c(js/${world})`, colors.yellow); + console.info(`Injecting: ${name} %c(js/${world})`, "color: #fff600"); } else { console.info( `Injecting: ${name} %c(js/${world})%c - %cframe(${label})(${window.location})`, - colors.yellow, - colors.inherit, - colors.blue, + "color: #fff600", + "color: inherit", + "color: #006fff", ); } if (world === "page") { @@ -86,26 +298,26 @@ ${userscript.code} (document.body ?? document.head ?? document.documentElement).append(div); } else { try { - Function( + // eslint-disable-next-line no-new-func + return Function( `{${Object.keys(userscript.apis).join(",")}}`, code, )(userscript.apis); } catch (error) { - console.error(`${filename}`, error); + console.error(`"${filename}" error:`, error); } - return; } } function injectCSS(name, code) { if (window.self === window.top) { - console.info(`Injecting ${name} %c(css)`, colors.green); + console.info(`Injecting ${name} %c(css)`, "color: #60f36c"); } else { console.info( `Injecting ${name} %c(css)%c - %cframe(${label})(${window.location})`, - colors.green, - colors.inherit, - colors.blue, + "color: #60f36c", + "color: inherit", + "color: #006fff", ); } // Safari lacks full support for tabs.insertCSS @@ -151,9 +363,6 @@ async function injection() { const response = await browser.runtime.sendMessage({ name: "REQ_USERSCRIPTS", }); - if (import.meta.env.MODE === "development") { - console.debug("REQ_USERSCRIPTS", response); - } // cancel injection if errors detected if (!response || response.error) { console.error(response?.error || "REQ_USERSCRIPTS returned undefined"); @@ -182,13 +391,14 @@ async function injection() { userscript.apis.GM_info = userscript.apis.GM.info; // if @grant explicitly set to none, empty grants array if (grants.includes("none")) grants.length = 0; - // @grant values exist for page scoped userscript + // @grant values exist for page scoped userscript. + // Keep the userscript in the page world and expose granted APIs through a + // content-world bridge instead of stripping grants. This preserves access to + // page globals while privileged APIs still execute in the content script. if (grants.length && injectInto === "page") { - // remove grants - grants.length = 0; - // log warning - console.warn( - `${filename} @grant values removed due to @inject-into value: ${injectInto} - https://github.com/quoid/userscripts/issues/265#issuecomment-1213462394`, + installPageGrantBridge(userscript, grants); + console.info( + `${filename} @grant values bridged for @inject-into value: ${injectInto}`, ); } // @grant exist for auto scoped userscript @@ -257,7 +467,7 @@ function listeners() { for (let i = 0; i < data.files.menu.length; i++) { const item = data.files.menu[i]; if (item.scriptObject.filename === filename) { - console.info(`Injecting ${filename} %c(js)`, colors.yellow); + console.info(`Injecting ${filename} %c(js)`, "color: #fff600"); injectJS(item); return; } @@ -279,13 +489,9 @@ function listeners() { } async function initialize() { - // avoid duplicate injection of content scripts - if (window["CS_ENTRY_USERSCRIPTS"]) return; - window["CS_ENTRY_USERSCRIPTS"] = 1; - // check user settings - const key = "US_GLOBAL_ACTIVE"; - const results = await browser.storage.local.get(key); - if (results[key] === false) return console.info("Userscripts off"); + const results = await browser.storage.local.get("US_GLOBAL_ACTIVE"); + if (results?.US_GLOBAL_ACTIVE === false) + return console.info("Userscripts off"); // start the injection process and add the listeners injection(); listeners(); From 8c0843ee751e020e3434839a0f6d814ba7bc7e0d Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:52:55 +0200 Subject: [PATCH 14/24] Update entry-userscripts.js --- src/ext/content-scripts/entry-userscripts.js | 61 +++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/src/ext/content-scripts/entry-userscripts.js b/src/ext/content-scripts/entry-userscripts.js index 0baa8317..fea1834b 100644 --- a/src/ext/content-scripts/entry-userscripts.js +++ b/src/ext/content-scripts/entry-userscripts.js @@ -1,4 +1,5 @@ import USAPI from "./api.js"; +import { colors } from "@shared/colors.js"; // code received from background page will be stored in this variable // code referenced again when strict CSPs block initial injection attempt @@ -92,6 +93,14 @@ function installPageGrantBridge(userscript, grants) { handler, response: serializableXhrResponse(response), }); + if ( + handler === "onloadend" || + handler === "onabort" || + handler === "onerror" || + handler === "ontimeout" + ) { + xhrControls.delete(id); + } }; } const control = USAPI.GM_xmlhttpRequest(details); @@ -100,8 +109,8 @@ function installPageGrantBridge(userscript, grants) { } if (method === "GM_addStyle" || method === "addStyle") { - const result = USAPI.addStyle(String(args[0] || "")); - respond(id, { type: "result", result }); + USAPI.addStyle(String(args[0] || "")); + respond(id, { type: "result", result: undefined }); return; } @@ -173,7 +182,7 @@ function getPageGrantClientPreamble(userscript) { const bridge = userscript.pageGrantBridge; if (!bridge) return ""; const info = userscript.apis?.GM?.info || userscript.apis?.GM_info || {}; - return `(() => {\n` + + return ( `const __US_BRIDGE_ID__ = ${JSON.stringify(bridge.bridgeId)};\n` + `const __US_REQUEST_EVENT__ = ${JSON.stringify(bridge.requestEvent)};\n` + `const __US_RESPONSE_EVENT__ = ${JSON.stringify(bridge.responseEvent)};\n` + @@ -181,12 +190,13 @@ function getPageGrantClientPreamble(userscript) { `const GM_info = ${JSON.stringify(info)};\n` + `const GM = { info: GM_info };\n` + `const __US_randomId = () => Date.now().toString(36) + '_' + Math.random().toString(36).slice(2);\n` + + `const __US_terminalXhrHandlers = new Set(['onloadend','onabort','onerror','ontimeout']);\n` + `const __US_callGrant = (method, args = []) => new Promise((resolve, reject) => {\n` + ` const id = __US_randomId();\n` + ` const onResponse = (event) => {\n` + ` const detail = event.detail || {};\n` + ` if (detail.id !== id) return;\n` + - ` if (detail.type === 'result') { document.removeEventListener(__US_RESPONSE_EVENT__, onResponse); resolve(detail.result); }\n` + + ` if (detail.type === 'result') { document.removeEventListener(__US_RESPONSE_EVENT__, onResponse); resolve(detail.result); return; }\n` + ` if (detail.type === 'error') { document.removeEventListener(__US_RESPONSE_EVENT__, onResponse); reject(new Error(detail.error || 'Userscripts grant bridge error')); }\n` + ` };\n` + ` document.addEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + @@ -205,7 +215,7 @@ function getPageGrantClientPreamble(userscript) { ` if (detail.type === 'xhr-event') {\n` + ` const cb = callbacks[detail.handler];\n` + ` if (typeof cb === 'function') cb(detail.response);\n` + - ` if (['onloadend','onabort','onerror','ontimeout'].includes(detail.handler)) document.removeEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + + ` if (__US_terminalXhrHandlers.has(detail.handler)) document.removeEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + ` return;\n` + ` }\n` + ` if (detail.type === 'error') {\n` + @@ -215,7 +225,7 @@ function getPageGrantClientPreamble(userscript) { ` };\n` + ` document.addEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + ` document.dispatchEvent(new CustomEvent(__US_REQUEST_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id, method: 'GM_xmlhttpRequest', args: [payload] } }));\n` + - ` return { abort() { document.dispatchEvent(new CustomEvent(__US_ABORT_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id } })); } };\n` + + ` return { abort() { document.removeEventListener(__US_RESPONSE_EVENT__, onResponse); document.dispatchEvent(new CustomEvent(__US_ABORT_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id } })); } };\n` + `}\n` + `const GM_addStyle = (css) => __US_callGrant('GM_addStyle', [css]);\n` + `const GM_setValue = (key, value) => __US_callGrant('GM_setValue', [key, value]);\n` + @@ -225,13 +235,13 @@ function getPageGrantClientPreamble(userscript) { `GM.xmlHttpRequest = (details) => new Promise((resolve, reject) => {\n` + ` GM_xmlhttpRequest({ ...(details || {}), onloadend: resolve, onerror: reject, ontimeout: reject, onabort: reject });\n` + `});\n` + - `GM.addStyle = GM_addStyle; GM.setValue = GM_setValue; GM.getValue = GM_getValue; GM.deleteValue = GM_deleteValue; GM.listValues = GM_listValues;\n` + - `try {\n`; + `GM.xmlhttpRequest = GM.xmlHttpRequest;\n` + + `GM.addStyle = GM_addStyle; GM.setValue = GM_setValue; GM.getValue = GM_getValue; GM.deleteValue = GM_deleteValue; GM.listValues = GM_listValues;\n` + ); } -function getPageGrantClientPostamble(userscript) { - if (!userscript.pageGrantBridge) return ""; - return `\n} catch (error) { console.error('Userscripts page grant bridge script error:', error); }\n})();`; +function getPageGrantClientPostamble(_userscript) { + return ""; } function triageJS(userscript) { @@ -268,9 +278,18 @@ function injectJS(userscript) { const name = userscript.scriptObject.name; const pageGrantPreamble = getPageGrantClientPreamble(userscript); const pageGrantPostamble = getPageGrantClientPostamble(userscript); - const code = `${pageGrantPreamble}${userscript.code}${pageGrantPostamble} //# sourceURL=${ - filename.replace(/\s/g, "-") + usTag - }`; + const code = `\ +(async () => { + try { +${pageGrantPreamble} +// ===UserScript===start=== +${userscript.code} +// ===UserScript====end==== +${pageGrantPostamble} + } catch (error) { + console.error(\`${filename.replaceAll("`", "\\`")}\`, error); + } +})(); //# sourceURL=${filename.replace(/[\s"']/g, "-") + usTag}`; let injectInto = userscript.scriptObject["inject-into"]; // change scope to content since strict CSP event detected if (injectInto === "auto" && (userscript.fallback || cspFallbackAttempted)) { @@ -279,13 +298,13 @@ function injectJS(userscript) { } const world = injectInto === "content" ? "content" : "page"; if (window.self === window.top) { - console.info(`Injecting: ${name} %c(js/${world})`, "color: #fff600"); + console.info(`Injecting: ${name} %c(js/${world})`, colors.yellow); } else { console.info( `Injecting: ${name} %c(js/${world})%c - %cframe(${label})(${window.location})`, - "color: #fff600", - "color: inherit", - "color: #006fff", + colors.yellow, + colors.inherit, + colors.blue, ); } if (world === "page") { @@ -316,8 +335,8 @@ function injectCSS(name, code) { console.info( `Injecting ${name} %c(css)%c - %cframe(${label})(${window.location})`, "color: #60f36c", - "color: inherit", - "color: #006fff", + colors.inherit, + colors.blue, ); } // Safari lacks full support for tabs.insertCSS @@ -467,7 +486,7 @@ function listeners() { for (let i = 0; i < data.files.menu.length; i++) { const item = data.files.menu[i]; if (item.scriptObject.filename === filename) { - console.info(`Injecting ${filename} %c(js)`, "color: #fff600"); + console.info(`Injecting ${filename} %c(js)`, colors.yellow); injectJS(item); return; } From 54c3feff4b3bfdf3979010ff3f77109027a25182 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:37:15 +0200 Subject: [PATCH 15/24] Update entry-userscripts.js --- src/ext/content-scripts/entry-userscripts.js | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/ext/content-scripts/entry-userscripts.js b/src/ext/content-scripts/entry-userscripts.js index fea1834b..41baa7dc 100644 --- a/src/ext/content-scripts/entry-userscripts.js +++ b/src/ext/content-scripts/entry-userscripts.js @@ -410,25 +410,18 @@ async function injection() { userscript.apis.GM_info = userscript.apis.GM.info; // if @grant explicitly set to none, empty grants array if (grants.includes("none")) grants.length = 0; - // @grant values exist for page scoped userscript. + // @grant values exist for page/auto scoped userscripts. // Keep the userscript in the page world and expose granted APIs through a - // content-world bridge instead of stripping grants. This preserves access to - // page globals while privileged APIs still execute in the content script. - if (grants.length && injectInto === "page") { + // content-world bridge instead of stripping grants or forcing content mode. + // This preserves access to page globals while privileged APIs still + // execute in the content script. When strict CSP blocks page injection, + // the existing fallback path will still retry in content. + if (grants.length && (injectInto === "page" || injectInto === "auto")) { installPageGrantBridge(userscript, grants); console.info( `${filename} @grant values bridged for @inject-into value: ${injectInto}`, ); } - // @grant exist for auto scoped userscript - if (grants.length && injectInto === "auto") { - // change scope - userscript.scriptObject["inject-into"] = "content"; - // log warning - console.warn( - `${filename} @inject-into value set to 'content' due to @grant values: ${grants} - https://github.com/quoid/userscripts/issues/265#issuecomment-1213462394`, - ); - } // loop through each userscript @grant value, add methods as needed for (let j = 0; j < grants.length; j++) { const grant = grants[j]; From 7d27e75028db96ca6d90f1ea0ef69bdfa1dae269 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:03:28 +0200 Subject: [PATCH 16/24] Update entry-userscripts.js --- src/ext/content-scripts/entry-userscripts.js | 325 +++++++++++++------ 1 file changed, 232 insertions(+), 93 deletions(-) diff --git a/src/ext/content-scripts/entry-userscripts.js b/src/ext/content-scripts/entry-userscripts.js index 41baa7dc..5631f77d 100644 --- a/src/ext/content-scripts/entry-userscripts.js +++ b/src/ext/content-scripts/entry-userscripts.js @@ -23,11 +23,157 @@ function pageGrantBridgeEventName(id, type) { return `__userscripts_page_grant_bridge_${id}_${type}__`; } -function serializableXhrResponse(response) { +const PAGE_BRIDGE_FILENAME_BOUND_METHODS = new Set([ + "setValue", + "getValue", + "deleteValue", + "listValues", +]); + +const PAGE_BRIDGE_CLIENT_METHOD_NAMES = { + addStyle: "GM_addStyle", + openInTab: "GM_openInTab", + closeTab: "GM_closeTab", + getTab: "GM_getTab", + saveTab: "GM_saveTab", + setClipboard: "GM_setClipboard", + setValue: "GM_setValue", + getValue: "GM_getValue", + deleteValue: "GM_deleteValue", + listValues: "GM_listValues", +}; + +function normalizePageGrantMethod(method) { + if (typeof method !== "string" || !method.length) return ""; + if (method === "GM_xmlhttpRequest" || method === "xmlHttpRequest") { + return "GM_xmlhttpRequest"; + } + if (method.startsWith("GM.")) return method.slice(3); + if (method.startsWith("GM_")) return method.slice(3); + return method; +} + +function isPageGrantMethodSupported(method) { + const normalizedMethod = normalizePageGrantMethod(method); + return ( + normalizedMethod === "GM_xmlhttpRequest" || + Object.prototype.hasOwnProperty.call(USAPI, normalizedMethod) + ); +} + +async function callPageGrantMethod(method, filename, args = []) { + const normalizedMethod = normalizePageGrantMethod(method); + if (normalizedMethod === "GM_xmlhttpRequest") { + throw new Error("GM_xmlhttpRequest must be handled separately"); + } + if (!Object.prototype.hasOwnProperty.call(USAPI, normalizedMethod)) { + throw new Error(`Unsupported bridged grant: ${method}`); + } + if (PAGE_BRIDGE_FILENAME_BOUND_METHODS.has(normalizedMethod)) { + return USAPI[normalizedMethod].bind({ US_filename: filename })(...args); + } + return USAPI[normalizedMethod](...args); +} + +function getPageGrantClientMethodDefinitions(grants) { + const methods = new Set(); + for (const grant of grants || []) { + const normalizedMethod = normalizePageGrantMethod(grant); + if (isPageGrantMethodSupported(normalizedMethod)) { + methods.add(normalizedMethod); + } + } + + const wrapperLines = []; + const assignmentLines = []; + for (const method of methods) { + if (method === "GM_xmlhttpRequest") continue; + const legacyName = PAGE_BRIDGE_CLIENT_METHOD_NAMES[method]; + if (!legacyName) continue; + wrapperLines.push( + `const ${legacyName} = (...args) => __US_callGrant(${JSON.stringify(legacyName)}, args);\n`, + ); + assignmentLines.push(`GM.${method} = ${legacyName};\n`); + } + + return { + hasXmlHttpRequest: methods.has("GM_xmlhttpRequest"), + methodWrapperCode: wrapperLines.join(""), + gmAssignmentCode: assignmentLines.join(""), + }; +} + +function getResponseContentType(response) { + if (!response || typeof response !== "object") return ""; + if (typeof response.contentType === "string" && response.contentType) { + return response.contentType; + } + if (typeof response.responseHeaders !== "string") return ""; + const match = response.responseHeaders.match( + /(?:^|\r?\n)content-type:\s*([^\r\n]+)/i, + ); + return match ? match[1].trim() : ""; +} + +async function serializeXhrResponseValue(value, responseType, contentType) { + if (value == null) return value; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (value instanceof ArrayBuffer) { + return { + __userscriptsType: "ArrayBuffer", + data: Array.from(new Uint8Array(value)), + }; + } + if (ArrayBuffer.isView(value)) { + return { + __userscriptsType: "ArrayBufferView", + data: Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)), + view: value.constructor?.name || "Uint8Array", + }; + } + if (value instanceof Blob) { + return { + __userscriptsType: "Blob", + data: Array.from(new Uint8Array(await value.arrayBuffer())), + mimeType: value.type || contentType || "", + }; + } + if (value instanceof Document) { + let serialized = ""; + try { + serialized = new XMLSerializer().serializeToString(value); + } catch { + serialized = value.documentElement?.outerHTML || ""; + } + return { + __userscriptsType: "Document", + data: serialized, + mimeType: value.contentType || contentType || "text/html", + }; + } + if (responseType === "json") { + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return null; + } + } + return value; +} + +async function serializableXhrResponse(response) { if (!response || typeof response !== "object") return response; const result = {}; + const contentType = getResponseContentType(response); for (const key of [ "readyState", + "contentType", "responseHeaders", "responseText", "responseType", @@ -36,20 +182,18 @@ function serializableXhrResponse(response) { "status", "statusText", ]) { + if (key === "contentType") { + if (contentType) result.contentType = contentType; + continue; + } if (key in response) result[key] = response[key]; } if ("response" in response) { - const value = response.response; - if ( - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" || - value == null - ) { - result.response = value; - } else { - result.response = response.responseText ?? undefined; - } + result.response = await serializeXhrResponseValue( + response.response, + response.responseType, + contentType, + ); } return result; } @@ -75,7 +219,7 @@ function installPageGrantBridge(userscript, grants) { if (!detail || detail.bridgeId !== bridgeId || !detail.id) return; const { id, method, args = [] } = detail; try { - if (method === "GM_xmlhttpRequest") { + if (normalizePageGrantMethod(method) === "GM_xmlhttpRequest") { const details = { ...(args[0] || {}) }; for (const handler of [ "onreadystatechange", @@ -87,11 +231,11 @@ function installPageGrantBridge(userscript, grants) { "ontimeout", "onloadend", ]) { - details[handler] = (response) => { + details[handler] = async (response) => { respond(id, { type: "xhr-event", handler, - response: serializableXhrResponse(response), + response: await serializableXhrResponse(response), }); if ( handler === "onloadend" || @@ -107,49 +251,8 @@ function installPageGrantBridge(userscript, grants) { xhrControls.set(id, control); return; } - - if (method === "GM_addStyle" || method === "addStyle") { - USAPI.addStyle(String(args[0] || "")); - respond(id, { type: "result", result: undefined }); - return; - } - - if (method === "GM_setValue" || method === "setValue") { - const result = await USAPI.setValue.bind({ US_filename: filename })( - args[0], - args[1], - ); - respond(id, { type: "result", result }); - return; - } - - if (method === "GM_getValue" || method === "getValue") { - const result = await USAPI.getValue.bind({ US_filename: filename })( - args[0], - args[1], - ); - respond(id, { type: "result", result }); - return; - } - - if (method === "GM_deleteValue" || method === "deleteValue") { - const result = await USAPI.deleteValue.bind({ US_filename: filename })( - args[0], - ); - respond(id, { type: "result", result }); - return; - } - - if (method === "GM_listValues" || method === "listValues") { - const result = await USAPI.listValues.bind({ US_filename: filename })(); - respond(id, { type: "result", result }); - return; - } - - respond(id, { - type: "error", - error: `Unsupported bridged grant: ${method}`, - }); + const result = await callPageGrantMethod(method, filename, args); + respond(id, { type: "result", result }); } catch (error) { respond(id, { type: "error", @@ -182,6 +285,8 @@ function getPageGrantClientPreamble(userscript) { const bridge = userscript.pageGrantBridge; if (!bridge) return ""; const info = userscript.apis?.GM?.info || userscript.apis?.GM_info || {}; + const { hasXmlHttpRequest, methodWrapperCode, gmAssignmentCode } = + getPageGrantClientMethodDefinitions(bridge.grants); return ( `const __US_BRIDGE_ID__ = ${JSON.stringify(bridge.bridgeId)};\n` + `const __US_REQUEST_EVENT__ = ${JSON.stringify(bridge.requestEvent)};\n` + @@ -191,6 +296,42 @@ function getPageGrantClientPreamble(userscript) { `const GM = { info: GM_info };\n` + `const __US_randomId = () => Date.now().toString(36) + '_' + Math.random().toString(36).slice(2);\n` + `const __US_terminalXhrHandlers = new Set(['onloadend','onabort','onerror','ontimeout']);\n` + + `const __US_parseHeaders = (raw) => {\n` + + ` const headers = {};\n` + + ` if (typeof raw !== 'string' || !raw) return headers;\n` + + ` for (const line of raw.split(/\\r?\\n/)) {\n` + + ` const match = /^([\\w-]+):\\s*(.+)$/.exec(line);\n` + + ` if (match) headers[match[1].toLowerCase()] = match[2];\n` + + ` }\n` + + ` return headers;\n` + + `};\n` + + `const __US_restoreXhrValue = (value, responseType, responseHeaders, contentType) => {\n` + + ` if (!value || typeof value !== 'object' || !value.__userscriptsType) return value;\n` + + ` const mimeType = contentType || (__US_parseHeaders(responseHeaders)['content-type'] || '');\n` + + ` if (value.__userscriptsType === 'ArrayBuffer') return new Uint8Array(value.data || []).buffer;\n` + + ` if (value.__userscriptsType === 'ArrayBufferView') return new Uint8Array(value.data || []);\n` + + ` if (value.__userscriptsType === 'Blob') return new Blob([new Uint8Array(value.data || [])], { type: value.mimeType || mimeType || '' });\n` + + ` if (value.__userscriptsType === 'Document') {\n` + + ` const parser = new DOMParser();\n` + + ` const type = (value.mimeType || mimeType || '').includes('html') ? 'text/html' : 'text/xml';\n` + + ` return parser.parseFromString(String(value.data || ''), type);\n` + + ` }\n` + + ` return value;\n` + + `};\n` + + `const __US_restoreXhrResponse = (response) => {\n` + + ` if (!response || typeof response !== 'object') return response;\n` + + ` const restored = { ...response };\n` + + ` restored.getAllResponseHeaders = () => String(restored.responseHeaders || '');\n` + + ` restored.getResponseHeader = (name) => __US_parseHeaders(restored.responseHeaders || '')[String(name || '').toLowerCase()] || null;\n` + + ` restored.response = __US_restoreXhrValue(restored.response, restored.responseType, restored.responseHeaders, restored.contentType);\n` + + ` if ((restored.responseType === '' || restored.responseType === 'text') && typeof restored.response === 'string') {\n` + + ` restored.responseText = restored.response;\n` + + ` }\n` + + ` if (restored.responseType === 'document' && restored.response instanceof Document) {\n` + + ` restored.responseXML = restored.response;\n` + + ` }\n` + + ` return restored;\n` + + `};\n` + `const __US_callGrant = (method, args = []) => new Promise((resolve, reject) => {\n` + ` const id = __US_randomId();\n` + ` const onResponse = (event) => {\n` + @@ -202,41 +343,39 @@ function getPageGrantClientPreamble(userscript) { ` document.addEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + ` document.dispatchEvent(new CustomEvent(__US_REQUEST_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id, method, args } }));\n` + `});\n` + - `function GM_xmlhttpRequest(details) {\n` + - ` const id = __US_randomId();\n` + - ` const callbacks = {};\n` + - ` const payload = { ...(details || {}) };\n` + - ` for (const key of ['onreadystatechange','onloadstart','onprogress','onabort','onerror','onload','ontimeout','onloadend']) {\n` + - ` if (typeof payload[key] === 'function') { callbacks[key] = payload[key]; delete payload[key]; }\n` + - ` }\n` + - ` const onResponse = (event) => {\n` + - ` const detail = event.detail || {};\n` + - ` if (detail.id !== id) return;\n` + - ` if (detail.type === 'xhr-event') {\n` + - ` const cb = callbacks[detail.handler];\n` + - ` if (typeof cb === 'function') cb(detail.response);\n` + - ` if (__US_terminalXhrHandlers.has(detail.handler)) document.removeEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + - ` return;\n` + - ` }\n` + - ` if (detail.type === 'error') {\n` + - ` document.removeEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + - ` if (typeof callbacks.onerror === 'function') callbacks.onerror({ error: detail.error });\n` + - ` }\n` + - ` };\n` + - ` document.addEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + - ` document.dispatchEvent(new CustomEvent(__US_REQUEST_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id, method: 'GM_xmlhttpRequest', args: [payload] } }));\n` + - ` return { abort() { document.removeEventListener(__US_RESPONSE_EVENT__, onResponse); document.dispatchEvent(new CustomEvent(__US_ABORT_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id } })); } };\n` + - `}\n` + - `const GM_addStyle = (css) => __US_callGrant('GM_addStyle', [css]);\n` + - `const GM_setValue = (key, value) => __US_callGrant('GM_setValue', [key, value]);\n` + - `const GM_getValue = (key, defaultValue) => __US_callGrant('GM_getValue', [key, defaultValue]);\n` + - `const GM_deleteValue = (key) => __US_callGrant('GM_deleteValue', [key]);\n` + - `const GM_listValues = () => __US_callGrant('GM_listValues');\n` + - `GM.xmlHttpRequest = (details) => new Promise((resolve, reject) => {\n` + - ` GM_xmlhttpRequest({ ...(details || {}), onloadend: resolve, onerror: reject, ontimeout: reject, onabort: reject });\n` + - `});\n` + - `GM.xmlhttpRequest = GM.xmlHttpRequest;\n` + - `GM.addStyle = GM_addStyle; GM.setValue = GM_setValue; GM.getValue = GM_getValue; GM.deleteValue = GM_deleteValue; GM.listValues = GM_listValues;\n` + (hasXmlHttpRequest + ? `function GM_xmlhttpRequest(details) {\n` + + ` const id = __US_randomId();\n` + + ` const callbacks = {};\n` + + ` const payload = { ...(details || {}) };\n` + + ` for (const key of ['onreadystatechange','onloadstart','onprogress','onabort','onerror','onload','ontimeout','onloadend']) {\n` + + ` if (typeof payload[key] === 'function') { callbacks[key] = payload[key]; delete payload[key]; }\n` + + ` }\n` + + ` const onResponse = (event) => {\n` + + ` const detail = event.detail || {};\n` + + ` if (detail.id !== id) return;\n` + + ` if (detail.type === 'xhr-event') {\n` + + ` const cb = callbacks[detail.handler];\n` + + ` if (typeof cb === 'function') cb(__US_restoreXhrResponse(detail.response));\n` + + ` if (__US_terminalXhrHandlers.has(detail.handler)) document.removeEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + + ` return;\n` + + ` }\n` + + ` if (detail.type === 'error') {\n` + + ` document.removeEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + + ` if (typeof callbacks.onerror === 'function') callbacks.onerror({ error: detail.error });\n` + + ` }\n` + + ` };\n` + + ` document.addEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + + ` document.dispatchEvent(new CustomEvent(__US_REQUEST_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id, method: 'GM_xmlhttpRequest', args: [payload] } }));\n` + + ` return { abort() { document.removeEventListener(__US_RESPONSE_EVENT__, onResponse); document.dispatchEvent(new CustomEvent(__US_ABORT_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id } })); } };\n` + + `}\n` + + `GM.xmlHttpRequest = (details) => new Promise((resolve, reject) => {\n` + + ` GM_xmlhttpRequest({ ...(details || {}), onloadend: resolve, onerror: reject, ontimeout: reject, onabort: reject });\n` + + `});\n` + + `GM.xmlhttpRequest = GM.xmlHttpRequest;\n` + : "") + + methodWrapperCode + + gmAssignmentCode ); } From aa3c737fcb40d0755b72119da0546967a65481e8 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:14:03 +0200 Subject: [PATCH 17/24] Update entry-userscripts.js --- src/ext/content-scripts/entry-userscripts.js | 219 ++++++++++++++++++- 1 file changed, 216 insertions(+), 3 deletions(-) diff --git a/src/ext/content-scripts/entry-userscripts.js b/src/ext/content-scripts/entry-userscripts.js index 5631f77d..edcecc35 100644 --- a/src/ext/content-scripts/entry-userscripts.js +++ b/src/ext/content-scripts/entry-userscripts.js @@ -23,6 +23,182 @@ function pageGrantBridgeEventName(id, type) { return `__userscripts_page_grant_bridge_${id}_${type}__`; } +function __US_getTypedArrayConstructor(viewName) { + const typedArrayConstructors = { + Int8Array, + Uint8Array, + Uint8ClampedArray, + Int16Array, + Uint16Array, + Int32Array, + Uint32Array, + Float32Array, + Float64Array, + BigInt64Array: + typeof BigInt64Array === "function" ? BigInt64Array : undefined, + BigUint64Array: + typeof BigUint64Array === "function" ? BigUint64Array : undefined, + DataView, + }; + return typedArrayConstructors[viewName] || Uint8Array; +} + +function __US_restoreTypedArrayView(data, viewName) { + const bytes = new Uint8Array(Array.isArray(data) ? data : []); + const TypedArrayConstructor = __US_getTypedArrayConstructor(viewName); + if (TypedArrayConstructor === DataView) { + return new DataView(bytes.buffer); + } + if ( + typeof TypedArrayConstructor?.BYTES_PER_ELEMENT === "number" && + TypedArrayConstructor.BYTES_PER_ELEMENT > 0 && + bytes.byteLength % TypedArrayConstructor.BYTES_PER_ELEMENT === 0 + ) { + return new TypedArrayConstructor(bytes.buffer); + } + return bytes; +} + +async function __US_serializeBridgeRequestData(value) { + if (typeof value === "undefined") return undefined; + if (typeof value === "string") { + return { __userscriptsRequestType: "Text", data: value }; + } + if ( + typeof ReadableStream === "function" && + value instanceof ReadableStream + ) { + throw new Error("ReadableStream is not supported by XMLHttpRequest"); + } + if (value instanceof Document) { + if (value instanceof XMLDocument) { + return { + __userscriptsRequestType: "Document", + data: new XMLSerializer().serializeToString(value), + mimeType: value.contentType || "text/xml", + }; + } + let html = value.documentElement?.outerHTML || ""; + if (value.doctype) { + html = `${html}`; + } + return { + __userscriptsRequestType: "Document", + data: html, + mimeType: value.contentType || "text/html", + }; + } + if (typeof File === "function" && value instanceof File) { + return { + __userscriptsRequestType: "File", + data: Array.from(new Uint8Array(await value.arrayBuffer())), + mimeType: value.type || "", + name: value.name, + lastModified: value.lastModified, + }; + } + if (value instanceof Blob) { + return { + __userscriptsRequestType: "Blob", + data: Array.from(new Uint8Array(await value.arrayBuffer())), + mimeType: value.type || "", + }; + } + if (value instanceof ArrayBuffer) { + return { + __userscriptsRequestType: "ArrayBuffer", + data: Array.from(new Uint8Array(value)), + }; + } + if (ArrayBuffer.isView(value)) { + return { + __userscriptsRequestType: "ArrayBufferView", + data: Array.from( + new Uint8Array(value.buffer, value.byteOffset, value.byteLength), + ), + view: value.constructor?.name || "Uint8Array", + }; + } + if (value instanceof FormData) { + const entries = []; + for (const [key, entryValue] of value.entries()) { + if (typeof entryValue === "string") { + entries.push([key, entryValue]); + } else { + entries.push([key, await __US_serializeBridgeRequestData(entryValue)]); + } + } + return { + __userscriptsRequestType: "FormData", + data: entries, + }; + } + if (value instanceof URLSearchParams) { + return { + __userscriptsRequestType: "URLSearchParams", + data: value.toString(), + }; + } + return value; +} + +function __US_restoreBridgeRequestData(value) { + if ( + !value || + typeof value !== "object" || + !value.__userscriptsRequestType + ) { + return value; + } + switch (value.__userscriptsRequestType) { + case "Text": + return String(value.data ?? ""); + case "Document": { + const parser = new DOMParser(); + const mimeType = + typeof value.mimeType === "string" && value.mimeType.includes("html") + ? "text/html" + : "text/xml"; + return parser.parseFromString(String(value.data || ""), mimeType); + } + case "File": { + const fileBytes = new Uint8Array(Array.isArray(value.data) ? value.data : []); + if (typeof File === "function") { + return new File([fileBytes], value.name || "file", { + type: value.mimeType || "", + lastModified: Number(value.lastModified) || Date.now(), + }); + } + return new Blob([fileBytes], { type: value.mimeType || "" }); + } + case "Blob": + return new Blob( + [new Uint8Array(Array.isArray(value.data) ? value.data : [])], + { type: value.mimeType || "" }, + ); + case "ArrayBuffer": + return new Uint8Array(Array.isArray(value.data) ? value.data : []).buffer; + case "ArrayBufferView": + return __US_restoreTypedArrayView(value.data, value.view); + case "FormData": { + const formData = new FormData(); + for (const [key, entryValue] of Array.isArray(value.data) ? value.data : []) { + formData.append( + key, + typeof entryValue === "string" + ? entryValue + : __US_restoreBridgeRequestData(entryValue), + ); + } + return formData; + } + case "URLSearchParams": + return new URLSearchParams(String(value.data || "")); + default: + return value; + } +} + const PAGE_BRIDGE_FILENAME_BOUND_METHODS = new Set([ "setValue", "getValue", @@ -137,6 +313,15 @@ async function serializeXhrResponseValue(value, responseType, contentType) { view: value.constructor?.name || "Uint8Array", }; } + if (typeof File === "function" && value instanceof File) { + return { + __userscriptsType: "File", + data: Array.from(new Uint8Array(await value.arrayBuffer())), + mimeType: value.type || contentType || "", + name: value.name, + lastModified: value.lastModified, + }; + } if (value instanceof Blob) { return { __userscriptsType: "Blob", @@ -221,6 +406,9 @@ function installPageGrantBridge(userscript, grants) { try { if (normalizePageGrantMethod(method) === "GM_xmlhttpRequest") { const details = { ...(args[0] || {}) }; + if ("data" in details) { + details.data = __US_restoreBridgeRequestData(details.data); + } for (const handler of [ "onreadystatechange", "onloadstart", @@ -294,6 +482,10 @@ function getPageGrantClientPreamble(userscript) { `const __US_ABORT_EVENT__ = ${JSON.stringify(bridge.abortEvent)};\n` + `const GM_info = ${JSON.stringify(info)};\n` + `const GM = { info: GM_info };\n` + + `${__US_getTypedArrayConstructor.toString()}\n` + + `${__US_restoreTypedArrayView.toString()}\n` + + `${__US_serializeBridgeRequestData.toString()}\n` + + `${__US_restoreBridgeRequestData.toString()}\n` + `const __US_randomId = () => Date.now().toString(36) + '_' + Math.random().toString(36).slice(2);\n` + `const __US_terminalXhrHandlers = new Set(['onloadend','onabort','onerror','ontimeout']);\n` + `const __US_parseHeaders = (raw) => {\n` + @@ -309,7 +501,14 @@ function getPageGrantClientPreamble(userscript) { ` if (!value || typeof value !== 'object' || !value.__userscriptsType) return value;\n` + ` const mimeType = contentType || (__US_parseHeaders(responseHeaders)['content-type'] || '');\n` + ` if (value.__userscriptsType === 'ArrayBuffer') return new Uint8Array(value.data || []).buffer;\n` + - ` if (value.__userscriptsType === 'ArrayBufferView') return new Uint8Array(value.data || []);\n` + + ` if (value.__userscriptsType === 'ArrayBufferView') return __US_restoreTypedArrayView(value.data, value.view);\n` + + ` if (value.__userscriptsType === 'File') {\n` + + ` const fileBytes = new Uint8Array(value.data || []);\n` + + ` if (typeof File === 'function') {\n` + + ` return new File([fileBytes], value.name || 'file', { type: value.mimeType || mimeType || '', lastModified: Number(value.lastModified) || Date.now() });\n` + + ` }\n` + + ` return new Blob([fileBytes], { type: value.mimeType || mimeType || '' });\n` + + ` }\n` + ` if (value.__userscriptsType === 'Blob') return new Blob([new Uint8Array(value.data || [])], { type: value.mimeType || mimeType || '' });\n` + ` if (value.__userscriptsType === 'Document') {\n` + ` const parser = new DOMParser();\n` + @@ -348,6 +547,8 @@ function getPageGrantClientPreamble(userscript) { ` const id = __US_randomId();\n` + ` const callbacks = {};\n` + ` const payload = { ...(details || {}) };\n` + + ` let requestStarted = false;\n` + + ` let requestCancelled = false;\n` + ` for (const key of ['onreadystatechange','onloadstart','onprogress','onabort','onerror','onload','ontimeout','onloadend']) {\n` + ` if (typeof payload[key] === 'function') { callbacks[key] = payload[key]; delete payload[key]; }\n` + ` }\n` + @@ -366,8 +567,20 @@ function getPageGrantClientPreamble(userscript) { ` }\n` + ` };\n` + ` document.addEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + - ` document.dispatchEvent(new CustomEvent(__US_REQUEST_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id, method: 'GM_xmlhttpRequest', args: [payload] } }));\n` + - ` return { abort() { document.removeEventListener(__US_RESPONSE_EVENT__, onResponse); document.dispatchEvent(new CustomEvent(__US_ABORT_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id } })); } };\n` + + ` (async () => {\n` + + ` try {\n` + + ` if ('data' in payload) payload.data = await __US_serializeBridgeRequestData(payload.data);\n` + + ` if (requestCancelled) return;\n` + + ` requestStarted = true;\n` + + ` document.dispatchEvent(new CustomEvent(__US_REQUEST_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id, method: 'GM_xmlhttpRequest', args: [payload] } }));\n` + + ` } catch (error) {\n` + + ` document.removeEventListener(__US_RESPONSE_EVENT__, onResponse);\n` + + ` const errorObj = { error: String(error?.message || error) };\n` + + ` if (typeof callbacks.onerror === 'function') callbacks.onerror(errorObj);\n` + + ` if (typeof callbacks.onloadend === 'function') callbacks.onloadend(errorObj);\n` + + ` }\n` + + ` })();\n` + + ` return { abort() { document.removeEventListener(__US_RESPONSE_EVENT__, onResponse); if (!requestStarted) { requestCancelled = true; return; } document.dispatchEvent(new CustomEvent(__US_ABORT_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id } })); } };\n` + `}\n` + `GM.xmlHttpRequest = (details) => new Promise((resolve, reject) => {\n` + ` GM_xmlhttpRequest({ ...(details || {}), onloadend: resolve, onerror: reject, ontimeout: reject, onabort: reject });\n` + From 17b6c5ae7435a738e9551553bdf32b933867f121 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:11:03 +0200 Subject: [PATCH 18/24] Update entry-userscripts.js --- src/ext/content-scripts/entry-userscripts.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ext/content-scripts/entry-userscripts.js b/src/ext/content-scripts/entry-userscripts.js index edcecc35..2196301c 100644 --- a/src/ext/content-scripts/entry-userscripts.js +++ b/src/ext/content-scripts/entry-userscripts.js @@ -482,10 +482,10 @@ function getPageGrantClientPreamble(userscript) { `const __US_ABORT_EVENT__ = ${JSON.stringify(bridge.abortEvent)};\n` + `const GM_info = ${JSON.stringify(info)};\n` + `const GM = { info: GM_info };\n` + - `${__US_getTypedArrayConstructor.toString()}\n` + - `${__US_restoreTypedArrayView.toString()}\n` + - `${__US_serializeBridgeRequestData.toString()}\n` + - `${__US_restoreBridgeRequestData.toString()}\n` + + `const __US_getTypedArrayConstructor = ${__US_getTypedArrayConstructor.toString()};\n` + + `const __US_restoreTypedArrayView = ${__US_restoreTypedArrayView.toString()};\n` + + `const __US_serializeBridgeRequestData = ${__US_serializeBridgeRequestData.toString()};\n` + + `const __US_restoreBridgeRequestData = ${__US_restoreBridgeRequestData.toString()};\n` + `const __US_randomId = () => Date.now().toString(36) + '_' + Math.random().toString(36).slice(2);\n` + `const __US_terminalXhrHandlers = new Set(['onloadend','onabort','onerror','ontimeout']);\n` + `const __US_parseHeaders = (raw) => {\n` + From 08f8fcc9f9be8e1903cb29fe785aa62f23c1ae49 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Mon, 6 Jul 2026 03:48:57 +0200 Subject: [PATCH 19/24] Update api.js --- src/ext/content-scripts/api.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ext/content-scripts/api.js b/src/ext/content-scripts/api.js index e2bf4cdb..87a649ae 100644 --- a/src/ext/content-scripts/api.js +++ b/src/ext/content-scripts/api.js @@ -231,7 +231,9 @@ async function xhrDataProcessor(data) { } if (ArrayBuffer.isView(data)) { return { - data: Array.from(new Uint8Array(data.buffer)), + data: Array.from( + new Uint8Array(data.buffer, data.byteOffset, data.byteLength), + ), type: "ArrayBufferView", }; } From c6e9c59834893237617df96fcbcb7a87651bec0f Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:57:38 +0200 Subject: [PATCH 20/24] Create build-macos-intel-adhoc.yml --- .github/workflows/build-macos-intel-adhoc.yml | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/build-macos-intel-adhoc.yml diff --git a/.github/workflows/build-macos-intel-adhoc.yml b/.github/workflows/build-macos-intel-adhoc.yml new file mode 100644 index 00000000..e65fef86 --- /dev/null +++ b/.github/workflows/build-macos-intel-adhoc.yml @@ -0,0 +1,65 @@ +name: Userscripts-Debug-macOS-Intel + +on: + workflow_dispatch: + +jobs: + build: + runs-on: macos-15-intel + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - run: | + if ! git describe --tags --abbrev=0 >/dev/null 2>&1; then + git tag v4.8.6 + fi + + - run: npm run build:mac-safari-15 + + - run: | + xcodebuild \ + -project xcode/Userscripts.xcodeproj \ + -scheme Mac \ + -configuration Debug \ + -derivedDataPath build \ + -destination 'platform=macOS,arch=x86_64' \ + ARCHS=x86_64 \ + ONLY_ACTIVE_ARCH=NO \ + CODE_SIGNING_ALLOWED=YES \ + CODE_SIGNING_REQUIRED=YES \ + CODE_SIGN_IDENTITY="-" \ + clean build + + - run: | + cd build/Build/Products/Debug + + find Userscripts-Debug.app -name "*.framework" -exec \ + codesign --force --sign - {} \; + + find Userscripts-Debug.app -name "*.appex" -exec \ + codesign --force --sign - {} \; + + codesign --force --sign - Userscripts-Debug.app + + codesign --verify --deep --strict --verbose=4 Userscripts-Debug.app + codesign -dv --verbose=4 Userscripts-Debug.app + + - run: | + cd build/Build/Products/Debug + ditto -c -k --sequesterRsrc --keepParent Userscripts-Debug.app Userscripts-Debug-macOS-Intel-adhoc.zip + + - uses: actions/upload-artifact@v4 + with: + name: Userscripts-Debug-macOS-Intel-adhoc + path: build/Build/Products/Debug/Userscripts-Debug-macOS-Intel-adhoc.zip From 60b09186d54fd93d0b84987a65b70b01456a7f67 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:26:37 +0200 Subject: [PATCH 21/24] Update main.js --- src/ext/background/main.js | 382 +++++++++++++++++++++++++++++-------- 1 file changed, 301 insertions(+), 81 deletions(-) diff --git a/src/ext/background/main.js b/src/ext/background/main.js index 761eda72..30ceb0be 100644 --- a/src/ext/background/main.js +++ b/src/ext/background/main.js @@ -1,6 +1,189 @@ -import { contentScriptRegistration, openExtensionPage } from "@ext/utils.js"; -import * as settingsStorage from "@ext/settings.js"; -import { connectNative, sendNativeMessage } from "@ext/native.js"; +import { openExtensionPage } from "../shared/utils.js"; +import * as settingsStorage from "../shared/settings.js"; +import { connectNative, sendNativeMessage } from "../shared/native.js"; + +const VOT_YANDEX_API_HOST = "api.browser.yandex.ru"; +const VOT_DNR_RULE_ID_YANDEX_HEADERS = 910001; +const VOT_ALLOWED_UA_CH_HEADERS = new Set([ + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "sec-ch-ua-full-version-list", +]); +const VOT_SUPPRESSED_UA_CH_HEADERS = [ + "sec-ch-ua-full-version", + "sec-ch-ua-platform-version", + "sec-ch-ua-arch", + "sec-ch-ua-bitness", + "sec-ch-ua-model", + "sec-ch-ua-wow64", +]; +const votDnrAppliedSignatures = new Map(); +let votDnrRuleUpdateQueue = Promise.resolve(); + +function normalizeHeaderName(name) { + return String(name || "").trim(); +} + +function hasDnr() { + return Boolean(browser?.declarativeNetRequest?.updateSessionRules); +} + +function updateSessionRulesCompat(args) { + const dnr = browser?.declarativeNetRequest; + const updateSessionRules = dnr?.updateSessionRules; + if (typeof updateSessionRules !== "function") { + return Promise.resolve(); + } + try { + const maybe = updateSessionRules({ + addRules: args.addRules || [], + removeRuleIds: args.removeRuleIds || [], + }); + if (maybe && typeof maybe.then === "function") { + return maybe; + } + } catch { + // fall through to callback style + } + return new Promise((resolve, reject) => { + try { + updateSessionRules( + { + addRules: args.addRules || [], + removeRuleIds: args.removeRuleIds || [], + }, + () => { + const err = browser.runtime?.lastError?.message; + if (err) reject(new Error(err)); + else resolve(); + }, + ); + } catch (error) { + reject(error); + } + }); +} + +function signatureFromDnrRequestHeaders(requestHeaders) { + const entries = requestHeaders + .map((header) => [ + `${normalizeHeaderName(String(header.header)).toLowerCase()}:${String(header.operation)}`, + String("value" in header ? header.value : ""), + ]) + .sort((a, b) => a[0].localeCompare(b[0])); + return JSON.stringify(entries); +} + +function queueVotDnrRuleUpdate(ruleId, signature, rule) { + if (signature === votDnrAppliedSignatures.get(ruleId)) { + return Promise.resolve(); + } + + votDnrRuleUpdateQueue = votDnrRuleUpdateQueue + .catch(() => undefined) + .then(async () => { + if (signature === votDnrAppliedSignatures.get(ruleId)) return; + await updateSessionRulesCompat({ + removeRuleIds: [ruleId], + addRules: [rule], + }); + votDnrAppliedSignatures.set(ruleId, signature); + }); + + return votDnrRuleUpdateQueue; +} + +function isVotYandexApiUrl(url) { + try { + const parsed = new URL(url); + return ( + parsed.protocol === "https:" && + String(parsed.hostname || "").toLowerCase() === VOT_YANDEX_API_HOST + ); + } catch { + return false; + } +} + +function isForbiddenToSetViaXhr(headerName) { + const name = normalizeHeaderName(headerName).toLowerCase(); + if (!name) return false; + if (name.startsWith("sec-")) return true; + if (name.startsWith("proxy-")) return true; + if (name === "user-agent") return true; + if (name === "origin") return true; + if (name === "referer") return true; + return false; +} + +function shouldStripVotYandexHeader(name) { + const normalized = normalizeHeaderName(name).toLowerCase(); + if (!normalized) return false; + if (normalized === "origin" || normalized === "referer") return true; + if (VOT_SUPPRESSED_UA_CH_HEADERS.includes(normalized)) return true; + if ( + normalized.startsWith("sec-ch-ua") && + !VOT_ALLOWED_UA_CH_HEADERS.has(normalized) + ) { + return true; + } + return false; +} + +function filterVotYandexHeadersForDnr(headers) { + const result = {}; + for (const [key, value] of Object.entries(headers || {})) { + const normalized = normalizeHeaderName(key); + if (!normalized) continue; + if (shouldStripVotYandexHeader(normalized)) continue; + result[normalized] = String(value); + } + return result; +} + +async function ensureVotYandexHeaderRule(url, forbiddenHeaders) { + if (!hasDnr()) return; + if (!isVotYandexApiUrl(url)) return; + + const requestHeaders = [ + { header: "Origin", operation: "remove" }, + { header: "Referer", operation: "remove" }, + ...VOT_SUPPRESSED_UA_CH_HEADERS.map((header) => ({ + header, + operation: "remove", + })), + ]; + + const headersToSet = filterVotYandexHeadersForDnr(forbiddenHeaders); + for (const [header, value] of Object.entries(headersToSet)) { + requestHeaders.push({ + header: normalizeHeaderName(header), + operation: "set", + value: String(value), + }); + } + + const signature = signatureFromDnrRequestHeaders(requestHeaders); + const rule = { + id: VOT_DNR_RULE_ID_YANDEX_HEADERS, + priority: 1, + action: { + type: "modifyHeaders", + requestHeaders, + }, + condition: { + urlFilter: "|https://api.browser.yandex.ru/", + resourceTypes: ["xmlhttprequest"], + }, + }; + + await queueVotDnrRuleUpdate( + VOT_DNR_RULE_ID_YANDEX_HEADERS, + signature, + rule, + ); +} // first sorts files by run-at value, then by weight value function userscriptSort(a, b) { @@ -61,18 +244,29 @@ function setClipboard(data, type = "text/plain") { } async function setBadgeCount() { - const clearBadge = () => browser.browserAction.setBadgeText({ text: null }); + const clearBadge = () => { + if (import.meta.env.SAFARI_VERSION < 16.4) { + browser.browserAction.setBadgeText({ text: "" }); + } else { + browser.browserAction.setBadgeText({ text: null }); + } + }; // @todo until better introduce in ios, only set badge on macOS - // set a text badge or an empty string in visionOS will cause the extension's icon to no longer be displayed const platform = await getPlatform(); + // set a text badge or an empty string in visionOS will cause the extension's icon to no longer be displayed + // set it to null to fix already affected users + if (platform === "visionos") { + browser.browserAction.setBadgeText({ text: null }); + return; + } if (platform !== "macos") return clearBadge(); // @todo settingsStorage.get("global_exclude_match") const settings = await settingsStorage.get([ "global_active", "toolbar_badge_count", ]); - if (settings["global_active"] === false) return clearBadge(); - if (settings["toolbar_badge_count"] === false) return clearBadge(); + if (settings.global_active === false) return clearBadge(); + if (settings.toolbar_badge_count === false) return clearBadge(); const currentTab = await browser.tabs.getCurrent(); // no active tabs exist (user closed all windows) @@ -113,82 +307,71 @@ async function setBadgeCount() { } } -// on startup set declarativeNetRequest rulesets +// on startup get declarativeNetRequests +// and set the requests for the session // should also check and refresh when: // 1. dnr item save event in the page occurs // 2. dnr item toggle event in the page occurs // 3. external editor changes script file content -async function setDNRRulesets() { +async function setSessionRules() { // not supported below safari 15.4 - if (!browser.declarativeNetRequest.updateDynamicRules) return; + if (!browser.declarativeNetRequest.updateSessionRules) return; + await clearAllSessionRules(); const message = { name: "REQ_REQUESTS" }; const response = await sendNativeMessage(message); if (response.error) { console.error(response.error); return; } + // there are no rules to apply + if (!response.length) return; // loop through response, parse the rules, push to array and log - /** @type {import("webextension-polyfill").DeclarativeNetRequest.Rule[]} */ - const addRules = []; - let ruleId = 1; + const rules = []; for (let i = 0; i < response.length; i++) { - if ( - ruleId > - browser.declarativeNetRequest.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES - ) { - console.warn( - "Rules exceed the maximum number, some rules will be ignored", - ); - break; - } - const ruleset = response[i]; - /** @type {Array} */ - let rules; - try { - const res = JSON.parse(ruleset.code); - // check if an array or single rule - if (Array.isArray(res)) { - rules = res; - } else if (typeof res === "object") { - rules = [res]; - } else { - console.warn(`Not a valid DNR ruleset: ${ruleset.name}`); - continue; - } - console.info(`Setting DNR ruleset: ${ruleset.name} (${rules.length})`); - } catch (error) { - console.warn( - `Failed parsed into a valid DNR ruleset: ${ruleset.name}`, - error, - ); - continue; - } - for (const rule of rules) { - // simple check if it is a rule object - if (!rule.action || !rule.condition || !rule.id) { - console.warn("Not a valid DNR rule:", rule); - continue; - } - // set unique ids for all rules to ensure no repeats - rule.id = ruleId++; - addRules.push(rule); + const rule = response[i]; + const code = JSON.parse(rule.code); + // check if an array or single rule + if (Array.isArray(code)) { + code.forEach((r) => rules.push(r)); + console.info(`Setting session rule: ${rule.name} (${code.length})`); + } else { + rules.push(code); + console.info(`Setting session rule: ${rule.name}`); } } - // remove all then add declarativeNetRequest rules + // generate unique ids for all rules to ensure no repeats + const ids = randomNumberSet(1000, rules.length); + rules.map((rule, index) => (rule.id = ids[index])); try { - const oldRules = await browser.declarativeNetRequest.getDynamicRules(); - const removeRuleIds = oldRules.map((rule) => rule.id); - await browser.declarativeNetRequest.updateDynamicRules({ - addRules, - removeRuleIds, - }); + await browser.declarativeNetRequest.updateSessionRules({ addRules: rules }); } catch (error) { - return console.error(error); + console.error(`Error setting session rules: ${error}`); + return; } - console.info(`Finished setting ${addRules.length} DNR rules`); + console.info(`Finished setting ${rules.length} session rules`); +} + +async function clearAllSessionRules() { + const rules = await browser.declarativeNetRequest.getSessionRules(); + if (!rules.length) return; + console.info(`Clearing ${rules.length} session rules`); + const ruleIds = rules.map((a) => a.id); + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: ruleIds, + }); } -// the current update logic is similar to setDNRRulesets() +function randomNumberSet(max, count) { + // generates a set of random unique numbers + // returns an array + const numbers = new Set(); + while (numbers.size < count) { + numbers.add(Math.floor(Math.random() * (max - 1 + 1)) + 1); + } + return [...numbers]; +} + +// the current update logic is similar to setSessionRules() // this feature needs a more detailed redesign in the future // https://github.com/quoid/userscripts/issues/453 async function getContextMenuItems() { @@ -221,7 +404,32 @@ async function getContextMenuItems() { } async function addContextMenuItem(userscript) { + // context-menu items persist for a session + // to avoid duplication, when created, save the filename to session storage + const savedItems = sessionStorage.getItem("menu"); + // if the session storage key doesn't exist use empty array + const activeItems = savedItems ? JSON.parse(savedItems) : []; + if (activeItems.indexOf(userscript.scriptObject.filename) !== -1) { + // if already saved, remove it, to get fresh code changes + await browser.menus.remove(userscript.scriptObject.filename); + } + // potential bug? https://developer.apple.com/forums/thread/685273 + // https://stackoverflow.com/q/68431201 + // parse through match values and change pathnames to deal with bug const patterns = userscript.scriptObject.matches; + patterns.forEach((pattern, index) => { + try { + const url = new URL(pattern); + let pathname = url.pathname; + if (pathname.length > 1 && pathname.endsWith("/")) { + pathname = pathname.slice(0, -1); + } + patterns[index] = `${url.protocol}//${url.hostname}${pathname}`; + } catch (error) { + // prevent breaking when non-url pattern present + } + }); + browser.menus.create( { contexts: ["all"], @@ -234,6 +442,9 @@ async function addContextMenuItem(userscript) { if (!browser.menus.onClicked.hasListener(contextClick)) { browser.menus.onClicked.addListener(contextClick); } + // save the context-menu item reference to sessionStorage + const value = JSON.stringify([userscript.scriptObject.filename]); + sessionStorage.setItem("menu", value); }, ); } @@ -537,10 +748,28 @@ async function handleMessage(message, sender) { if (details.overrideMimeType) { xhr.overrideMimeType(details.overrideMimeType); } + const allHeaders = + typeof details.headers === "object" && details.headers + ? details.headers + : {}; + const headers = {}; + const forbiddenHeaders = {}; + for (const [key, value] of Object.entries(allHeaders)) { + if (isForbiddenToSetViaXhr(key)) forbiddenHeaders[key] = value; + else headers[key] = value; + } xhr.open(method, url, true, user, password); + try { + await ensureVotYandexHeaderRule(url, forbiddenHeaders); + } catch (error) { + console.warn( + "[Userscripts][VOT] Failed to apply Yandex header rule; direct transport may break:", + error, + ); + } // must set headers after `xhr.open()`, but before `xhr.send()` - if (typeof details.headers === "object") { - for (const [key, val] of Object.entries(details.headers)) { + if (typeof headers === "object") { + for (const [key, val] of Object.entries(headers)) { xhr.setRequestHeader(key, val); } } @@ -550,30 +779,21 @@ async function handleMessage(message, sender) { } return { status: "fulfilled" }; } - case "REFRESH_DNR_RULES": { - setDNRRulesets(); + case "REFRESH_SESSION_RULES": { + setSessionRules(); break; } case "REFRESH_CONTEXT_MENU_SCRIPTS": { getContextMenuItems(); break; } - case "WEB_USERJS_POPUP": { - const currentTab = await browser.tabs.getCurrent(); - if (currentTab.id === sender.tab.id) { - browser.browserAction.openPopup(); - } - break; - } } } browser.runtime.onInstalled.addListener(async () => { - await nativeChecks(); - const enable = await settingsStorage.get("augmented_userjs_install"); - await contentScriptRegistration(enable); + nativeChecks(); }); browser.runtime.onStartup.addListener(async () => { - setDNRRulesets(); + setSessionRules(); getContextMenuItems(); }); // listens for messages from content script, popup and page @@ -587,17 +807,17 @@ browser.windows.onFocusChanged.addListener(async (windowId) => { } nativeChecks(); setBadgeCount(); - setDNRRulesets(); + setSessionRules(); getContextMenuItems(); }); browser.webNavigation.onCompleted.addListener(setBadgeCount); // handle native app messages const port = connectNative(); -port.onMessage.addListener(async (message) => { +port.onMessage.addListener((message) => { // console.info(message); // DEBUG if (message.name === "SAVE_LOCATION_CHANGED") { - await openExtensionPage(); + openExtensionPage(); if (message?.userInfo?.returnApp === true) { sendNativeMessage({ name: "OPEN_APP" }); } From 61c621a3bbb595c07af2f341a27d7fcc4f1cf458 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:11:29 +0200 Subject: [PATCH 22/24] Update main.js --- src/ext/background/main.js | 227 ++++++++++++++++++++++++++----------- 1 file changed, 162 insertions(+), 65 deletions(-) diff --git a/src/ext/background/main.js b/src/ext/background/main.js index 30ceb0be..cc3cba41 100644 --- a/src/ext/background/main.js +++ b/src/ext/background/main.js @@ -2,24 +2,9 @@ import { openExtensionPage } from "../shared/utils.js"; import * as settingsStorage from "../shared/settings.js"; import { connectNative, sendNativeMessage } from "../shared/native.js"; -const VOT_YANDEX_API_HOST = "api.browser.yandex.ru"; -const VOT_DNR_RULE_ID_YANDEX_HEADERS = 910001; -const VOT_ALLOWED_UA_CH_HEADERS = new Set([ - "sec-ch-ua", - "sec-ch-ua-mobile", - "sec-ch-ua-platform", - "sec-ch-ua-full-version-list", -]); -const VOT_SUPPRESSED_UA_CH_HEADERS = [ - "sec-ch-ua-full-version", - "sec-ch-ua-platform-version", - "sec-ch-ua-arch", - "sec-ch-ua-bitness", - "sec-ch-ua-model", - "sec-ch-ua-wow64", -]; -const votDnrAppliedSignatures = new Map(); -let votDnrRuleUpdateQueue = Promise.resolve(); +const FORBIDDEN_HEADER_RULE_BASE_ID = 910000; +const dnrAppliedSignatures = new Map(); +let dnrRuleUpdateQueue = Promise.resolve(); function normalizeHeaderName(name) { return String(name || "").trim(); @@ -75,23 +60,35 @@ function signatureFromDnrRequestHeaders(requestHeaders) { return JSON.stringify(entries); } -function queueVotDnrRuleUpdate(ruleId, signature, rule) { - if (signature === votDnrAppliedSignatures.get(ruleId)) { +function queueDnrRuleUpdate(ruleId, signature, rule) { + if (signature === dnrAppliedSignatures.get(ruleId)) { return Promise.resolve(); } - votDnrRuleUpdateQueue = votDnrRuleUpdateQueue + dnrRuleUpdateQueue = dnrRuleUpdateQueue .catch(() => undefined) .then(async () => { - if (signature === votDnrAppliedSignatures.get(ruleId)) return; + if (signature === dnrAppliedSignatures.get(ruleId)) return; await updateSessionRulesCompat({ removeRuleIds: [ruleId], addRules: [rule], }); - votDnrAppliedSignatures.set(ruleId, signature); + dnrAppliedSignatures.set(ruleId, signature); }); - return votDnrRuleUpdateQueue; + return dnrRuleUpdateQueue; +} + +function hashStringToInt(value) { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash * 31 + value.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +function makeForbiddenHeaderRuleId(url) { + return FORBIDDEN_HEADER_RULE_BASE_ID + (hashStringToInt(url) % 8000) + 1; } function isVotYandexApiUrl(url) { @@ -99,13 +96,17 @@ function isVotYandexApiUrl(url) { const parsed = new URL(url); return ( parsed.protocol === "https:" && - String(parsed.hostname || "").toLowerCase() === VOT_YANDEX_API_HOST + String(parsed.hostname || "").toLowerCase() === "api.browser.yandex.ru" ); } catch { return false; } } +function shouldDebugVotTransport(url) { + return isVotYandexApiUrl(url); +} + function isForbiddenToSetViaXhr(headerName) { const name = normalizeHeaderName(headerName).toLowerCase(); if (!name) return false; @@ -117,45 +118,40 @@ function isForbiddenToSetViaXhr(headerName) { return false; } -function shouldStripVotYandexHeader(name) { - const normalized = normalizeHeaderName(name).toLowerCase(); - if (!normalized) return false; - if (normalized === "origin" || normalized === "referer") return true; - if (VOT_SUPPRESSED_UA_CH_HEADERS.includes(normalized)) return true; - if ( - normalized.startsWith("sec-ch-ua") && - !VOT_ALLOWED_UA_CH_HEADERS.has(normalized) - ) { - return true; - } - return false; -} - -function filterVotYandexHeadersForDnr(headers) { +function buildForbiddenHeadersForDnr(headers) { const result = {}; + const requestedForbiddenHeaderNames = new Set( + Object.keys(headers || {}).map((key) => normalizeHeaderName(key).toLowerCase()), + ); for (const [key, value] of Object.entries(headers || {})) { const normalized = normalizeHeaderName(key); if (!normalized) continue; - if (shouldStripVotYandexHeader(normalized)) continue; result[normalized] = String(value); } - return result; + return { headersToSet: result, requestedForbiddenHeaderNames }; } -async function ensureVotYandexHeaderRule(url, forbiddenHeaders) { +async function ensureForbiddenHeaderRule(url, forbiddenHeaders) { if (!hasDnr()) return; - if (!isVotYandexApiUrl(url)) return; - - const requestHeaders = [ - { header: "Origin", operation: "remove" }, - { header: "Referer", operation: "remove" }, - ...VOT_SUPPRESSED_UA_CH_HEADERS.map((header) => ({ - header, - operation: "remove", - })), - ]; - - const headersToSet = filterVotYandexHeadersForDnr(forbiddenHeaders); + if (!forbiddenHeaders || !Object.keys(forbiddenHeaders).length) return; + + let parsedUrl; + try { + parsedUrl = new URL(url); + } catch { + return; + } + + const { headersToSet, requestedForbiddenHeaderNames } = + buildForbiddenHeadersForDnr(forbiddenHeaders); + const requestHeaders = []; + + if (!requestedForbiddenHeaderNames.has("origin")) { + requestHeaders.push({ header: "Origin", operation: "remove" }); + } + if (!requestedForbiddenHeaderNames.has("referer")) { + requestHeaders.push({ header: "Referer", operation: "remove" }); + } for (const [header, value] of Object.entries(headersToSet)) { requestHeaders.push({ header: normalizeHeaderName(header), @@ -164,25 +160,26 @@ async function ensureVotYandexHeaderRule(url, forbiddenHeaders) { }); } + if (!requestHeaders.length) return; + const signature = signatureFromDnrRequestHeaders(requestHeaders); + const ruleId = makeForbiddenHeaderRuleId( + `${parsedUrl.origin}${parsedUrl.pathname}`, + ); const rule = { - id: VOT_DNR_RULE_ID_YANDEX_HEADERS, + id: ruleId, priority: 1, action: { type: "modifyHeaders", requestHeaders, }, condition: { - urlFilter: "|https://api.browser.yandex.ru/", + urlFilter: `|${parsedUrl.origin}${parsedUrl.pathname}`, resourceTypes: ["xmlhttprequest"], }, }; - await queueVotDnrRuleUpdate( - VOT_DNR_RULE_ID_YANDEX_HEADERS, - signature, - rule, - ); + await queueDnrRuleUpdate(ruleId, signature, rule); } // first sorts files by run-at value, then by weight value @@ -581,7 +578,15 @@ async function handleMessage(message, sender) { }); // receive messages from content script and process them port.onMessage.addListener((msg) => { - if (msg.name === "ABORT") xhr.abort(); + if (msg.name === "ABORT") { + if (shouldDebugVotTransport(message.details?.url)) { + console.log("[Userscripts][VOT][xhr] received ABORT", { + url: message.details?.url, + method: message.details?.method || "GET", + }); + } + xhr.abort(); + } if (msg.name === "DISCONNECT") port.disconnect(); }); // handle port disconnect and clean tasks @@ -758,12 +763,86 @@ async function handleMessage(message, sender) { if (isForbiddenToSetViaXhr(key)) forbiddenHeaders[key] = value; else headers[key] = value; } + if (shouldDebugVotTransport(url)) { + console.log("[Userscripts][VOT][xhr] start", { + url, + method, + responseType: details.responseType, + timeout: details.timeout, + headers, + forbiddenHeaders, + }); + } + xhr.onabort = () => { + if (shouldDebugVotTransport(url)) { + console.log("[Userscripts][VOT][xhr] onabort", { + url, + method, + readyState: xhr.readyState, + status: xhr.status, + statusText: xhr.statusText, + }); + } + }; + xhr.onerror = () => { + if (shouldDebugVotTransport(url)) { + console.log("[Userscripts][VOT][xhr] onerror", { + url, + method, + readyState: xhr.readyState, + status: xhr.status, + statusText: xhr.statusText, + }); + } + }; + xhr.ontimeout = () => { + if (shouldDebugVotTransport(url)) { + console.log("[Userscripts][VOT][xhr] ontimeout", { + url, + method, + readyState: xhr.readyState, + status: xhr.status, + statusText: xhr.statusText, + }); + } + }; + xhr.onloadstart = () => { + if (shouldDebugVotTransport(url)) { + console.log("[Userscripts][VOT][xhr] onloadstart", { + url, + method, + readyState: xhr.readyState, + }); + } + }; + xhr.onloadend = () => { + if (shouldDebugVotTransport(url)) { + console.log("[Userscripts][VOT][xhr] onloadend", { + url, + method, + readyState: xhr.readyState, + status: xhr.status, + statusText: xhr.statusText, + responseURL: xhr.responseURL, + responseHeaders: xhr.readyState >= xhr.HEADERS_RECEIVED + ? xhr.getAllResponseHeaders() + : "", + }); + } + }; xhr.open(method, url, true, user, password); try { - await ensureVotYandexHeaderRule(url, forbiddenHeaders); + await ensureForbiddenHeaderRule(url, forbiddenHeaders); + if (shouldDebugVotTransport(url)) { + console.log("[Userscripts][VOT][xhr] forbidden-header DNR ready", { + url, + method, + forbiddenHeaderNames: Object.keys(forbiddenHeaders), + }); + } } catch (error) { console.warn( - "[Userscripts][VOT] Failed to apply Yandex header rule; direct transport may break:", + "[Userscripts][VOT] Failed to apply forbidden-header rule; direct transport may break:", error, ); } @@ -773,6 +852,24 @@ async function handleMessage(message, sender) { xhr.setRequestHeader(key, val); } } + if (shouldDebugVotTransport(url)) { + console.log("[Userscripts][VOT][xhr] send", { + url, + method, + headerNames: Object.keys(headers), + forbiddenHeaderNames: Object.keys(forbiddenHeaders), + bodyKind: + body == null + ? "empty" + : body instanceof Uint8Array + ? "Uint8Array" + : body instanceof FormData + ? "FormData" + : body instanceof URLSearchParams + ? "URLSearchParams" + : typeof body, + }); + } xhr.send(body); } catch (error) { console.error(error); From a6e1e3e750135f0e81b7723ff3843e591b50e0aa Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:14:28 +0200 Subject: [PATCH 23/24] Update build-macos-intel.yml --- .github/workflows/build-macos-intel.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-macos-intel.yml b/.github/workflows/build-macos-intel.yml index 1161fe2c..a32c5c20 100644 --- a/.github/workflows/build-macos-intel.yml +++ b/.github/workflows/build-macos-intel.yml @@ -1,4 +1,4 @@ -name: Userscripts-Debug-macOS-Intel-adhoc +name: Userscripts-Debug-macOS-In on: workflow_dispatch: From 29c62874339be06420ff7ed7afeca0fba8c0c7d2 Mon Sep 17 00:00:00 2001 From: Acobat12 <99601810+Acobat12@users.noreply.github.com> Date: Mon, 6 Jul 2026 23:01:51 +0200 Subject: [PATCH 24/24] Update main.js --- src/ext/background/main.js | 481 +++++++------------------------------ 1 file changed, 82 insertions(+), 399 deletions(-) diff --git a/src/ext/background/main.js b/src/ext/background/main.js index cc3cba41..761eda72 100644 --- a/src/ext/background/main.js +++ b/src/ext/background/main.js @@ -1,186 +1,6 @@ -import { openExtensionPage } from "../shared/utils.js"; -import * as settingsStorage from "../shared/settings.js"; -import { connectNative, sendNativeMessage } from "../shared/native.js"; - -const FORBIDDEN_HEADER_RULE_BASE_ID = 910000; -const dnrAppliedSignatures = new Map(); -let dnrRuleUpdateQueue = Promise.resolve(); - -function normalizeHeaderName(name) { - return String(name || "").trim(); -} - -function hasDnr() { - return Boolean(browser?.declarativeNetRequest?.updateSessionRules); -} - -function updateSessionRulesCompat(args) { - const dnr = browser?.declarativeNetRequest; - const updateSessionRules = dnr?.updateSessionRules; - if (typeof updateSessionRules !== "function") { - return Promise.resolve(); - } - try { - const maybe = updateSessionRules({ - addRules: args.addRules || [], - removeRuleIds: args.removeRuleIds || [], - }); - if (maybe && typeof maybe.then === "function") { - return maybe; - } - } catch { - // fall through to callback style - } - return new Promise((resolve, reject) => { - try { - updateSessionRules( - { - addRules: args.addRules || [], - removeRuleIds: args.removeRuleIds || [], - }, - () => { - const err = browser.runtime?.lastError?.message; - if (err) reject(new Error(err)); - else resolve(); - }, - ); - } catch (error) { - reject(error); - } - }); -} - -function signatureFromDnrRequestHeaders(requestHeaders) { - const entries = requestHeaders - .map((header) => [ - `${normalizeHeaderName(String(header.header)).toLowerCase()}:${String(header.operation)}`, - String("value" in header ? header.value : ""), - ]) - .sort((a, b) => a[0].localeCompare(b[0])); - return JSON.stringify(entries); -} - -function queueDnrRuleUpdate(ruleId, signature, rule) { - if (signature === dnrAppliedSignatures.get(ruleId)) { - return Promise.resolve(); - } - - dnrRuleUpdateQueue = dnrRuleUpdateQueue - .catch(() => undefined) - .then(async () => { - if (signature === dnrAppliedSignatures.get(ruleId)) return; - await updateSessionRulesCompat({ - removeRuleIds: [ruleId], - addRules: [rule], - }); - dnrAppliedSignatures.set(ruleId, signature); - }); - - return dnrRuleUpdateQueue; -} - -function hashStringToInt(value) { - let hash = 0; - for (let i = 0; i < value.length; i++) { - hash = (hash * 31 + value.charCodeAt(i)) | 0; - } - return Math.abs(hash); -} - -function makeForbiddenHeaderRuleId(url) { - return FORBIDDEN_HEADER_RULE_BASE_ID + (hashStringToInt(url) % 8000) + 1; -} - -function isVotYandexApiUrl(url) { - try { - const parsed = new URL(url); - return ( - parsed.protocol === "https:" && - String(parsed.hostname || "").toLowerCase() === "api.browser.yandex.ru" - ); - } catch { - return false; - } -} - -function shouldDebugVotTransport(url) { - return isVotYandexApiUrl(url); -} - -function isForbiddenToSetViaXhr(headerName) { - const name = normalizeHeaderName(headerName).toLowerCase(); - if (!name) return false; - if (name.startsWith("sec-")) return true; - if (name.startsWith("proxy-")) return true; - if (name === "user-agent") return true; - if (name === "origin") return true; - if (name === "referer") return true; - return false; -} - -function buildForbiddenHeadersForDnr(headers) { - const result = {}; - const requestedForbiddenHeaderNames = new Set( - Object.keys(headers || {}).map((key) => normalizeHeaderName(key).toLowerCase()), - ); - for (const [key, value] of Object.entries(headers || {})) { - const normalized = normalizeHeaderName(key); - if (!normalized) continue; - result[normalized] = String(value); - } - return { headersToSet: result, requestedForbiddenHeaderNames }; -} - -async function ensureForbiddenHeaderRule(url, forbiddenHeaders) { - if (!hasDnr()) return; - if (!forbiddenHeaders || !Object.keys(forbiddenHeaders).length) return; - - let parsedUrl; - try { - parsedUrl = new URL(url); - } catch { - return; - } - - const { headersToSet, requestedForbiddenHeaderNames } = - buildForbiddenHeadersForDnr(forbiddenHeaders); - const requestHeaders = []; - - if (!requestedForbiddenHeaderNames.has("origin")) { - requestHeaders.push({ header: "Origin", operation: "remove" }); - } - if (!requestedForbiddenHeaderNames.has("referer")) { - requestHeaders.push({ header: "Referer", operation: "remove" }); - } - for (const [header, value] of Object.entries(headersToSet)) { - requestHeaders.push({ - header: normalizeHeaderName(header), - operation: "set", - value: String(value), - }); - } - - if (!requestHeaders.length) return; - - const signature = signatureFromDnrRequestHeaders(requestHeaders); - const ruleId = makeForbiddenHeaderRuleId( - `${parsedUrl.origin}${parsedUrl.pathname}`, - ); - const rule = { - id: ruleId, - priority: 1, - action: { - type: "modifyHeaders", - requestHeaders, - }, - condition: { - urlFilter: `|${parsedUrl.origin}${parsedUrl.pathname}`, - resourceTypes: ["xmlhttprequest"], - }, - }; - - await queueDnrRuleUpdate(ruleId, signature, rule); -} +import { contentScriptRegistration, openExtensionPage } from "@ext/utils.js"; +import * as settingsStorage from "@ext/settings.js"; +import { connectNative, sendNativeMessage } from "@ext/native.js"; // first sorts files by run-at value, then by weight value function userscriptSort(a, b) { @@ -241,29 +61,18 @@ function setClipboard(data, type = "text/plain") { } async function setBadgeCount() { - const clearBadge = () => { - if (import.meta.env.SAFARI_VERSION < 16.4) { - browser.browserAction.setBadgeText({ text: "" }); - } else { - browser.browserAction.setBadgeText({ text: null }); - } - }; + const clearBadge = () => browser.browserAction.setBadgeText({ text: null }); // @todo until better introduce in ios, only set badge on macOS - const platform = await getPlatform(); // set a text badge or an empty string in visionOS will cause the extension's icon to no longer be displayed - // set it to null to fix already affected users - if (platform === "visionos") { - browser.browserAction.setBadgeText({ text: null }); - return; - } + const platform = await getPlatform(); if (platform !== "macos") return clearBadge(); // @todo settingsStorage.get("global_exclude_match") const settings = await settingsStorage.get([ "global_active", "toolbar_badge_count", ]); - if (settings.global_active === false) return clearBadge(); - if (settings.toolbar_badge_count === false) return clearBadge(); + if (settings["global_active"] === false) return clearBadge(); + if (settings["toolbar_badge_count"] === false) return clearBadge(); const currentTab = await browser.tabs.getCurrent(); // no active tabs exist (user closed all windows) @@ -304,71 +113,82 @@ async function setBadgeCount() { } } -// on startup get declarativeNetRequests -// and set the requests for the session +// on startup set declarativeNetRequest rulesets // should also check and refresh when: // 1. dnr item save event in the page occurs // 2. dnr item toggle event in the page occurs // 3. external editor changes script file content -async function setSessionRules() { +async function setDNRRulesets() { // not supported below safari 15.4 - if (!browser.declarativeNetRequest.updateSessionRules) return; - await clearAllSessionRules(); + if (!browser.declarativeNetRequest.updateDynamicRules) return; const message = { name: "REQ_REQUESTS" }; const response = await sendNativeMessage(message); if (response.error) { console.error(response.error); return; } - // there are no rules to apply - if (!response.length) return; // loop through response, parse the rules, push to array and log - const rules = []; + /** @type {import("webextension-polyfill").DeclarativeNetRequest.Rule[]} */ + const addRules = []; + let ruleId = 1; for (let i = 0; i < response.length; i++) { - const rule = response[i]; - const code = JSON.parse(rule.code); - // check if an array or single rule - if (Array.isArray(code)) { - code.forEach((r) => rules.push(r)); - console.info(`Setting session rule: ${rule.name} (${code.length})`); - } else { - rules.push(code); - console.info(`Setting session rule: ${rule.name}`); + if ( + ruleId > + browser.declarativeNetRequest.MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + ) { + console.warn( + "Rules exceed the maximum number, some rules will be ignored", + ); + break; + } + const ruleset = response[i]; + /** @type {Array} */ + let rules; + try { + const res = JSON.parse(ruleset.code); + // check if an array or single rule + if (Array.isArray(res)) { + rules = res; + } else if (typeof res === "object") { + rules = [res]; + } else { + console.warn(`Not a valid DNR ruleset: ${ruleset.name}`); + continue; + } + console.info(`Setting DNR ruleset: ${ruleset.name} (${rules.length})`); + } catch (error) { + console.warn( + `Failed parsed into a valid DNR ruleset: ${ruleset.name}`, + error, + ); + continue; + } + for (const rule of rules) { + // simple check if it is a rule object + if (!rule.action || !rule.condition || !rule.id) { + console.warn("Not a valid DNR rule:", rule); + continue; + } + // set unique ids for all rules to ensure no repeats + rule.id = ruleId++; + addRules.push(rule); } } - // generate unique ids for all rules to ensure no repeats - const ids = randomNumberSet(1000, rules.length); - rules.map((rule, index) => (rule.id = ids[index])); + // remove all then add declarativeNetRequest rules try { - await browser.declarativeNetRequest.updateSessionRules({ addRules: rules }); + const oldRules = await browser.declarativeNetRequest.getDynamicRules(); + const removeRuleIds = oldRules.map((rule) => rule.id); + await browser.declarativeNetRequest.updateDynamicRules({ + addRules, + removeRuleIds, + }); } catch (error) { - console.error(`Error setting session rules: ${error}`); - return; + return console.error(error); } - console.info(`Finished setting ${rules.length} session rules`); -} - -async function clearAllSessionRules() { - const rules = await browser.declarativeNetRequest.getSessionRules(); - if (!rules.length) return; - console.info(`Clearing ${rules.length} session rules`); - const ruleIds = rules.map((a) => a.id); - await browser.declarativeNetRequest.updateSessionRules({ - removeRuleIds: ruleIds, - }); + console.info(`Finished setting ${addRules.length} DNR rules`); } -function randomNumberSet(max, count) { - // generates a set of random unique numbers - // returns an array - const numbers = new Set(); - while (numbers.size < count) { - numbers.add(Math.floor(Math.random() * (max - 1 + 1)) + 1); - } - return [...numbers]; -} - -// the current update logic is similar to setSessionRules() +// the current update logic is similar to setDNRRulesets() // this feature needs a more detailed redesign in the future // https://github.com/quoid/userscripts/issues/453 async function getContextMenuItems() { @@ -401,32 +221,7 @@ async function getContextMenuItems() { } async function addContextMenuItem(userscript) { - // context-menu items persist for a session - // to avoid duplication, when created, save the filename to session storage - const savedItems = sessionStorage.getItem("menu"); - // if the session storage key doesn't exist use empty array - const activeItems = savedItems ? JSON.parse(savedItems) : []; - if (activeItems.indexOf(userscript.scriptObject.filename) !== -1) { - // if already saved, remove it, to get fresh code changes - await browser.menus.remove(userscript.scriptObject.filename); - } - // potential bug? https://developer.apple.com/forums/thread/685273 - // https://stackoverflow.com/q/68431201 - // parse through match values and change pathnames to deal with bug const patterns = userscript.scriptObject.matches; - patterns.forEach((pattern, index) => { - try { - const url = new URL(pattern); - let pathname = url.pathname; - if (pathname.length > 1 && pathname.endsWith("/")) { - pathname = pathname.slice(0, -1); - } - patterns[index] = `${url.protocol}//${url.hostname}${pathname}`; - } catch (error) { - // prevent breaking when non-url pattern present - } - }); - browser.menus.create( { contexts: ["all"], @@ -439,9 +234,6 @@ async function addContextMenuItem(userscript) { if (!browser.menus.onClicked.hasListener(contextClick)) { browser.menus.onClicked.addListener(contextClick); } - // save the context-menu item reference to sessionStorage - const value = JSON.stringify([userscript.scriptObject.filename]); - sessionStorage.setItem("menu", value); }, ); } @@ -578,15 +370,7 @@ async function handleMessage(message, sender) { }); // receive messages from content script and process them port.onMessage.addListener((msg) => { - if (msg.name === "ABORT") { - if (shouldDebugVotTransport(message.details?.url)) { - console.log("[Userscripts][VOT][xhr] received ABORT", { - url: message.details?.url, - method: message.details?.method || "GET", - }); - } - xhr.abort(); - } + if (msg.name === "ABORT") xhr.abort(); if (msg.name === "DISCONNECT") port.disconnect(); }); // handle port disconnect and clean tasks @@ -753,144 +537,43 @@ async function handleMessage(message, sender) { if (details.overrideMimeType) { xhr.overrideMimeType(details.overrideMimeType); } - const allHeaders = - typeof details.headers === "object" && details.headers - ? details.headers - : {}; - const headers = {}; - const forbiddenHeaders = {}; - for (const [key, value] of Object.entries(allHeaders)) { - if (isForbiddenToSetViaXhr(key)) forbiddenHeaders[key] = value; - else headers[key] = value; - } - if (shouldDebugVotTransport(url)) { - console.log("[Userscripts][VOT][xhr] start", { - url, - method, - responseType: details.responseType, - timeout: details.timeout, - headers, - forbiddenHeaders, - }); - } - xhr.onabort = () => { - if (shouldDebugVotTransport(url)) { - console.log("[Userscripts][VOT][xhr] onabort", { - url, - method, - readyState: xhr.readyState, - status: xhr.status, - statusText: xhr.statusText, - }); - } - }; - xhr.onerror = () => { - if (shouldDebugVotTransport(url)) { - console.log("[Userscripts][VOT][xhr] onerror", { - url, - method, - readyState: xhr.readyState, - status: xhr.status, - statusText: xhr.statusText, - }); - } - }; - xhr.ontimeout = () => { - if (shouldDebugVotTransport(url)) { - console.log("[Userscripts][VOT][xhr] ontimeout", { - url, - method, - readyState: xhr.readyState, - status: xhr.status, - statusText: xhr.statusText, - }); - } - }; - xhr.onloadstart = () => { - if (shouldDebugVotTransport(url)) { - console.log("[Userscripts][VOT][xhr] onloadstart", { - url, - method, - readyState: xhr.readyState, - }); - } - }; - xhr.onloadend = () => { - if (shouldDebugVotTransport(url)) { - console.log("[Userscripts][VOT][xhr] onloadend", { - url, - method, - readyState: xhr.readyState, - status: xhr.status, - statusText: xhr.statusText, - responseURL: xhr.responseURL, - responseHeaders: xhr.readyState >= xhr.HEADERS_RECEIVED - ? xhr.getAllResponseHeaders() - : "", - }); - } - }; xhr.open(method, url, true, user, password); - try { - await ensureForbiddenHeaderRule(url, forbiddenHeaders); - if (shouldDebugVotTransport(url)) { - console.log("[Userscripts][VOT][xhr] forbidden-header DNR ready", { - url, - method, - forbiddenHeaderNames: Object.keys(forbiddenHeaders), - }); - } - } catch (error) { - console.warn( - "[Userscripts][VOT] Failed to apply forbidden-header rule; direct transport may break:", - error, - ); - } // must set headers after `xhr.open()`, but before `xhr.send()` - if (typeof headers === "object") { - for (const [key, val] of Object.entries(headers)) { + if (typeof details.headers === "object") { + for (const [key, val] of Object.entries(details.headers)) { xhr.setRequestHeader(key, val); } } - if (shouldDebugVotTransport(url)) { - console.log("[Userscripts][VOT][xhr] send", { - url, - method, - headerNames: Object.keys(headers), - forbiddenHeaderNames: Object.keys(forbiddenHeaders), - bodyKind: - body == null - ? "empty" - : body instanceof Uint8Array - ? "Uint8Array" - : body instanceof FormData - ? "FormData" - : body instanceof URLSearchParams - ? "URLSearchParams" - : typeof body, - }); - } xhr.send(body); } catch (error) { console.error(error); } return { status: "fulfilled" }; } - case "REFRESH_SESSION_RULES": { - setSessionRules(); + case "REFRESH_DNR_RULES": { + setDNRRulesets(); break; } case "REFRESH_CONTEXT_MENU_SCRIPTS": { getContextMenuItems(); break; } + case "WEB_USERJS_POPUP": { + const currentTab = await browser.tabs.getCurrent(); + if (currentTab.id === sender.tab.id) { + browser.browserAction.openPopup(); + } + break; + } } } browser.runtime.onInstalled.addListener(async () => { - nativeChecks(); + await nativeChecks(); + const enable = await settingsStorage.get("augmented_userjs_install"); + await contentScriptRegistration(enable); }); browser.runtime.onStartup.addListener(async () => { - setSessionRules(); + setDNRRulesets(); getContextMenuItems(); }); // listens for messages from content script, popup and page @@ -904,17 +587,17 @@ browser.windows.onFocusChanged.addListener(async (windowId) => { } nativeChecks(); setBadgeCount(); - setSessionRules(); + setDNRRulesets(); getContextMenuItems(); }); browser.webNavigation.onCompleted.addListener(setBadgeCount); // handle native app messages const port = connectNative(); -port.onMessage.addListener((message) => { +port.onMessage.addListener(async (message) => { // console.info(message); // DEBUG if (message.name === "SAVE_LOCATION_CHANGED") { - openExtensionPage(); + await openExtensionPage(); if (message?.userInfo?.returnApp === true) { sendNativeMessage({ name: "OPEN_APP" }); }