diff --git a/.gitattributes b/.gitattributes index d85906ec..bad24153 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,3 @@ +package-lock.json linguist-generated pnpm-lock.yaml linguist-generated -public linguist-generated -xcode/iOS-App/Base.lproj/Main.html linguist-generated \ No newline at end of file +public linguist-generated \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 41bd0485..89f2044d 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,6 +9,6 @@ jobs: - name: Install modules run: npm install - name: Run ESLint - run: npm run lint-js + run: npm run lint:js - name: Run Stylelint - run: npm run lint-css + run: npm run lint:css diff --git a/.gitignore b/.gitignore index 73b92ed1..0a6c392f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,20 @@ +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temp/Dist/Built +temp +dist +dist-ssr +build +built +*.local + # Logs logs *.log @@ -7,11 +24,11 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +# npm node_modules -temp -dist -dist-ssr -*.local +#package-lock.json +yarn.lock +pnpm-lock.yaml # Editor directories and files .vscode/* @@ -25,20 +42,10 @@ dist-ssr *.sln *.sw? -# OS generated files -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db - # xcode xcuserdata/ *.xcuserstate *.dev.xcconfig XCBuildData xcode/**/build/ -xcode/**/dist/ -# xcode/Userscripts-iOS/Base.lproj/Main.html \ No newline at end of file +xcode/**/dist/ \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d53a574f..080f3fa1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,30 +4,30 @@ "version": "2.0.0", "tasks": [ { - "label": "pnpm: install", + "label": "npm: install", "detail": "Install dev dependencies", "type": "shell", - "command": "pnpm install", + "command": "npm install", "group": "build", "options": { "cwd": "./" } }, { - "label": "pnpm: dev", + "label": "npm: dev", "detail": "Run dev server", "type": "shell", - "command": "pnpm dev", + "command": "npm run dev", "group": "build", "options": { "cwd": "./" } }, { - "label": "pnpm: build", + "label": "npm: build", "detail": "Build to xcode dist path", "type": "shell", - "command": "pnpm build", + "command": "npm run build", "group": "build", "options": { "cwd": "./" @@ -44,14 +44,14 @@ } }, { - "label": "xcode: rebuild", - "detail": "pnpm build + xcodebuild", + "label": "build all", + "detail": "npm build + xcodebuild", "presentation": { "reveal": "silent" }, "dependsOrder": "sequence", "dependsOn": [ - "pnpm: build", + "npm: build", "xcode: build" ], "group": { diff --git a/docs/dev.md b/docs/dev.md index 8a8a331b..85eb1b96 100644 --- a/docs/dev.md +++ b/docs/dev.md @@ -5,18 +5,20 @@ The `xcode` directory is the root of the Xcode project where the Safari extensio For other directory structure instructions, please refer to [structure.md](structure.md). # Environment -- [`Node.js`](https://nodejs.dev/en/learn/how-to-install-nodejs/) -- [`pnpm`](https://pnpm.io/installation) (optional) -- [`Xcode`](https://geo.itunes.apple.com/app/xcode/id497799835) +- [`Node.js`](https://nodejs.org/) +- [`Xcode`](https://geo.itunes.apple.com/app/id497799835) # Dev -- `pnpm install` -- `pnpm dev` +- `npm install`[^1] +- `npm run dev`[^1] # Build -- `pnpm build` +- `npm run build`[^1] - `cd ./xcode` -- `xcodebuild` or build with Xcode App +- `xcodebuild`[^1][^2] or build with `Xcode` App + +[^1]: These commands can also be executed directly through the VSCode Run Task. Please refer to: [/.vscode/tasks.json](../.vscode/tasks.json) +[^2]: Local setup may be required. Please refer to: [Building from the Command Line with Xcode FAQ](https://developer.apple.com/library/archive/technotes/tn2339/_index.html) # Xcode @@ -28,9 +30,9 @@ The Xcode project contains two configurations, which have independent `xcconfig` - `Release`: A developer account is required, which means it can be used for distribution as well as running on real iOS/iPadOS devices. You will need to override your developer account information in `xcconfig` to complete the build. ### xcconfig -All `xcconfig` files are in the `./xcode/xcconfig/` directory. Each `.xcconfig` file can be overridden by `.dev.xcconfig` in the same path, they will be ignored by `git`, so you can override any build settings locally. +All `xcconfig` files are in the [`/xcode/xcconfig/`](../xcode/xcconfig) directory. Each `.xcconfig` file can be overridden by `.dev.xcconfig` in the same path, they will be ignored by `git`, so you can override any build settings locally. -For example, you can create an `Userscripts-Release.dev.xcconfig` file to override `Userscripts-Release.xcconfig` and fill in your own developer account information there: +For example, you can create an `Userscripts-Release.dev.xcconfig` file to override [`Userscripts-Release.xcconfig`](../xcode/xcconfig/Userscripts-Release.xcconfig) and fill in your own developer account information there: `Userscripts-Release.dev.xcconfig` ``` diff --git a/docs/structure.md b/docs/structure.md index 5535ea2a..602cadea 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -11,18 +11,22 @@ Readme references and project history assets Project Static Asset https://vitejs.dev/guide/assets.html#the-public-directory -### `./public/page/jshint.min.js` +### `./public/extension-page/jshint.min.js` CodeMirror 5 `codemirror/addon/lint/javascript-lint.js` depends on `jshint.js` https://codemirror.net/5/doc/manual.html#addon_lint https://github.com/jshint/jshint/blob/main/dist/jshint.js +## `scripts` +Custom build scripts ref in `package.json` +Used to build the source code in `src` below + ## `src` -Project Web App Source Code -Build the project from here to extension resources +Project Web App/Ext Source Code +Build the project from here to xcode resources ## `xcode` -Project Native App Source Code -Build the project from here to extension bundle app +Project Bundled App Source Code +Build the project from here to extension bundled app # About [Userscripts](https://github.com/quoid/userscripts) @ 2018-2023 diff --git a/entry-app-webview.html b/entry-app-webview.html new file mode 100644 index 00000000..10167879 --- /dev/null +++ b/entry-app-webview.html @@ -0,0 +1,14 @@ + + + + + + + + Userscripts App + + +
+ + + diff --git a/entry-popup.html b/entry-ext-action-popup.html similarity index 83% rename from entry-popup.html rename to entry-ext-action-popup.html index 2694df66..e135d374 100644 --- a/entry-popup.html +++ b/entry-ext-action-popup.html @@ -9,6 +9,6 @@
- + diff --git a/entry-page.html b/entry-ext-extension-page.html similarity index 73% rename from entry-page.html rename to entry-ext-extension-page.html index 82a9df6b..fbbce42d 100644 --- a/entry-page.html +++ b/entry-ext-extension-page.html @@ -9,7 +9,7 @@
- - + + diff --git a/etc/App-iOS-WebView.zip b/etc/App-iOS-WebView.zip deleted file mode 100644 index 49863444..00000000 Binary files a/etc/App-iOS-WebView.zip and /dev/null differ diff --git a/index.html b/index.html index b97bad98..a0b12e43 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,7 @@
- + + +
+ Userscripts App Icon + +

You can turn on the Userscripts iOS Safari extension in Settings. + Read the docs. +

+ +
CURRENT DIRECTORY:
+
+ +
+
+ + diff --git a/src/app/app.css b/src/app/app.css new file mode 100644 index 00000000..2aa6eab7 --- /dev/null +++ b/src/app/app.css @@ -0,0 +1,51 @@ +html { + font-size: 100%; + height: 100vh; + overflow: hidden; +} + +body { + background-color: var(--color-bg-secondary); + color: var(--text-color-primary); + font: var(--text-medium); + height: 100%; + letter-spacing: var(--letter-spacing-medium); + position: relative; + text-rendering: optimizelegibility; + -webkit-font-smoothing: antialiased; +} + +@media screen and (height <= 400px) { + html { + font-size: 80%; + } + + p { + font: var(--text-large); + letter-spacing: var(--letter-spacing-large); + } +} + +@media screen and (height >= 700px) and (width >= 600px) { + html { + font-size: 150%; + } +} + +#app { + height: 100%; +} + +button { + border: none; + cursor: pointer; + padding: 0; + user-select: none; +} + +.link { + background: none; + color: var(--color-blue); + cursor: pointer; + text-decoration: underline; +} \ No newline at end of file diff --git a/src/assets/logo.png b/src/app/img/icon.png similarity index 100% rename from src/assets/logo.png rename to src/app/img/icon.png diff --git a/src/shared/img/logo.svg b/src/app/img/logo.svg similarity index 100% rename from src/shared/img/logo.svg rename to src/app/img/logo.svg diff --git a/src/app/main.js b/src/app/main.js new file mode 100644 index 00000000..d7459fa0 --- /dev/null +++ b/src/app/main.js @@ -0,0 +1,10 @@ +import "./reset.css"; +import "./variables.css"; +import "./app.css"; +import App from "./App.svelte"; + +const app = new App({ + target: document.getElementById("app") +}); + +export default app; diff --git a/src/app/reset.css b/src/app/reset.css new file mode 100644 index 00000000..6c181e6b --- /dev/null +++ b/src/app/reset.css @@ -0,0 +1,44 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body, +p { + margin: 0; +} + +ul { + list-style: none; + margin: 0; + padding: 0; +} + +button, +input, +select, +textarea { + font-family: inherit; + font-size: 100%; + line-height: inherit; + margin: 0; +} + +button, +input[type="button"], +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; +} + +::-webkit-search-decoration, +::-webkit-search-cancel-button, +::-webkit-search-results-button, +::-webkit-search-results-decoration { + -webkit-appearance: none; +} + +img { + border-style: none; +} diff --git a/src/shared/variables.css b/src/app/variables.css similarity index 100% rename from src/shared/variables.css rename to src/app/variables.css diff --git a/src/App.svelte b/src/dev/App.svelte similarity index 68% rename from src/App.svelte rename to src/dev/App.svelte index ac5d3e65..1d9c1db6 100644 --- a/src/App.svelte +++ b/src/dev/App.svelte @@ -11,8 +11,9 @@

Userscripts dev

- page, - popup + native-app, + action-popup, + extension-page

diff --git a/src/app.css b/src/dev/app.css similarity index 100% rename from src/app.css rename to src/dev/app.css diff --git a/xcode/Mac-App/Assets.xcassets/AppIcon.appiconset/256-1.png b/src/dev/assets/logo.png similarity index 100% rename from xcode/Mac-App/Assets.xcassets/AppIcon.appiconset/256-1.png rename to src/dev/assets/logo.png diff --git a/src/main.js b/src/dev/main.js similarity index 100% rename from src/main.js rename to src/dev/main.js diff --git a/src/vite-env.d.ts b/src/dev/vite-env.d.ts similarity index 100% rename from src/vite-env.d.ts rename to src/dev/vite-env.d.ts diff --git a/src/popup/App.svelte b/src/ext/action-popup/App.svelte similarity index 100% rename from src/popup/App.svelte rename to src/ext/action-popup/App.svelte diff --git a/src/popup/Components/PopupItem.svelte b/src/ext/action-popup/Components/PopupItem.svelte similarity index 100% rename from src/popup/Components/PopupItem.svelte rename to src/ext/action-popup/Components/PopupItem.svelte diff --git a/src/popup/Components/View.svelte b/src/ext/action-popup/Components/View.svelte similarity index 100% rename from src/popup/Components/View.svelte rename to src/ext/action-popup/Components/View.svelte diff --git a/src/popup/Components/Views/AllItemsView.svelte b/src/ext/action-popup/Components/Views/AllItemsView.svelte similarity index 100% rename from src/popup/Components/Views/AllItemsView.svelte rename to src/ext/action-popup/Components/Views/AllItemsView.svelte diff --git a/src/popup/Components/Views/InstallView.svelte b/src/ext/action-popup/Components/Views/InstallView.svelte similarity index 100% rename from src/popup/Components/Views/InstallView.svelte rename to src/ext/action-popup/Components/Views/InstallView.svelte diff --git a/src/popup/Components/Views/UpdateView.svelte b/src/ext/action-popup/Components/Views/UpdateView.svelte similarity index 100% rename from src/popup/Components/Views/UpdateView.svelte rename to src/ext/action-popup/Components/Views/UpdateView.svelte diff --git a/src/popup/app.css b/src/ext/action-popup/app.css similarity index 100% rename from src/popup/app.css rename to src/ext/action-popup/app.css diff --git a/src/popup/main.js b/src/ext/action-popup/main.js similarity index 91% rename from src/popup/main.js rename to src/ext/action-popup/main.js index 03752163..ed71b808 100644 --- a/src/popup/main.js +++ b/src/ext/action-popup/main.js @@ -1,9 +1,9 @@ -import "./app.css"; import "../shared/reset.css"; import "../shared/variables.css"; +import "./app.css"; import App from "./App.svelte"; -if (import.meta.env.DEV) { // vite feat that only import in dev mode +if (import.meta.env.MODE === "development") { // vite feat that only import in dev mode const modules = import.meta.glob("../shared/dev.js", {eager: true}); window.browser = modules["../shared/dev.js"].browser; console.info("DEV-ENV", import.meta.env, modules, browser); diff --git a/xcode/Safari-Extension/Resources/background.js b/src/ext/background/main.js similarity index 91% rename from xcode/Safari-Extension/Resources/background.js rename to src/ext/background/main.js index 5e0a8feb..d8a57ee3 100644 --- a/xcode/Safari-Extension/Resources/background.js +++ b/src/ext/background/main.js @@ -1,14 +1,6 @@ -// functions from "src/shared/utils.js"; -async function openExtensionPage() { - const url = browser.runtime.getURL("dist/entry-page.html"); - const tabs = await browser.tabs.query({url}); - const tab = tabs.find(e => e.url.startsWith(url)); - if (!tab) return browser.tabs.create({url}); - browser.tabs.update(tab.id, {active: true}); - browser.windows.update(tab.windowId, {focused: true}); -} - -// functions from "src/shared/settings.js"; +import {openExtensionPage} from "../shared/utils.js"; +// eslint-disable-next-line import/no-namespace +import * as settingsStorage from "../shared/settings.js"; // first sorts files by run-at value, then by weight value function userscriptSort(a, b) { @@ -81,11 +73,10 @@ async function setBadgeCount() { // @todo until better introduce in ios, only set badge on macOS const platform = await getPlatform(); if (platform !== "macos") return clearBadge(); - // @todo after the background script is modularized, import and use: - // settingsStorage.get(["global_active","toolbar_badge_count","global_exclude_match"]) - const results = await browser.storage.local.get(["US_GLOBAL_ACTIVE", "US_TOOLBAR_BADGE_COUNT"]); - if (results?.US_GLOBAL_ACTIVE === false) return clearBadge(); - if (results?.US_TOOLBAR_BADGE_COUNT === false) 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(); const currentTab = await browser.tabs.getCurrent(); // no active tabs exist (user closed all windows) @@ -280,12 +271,11 @@ function contextClick(info, tab) { async function nativeChecks() { const response = await browser.runtime.sendNativeMessage({name: "NATIVE_CHECKS"}); - // note: use settings.js once background page modularization if (response.error) { - browser.storage.local.set({US_ERROR_NATIVE: response}); + settingsStorage.set({error_native: response}); return false; } - browser.storage.local.remove("US_ERROR_NATIVE"); + settingsStorage.reset("error_native"); return true; } @@ -414,18 +404,21 @@ function handleMessage(request, sender, sendResponse) { if (["", "text"].indexOf(xhr.responseType) !== -1) { x.responseText = xhr.responseText; } - // need to convert arraybuffer data to postMessage - if (xhr.responseType === "arraybuffer") { - const arr = Array.from(new Uint8Array(xhr.response)); - x.response = arr; - } - // need to blob arraybuffer data to postMessage - if (xhr.responseType === "blob") { - const base64data = await readAsDataURL(xhr.response); - x.response = { - data: base64data, - type: xhr.responseType - }; + // only process when xhr is complete and data exist + if (xhr.readyState === 4 && xhr.response !== null) { + // need to convert arraybuffer data to postMessage + if (xhr.responseType === "arraybuffer") { + const arr = Array.from(new Uint8Array(xhr.response)); + x.response = arr; + } + // need to convert blob data to postMessage + if (xhr.responseType === "blob") { + const base64data = await readAsDataURL(xhr.response); + x.response = { + data: base64data, + type: xhr.responseType + }; + } } port.postMessage({name: e, event, response: x}); }; diff --git a/src/ext/content-scripts/api.js b/src/ext/content-scripts/api.js new file mode 100644 index 00000000..3907cb98 --- /dev/null +++ b/src/ext/content-scripts/api.js @@ -0,0 +1,232 @@ +function setValue(key, value) { + if (typeof key !== "string" || !key.length) { + return console.error("setValue invalid key arg"); + } + if (value == null) { + return console.error("setValue invalid value arg"); + } + return new Promise(resolve => { + const item = {}; + item[`${this.US_filename}---${key}`] = value; + browser.storage.local.set(item, () => resolve({success: 1})); + }); +} + +function getValue(key, defaultValue) { + if (typeof key !== "string" || !key.length) { + return console.error("getValue invalid key arg"); + } + const prefixedKey = `${this.US_filename}---${key}`; + return new Promise(resolve => { + browser.storage.local.get(prefixedKey, item => { + if (Object.keys(item).length === 0) { + if (defaultValue != null) { + resolve(defaultValue); + } else { + resolve(undefined); + } + } else { + resolve(Object.values(item)[0]); + } + }); + }); +} + +function deleteValue(key) { + if (typeof key !== "string" || !key.length) { + return console.error("deleteValue missing key arg"); + } + return new Promise(resolve => { + const prefixedKey = `${this.US_filename}---${key}`; + browser.storage.local.remove(prefixedKey, () => { + resolve({success: 1}); + }); + }); +} + +function listValues() { + return new Promise(resolve => { + const prefix = `${this.US_filename}---`; + const keys = []; + browser.storage.local.get().then(items => { + for (const key in items) { + if (key.startsWith(prefix)) { + const k = key.replace(prefix, ""); + keys.push(k); + } + } + resolve(keys); + }); + }); +} + +function openInTab(url, openInBackground = false) { + if (!url) return console.error("openInTab missing url arg"); + return new Promise(resolve => { + const message = { + name: "API_OPEN_TAB", + url, + active: !openInBackground + }; + browser.runtime.sendMessage(message, response => resolve(response)); + }); +} + +function getTab() { + return new Promise(resolve => { + const message = {name: "API_GET_TAB"}; + browser.runtime.sendMessage(message, response => { + resolve(response); + }); + }); +} + +function saveTab(tab) { + if (tab == null) return console.error("saveTab invalid arg"); + return new Promise(resolve => { + const message = { + name: "API_SAVE_TAB", + tab + }; + browser.runtime.sendMessage(message, response => { + resolve(response); + }); + }); +} + +function closeTab(tabId) { + return new Promise(resolve => { + const message = { + name: "API_CLOSE_TAB", + tabId + }; + browser.runtime.sendMessage(message, response => resolve(response)); + }); +} + +function addStyle(css) { + if (typeof css !== "string") { + return console.error("addStyle invalid css arg"); + } + return new Promise(resolve => { + const message = { + name: "API_ADD_STYLE", + css + }; + browser.runtime.sendMessage(message, response => resolve(response)); + }); +} + +function setClipboard(clipboardData, type) { + return new Promise(resolve => { + const message = { + name: "API_SET_CLIPBOARD", + clipboardData, + type + }; + browser.runtime.sendMessage(message, response => { + resolve(response); + }); + }); +} + +function xhr(details) { + if (details == null) return console.error("xhr invalid details arg"); + if (!details.url) return console.error("xhr details missing url key"); + // generate random port name for single xhr + const xhrPortName = Math.random().toString(36).substring(1, 9); + // strip out functions from details + const detailsParsed = JSON.parse(JSON.stringify(details)); + // get all the "on" events from XMLHttpRequest object + const events = []; + for (const k in XMLHttpRequest.prototype) { + if (k.slice(0, 2) === "on") events.push(k); + } + // check which functions are included in the original details object + // add a bool to indicate if event listeners should be attached + for (const e of events) { + if (typeof details[e] === "function") detailsParsed[e] = true; + } + // define return method, will be populated after port is established + const response = { + abort: () => console.error("xhr has not yet been initialized") + }; + // port listener, most of the messaging logic goes here + const listener = port => { + if (port.name !== xhrPortName) return; + port.onMessage.addListener(async msg => { + if ( + events.includes(msg.name) + && typeof details[msg.name] === "function" + ) { + // process xhr response + const r = msg.response; + // only process when xhr is complete and data exist + if (r.readyState === 4 && r.response !== null) { + if (r.responseType === "arraybuffer") { + // arraybuffer responses had their data converted in background + // convert it back to arraybuffer + try { + const buffer = new Uint8Array(r.response).buffer; + r.response = buffer; + } catch (err) { + console.error("error parsing xhr arraybuffer", err); + } + } else if (r.responseType === "blob" && r.response.data) { + // blob responses had their data converted in background + // convert it back to blob + const resp = await fetch(r.response.data); + const b = await resp.blob(); + r.response = b; + } + } + // call userscript method + details[msg.name](msg.response); + } + // all messages received + // tell background it's safe to close port + if (msg.name === "onloadend") { + port.postMessage({name: "DISCONNECT"}); + } + }); + + // handle port disconnect and clean tasks + port.onDisconnect.addListener(p => { + if (p?.error) { + console.error(`port disconnected due to an error: ${p.error.message}`); + } + browser.runtime.onConnect.removeListener(listener); + }); + // fill the method returned to the user script + response.abort = () => port.postMessage({name: "ABORT"}); + }; + // wait for the background to establish a port connection + browser.runtime.onConnect.addListener(listener); + // pass the basic information to the background through a common message + const message = { + name: "API_XHR", + details: detailsParsed, + xhrPortName, + events + }; + browser.runtime.sendMessage(message); + return response; +} + +export default { + setValue, + getValue, + listValues, + deleteValue, + openInTab, + getTab, + saveTab, + closeTab, + addStyle, + setClipboard, + // notification, + // registerMenuCommand, + // getResourceUrl, + xmlHttpRequest: xhr, + GM_xmlhttpRequest: xhr +}; diff --git a/src/ext/content-scripts/main.js b/src/ext/content-scripts/main.js new file mode 100644 index 00000000..6d23a254 --- /dev/null +++ b/src/ext/content-scripts/main.js @@ -0,0 +1,233 @@ +import apis from "./api.js"; + +// code received from background page will be stored in this variable +// code referenced again when strict CSPs block initial injection attempt +let data; +// determines whether strict csp injection has already run (JS only) +let cspFallbackAttempted = false; + +// label used to distinguish frames in console +const label = randomLabel(); +const usTag = window.self === window.top ? "" : `(${label})`; + +function randomLabel() { + const a = "ABCDEFGHIJKLMNOPQRSTUVWXYZ", r = Math.random(); + return a[Math.floor(r * a.length)] + r.toString().slice(5, 6); +} + +function triageJS(userscript) { + const runAt = userscript.scriptObject["run-at"]; + if (runAt === "document-start") { + injectJS(userscript); + } else if (runAt === "document-end") { + if (document.readyState !== "loading") { + injectJS(userscript); + } else { + document.addEventListener("DOMContentLoaded", () => { + injectJS(userscript); + }); + } + } else if (runAt === "document-idle") { + if (document.readyState === "complete") { + injectJS(userscript); + } else { + document.addEventListener("readystatechange", () => { + if (document.readyState === "complete") { + injectJS(userscript); + } + }); + } + } +} + +function injectJS(userscript) { + const filename = userscript.scriptObject.filename; + const name = userscript.scriptObject.name; + const code = `${userscript.code} //# 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)) { + injectInto = "content"; + console.warn(`Attempting fallback injection for ${name}`); + } else if (window.self === window.top) { + console.info(`Injecting ${name} %c(js)`, "color: #fff600"); + } else { + console.info(`Injecting ${name} %c(js)%c - %cframe(${label})(${window.location})`, "color: #fff600", "color: inherit", "color: #006fff"); + } + if (injectInto !== "content") { + const div = document.createElement("div"); + div.style.display = "none"; + const shadowRoot = div.attachShadow({mode: "closed"}); + document.body.append(div); + const tag = document.createElement("script"); + tag.textContent = code; + shadowRoot.append(tag); + } else { + try { + // eslint-disable-next-line no-new-func + return Function(`{${Object.keys(userscript.apis).join(",")}}`, code)(userscript.apis); + } catch (error) { + console.error(`"${filename}" error:`, error); + } + } +} + +function injectCSS(name, code) { + if (window.self === window.top) { + console.info(`Injecting ${name} %c(css)`, "color: #60f36c"); + } else { + console.info(`Injecting ${name} %c(css)%c - %cframe(${label})(${window.location})`, "color: #60f36c", "color: inherit", "color: #006fff"); + } + // Safari lacks full support for tabs.insertCSS + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/insertCSS + // specifically frameId and cssOrigin + // if support for those details keys arrives, the method below can be used + // NOTE: manifest V3 does support frameId, but not origin + // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/insertCSS + + // write the css code to head of the document + const tag = document.createElement("style"); + tag.textContent = code; + document.head.appendChild(tag); +} + +function cspFallback(e) { + // if a security policy violation event has occurred + // and the directive is script-src or script-src-elem + // it's fair to assume that there is a strict CSP for javascript + // and that injection was blocked for all userscripts + // when any script-src violation is detected, re-attempt injection + if ( + e.effectiveDirective === "script-src" + || e.effectiveDirective === "script-src-elem" + ) { + // get all "auto" code + // since other code can trigger a security policy violation event + // make sure data var is not undefined before attempting fallback + if (!data || cspFallbackAttempted) return; + // update global that tracks security policy violations + cspFallbackAttempted = 1; + // for all userscripts with @inject-into: auto, attempt re-injection + for (let i = 0; i < data.files.js.length; i++) { + const userscript = data.files.js[i]; + if (userscript.scriptObject["inject-into"] !== "auto") continue; + userscript.fallback = 1; + triageJS(userscript); + } + } +} + +function injection() { + browser.runtime.sendMessage({name: "REQ_USERSCRIPTS"}, response => { + // cancel injection if errors detected + if (!response || response.error) { + console.error(response?.error || "REQ_USERSCRIPTS returned undefined"); + return; + } + // save response locally in case CSP events occur + data = response; + // combine regular and context-menu scripts + const scripts = [...data.files.js, ...data.files.menu]; + // loop through each userscript and prepare for processing + for (let i = 0; i < scripts.length; i++) { + const userscript = scripts[i]; + const filename = userscript.scriptObject.filename; + const grants = userscript.scriptObject.grant; + const injectInto = userscript.scriptObject["inject-into"]; + // create GM.info object, all userscripts get access to GM.info + userscript.apis = {GM: {}}; + userscript.apis.GM.info = { + script: userscript.scriptObject, + scriptHandler: data.scriptHandler, + scriptHandlerVersion: data.scriptHandlerVersion, + scriptMetaStr: userscript.scriptMetaStr + }; + // add GM_info + 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`); + } + // loop through each userscript @grant value, add methods as needed + for (let j = 0; j < grants.length; j++) { + const grant = grants[j]; + const method = grant.split(".")[1] || grant.split(".")[0]; + // ensure API method exists in apis object + if (!Object.keys(apis).includes(method)) continue; + // add granted methods + switch (method) { + case "info": + case "GM_info": + continue; + case "getValue": + case "setValue": + case "deleteValue": + case "listValues": + userscript.apis.GM[method] = apis[method].bind({US_filename: filename}); + break; + case "GM_xmlhttpRequest": + userscript.apis[method] = apis[method]; + break; + default: + userscript.apis.GM[method] = apis[method]; + } + } + // triage userjs item for injection + triageJS(userscript); + } + // loop through each usercss and inject + for (let i = 0; i < data.files.css.length; i++) { + const userstyle = data.files.css[i]; + injectCSS(userstyle.name, userstyle.code); + } + }); +} + +function listeners() { + // listens for messages from background, popup, etc... + browser.runtime.onMessage.addListener(request => { + const name = request.name; + if (name === "CONTEXT_RUN") { + // from bg script when context-menu item is clicked + // double check to ensure context-menu scripts only run in top windows + if (window !== window.top) return; + + // loop through context-menu scripts saved to data object and find match + // if no match found, nothing will execute and error will log + const filename = request.menuItemId; + 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"); + injectJS(item); + return; + } + } + console.error(`Couldn't find ${filename} code!`); + } + }); + // listen for CSP violations + document.addEventListener("securitypolicyviolation", cspFallback); +} + +async function initialize() { + 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(); +} + +initialize(); diff --git a/src/page/App.svelte b/src/ext/extension-page/App.svelte similarity index 100% rename from src/page/App.svelte rename to src/ext/extension-page/App.svelte diff --git a/src/page/Components/Editor/CodeMirror.svelte b/src/ext/extension-page/Components/Editor/CodeMirror.svelte similarity index 99% rename from src/page/Components/Editor/CodeMirror.svelte rename to src/ext/extension-page/Components/Editor/CodeMirror.svelte index 9fce5705..849662ef 100644 --- a/src/page/Components/Editor/CodeMirror.svelte +++ b/src/ext/extension-page/Components/Editor/CodeMirror.svelte @@ -27,6 +27,7 @@ diff --git a/xcode/iOS-App/ViewController.swift b/xcode/iOS-App/ViewController.swift deleted file mode 100644 index e21c5cbe..00000000 --- a/xcode/iOS-App/ViewController.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// ViewController.swift -// Userscripts-iOS -// -// Created by Justin Wasack on 10/3/21. -// Copyright © 2021 Justin Wasack. All rights reserved. -// - -import UIKit -import WebKit -import UniformTypeIdentifiers -import os - -private let logger = USLogger(#fileID) - -class ViewController: UIViewController, WKNavigationDelegate, WKScriptMessageHandler, UIDocumentPickerDelegate { - - @IBOutlet var webView: WKWebView! - - override func viewDidLoad() { - super.viewDidLoad() - let backgroundColor = UIColor.init(red: (47/255.0), green: (51/255.0), blue: (55/255.0), alpha: 1.0) - view.setValue(backgroundColor, forKey: "backgroundColor") - self.webView.isOpaque = false - self.webView.backgroundColor = backgroundColor - self.webView.navigationDelegate = self - self.webView.scrollView.isScrollEnabled = false - self.webView.configuration.userContentController.add(self, name: "controller") - - self.webView.loadFileURL(Bundle.main.url(forResource: "Main", withExtension: "html")!, allowingReadAccessTo: Bundle.main.resourceURL!) - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "??" - let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "??" - webView.evaluateJavaScript("printDirectory('\(Preferences.scriptsDirectoryUrl.absoluteString)')") - webView.evaluateJavaScript("printVersion('v\(appVersion)', '(\(buildNumber))')") - } - - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - if navigationAction.navigationType == .linkActivated { - guard - let url = navigationAction.request.url, - UIApplication.shared.canOpenURL(url) - else { - return - } - UIApplication.shared.open(url) - decisionHandler(.cancel) - } else { - decisionHandler(.allow) - } - } - - func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { - guard let name = message.body as? String else { - logger?.error("\(#function, privacy: .public) - Userscripts iOS received a message without a name") - return - } - if name == "SET_READ_LOCATION" { - // https://developer.apple.com/documentation/uikit/view_controllers/providing_access_to_directories - logger?.info("\(#function, privacy: .public) - Userscripts iOS has requested to set the readLocation") - let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder]) - documentPicker.delegate = self - documentPicker.directoryURL = getDocumentsDirectory() - present(documentPicker, animated: true, completion: nil) - } - } - - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { - Preferences.scriptsDirectoryUrl = url - webView.evaluateJavaScript("printDirectory('\(Preferences.scriptsDirectoryUrl.absoluteString)')") - } -} diff --git a/xcode/xcconfig/Mac-App.xcconfig b/xcode/xcconfig/Mac-App.xcconfig index 76526d70..d477275f 100644 --- a/xcode/xcconfig/Mac-App.xcconfig +++ b/xcode/xcconfig/Mac-App.xcconfig @@ -1,11 +1,12 @@ +SRC_DIR = App-Mac ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon -CODE_SIGN_ENTITLEMENTS = Mac-App/App.entitlements +CODE_SIGN_ENTITLEMENTS = $(SRC_DIR)/App.entitlements COMBINE_HIDPI_IMAGES = YES CURRENT_PROJECT_VERSION = $(MAC_APP_BUILD) ENABLE_HARDENED_RUNTIME = YES GENERATE_INFOPLIST_FILE = YES -INFOPLIST_FILE = Mac-App/Info.plist +INFOPLIST_FILE = $(SRC_DIR)/Info.plist INFOPLIST_KEY_CFBundleDisplayName = $(PROJECT_NAME)-$(TARGET_NAME) INFOPLIST_KEY_CFBundleDisplayName[config=Debug] = $(PROJECT_NAME)-$(TARGET_NAME)-Debug LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks @@ -15,4 +16,4 @@ PRODUCT_NAME = $(PROJECT_NAME) PRODUCT_NAME[config=Debug] = $(PROJECT_NAME)-Debug // Override this file -#include? "Mac-App.dev.xcconfig" \ No newline at end of file +#include? "Mac-App.dev.xcconfig" diff --git a/xcode/xcconfig/Mac-Safari-Extension.xcconfig b/xcode/xcconfig/Mac-Safari-Extension.xcconfig index 13f53620..6e392d8a 100644 --- a/xcode/xcconfig/Mac-Safari-Extension.xcconfig +++ b/xcode/xcconfig/Mac-Safari-Extension.xcconfig @@ -1,8 +1,9 @@ -CODE_SIGN_ENTITLEMENTS = Safari-Extension/Mac.entitlements +SRC_DIR = Ext-Safari +CODE_SIGN_ENTITLEMENTS = $(SRC_DIR)/Mac.entitlements CURRENT_PROJECT_VERSION = $(MAC_APP_BUILD) ENABLE_HARDENED_RUNTIME = YES GENERATE_INFOPLIST_FILE = YES -INFOPLIST_FILE = Safari-Extension/Info.plist +INFOPLIST_FILE = $(SRC_DIR)/Info.plist INFOPLIST_KEY_CFBundleDisplayName = $(PRODUCT_NAME) INFOPLIST_KEY_CFBundleDisplayName[config=Debug] = $(PRODUCT_NAME)-Debug LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/../Frameworks @executable_path/../../../../Frameworks @@ -10,6 +11,9 @@ MARKETING_VERSION = $(MAC_APP_VERSION) PRODUCT_BUNDLE_IDENTIFIER = $(EXT_IDENTIFIER) PRODUCT_NAME = $(PROJECT_NAME)-$(TARGET_NAME) SKIP_INSTALL = YES +MANIFEST_FILE = $(SRC_DIR)/Resources/ext-v4-manifest.json +MANIFEST_NAME = $(PROJECT_NAME) +MANIFEST_NAME[config=Debug] = $(PROJECT_NAME)-Debug // Override this file -#include? "Mac-Safari-Extension.dev.xcconfig" \ No newline at end of file +#include? "Mac-Safari-Extension.dev.xcconfig" diff --git a/xcode/xcconfig/Mac-Tests.xcconfig b/xcode/xcconfig/Mac-Tests.xcconfig index 998554bc..01a14d09 100644 --- a/xcode/xcconfig/Mac-Tests.xcconfig +++ b/xcode/xcconfig/Mac-Tests.xcconfig @@ -8,4 +8,4 @@ SWIFT_EMIT_LOC_STRINGS = NO TEST_HOST = $(BUILT_PRODUCTS_DIR)/Userscripts-Debug.app/Contents/MacOS/Userscripts-Debug // Override this file -#include? "Mac-Tests.dev.xcconfig" \ No newline at end of file +#include? "Mac-Tests.dev.xcconfig" diff --git a/xcode/xcconfig/Userscripts-Base.xcconfig b/xcode/xcconfig/Userscripts-Base.xcconfig index 7f35556a..4113b7fa 100644 --- a/xcode/xcconfig/Userscripts-Base.xcconfig +++ b/xcode/xcconfig/Userscripts-Base.xcconfig @@ -61,4 +61,4 @@ SWIFT_OPTIMIZATION_LEVEL[config=Release] = -O SWIFT_VERSION = 5.0 // Override this file -#include? "Userscripts-Base.dev.xcconfig" \ No newline at end of file +#include? "Userscripts-Base.dev.xcconfig" diff --git a/xcode/xcconfig/Userscripts-Debug.xcconfig b/xcode/xcconfig/Userscripts-Debug.xcconfig index a0060f16..c596ca04 100644 --- a/xcode/xcconfig/Userscripts-Debug.xcconfig +++ b/xcode/xcconfig/Userscripts-Debug.xcconfig @@ -21,4 +21,4 @@ MAC_SHARED_GID = 00000.$(ORG_IDENTIFIER).userscripts APP_URL_SCHEME = userscriptsdebug // Override this file -#include? "Userscripts-Debug.dev.xcconfig" \ No newline at end of file +#include? "Userscripts-Debug.dev.xcconfig" diff --git a/xcode/xcconfig/iOS-App.xcconfig b/xcode/xcconfig/iOS-App.xcconfig index f52d56d6..005ffcaf 100644 --- a/xcode/xcconfig/iOS-App.xcconfig +++ b/xcode/xcconfig/iOS-App.xcconfig @@ -1,10 +1,11 @@ +SRC_DIR = App-iOS ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor -CODE_SIGN_ENTITLEMENTS = iOS-App/App.entitlements +CODE_SIGN_ENTITLEMENTS = $(SRC_DIR)/App.entitlements CURRENT_PROJECT_VERSION = $(IOS_APP_BUILD) GENERATE_INFOPLIST_FILE = YES -INFOPLIST_FILE = iOS-App/Info.plist +INFOPLIST_FILE = $(SRC_DIR)/Info.plist INFOPLIST_KEY_CFBundleDisplayName = $(PROJECT_NAME) INFOPLIST_KEY_CFBundleDisplayName[config=Debug] = $(PROJECT_NAME)-D INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES @@ -27,4 +28,4 @@ TARGETED_DEVICE_FAMILY = 1,2 VALIDATE_PRODUCT[config=Release] = YES // Override this file -#include? "iOS-App.dev.xcconfig" \ No newline at end of file +#include? "iOS-App.dev.xcconfig" diff --git a/xcode/xcconfig/iOS-Safari-Extension.xcconfig b/xcode/xcconfig/iOS-Safari-Extension.xcconfig index 0bdd357f..7df9eae4 100644 --- a/xcode/xcconfig/iOS-Safari-Extension.xcconfig +++ b/xcode/xcconfig/iOS-Safari-Extension.xcconfig @@ -1,7 +1,8 @@ -CODE_SIGN_ENTITLEMENTS = Safari-Extension/iOS.entitlements +SRC_DIR = Ext-Safari +CODE_SIGN_ENTITLEMENTS = $(SRC_DIR)/iOS.entitlements CURRENT_PROJECT_VERSION = $(IOS_APP_BUILD) GENERATE_INFOPLIST_FILE = YES -INFOPLIST_FILE = Safari-Extension/Info.plist +INFOPLIST_FILE = $(SRC_DIR)/Info.plist INFOPLIST_KEY_CFBundleDisplayName = $(PRODUCT_NAME) INFOPLIST_KEY_CFBundleDisplayName[config=Debug] = $(PROJECT_NAME)-D LD_RUNPATH_SEARCH_PATHS = $(inherited) @executable_path/Frameworks @executable_path/../../Frameworks @@ -14,6 +15,9 @@ SKIP_INSTALL = YES SWIFT_EMIT_LOC_STRINGS = YES TARGETED_DEVICE_FAMILY = 1,2 VALIDATE_PRODUCT[config=Release] = YES +MANIFEST_FILE = $(SRC_DIR)/Resources/ext-v4-manifest.json +MANIFEST_NAME = $(PROJECT_NAME) +MANIFEST_NAME[config=Debug] = $(PROJECT_NAME)-Debug // Override this file -#include? "iOS-Safari-Extension.dev.xcconfig" \ No newline at end of file +#include? "iOS-Safari-Extension.dev.xcconfig"