diff --git a/.github/workflows/build-local-app.yml b/.github/workflows/build-local-app.yml new file mode 100644 index 00000000..e8959ae3 --- /dev/null +++ b/.github/workflows/build-local-app.yml @@ -0,0 +1,50 @@ +name: Build local 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 + + - name: Install dependencies + run: npm ci + + - name: Add fallback tag + run: git tag v4.8.6 || true + + - name: Build web assets + run: npm run build:mac-safari-15 + + - name: Build Xcode app + 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 \ + 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-Debug-macOS-Intel + path: build/Build/Products/Debug/Userscripts-Debug-macOS-Intel.zip 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 diff --git a/.github/workflows/build-macos-intel.yml b/.github/workflows/build-macos-intel.yml new file mode 100644 index 00000000..a32c5c20 --- /dev/null +++ b/.github/workflows/build-macos-intel.yml @@ -0,0 +1,51 @@ +name: Userscripts-Debug-macOS-In + +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=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 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", }; } diff --git a/src/ext/content-scripts/entry-userscripts.js b/src/ext/content-scripts/entry-userscripts.js index 362bfacb..2196301c 100644 --- a/src/ext/content-scripts/entry-userscripts.js +++ b/src/ext/content-scripts/entry-userscripts.js @@ -17,6 +17,585 @@ 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 __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", + "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 (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", + 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", + "responseURL", + "finalUrl", + "status", + "statusText", + ]) { + if (key === "contentType") { + if (contentType) result.contentType = contentType; + continue; + } + if (key in response) result[key] = response[key]; + } + if ("response" in response) { + result.response = await serializeXhrResponseValue( + response.response, + response.responseType, + contentType, + ); + } + 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 (normalizePageGrantMethod(method) === "GM_xmlhttpRequest") { + const details = { ...(args[0] || {}) }; + if ("data" in details) { + details.data = __US_restoreBridgeRequestData(details.data); + } + for (const handler of [ + "onreadystatechange", + "onloadstart", + "onprogress", + "onabort", + "onerror", + "onload", + "ontimeout", + "onloadend", + ]) { + details[handler] = async (response) => { + respond(id, { + type: "xhr-event", + handler, + response: await serializableXhrResponse(response), + }); + if ( + handler === "onloadend" || + handler === "onabort" || + handler === "onerror" || + handler === "ontimeout" + ) { + xhrControls.delete(id); + } + }; + } + const control = USAPI.GM_xmlhttpRequest(details); + xhrControls.set(id, control); + return; + } + const result = await callPageGrantMethod(method, filename, args); + respond(id, { type: "result", result }); + } 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 || {}; + 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` + + `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_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` + + ` 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 __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` + + ` 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` + + ` const detail = event.detail || {};\n` + + ` if (detail.id !== id) return;\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` + + ` document.dispatchEvent(new CustomEvent(__US_REQUEST_EVENT__, { detail: { bridgeId: __US_BRIDGE_ID__, id, method, args } }));\n` + + `});\n` + + (hasXmlHttpRequest + ? `function GM_xmlhttpRequest(details) {\n` + + ` 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` + + ` 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` + + ` (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` + + `});\n` + + `GM.xmlhttpRequest = GM.xmlHttpRequest;\n` + : "") + + methodWrapperCode + + gmAssignmentCode + ); +} + +function getPageGrantClientPostamble(_userscript) { + return ""; +} + function triageJS(userscript) { const runAt = userscript.scriptObject["run-at"]; if (runAt === "document-start") { @@ -49,12 +628,16 @@ function triageJS(userscript) { function injectJS(userscript) { const filename = userscript.scriptObject.filename; const name = userscript.scriptObject.name; + const pageGrantPreamble = getPageGrantClientPreamble(userscript); + const pageGrantPostamble = getPageGrantClientPostamble(userscript); const code = `\ (async () => { try { +${pageGrantPreamble} // ===UserScript===start=== ${userscript.code} // ===UserScript====end==== +${pageGrantPostamble} } catch (error) { console.error(\`${filename.replaceAll("`", "\\`")}\`, error); } @@ -86,24 +669,24 @@ ${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, + "color: #60f36c", colors.inherit, colors.blue, ); @@ -151,9 +734,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,22 +762,16 @@ 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 - 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`, - ); - } - // @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`, + // @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 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}`, ); } // loop through each userscript @grant value, add methods as needed @@ -279,13 +853,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();